import type { GetTokenOptions, TokenCredential } from '@azure/core-auth'; import { NetworkError } from '@azure/msal-common'; import { LogLevel, ManagedIdentityApplication, ManagedIdentityConfiguration, AuthenticationResult, PublicClientApplication, ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo } from '@azure/msal-node'; import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError, BasicAuth } from '@redis/client/dist/lib/authx'; import { AzureIdentityProvider } from './azure-identity-provider'; import { AuthenticationResponse, DEFAULT_CREDENTIALS_MAPPER, EntraidCredentialsProvider, OID_CREDENTIALS_MAPPER } from './entraid-credentials-provider'; import { MSALIdentityProvider } from './msal-identity-provider'; /** * This class is used to create credentials providers for different types of authentication flows. */ export class EntraIdCredentialsProviderFactory { /** * This method is used to create a ManagedIdentityProvider for both system-assigned and user-assigned managed identities. * * @param params * @param userAssignedClientId For user-assigned managed identities, the developer needs to pass either the client ID, * full resource identifier, or the object ID of the managed identity when creating ManagedIdentityApplication. * */ public static createManagedIdentityProvider( params: CredentialParams, userAssignedClientId?: string ): EntraidCredentialsProvider { const config: ManagedIdentityConfiguration = { // For user-assigned identity, include the client ID ...(userAssignedClientId && { managedIdentityIdParams: { userAssignedClientId } }), system: { loggerOptions } }; const client = new ManagedIdentityApplication(config); const idp = new MSALIdentityProvider( () => client.acquireToken({ resource: params.scopes?.[0] ?? REDIS_SCOPE, forceRefresh: true }).then(x => x === null ? Promise.reject('Token is null') : x) ); return new EntraidCredentialsProvider( new TokenManager(idp, params.tokenManagerConfig), idp, { onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER, onRetryableError: params.onRetryableError } ); } /** * This method is used to create a credentials provider for system-assigned managed identities. * @param params */ static createForSystemAssignedManagedIdentity( params: CredentialParams ): EntraidCredentialsProvider { return this.createManagedIdentityProvider(params); } /** * This method is used to create a credentials provider for user-assigned managed identities. * It will include the client ID as the userAssignedClientId in the ManagedIdentityConfiguration. * @param params */ static createForUserAssignedManagedIdentity( params: CredentialParams & { userAssignedClientId: string } ): EntraidCredentialsProvider { return this.createManagedIdentityProvider(params, params.userAssignedClientId); } static #createForClientCredentials( authConfig: NodeAuthOptions, params: CredentialParams ): EntraidCredentialsProvider { const config: Configuration = { auth: { ...authConfig, authority: this.getAuthority(params.authorityConfig ?? { type: 'default' }) }, system: { loggerOptions } }; const client = new ConfidentialClientApplication(config); const idp = new MSALIdentityProvider( () => client.acquireTokenByClientCredential({ skipCache: true, scopes: params.scopes ?? [REDIS_SCOPE_DEFAULT] }).then(x => x === null ? Promise.reject('Token is null') : x) ); return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp, { onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER, onRetryableError: params.onRetryableError }); } /** * This method is used to create a credentials provider for service principals using certificate. * @param params */ static createForClientCredentialsWithCertificate( params: ClientCredentialsWithCertificateParams ): EntraidCredentialsProvider { return this.#createForClientCredentials( { clientId: params.clientId, clientCertificate: params.certificate }, params ); } /** * This method is used to create a credentials provider for service principals using client secret. * @param params */ static createForClientCredentials( params: ClientSecretCredentialsParams ): EntraidCredentialsProvider { return this.#createForClientCredentials( { clientId: params.clientId, clientSecret: params.clientSecret }, params ); } /** * This method is used to create a credentials provider using DefaultAzureCredential. * * The user needs to create a configured instance of DefaultAzureCredential ( or any other class that implements TokenCredential )and pass it to this method. * * The default credentials mapper for this method is OID_CREDENTIALS_MAPPER which extracts the object ID from JWT * encoded token. * * Depending on the actual flow that DefaultAzureCredential uses, the user may need to provide different * credential mapper via the credentialsMapper parameter. * */ static createForDefaultAzureCredential( { credential, scopes, options, tokenManagerConfig, onReAuthenticationError, credentialsMapper, onRetryableError }: DefaultAzureCredentialsParams ): EntraidCredentialsProvider { const idp = new AzureIdentityProvider( () => credential.getToken(scopes, options).then(x => x === null ? Promise.reject('Token is null') : x) ); return new EntraidCredentialsProvider(new TokenManager(idp, tokenManagerConfig), idp, { onReAuthenticationError: onReAuthenticationError, credentialsMapper: credentialsMapper ?? OID_CREDENTIALS_MAPPER, onRetryableError: onRetryableError }); } /** * This method is used to create a credentials provider for the Authorization Code Flow with PKCE. * @param params */ static createForAuthorizationCodeWithPKCE( params: AuthCodePKCEParams ): { getPKCECodes: () => Promise<{ verifier: string; challenge: string; challengeMethod: string; }>; getAuthCodeUrl: ( pkceCodes: { challenge: string; challengeMethod: string } ) => Promise; createCredentialsProvider: ( params: PKCEParams ) => EntraidCredentialsProvider; } { const requiredScopes = ['user.read', 'offline_access']; const scopes = [...new Set([...(params.scopes || []), ...requiredScopes])]; const authFlow = AuthCodeFlowHelper.create({ clientId: params.clientId, redirectUri: params.redirectUri, scopes: scopes, authorityConfig: params.authorityConfig }); return { getPKCECodes: AuthCodeFlowHelper.generatePKCE, getAuthCodeUrl: (pkceCodes) => authFlow.getAuthCodeUrl(pkceCodes), createCredentialsProvider: (pkceParams) => { // This is used to store the initial credentials account to be used // for silent token acquisition after the initial token acquisition. let initialCredentialsAccount: AccountInfo | null = null; const idp = new MSALIdentityProvider( async () => { if (!initialCredentialsAccount) { let authResult = await authFlow.acquireTokenByCode(pkceParams); initialCredentialsAccount = authResult.account; return authResult; } else { return authFlow.client.acquireTokenSilent({ forceRefresh: true, account: initialCredentialsAccount, scopes }); } } ); const tm = new TokenManager(idp, params.tokenManagerConfig); return new EntraidCredentialsProvider(tm, idp, { onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: params.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER, onRetryableError: params.onRetryableError }); } }; } static getAuthority(config: AuthorityConfig): string { switch (config.type) { case 'multi-tenant': return `https://login.microsoftonline.com/${config.tenantId}`; case 'custom': return config.authorityUrl; case 'default': return 'https://login.microsoftonline.com/common'; default: throw new Error('Invalid authority configuration'); } } } export const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default'; export const REDIS_SCOPE = 'https://redis.azure.com' export type AuthorityConfig = | { type: 'multi-tenant'; tenantId: string } | { type: 'custom'; authorityUrl: string } | { type: 'default' }; export type PKCEParams = { code: string; verifier: string; clientInfo?: string; } export type CredentialParams = { clientId: string; scopes?: string[]; authorityConfig?: AuthorityConfig; tokenManagerConfig: TokenManagerConfig onReAuthenticationError?: (error: ReAuthenticationError) => void credentialsMapper?: (token: AuthenticationResponse) => BasicAuth onRetryableError?: (error: string) => void } export type DefaultAzureCredentialsParams = { scopes: string | string[], options?: GetTokenOptions, credential: TokenCredential tokenManagerConfig: TokenManagerConfig onReAuthenticationError?: (error: ReAuthenticationError) => void credentialsMapper?: (token: AuthenticationResponse) => BasicAuth onRetryableError?: (error: string) => void } export type AuthCodePKCEParams = CredentialParams & { redirectUri: string; }; export type ClientSecretCredentialsParams = CredentialParams & { clientSecret: string; }; export type ClientCredentialsWithCertificateParams = CredentialParams & { certificate: { thumbprint: string; privateKey: string; x5c?: string; }; }; const loggerOptions = { loggerCallback(loglevel: LogLevel, message: string, containsPii: boolean) { if (!containsPii) console.log(message); }, piiLoggingEnabled: false, logLevel: LogLevel.Error } /** * The most important part of the RetryPolicy is the `isRetryable` function. This function is used to determine if a request should be retried based * on the error returned from the identity provider. The default for is to retry on network errors only. */ export const DEFAULT_RETRY_POLICY: RetryPolicy = { // currently only retry on network errors isRetryable: (error: unknown) => error instanceof NetworkError, maxAttempts: 10, initialDelayMs: 100, maxDelayMs: 100000, backoffMultiplier: 2, jitterPercentage: 0.1 }; export const DEFAULT_TOKEN_MANAGER_CONFIG: TokenManagerConfig = { retry: DEFAULT_RETRY_POLICY, expirationRefreshRatio: 0.7 // Refresh token when 70% of the token has expired } /** * This class is used to help with the Authorization Code Flow with PKCE. * It provides methods to generate PKCE codes, get the authorization URL, and create the credential provider. */ export class AuthCodeFlowHelper { private constructor( readonly client: PublicClientApplication, readonly scopes: string[], readonly redirectUri: string ) {} async getAuthCodeUrl(pkceCodes: { challenge: string; challengeMethod: string; }): Promise { const authCodeUrlParameters: AuthorizationUrlRequest = { scopes: this.scopes, redirectUri: this.redirectUri, codeChallenge: pkceCodes.challenge, codeChallengeMethod: pkceCodes.challengeMethod }; return this.client.getAuthCodeUrl(authCodeUrlParameters); } async acquireTokenByCode(params: PKCEParams): Promise { const tokenRequest: AuthorizationCodeRequest = { code: params.code, scopes: this.scopes, redirectUri: this.redirectUri, codeVerifier: params.verifier, clientInfo: params.clientInfo }; return this.client.acquireTokenByCode(tokenRequest); } static async generatePKCE(): Promise<{ verifier: string; challenge: string; challengeMethod: string; }> { const cryptoProvider = new CryptoProvider(); const { verifier, challenge } = await cryptoProvider.generatePkceCodes(); return { verifier, challenge, challengeMethod: 'S256' }; } static create(params: { clientId: string; redirectUri: string; scopes?: string[]; authorityConfig?: AuthorityConfig; }): AuthCodeFlowHelper { const config = { auth: { clientId: params.clientId, authority: EntraIdCredentialsProviderFactory.getAuthority(params.authorityConfig ?? { type: 'default' }) }, system: { loggerOptions } }; return new AuthCodeFlowHelper( new PublicClientApplication(config), params.scopes ?? ['user.read'], params.redirectUri ); } }