Skip to content

Commit 332eb85

Browse files
committed
feat(auth): add Entra ID identity provider integration
Introduces Entra ID (former Azure AD) authentication support with multiple authentication flows and automated token lifecycle management. Key additions: - Add EntraIdCredentialsProvider for handling Entra ID authentication flows - Implement MSALIdentityProvider to integrate with MSAL/EntraID authentication library - Add support for multiple authentication methods: - Managed identities (system and user-assigned) - Client credentials with certificate - Client credentials with secret - Authorization Code flow with PKCE - Add factory class with builder methods for each authentication flow - Include sample Express server implementation for Authorization Code flow - Export core auth types from client package for reuse - Add comprehensive configuration options for authority and token management Package exports (@redis/client): - Added package.json "exports" field to enable auth folder barrel exports - Included additional export paths ("./index" and "./dist/*") to maintain compatibility with existing deep imports across the monorepo packages This change enables Azure-based authentication flows while maintaining consistent token lifecycle management patterns established in the core authentication system.
1 parent 0d78fb0 commit 332eb85

11 files changed

+1739
-43
lines changed

package-lock.json

+1,020-24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './token-manager';
2+
export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError };
3+
import { Disposable } from './types';
4+
export { Disposable };
5+
6+
import { CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, CredentialsError, StreamingCredentialsListener, AsyncCredentialsProvider, ReAuthenticationError, BasicAuth } from './credentials-provider';
7+
export { CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, CredentialsError, StreamingCredentialsListener, AsyncCredentialsProvider, ReAuthenticationError, BasicAuth };
8+
9+
import { Token } from './token';
10+
export { Token };
11+
12+
import { IdentityProvider, TokenResponse } from './identity-provider';
13+
export { IdentityProvider, TokenResponse };

packages/client/lib/client/index.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import COMMANDS from '../commands';
2-
import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError } from './authx/credentials-provider';
3-
import {Disposable} from './authx/types';
2+
import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, Disposable } from './authx/';
43
import RedisSocket, { RedisSocketOptions } from './socket';
54
import RedisCommandsQueue, { CommandOptions } from './commands-queue';
65
import { EventEmitter } from 'node:events';

packages/entraid/README.md

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
import { NetworkError } from '@azure/msal-common';
2+
import {
3+
LogLevel,
4+
ManagedIdentityApplication,
5+
ManagedIdentityConfiguration,
6+
AuthenticationResult,
7+
PublicClientApplication,
8+
ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo
9+
} from '@azure/msal-node';
10+
import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } from '@redis/client/lib/client/authx';
11+
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
12+
import { MSALIdentityProvider } from './msal-identity-provider';
13+
14+
const FALLBACK_SCOPE = 'https://redis.azure.com/.default';
15+
16+
export type AuthorityConfig =
17+
| { type: 'multi-tenant'; tenantId: string }
18+
| { type: 'custom'; authorityUrl: string }
19+
| { type: 'default' };
20+
21+
export type PKCEParams = {
22+
code: string;
23+
verifier: string;
24+
clientInfo?: string;
25+
}
26+
27+
export type CredentialParams = {
28+
clientId: string;
29+
scopes?: string[];
30+
authorityConfig?: AuthorityConfig;
31+
32+
tokenManagerConfig: TokenManagerConfig
33+
onReAuthenticationError?: (error: ReAuthenticationError) => void;
34+
}
35+
36+
export type AuthCodePKCEParams = CredentialParams & {
37+
redirectUri: string;
38+
};
39+
40+
export type ClientSecretCredentialsParams = CredentialParams & {
41+
clientSecret: string;
42+
};
43+
44+
export type ClientCredentialsWithCertificateParams = CredentialParams & {
45+
certificate: {
46+
thumbprint: string;
47+
privateKey: string;
48+
x5c?: string;
49+
};
50+
};
51+
52+
const loggerOptions = {
53+
loggerCallback(loglevel: LogLevel, message: string, containsPii: boolean) {
54+
if (!containsPii) console.log(message);
55+
},
56+
piiLoggingEnabled: false,
57+
logLevel: LogLevel.Verbose
58+
}
59+
60+
/**
61+
* The most imporant part of the RetryPolicy is the shouldRetry function. This function is used to determine if a request should be retried based
62+
* on the error returned from the identity provider. The defaultRetryPolicy is used to retry on network errors only.
63+
*/
64+
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
65+
// currently only retry on network errors
66+
shouldRetry: (error: unknown) => error instanceof NetworkError,
67+
maxAttempts: 10,
68+
initialDelayMs: 100,
69+
maxDelayMs: 100000,
70+
backoffMultiplier: 2,
71+
jitterPercentage: 0.1
72+
73+
};
74+
75+
export const DEFAULT_TOKEN_MANAGER_CONFIG: TokenManagerConfig = {
76+
retry: DEFAULT_RETRY_POLICY,
77+
expirationRefreshRatio: 0.7 // Refresh token when 70% of the token has expired
78+
}
79+
80+
/**
81+
* This class is used to help with the Authorization Code Flow with PKCE.
82+
* It provides methods to generate PKCE codes, get the authorization URL, and create the credential provider.
83+
*/
84+
export class AuthCodeFlowHelper {
85+
private constructor(
86+
readonly client: PublicClientApplication,
87+
readonly scopes: string[],
88+
readonly redirectUri: string
89+
) {}
90+
91+
async getAuthCodeUrl(pkceCodes: {
92+
challenge: string;
93+
challengeMethod: string;
94+
}): Promise<string> {
95+
const authCodeUrlParameters: AuthorizationUrlRequest = {
96+
scopes: this.scopes,
97+
redirectUri: this.redirectUri,
98+
codeChallenge: pkceCodes.challenge,
99+
codeChallengeMethod: pkceCodes.challengeMethod
100+
};
101+
102+
return this.client.getAuthCodeUrl(authCodeUrlParameters);
103+
}
104+
105+
async acquireTokenByCode(params: PKCEParams): Promise<AuthenticationResult> {
106+
const tokenRequest: AuthorizationCodeRequest = {
107+
code: params.code,
108+
scopes: this.scopes,
109+
redirectUri: this.redirectUri,
110+
codeVerifier: params.verifier,
111+
clientInfo: params.clientInfo
112+
};
113+
114+
return this.client.acquireTokenByCode(tokenRequest);
115+
}
116+
117+
static async generatePKCE(): Promise<{
118+
verifier: string;
119+
challenge: string;
120+
challengeMethod: string;
121+
}> {
122+
const cryptoProvider = new CryptoProvider();
123+
const { verifier, challenge } = await cryptoProvider.generatePkceCodes();
124+
return {
125+
verifier,
126+
challenge,
127+
challengeMethod: 'S256'
128+
};
129+
}
130+
131+
static create(params: {
132+
clientId: string;
133+
redirectUri: string;
134+
scopes?: string[];
135+
authorityConfig?: AuthorityConfig;
136+
}): AuthCodeFlowHelper {
137+
const config = {
138+
auth: {
139+
clientId: params.clientId,
140+
authority: EntraIdCredentialsProviderFactory.getAuthority(params.authorityConfig ?? { type: 'default' })
141+
},
142+
system: {
143+
loggerOptions
144+
}
145+
};
146+
147+
return new AuthCodeFlowHelper(
148+
new PublicClientApplication(config),
149+
params.scopes ?? ['user.read'],
150+
params.redirectUri
151+
);
152+
}
153+
}
154+
155+
/**
156+
* This class is used to create credentials providers for different types of authentication flows.
157+
*/
158+
export class EntraIdCredentialsProviderFactory {
159+
160+
static getAuthority(config: AuthorityConfig): string {
161+
switch (config.type) {
162+
case 'multi-tenant':
163+
return `https://login.microsoftonline.com/${config.tenantId}`;
164+
case 'custom':
165+
return config.authorityUrl;
166+
case 'default':
167+
return 'https://login.microsoftonline.com/common';
168+
default:
169+
throw new Error('Invalid authority configuration');
170+
}
171+
}
172+
173+
/**
174+
* This method is used to create a ManagedIdentityProvider for both system-assigned and user-assigned managed identities.
175+
* for user-assigned managed identities, the developer needs to pass either the client ID, full resource identifier,
176+
* or the object ID of the managed identity when creating ManagedIdentityApplication.
177+
*
178+
* @param params
179+
* @param userAssignedClientId For user-assigned managed identities, the developer needs to pass either the client ID,
180+
* full resource identifier, or the object ID of the managed identity when creating ManagedIdentityApplication.
181+
*
182+
* @private
183+
*/
184+
public static createManagedIdentityProvider(
185+
params: CredentialParams, userAssignedClientId?: string
186+
): EntraidCredentialsProvider {
187+
const config: ManagedIdentityConfiguration = {
188+
// For user-assigned identity, include the client ID
189+
...(userAssignedClientId && {
190+
managedIdentityIdParams: {
191+
userAssignedClientId
192+
}
193+
}),
194+
system: {
195+
loggerOptions
196+
}
197+
};
198+
199+
const client = new ManagedIdentityApplication(config);
200+
201+
const idp = new MSALIdentityProvider(
202+
() => client.acquireToken({
203+
resource: params.scopes?.[0] ?? FALLBACK_SCOPE,
204+
forceRefresh: true
205+
}).then(x => x === null ? Promise.reject('Token is null') : x)
206+
);
207+
208+
return new EntraidCredentialsProvider(
209+
new TokenManager(idp, params.tokenManagerConfig),
210+
idp,
211+
{ onReAuthenticationError: params.onReAuthenticationError }
212+
);
213+
}
214+
215+
/**
216+
* This method is used to create a credentials provider for system-assigned managed identities.
217+
* @param params
218+
*/
219+
static createForSystemAssignedManagedIdentity(
220+
params: CredentialParams
221+
): EntraidCredentialsProvider {
222+
return this.createManagedIdentityProvider(params);
223+
}
224+
225+
/**
226+
* This method is used to create a credentials provider for user-assigned managed identities.
227+
* It will include the client ID as the userAssignedClientId in the ManagedIdentityConfiguration.
228+
* @param params
229+
*/
230+
static createForUserAssignedManagedIdentity(
231+
params: CredentialParams
232+
): EntraidCredentialsProvider {
233+
return this.createManagedIdentityProvider(params, params.clientId);
234+
}
235+
236+
private static _createForClientCredentials(
237+
authConfig: NodeAuthOptions,
238+
params: CredentialParams
239+
): EntraidCredentialsProvider {
240+
const config: Configuration = {
241+
auth: {
242+
...authConfig,
243+
authority: this.getAuthority(params.authorityConfig ?? { type: 'default' })
244+
},
245+
system: {
246+
loggerOptions
247+
}
248+
};
249+
250+
const client = new ConfidentialClientApplication(config);
251+
252+
const idp = new MSALIdentityProvider(
253+
() => client.acquireTokenByClientCredential({
254+
skipCache: true,
255+
scopes: params.scopes ?? [FALLBACK_SCOPE]
256+
}).then(x => x === null ? Promise.reject('Token is null') : x)
257+
);
258+
259+
return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp,
260+
{ onReAuthenticationError: params.onReAuthenticationError });
261+
}
262+
263+
/**
264+
* This method is used to create a credentials provider for service principals using certificate.
265+
* @param params
266+
*/
267+
static createForClientCredentialsWithCertificate(
268+
params: ClientCredentialsWithCertificateParams
269+
): EntraidCredentialsProvider {
270+
return this._createForClientCredentials(
271+
{
272+
clientId: params.clientId,
273+
clientCertificate: params.certificate
274+
},
275+
params
276+
);
277+
}
278+
279+
/**
280+
* This method is used to create a credentials provider for service principals using client secret.
281+
* @param params
282+
*/
283+
static createForClientCredentials(
284+
params: ClientSecretCredentialsParams
285+
): EntraidCredentialsProvider {
286+
return this._createForClientCredentials(
287+
{
288+
clientId: params.clientId,
289+
clientSecret: params.clientSecret
290+
},
291+
params
292+
);
293+
}
294+
295+
/**
296+
* This method is used to create a credentials provider for the Authorization Code Flow with PKCE.
297+
* @param params
298+
*/
299+
static createForAuthorizationCodeWithPKCE(
300+
params: AuthCodePKCEParams
301+
): {
302+
getPKCECodes: () => Promise<{
303+
verifier: string;
304+
challenge: string;
305+
challengeMethod: string;
306+
}>;
307+
getAuthCodeUrl: (
308+
pkceCodes: { challenge: string; challengeMethod: string }
309+
) => Promise<string>;
310+
createCredentialsProvider: (
311+
params: PKCEParams
312+
) => EntraidCredentialsProvider;
313+
} {
314+
315+
const requiredScopes = ['user.read', 'offline_access'];
316+
const scopes = [...new Set([...(params.scopes || []), ...requiredScopes])];
317+
318+
const authFlow = AuthCodeFlowHelper.create({
319+
clientId: params.clientId,
320+
redirectUri: params.redirectUri,
321+
scopes: scopes,
322+
authorityConfig: params.authorityConfig
323+
});
324+
325+
return {
326+
getPKCECodes: AuthCodeFlowHelper.generatePKCE,
327+
getAuthCodeUrl: (pkceCodes) => authFlow.getAuthCodeUrl(pkceCodes),
328+
createCredentialsProvider: (pkceParams) => {
329+
330+
// This is used to store the initial credentials account to be used
331+
// for silent token acquisition after the initial token acquisition.
332+
let initialCredentialsAccount: AccountInfo | null = null;
333+
334+
const idp = new MSALIdentityProvider(
335+
async () => {
336+
if (!initialCredentialsAccount) {
337+
let authResult = await authFlow.acquireTokenByCode(pkceParams);
338+
initialCredentialsAccount = authResult.account;
339+
return authResult;
340+
} else {
341+
return authFlow.client.acquireTokenSilent({
342+
forceRefresh: true,
343+
account: initialCredentialsAccount,
344+
scopes
345+
});
346+
}
347+
348+
}
349+
);
350+
const tm = new TokenManager(idp, params.tokenManagerConfig);
351+
return new EntraidCredentialsProvider(tm, idp, { onReAuthenticationError: params.onReAuthenticationError });
352+
}
353+
};
354+
}
355+
}

0 commit comments

Comments
 (0)