You've already forked node-redis
mirror of
https://github.com/redis/node-redis.git
synced 2025-08-06 02:15:48 +03:00
feat(entraid): add support for azure identity (#2901)
This PR adds support for using Azure Identity's credential classes with Redis Enterprise Entra ID authentication. The main changes include: - Add a new factory method createForDefaultAzureCredential to enable using Azure Identity credentials - Add @azure/identity as a dependency to support the new authentication flow - Add support for DefaultAzureCredential, EnvironmentCredential, and any other TokenCredential implementation - Create a new AzureIdentityProvider to support DefaultAzureCredential - Update documentation and README with usage examples for DefaultAzureCredential - Add integration tests for the new authentication methods - Include a sample application demonstrating interactive browser authentication - Export constants for Redis scopes / credential mappers to simplify authentication configuration
This commit is contained in:
22
packages/entraid/lib/azure-identity-provider.ts
Normal file
22
packages/entraid/lib/azure-identity-provider.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AccessToken } from '@azure/core-auth';
|
||||
|
||||
import { IdentityProvider, TokenResponse } from '@redis/client/dist/lib/authx';
|
||||
|
||||
export class AzureIdentityProvider implements IdentityProvider<AccessToken> {
|
||||
private readonly getToken: () => Promise<AccessToken>;
|
||||
|
||||
constructor(getToken: () => Promise<AccessToken>) {
|
||||
this.getToken = getToken;
|
||||
}
|
||||
|
||||
async requestToken(): Promise<TokenResponse<AccessToken>> {
|
||||
const result = await this.getToken();
|
||||
return {
|
||||
token: result,
|
||||
ttlMs: result.expiresOnTimestamp - Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import type { GetTokenOptions, TokenCredential } from '@azure/core-auth';
|
||||
import { NetworkError } from '@azure/msal-common';
|
||||
import {
|
||||
LogLevel,
|
||||
@@ -7,8 +8,9 @@ import {
|
||||
PublicClientApplication,
|
||||
ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo
|
||||
} from '@azure/msal-node';
|
||||
import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } from '@redis/client/dist/lib/authx';
|
||||
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -51,7 +53,11 @@ export class EntraIdCredentialsProviderFactory {
|
||||
return new EntraidCredentialsProvider(
|
||||
new TokenManager(idp, params.tokenManagerConfig),
|
||||
idp,
|
||||
{ onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: OID_CREDENTIALS_MAPPER }
|
||||
{
|
||||
onReAuthenticationError: params.onReAuthenticationError,
|
||||
credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER,
|
||||
onRetryableError: params.onRetryableError
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +108,8 @@ export class EntraIdCredentialsProviderFactory {
|
||||
return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp,
|
||||
{
|
||||
onReAuthenticationError: params.onReAuthenticationError,
|
||||
credentialsMapper: OID_CREDENTIALS_MAPPER
|
||||
credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER,
|
||||
onRetryableError: params.onRetryableError
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +145,42 @@ export class EntraIdCredentialsProviderFactory {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -194,7 +237,11 @@ export class EntraIdCredentialsProviderFactory {
|
||||
}
|
||||
);
|
||||
const tm = new TokenManager(idp, params.tokenManagerConfig);
|
||||
return new EntraidCredentialsProvider(tm, idp, { onReAuthenticationError: params.onReAuthenticationError });
|
||||
return new EntraidCredentialsProvider(tm, idp, {
|
||||
onReAuthenticationError: params.onReAuthenticationError,
|
||||
credentialsMapper: params.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER,
|
||||
onRetryableError: params.onRetryableError
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -214,8 +261,8 @@ export class EntraIdCredentialsProviderFactory {
|
||||
|
||||
}
|
||||
|
||||
const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default';
|
||||
const REDIS_SCOPE = 'https://redis.azure.com'
|
||||
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 }
|
||||
@@ -234,7 +281,19 @@ export type CredentialParams = {
|
||||
authorityConfig?: AuthorityConfig;
|
||||
|
||||
tokenManagerConfig: TokenManagerConfig
|
||||
onReAuthenticationError?: (error: ReAuthenticationError) => void;
|
||||
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 & {
|
||||
@@ -356,16 +415,3 @@ export class AuthCodeFlowHelper {
|
||||
}
|
||||
}
|
||||
|
||||
const OID_CREDENTIALS_MAPPER = (token: AuthenticationResult) => {
|
||||
|
||||
// Client credentials flow is app-only authentication (no user context),
|
||||
// so only access token is provided without user-specific claims (uniqueId, idToken, ...)
|
||||
// this means that we need to extract the oid from the access token manually
|
||||
const accessToken = JSON.parse(Buffer.from(token.accessToken.split('.')[1], 'base64').toString());
|
||||
|
||||
return ({
|
||||
username: accessToken.oid,
|
||||
password: token.accessToken
|
||||
})
|
||||
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { AuthenticationResult } from '@azure/msal-common/node';
|
||||
import { AccessToken } from '@azure/core-auth';
|
||||
import {
|
||||
BasicAuth, StreamingCredentialsProvider, IdentityProvider, TokenManager,
|
||||
ReAuthenticationError, StreamingCredentialsListener, IDPError, Token, Disposable
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
* Please use one of the factory functions in `entraid-credetfactories.ts` to create an instance of this class for the different
|
||||
* type of authentication flows.
|
||||
*/
|
||||
|
||||
export type AuthenticationResponse = AuthenticationResult | AccessToken
|
||||
|
||||
export class EntraidCredentialsProvider implements StreamingCredentialsProvider {
|
||||
readonly type = 'streaming-credentials-provider';
|
||||
|
||||
@@ -24,11 +28,11 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
|
||||
}> = [];
|
||||
|
||||
constructor(
|
||||
public readonly tokenManager: TokenManager<AuthenticationResult>,
|
||||
public readonly idp: IdentityProvider<AuthenticationResult>,
|
||||
public readonly tokenManager: TokenManager<AuthenticationResponse>,
|
||||
public readonly idp: IdentityProvider<AuthenticationResponse>,
|
||||
private readonly options: {
|
||||
onReAuthenticationError?: (error: ReAuthenticationError) => void;
|
||||
credentialsMapper?: (token: AuthenticationResult) => BasicAuth;
|
||||
credentialsMapper?: (token: AuthenticationResponse) => BasicAuth;
|
||||
onRetryableError?: (error: string) => void;
|
||||
} = {}
|
||||
) {
|
||||
@@ -69,7 +73,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
|
||||
|
||||
onReAuthenticationError: (error: ReAuthenticationError) => void;
|
||||
|
||||
#credentialsMapper: (token: AuthenticationResult) => BasicAuth;
|
||||
#credentialsMapper: (token: AuthenticationResponse) => BasicAuth;
|
||||
|
||||
#createTokenManagerListener(subscribers: Set<StreamingCredentialsListener<BasicAuth>>) {
|
||||
return {
|
||||
@@ -80,7 +84,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
|
||||
this.options.onRetryableError?.(error.message);
|
||||
}
|
||||
},
|
||||
onNext: (token: { value: AuthenticationResult }): void => {
|
||||
onNext: (token: { value: AuthenticationResult | AccessToken }): void => {
|
||||
const credentials = this.#credentialsMapper(token.value);
|
||||
subscribers.forEach(listener => listener.onNext(credentials));
|
||||
}
|
||||
@@ -101,10 +105,10 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
|
||||
};
|
||||
}
|
||||
|
||||
async #startTokenManagerAndObtainInitialToken(): Promise<Token<AuthenticationResult>> {
|
||||
const initialResponse = await this.idp.requestToken();
|
||||
const token = this.tokenManager.wrapAndSetCurrentToken(initialResponse.token, initialResponse.ttlMs);
|
||||
async #startTokenManagerAndObtainInitialToken(): Promise<Token<AuthenticationResponse>> {
|
||||
const { ttlMs, token: initialToken } = await this.idp.requestToken();
|
||||
|
||||
const token = this.tokenManager.wrapAndSetCurrentToken(initialToken, ttlMs);
|
||||
this.#tokenManagerDisposable = this.tokenManager.start(
|
||||
this.#createTokenManagerListener(this.#listeners),
|
||||
this.tokenManager.calculateRefreshTime(token)
|
||||
@@ -131,10 +135,61 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
|
||||
|
||||
}
|
||||
|
||||
const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResult): BasicAuth => ({
|
||||
username: token.uniqueId,
|
||||
password: token.accessToken
|
||||
});
|
||||
export const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResponse): BasicAuth => {
|
||||
if (isAuthenticationResult(token)) {
|
||||
return {
|
||||
username: token.uniqueId,
|
||||
password: token.accessToken
|
||||
}
|
||||
} else {
|
||||
return OID_CREDENTIALS_MAPPER(token)
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_ERROR_HANDLER = (error: ReAuthenticationError) =>
|
||||
console.error('ReAuthenticationError', error);
|
||||
console.error('ReAuthenticationError', error);
|
||||
|
||||
export const OID_CREDENTIALS_MAPPER = (token: (AuthenticationResult | AccessToken)) => {
|
||||
|
||||
if (isAuthenticationResult(token)) {
|
||||
// Client credentials flow is app-only authentication (no user context),
|
||||
// so only access token is provided without user-specific claims (uniqueId, idToken, ...)
|
||||
// this means that we need to extract the oid from the access token manually
|
||||
const accessToken = JSON.parse(Buffer.from(token.accessToken.split('.')[1], 'base64').toString());
|
||||
|
||||
return ({
|
||||
username: accessToken.oid,
|
||||
password: token.accessToken
|
||||
})
|
||||
} else {
|
||||
const accessToken = JSON.parse(Buffer.from(token.token.split('.')[1], 'base64').toString());
|
||||
|
||||
return ({
|
||||
username: accessToken.oid,
|
||||
password: token.token
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a token is an MSAL AuthenticationResult
|
||||
*
|
||||
* @param auth - The token to check
|
||||
* @returns true if the token is an AuthenticationResult
|
||||
*/
|
||||
export function isAuthenticationResult(auth: AuthenticationResult | AccessToken): auth is AuthenticationResult {
|
||||
return typeof (auth as AuthenticationResult).accessToken === 'string' &&
|
||||
!('token' in auth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a token is an Azure Identity AccessToken
|
||||
*
|
||||
* @param auth - The token to check
|
||||
* @returns true if the token is an AccessToken
|
||||
*/
|
||||
export function isAccessToken(auth: AuthenticationResult | AccessToken): auth is AccessToken {
|
||||
return typeof (auth as AccessToken).token === 'string' &&
|
||||
!('accessToken' in auth);
|
||||
}
|
@@ -11,21 +11,15 @@ export class MSALIdentityProvider implements IdentityProvider<AuthenticationResu
|
||||
}
|
||||
|
||||
async requestToken(): Promise<TokenResponse<AuthenticationResult>> {
|
||||
try {
|
||||
const result = await this.getToken();
|
||||
const result = await this.getToken();
|
||||
|
||||
if (!result?.accessToken || !result?.expiresOn) {
|
||||
throw new Error('Invalid token response');
|
||||
}
|
||||
return {
|
||||
token: result,
|
||||
ttlMs: result.expiresOn.getTime() - Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
if (!result?.accessToken || !result?.expiresOn) {
|
||||
throw new Error('Invalid token response');
|
||||
}
|
||||
return {
|
||||
token: result,
|
||||
ttlMs: result.expiresOn.getTime() - Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user