From c37d42d0a765211d0d537119708cb3e01c7f592c Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Fri, 10 Jan 2025 11:17:59 +0200 Subject: [PATCH] feat(auth): add EntraId integration tests - Add integration tests for token renewal and re-authentication flows - Update credentials provider to use uniqueId as username instead of account username - Add test utilities for loading Redis endpoint configurations - Split TypeScript configs into separate files for samples and integration tests --- packages/authx/lib/token-manager.spec.ts | 16 +- packages/authx/lib/token-manager.ts | 90 +++++-- packages/client/lib/client/index.ts | 30 ++- packages/entraid/README.md | 125 ++++++++++ .../entraid-integration.spec.ts | 231 ++++++++++++++++++ .../entra-id-credentials-provider-factory.ts | 50 ++-- .../lib/entraid-credentials-provider.spec.ts | 6 +- .../lib/entraid-credentials-provider.ts | 99 ++++---- .../entraid/lib/msal-identity-provider.ts | 1 - packages/entraid/package.json | 3 +- .../entraid/samples/auth-code-pkce/index.ts | 4 - .../entraid/tsconfig.integration-tests.json | 10 + packages/entraid/tsconfig.json | 5 +- packages/entraid/tsconfig.samples.json | 10 + packages/test-utils/lib/cae-client-testing.ts | 30 +++ 15 files changed, 592 insertions(+), 118 deletions(-) create mode 100644 packages/entraid/integration-tests/entraid-integration.spec.ts create mode 100644 packages/entraid/tsconfig.integration-tests.json create mode 100644 packages/entraid/tsconfig.samples.json create mode 100644 packages/test-utils/lib/cae-client-testing.ts diff --git a/packages/authx/lib/token-manager.spec.ts b/packages/authx/lib/token-manager.spec.ts index 832d10f9f3..d0f404ef0b 100644 --- a/packages/authx/lib/token-manager.spec.ts +++ b/packages/authx/lib/token-manager.spec.ts @@ -328,7 +328,7 @@ describe('TokenManager', () => { assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after failure'); assert.equal(listener.errors.length, 1, 'Should receive error'); assert.equal(listener.errors[0].message, 'Fatal error', 'Should have correct error message'); - assert.equal(listener.errors[0].isFatal, true, 'Should be a fatal error'); + assert.equal(listener.errors[0].isRetryable, false, 'Should be a fatal error'); // verify that the token manager is stopped and no more requests are made after the error and expected refresh time await delay(80); @@ -352,7 +352,7 @@ describe('TokenManager', () => { initialDelayMs: 100, maxDelayMs: 1000, backoffMultiplier: 2, - shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' + isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; @@ -389,7 +389,7 @@ describe('TokenManager', () => { // Should have first error but not stop due to retry config assert.equal(listener.errors.length, 1, 'Should have first error'); assert.ok(listener.errors[0].message.includes('attempt 1'), 'Error should indicate first attempt'); - assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); assert.equal(manager.isRunning(), true, 'Should continue running during retries'); // Advance past first retry (delay: 100ms due to backoff) @@ -401,7 +401,7 @@ describe('TokenManager', () => { assert.equal(listener.errors.length, 2, 'Should have second error'); assert.ok(listener.errors[1].message.includes('attempt 2'), 'Error should indicate second attempt'); - assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); assert.equal(manager.isRunning(), true, 'Should continue running during retries'); // Advance past second retry (delay: 200ms due to backoff) @@ -435,7 +435,7 @@ describe('TokenManager', () => { maxDelayMs: 1000, backoffMultiplier: 2, jitterPercentage: 0, - shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' + isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; @@ -470,7 +470,7 @@ describe('TokenManager', () => { // First error assert.equal(listener.errors.length, 1, 'Should have first error'); assert.equal(manager.isRunning(), true, 'Should continue running after first error'); - assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); // Advance past first retry await delay(100); @@ -483,7 +483,7 @@ describe('TokenManager', () => { // Second error assert.equal(listener.errors.length, 2, 'Should have second error'); assert.equal(manager.isRunning(), true, 'Should continue running after second error'); - assert.equal(listener.errors[1].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[1].isRetryable, true, 'Should not be a fatal error'); // Advance past second retry await delay(200); @@ -495,7 +495,7 @@ describe('TokenManager', () => { // Should stop after max retries assert.equal(listener.errors.length, 3, 'Should have final error'); - assert.equal(listener.errors[2].isFatal, true, 'Should not be a fatal error'); + assert.equal(listener.errors[2].isRetryable, false, 'Should be a fatal error'); assert.equal(manager.isRunning(), false, 'Should stop after max retries exceeded'); assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests'); diff --git a/packages/authx/lib/token-manager.ts b/packages/authx/lib/token-manager.ts index fc780f1c4e..a3dcac9154 100644 --- a/packages/authx/lib/token-manager.ts +++ b/packages/authx/lib/token-manager.ts @@ -5,18 +5,70 @@ import { Token } from './token'; * The configuration for retrying token refreshes. */ export interface RetryPolicy { - // The maximum number of attempts to retry token refreshes. + /** + * The maximum number of attempts to retry token refreshes. + */ maxAttempts: number; - // The initial delay in milliseconds before the first retry. + + /** + * The initial delay in milliseconds before the first retry. + */ initialDelayMs: number; - // The maximum delay in milliseconds between retries (the calculated delay will be capped at this value). + + /** + * The maximum delay in milliseconds between retries. + * The calculated delay will be capped at this value. + */ maxDelayMs: number; - // The multiplier for exponential backoff between retries. e.g. 2 will double the delay each time. + + /** + * The multiplier for exponential backoff between retries. + * @example + * A value of 2 will double the delay each time: + * - 1st retry: initialDelayMs + * - 2nd retry: initialDelayMs * 2 + * - 3rd retry: initialDelayMs * 4 + */ backoffMultiplier: number; - // The percentage of jitter to apply to the delay. e.g. 0.1 will add or subtract up to 10% of the delay. + + /** + * The percentage of jitter to apply to the delay. + * @example + * A value of 0.1 will add or subtract up to 10% of the delay. + */ jitterPercentage?: number; - // A custom function to determine if a retry should be attempted based on the error and attempt number. - shouldRetry?: (error: unknown, attempt: number) => boolean; + + /** + * Function to classify errors from the identity provider as retryable or non-retryable. + * Used to determine if a token refresh failure should be retried based on the type of error. + * + * The default behavior is to retry all types of errors if no function is provided. + * + * Common use cases: + * - Network errors that may be transient (should retry) + * - Invalid credentials (should not retry) + * - Rate limiting responses (should retry) + * + * @param error - The error from the identity provider3 + * @param attempt - Current retry attempt (0-based) + * @returns `true` if the error is considered transient and the operation should be retried + * + * @example + * ```typescript + * const retryPolicy: RetryPolicy = { + * maxAttempts: 3, + * initialDelayMs: 1000, + * maxDelayMs: 5000, + * backoffMultiplier: 2, + * isRetryable: (error) => { + * // Retry on network errors or rate limiting + * return error instanceof NetworkError || + * error instanceof RateLimitError; + * } + * }; + * ``` + */ + isRetryable?: (error: unknown, attempt: number) => boolean; } /** @@ -36,14 +88,13 @@ export interface TokenManagerConfig { } /** - * IDPError is an error that occurs while calling the underlying IdentityProvider. + * IDPError indicates a failure from the identity provider. * - * It can be transient and if retry policy is configured, the token manager will attempt to obtain a token again. - * This means that receiving non-fatal error is not a stream termination event. - * The stream will be terminated only if the error is fatal. + * The `isRetryable` flag is determined by the RetryPolicy's error classification function - if an error is + * classified as retryable, it will be marked as transient and the token manager will attempt to recover. */ export class IDPError extends Error { - constructor(public readonly message: string, public readonly isFatal: boolean) { + constructor(public readonly message: string, public readonly isRetryable: boolean) { super(message); this.name = 'IDPError'; } @@ -105,7 +156,6 @@ export class TokenManager { */ public start(listener: TokenStreamListener, initialDelayMs: number = 0): Disposable { if (this.listener) { - console.log('TokenManager is already running, stopping the previous instance'); this.stop(); } @@ -142,14 +192,14 @@ export class TokenManager { private shouldRetry(error: unknown): boolean { if (!this.config.retry) return false; - const { maxAttempts, shouldRetry } = this.config.retry; + const { maxAttempts, isRetryable } = this.config.retry; if (this.retryAttempt >= maxAttempts) { return false; } - if (shouldRetry) { - return shouldRetry(error, this.retryAttempt); + if (isRetryable) { + return isRetryable(error, this.retryAttempt); } return false; @@ -172,10 +222,10 @@ export class TokenManager { if (this.shouldRetry(error)) { this.retryAttempt++; const retryDelay = this.calculateRetryDelay(); - this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, false) + this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true) this.scheduleNextRefresh(retryDelay); } else { - this.notifyError(error, true); + this.notifyError(error, false); this.stop(); } } @@ -255,13 +305,13 @@ export class TokenManager { return this.currentToken; } - private notifyError = (error: unknown, isFatal: boolean): void => { + private notifyError(error: unknown, isRetryable: boolean): void { const errorMessage = error instanceof Error ? error.message : String(error); if (!this.listener) { throw new Error(`TokenManager is not running but received an error: ${errorMessage}`); } - this.listener.onError(new IDPError(errorMessage, isFatal)); + this.listener.onError(new IDPError(errorMessage, isRetryable)); } } \ No newline at end of file diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 76009946e2..30e26e18db 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -303,7 +303,7 @@ export default class RedisClient< #epoch: number; #watchEpoch?: number; - private credentialsSubscription: Disposable | null = null; + #credentialsSubscription: Disposable | null = null; get options(): RedisClientOptions | undefined { return this._self.#options; @@ -394,19 +394,17 @@ export default class RedisClient< } } - private subscribeForStreamingCredentials(cp: StreamingCredentialsProvider): Promise<[BasicAuth, Disposable]> { + #subscribeForStreamingCredentials(cp: StreamingCredentialsProvider): Promise<[BasicAuth, Disposable]> { return cp.subscribe({ onNext: credentials => { this.reAuthenticate(credentials).catch(error => { const errorMessage = error instanceof Error ? error.message : String(error); - console.error('Error during re-authentication', errorMessage); cp.onReAuthenticationError(new CredentialsError(errorMessage)); }); }, onError: (e: Error) => { const errorMessage = `Error from streaming credentials provider: ${e.message}`; - console.error(errorMessage); cp.onReAuthenticationError(new UnableToObtainNewCredentialsError(errorMessage)); } }); @@ -431,8 +429,8 @@ export default class RedisClient< if (cp && cp.type === 'streaming-credentials-provider') { - const [credentials, disposable] = await this.subscribeForStreamingCredentials(cp) - this.credentialsSubscription = disposable; + const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + this.#credentialsSubscription = disposable; if (credentials.password) { hello.AUTH = { @@ -467,8 +465,8 @@ export default class RedisClient< if (cp && cp.type === 'streaming-credentials-provider') { - const [credentials, disposable] = await this.subscribeForStreamingCredentials(cp) - this.credentialsSubscription = disposable; + const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + this.#credentialsSubscription = disposable; if (credentials.username || credentials.password) { commands.push( @@ -1105,8 +1103,8 @@ export default class RedisClient< const chainId = Symbol('Reset Chain'), promises = [this._self.#queue.reset(chainId)], selectedDB = this._self.#options?.database ?? 0; - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.[Symbol.dispose](); + this._self.#credentialsSubscription = null; for (const command of (await this._self.#handshake(selectedDB))) { promises.push( this._self.#queue.addCommand(command, { @@ -1158,8 +1156,8 @@ export default class RedisClient< * @deprecated use .close instead */ QUIT(): Promise { - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.[Symbol.dispose](); + this._self.#credentialsSubscription = null; return this._self.#socket.quit(async () => { clearTimeout(this._self.#pingTimer); const quitPromise = this._self.#queue.addCommand(['QUIT']); @@ -1198,8 +1196,8 @@ export default class RedisClient< resolve(); }; this._self.#socket.on('data', maybeClose); - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.[Symbol.dispose](); + this._self.#credentialsSubscription = null; }); } @@ -1210,8 +1208,8 @@ export default class RedisClient< clearTimeout(this._self.#pingTimer); this._self.#queue.flushAll(new DisconnectsClientError()); this._self.#socket.destroy(); - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.[Symbol.dispose](); + this._self.#credentialsSubscription = null; } ref() { diff --git a/packages/entraid/README.md b/packages/entraid/README.md index e69de29bb2..177f8c8817 100644 --- a/packages/entraid/README.md +++ b/packages/entraid/README.md @@ -0,0 +1,125 @@ +# @redis/entraid + +Token-based authentication provider for Redis clients using Microsoft Entra ID (formerly Azure Active Directory). + +## Features + +- Token-based authentication using Microsoft Entra ID +- Automatic token refresh before expiration +- Automatic re-authentication of all connections after token refresh +- Support for multiple authentication flows: + - Managed identities (system-assigned and user-assigned) + - Service principals (with or without certificates) + - Authorization Code with PKCE flow +- Built-in retry mechanisms for transient failures + +## Installation + +```bash +npm install @redis/entraid +``` + +## Usage + +### Basic Setup with Client Credentials ( Service Principal ) + +```typescript +import { createClient } from '@redis/client'; +import { EntraIdCredentialsProviderFactory } from '@redis/entraid'; + +const provider = EntraIdCredentialsProviderFactory.createForClientCredentials({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', + authorityConfig: { + type: 'multi-tenant', + tenantId: 'your-tenant-id' + }, + tokenManagerConfig: { + expirationRefreshRatio: 0.8 // Refresh token after 80% of its lifetime + } +}); + +const client = createClient({ + url: 'redis://your-host', + credentialsProvider: provider +}); + +await client.connect(); +``` + +## Important Limitations + +### RESP2 PUB/SUB Limitations + +#### ⚠️ When using RESP2 (Redis Serialization Protocol 2), there are important limitations with PUB/SUB: + +- **No Re-Authentication in PUB/SUB Mode**: In RESP2, once a connection enters PUB/SUB mode, the socket is blocked and + cannot process out-of-band commands like AUTH. This means that connections in PUB/SUB mode cannot be re-authenticated + when tokens are refreshed. + +- **Connection Eviction**: As a result, PUB/SUB connections will be evicted by the Redis proxy when their tokens expire. + The client will need to establish new connections with fresh tokens. + + +### Transaction Safety + +#### ⚠️ Important Note About Transactions + +When using token-based authentication, special care must be taken with Redis transactions. +The token manager runs in the background and may attempt to re-authenticate connections at any time by sending AUTH commands. +This can interfere with manually constructed transactions. + +##### ✅ Recommended: Use the Official Transaction API + +Always use the official transaction API provided by the client: + +```typescript +// Correct way to handle transactions +const multi = client.multi(); +multi.set('key1', 'value1'); +multi.set('key2', 'value2'); +await multi.exec(); +``` + +##### ❌ Avoid: Manual Transaction Construction + +Do not manually construct transactions by sending individual MULTI/EXEC commands: + +```typescript +// Incorrect and potentially dangerous +await client.sendCommand(['MULTI']); +await client.sendCommand(['SET', 'key1', 'value1']); +await client.sendCommand(['SET', 'key2', 'value2']); +await client.sendCommand(['EXEC']); // Risk of AUTH command being injected before EXEC +``` + +The official transaction API ensures proper coordination between transaction commands and authentication updates. +It prevents the token manager from injecting AUTH commands in the middle of your transaction, which is particularly +important in applications where transaction atomicity is critical, such as payment processing or inventory management. + + +## Error Handling + +The provider includes built-in retry mechanisms for transient errors: + +```typescript +const provider = EntraIdCredentialsProviderFactory.createForClientCredentials({ + // ... other config ... + tokenManagerConfig: { + retry: { + maxAttempts: 3, + initialDelayMs: 100, + maxDelayMs: 1000, + backoffMultiplier: 2 + } + } +}); +``` + +### Other Considerations + +- Token refresh operations are asynchronous and may occur in the background +- During token refresh, there is a critical window where all connections must be re-authenticated before the old token + expires + + diff --git a/packages/entraid/integration-tests/entraid-integration.spec.ts b/packages/entraid/integration-tests/entraid-integration.spec.ts new file mode 100644 index 0000000000..74ff30a296 --- /dev/null +++ b/packages/entraid/integration-tests/entraid-integration.spec.ts @@ -0,0 +1,231 @@ +import { BasicAuth } from '@redis/authx'; +import { createClient } from '@redis/client'; +import { EntraIdCredentialsProviderFactory } from '../lib/entra-id-credentials-provider-factory'; +import { strict as assert } from 'node:assert'; +import { spy, SinonSpy } from 'sinon'; +import { randomUUID } from 'crypto'; +import { loadFromFile, RedisEndpointsConfig } from '@redis/test-utils/lib/cae-client-testing'; +import { EntraidCredentialsProvider } from '../lib/entraid-credentials-provider'; +import * as crypto from 'node:crypto'; + +describe('EntraID Integration Tests', () => { + + it('client configured with client secret should be able to authenticate/re-authenticate', async () => { + const config = await readConfigFromEnv(); + await runAuthenticationTest(() => + EntraIdCredentialsProviderFactory.createForClientCredentials({ + clientId: config.clientId, + clientSecret: config.clientSecret, + authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId }, + tokenManagerConfig: { + expirationRefreshRatio: 0.0001 + } + }) + ); + }); + + it('client configured with client certificate should be able to authenticate/re-authenticate', async () => { + const config = await readConfigFromEnv(); + await runAuthenticationTest(() => + EntraIdCredentialsProviderFactory.createForClientCredentialsWithCertificate({ + clientId: config.clientId, + certificate: convertCertsForMSAL(config.cert, config.privateKey), + authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId }, + tokenManagerConfig: { + expirationRefreshRatio: 0.0001 + } + }) + ); + }); + + it('client with system managed identity should be able to authenticate/re-authenticate', async () => { + const config = await readConfigFromEnv(); + await runAuthenticationTest(() => + EntraIdCredentialsProviderFactory.createForSystemAssignedManagedIdentity({ + clientId: config.clientId, + authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId }, + tokenManagerConfig: { + expirationRefreshRatio: 0.00001 + } + }) + ); + }); + + it('client with user managed system identity should be able to authenticate/re-authenticate', async () => { + const config = await readConfigFromEnv(); + await runAuthenticationTest(() => + EntraIdCredentialsProviderFactory.createForUserAssignedManagedIdentity({ + clientId: config.clientId, + userAssignedClientId: config.userAssignedManagedId, + authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId }, + tokenManagerConfig: { + expirationRefreshRatio: 0.0001 + } + }) + ); + }); + + interface TestConfig { + clientId: string; + clientSecret: string; + authority: string; + tenantId: string; + redisScopes: string; + cert: string; + privateKey: string; + userAssignedManagedId: string; + endpoints: RedisEndpointsConfig; + } + + const readConfigFromEnv = async (): Promise => { + const requiredEnvVars = { + AZURE_CLIENT_ID: process.env.AZURE_CLIENT_ID, + AZURE_CLIENT_SECRET: process.env.AZURE_CLIENT_SECRET, + AZURE_AUTHORITY: process.env.AZURE_AUTHORITY, + AZURE_TENANT_ID: process.env.AZURE_TENANT_ID, + AZURE_REDIS_SCOPES: process.env.AZURE_REDIS_SCOPES, + AZURE_CERT: process.env.AZURE_CERT, + AZURE_PRIVATE_KEY: process.env.AZURE_PRIVATE_KEY, + AZURE_USER_ASSIGNED_MANAGED_ID: process.env.AZURE_USER_ASSIGNED_MANAGED_ID, + REDIS_ENDPOINTS_CONFIG_PATH: process.env.REDIS_ENDPOINTS_CONFIG_PATH + }; + + Object.entries(requiredEnvVars).forEach(([key, value]) => { + if (value == undefined) { + throw new Error(`${key} environment variable must be set`); + } + }); + + return { + endpoints: await loadFromFile(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH), + clientId: requiredEnvVars.AZURE_CLIENT_ID, + clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET, + authority: requiredEnvVars.AZURE_AUTHORITY, + tenantId: requiredEnvVars.AZURE_TENANT_ID, + redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES, + cert: requiredEnvVars.AZURE_CERT, + privateKey: requiredEnvVars.AZURE_PRIVATE_KEY, + userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID + }; + }; + + interface TokenDetail { + token: string; + exp: number; + iat: number; + lifetime: number; + uti: string; + } + + const setupTestClient = async (credentialsProvider: EntraidCredentialsProvider) => { + const config = await readConfigFromEnv(); + const client = createClient({ + url: config.endpoints['standalone-entraid-acl'].endpoints[0], + credentialsProvider + }); + + const clientInstance = (client as any)._self; + const reAuthSpy: SinonSpy = spy(clientInstance, 'reAuthenticate'); + + return { client, reAuthSpy }; + }; + + const runClientOperations = async (client: any) => { + const startTime = Date.now(); + while (Date.now() - startTime < 1000) { + const key = randomUUID(); + await client.set(key, 'value'); + const value = await client.get(key); + assert.equal(value, 'value'); + await client.del(key); + } + }; + + const validateTokens = (reAuthSpy: SinonSpy) => { + assert(reAuthSpy.callCount >= 1, + `reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`); + + const tokenDetails: TokenDetail[] = reAuthSpy.getCalls().map(call => { + const creds = call.args[0] as BasicAuth; + const tokenPayload = JSON.parse( + Buffer.from(creds.password.split('.')[1], 'base64').toString() + ); + + return { + token: creds.password, + exp: tokenPayload.exp, + iat: tokenPayload.iat, + lifetime: tokenPayload.exp - tokenPayload.iat, + uti: tokenPayload.uti + }; + }); + + // Verify unique tokens + const uniqueTokens = new Set(tokenDetails.map(detail => detail.token)); + assert.equal( + uniqueTokens.size, + reAuthSpy.callCount, + `Expected ${reAuthSpy.callCount} different tokens, but got ${uniqueTokens.size} unique tokens` + ); + + // Verify all tokens are not cached (i.e. have the same lifetime) + const uniqueLifetimes = new Set(tokenDetails.map(detail => detail.lifetime)); + assert.equal( + uniqueLifetimes.size, + 1, + `Expected all tokens to have the same lifetime, but found ${uniqueLifetimes.size} different lifetimes: ${[uniqueLifetimes].join(', ')} seconds` + ); + + // Verify that all tokens have different uti (unique token identifier) + const uniqueUti = new Set(tokenDetails.map(detail => detail.uti)); + assert.equal( + uniqueUti.size, + reAuthSpy.callCount, + `Expected all tokens to have different uti, but found ${uniqueUti.size} different uti in: ${[uniqueUti].join(', ')}` + ); + }; + + const runAuthenticationTest = async (setupCredentialsProvider: () => any) => { + const { client, reAuthSpy } = await setupTestClient(setupCredentialsProvider()); + + try { + await client.connect(); + await runClientOperations(client); + validateTokens(reAuthSpy); + } finally { + await client.destroy(); + } + }; + +}); + +function getCertificate(certBase64) { + try { + const decodedCert = Buffer.from(certBase64, 'base64'); + const cert = new crypto.X509Certificate(decodedCert); + return cert; + } catch (error) { + console.error('Error parsing certificate:', error); + throw error; + } +} + +function getCertificateThumbprint(certBase64) { + const cert = getCertificate(certBase64); + return cert.fingerprint.replace(/:/g, ''); +} + +function convertCertsForMSAL(certBase64, privateKeyBase64) { + const thumbprint = getCertificateThumbprint(certBase64); + + const privateKeyPEM = `-----BEGIN PRIVATE KEY-----\n${privateKeyBase64}\n-----END PRIVATE KEY-----`; + + return { + thumbprint: thumbprint, + privateKey: privateKeyPEM, + x5c: certBase64 + } + +} + + diff --git a/packages/entraid/lib/entra-id-credentials-provider-factory.ts b/packages/entraid/lib/entra-id-credentials-provider-factory.ts index 1eb32c2195..e155191b54 100644 --- a/packages/entraid/lib/entra-id-credentials-provider-factory.ts +++ b/packages/entraid/lib/entra-id-credentials-provider-factory.ts @@ -11,13 +11,12 @@ import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } import { EntraidCredentialsProvider } 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 @@ -44,7 +43,7 @@ export class EntraIdCredentialsProviderFactory { const idp = new MSALIdentityProvider( () => client.acquireToken({ - resource: params.scopes?.[0] ?? FALLBACK_SCOPE, + resource: params.scopes?.[0] ?? REDIS_SCOPE, forceRefresh: true }).then(x => x === null ? Promise.reject('Token is null') : x) ); @@ -52,7 +51,7 @@ export class EntraIdCredentialsProviderFactory { return new EntraidCredentialsProvider( new TokenManager(idp, params.tokenManagerConfig), idp, - { onReAuthenticationError: params.onReAuthenticationError } + { onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: OID_CREDENTIALS_MAPPER } ); } @@ -72,12 +71,12 @@ export class EntraIdCredentialsProviderFactory { * @param params */ static createForUserAssignedManagedIdentity( - params: CredentialParams + params: CredentialParams & { userAssignedClientId: string } ): EntraidCredentialsProvider { - return this.createManagedIdentityProvider(params, params.clientId); + return this.createManagedIdentityProvider(params, params.userAssignedClientId); } - private static _createForClientCredentials( + static #createForClientCredentials( authConfig: NodeAuthOptions, params: CredentialParams ): EntraidCredentialsProvider { @@ -96,12 +95,15 @@ export class EntraIdCredentialsProviderFactory { const idp = new MSALIdentityProvider( () => client.acquireTokenByClientCredential({ skipCache: true, - scopes: params.scopes ?? [FALLBACK_SCOPE] + 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 }); + { + onReAuthenticationError: params.onReAuthenticationError, + credentialsMapper: OID_CREDENTIALS_MAPPER + }); } /** @@ -111,7 +113,7 @@ export class EntraIdCredentialsProviderFactory { static createForClientCredentialsWithCertificate( params: ClientCredentialsWithCertificateParams ): EntraidCredentialsProvider { - return this._createForClientCredentials( + return this.#createForClientCredentials( { clientId: params.clientId, clientCertificate: params.certificate @@ -127,7 +129,7 @@ export class EntraIdCredentialsProviderFactory { static createForClientCredentials( params: ClientSecretCredentialsParams ): EntraidCredentialsProvider { - return this._createForClientCredentials( + return this.#createForClientCredentials( { clientId: params.clientId, clientSecret: params.clientSecret @@ -210,11 +212,10 @@ export class EntraIdCredentialsProviderFactory { } } - } - -const FALLBACK_SCOPE = 'https://redis.azure.com/.default'; +const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default'; +const REDIS_SCOPE = 'https://redis.azure.com' export type AuthorityConfig = | { type: 'multi-tenant'; tenantId: string } @@ -257,16 +258,16 @@ const loggerOptions = { if (!containsPii) console.log(message); }, piiLoggingEnabled: false, - logLevel: LogLevel.Verbose + logLevel: LogLevel.Error } /** - * The most imporant part of the RetryPolicy is the shouldRetry function. This function is used to determine if a request should be retried based - * on the error returned from the identity provider. The defaultRetryPolicy is used to retry on network errors only. + * 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 - shouldRetry: (error: unknown) => error instanceof NetworkError, + isRetryable: (error: unknown) => error instanceof NetworkError, maxAttempts: 10, initialDelayMs: 100, maxDelayMs: 100000, @@ -355,3 +356,16 @@ 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 + }) + +} diff --git a/packages/entraid/lib/entraid-credentials-provider.spec.ts b/packages/entraid/lib/entraid-credentials-provider.spec.ts index 8967bd1883..a5df1d386c 100644 --- a/packages/entraid/lib/entraid-credentials-provider.spec.ts +++ b/packages/entraid/lib/entraid-credentials-provider.spec.ts @@ -134,15 +134,15 @@ describe('EntraID CredentialsProvider Subscription Behavior', () => { private readonly tokenSequence: AuthenticationResult[] = [ { accessToken: 'initial-token', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult, { accessToken: 'refresh-token-1', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult, { accessToken: 'refresh-token-2', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult ] ) {} diff --git a/packages/entraid/lib/entraid-credentials-provider.ts b/packages/entraid/lib/entraid-credentials-provider.ts index 662954e25f..e80cd07f2c 100644 --- a/packages/entraid/lib/entraid-credentials-provider.ts +++ b/packages/entraid/lib/entraid-credentials-provider.ts @@ -12,32 +12,28 @@ import { export class EntraidCredentialsProvider implements StreamingCredentialsProvider { readonly type = 'streaming-credentials-provider'; - private readonly listeners: Set> = new Set(); + readonly #listeners: Set> = new Set(); - private tokenManagerDisposable: Disposable | null = null; - private isStarting: boolean = false; + #tokenManagerDisposable: Disposable | null = null; + #isStarting: boolean = false; - private pendingSubscribers: Array<{ + #pendingSubscribers: Array<{ resolve: (value: [BasicAuth, Disposable]) => void; reject: (error: Error) => void; pendingListener: StreamingCredentialsListener; }> = []; constructor( - private readonly tokenManager: TokenManager, - private readonly idp: IdentityProvider, - options: { - onReAuthenticationError?: (error: ReAuthenticationError) => void - credentialsMapper?: (token: AuthenticationResult) => BasicAuth + public readonly tokenManager: TokenManager, + public readonly idp: IdentityProvider, + private readonly options: { + onReAuthenticationError?: (error: ReAuthenticationError) => void; + credentialsMapper?: (token: AuthenticationResult) => BasicAuth; + onRetryableError?: (error: string) => void; } = {} ) { - this.onReAuthenticationError = options.onReAuthenticationError ?? - ((error) => console.error('ReAuthenticationError', error)); - this.credentialsMapper = options.credentialsMapper ?? ((token) => ({ - username: token.account?.username ?? undefined, - password: token.accessToken - })); - + this.onReAuthenticationError = options.onReAuthenticationError ?? DEFAULT_ERROR_HANDLER; + this.#credentialsMapper = options.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER; } async subscribe( @@ -47,81 +43,98 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider const currentToken = this.tokenManager.getCurrentToken(); if (currentToken) { - return [this.credentialsMapper(currentToken.value), this.createDisposable(listener)]; + return [this.#credentialsMapper(currentToken.value), this.#createDisposable(listener)]; } - if (this.isStarting) { + if (this.#isStarting) { return new Promise((resolve, reject) => { - this.pendingSubscribers.push({ resolve, reject, pendingListener: listener }); + this.#pendingSubscribers.push({ resolve, reject, pendingListener: listener }); }); } - this.isStarting = true; + this.#isStarting = true; try { - const initialToken = await this.startTokenManagerAndObtainInitialToken(); + const initialToken = await this.#startTokenManagerAndObtainInitialToken(); - this.pendingSubscribers.forEach(({ resolve, pendingListener }) => { - resolve([this.credentialsMapper(initialToken.value), this.createDisposable(pendingListener)]); + this.#pendingSubscribers.forEach(({ resolve, pendingListener }) => { + resolve([this.#credentialsMapper(initialToken.value), this.#createDisposable(pendingListener)]); }); - this.pendingSubscribers = []; + this.#pendingSubscribers = []; - return [this.credentialsMapper(initialToken.value), this.createDisposable(listener)]; + return [this.#credentialsMapper(initialToken.value), this.#createDisposable(listener)]; } finally { - this.isStarting = false; + this.#isStarting = false; } } onReAuthenticationError: (error: ReAuthenticationError) => void; - private credentialsMapper: (token: AuthenticationResult) => BasicAuth ; + #credentialsMapper: (token: AuthenticationResult) => BasicAuth; - private createTokenManagerListener(subscribers: Set>) { + #createTokenManagerListener(subscribers: Set>) { return { onError: (error: IDPError): void => { - if (error.isFatal) { + if (!error.isRetryable) { subscribers.forEach(listener => listener.onError(error)); } else { - console.log('Transient identity provider error', error); + this.options.onRetryableError?.(error.message); } }, onNext: (token: { value: AuthenticationResult }): void => { - const credentials = this.credentialsMapper(token.value); + const credentials = this.#credentialsMapper(token.value); subscribers.forEach(listener => listener.onNext(credentials)); } }; } - private createDisposable(listener: StreamingCredentialsListener): Disposable { - this.listeners.add(listener); + #createDisposable(listener: StreamingCredentialsListener): Disposable { + this.#listeners.add(listener); return { [Symbol.dispose]: () => { - this.listeners.delete(listener); - if (this.listeners.size === 0 && this.tokenManagerDisposable) { - this.tokenManagerDisposable[Symbol.dispose](); - this.tokenManagerDisposable = null; + this.#listeners.delete(listener); + if (this.#listeners.size === 0 && this.#tokenManagerDisposable) { + this.#tokenManagerDisposable[Symbol.dispose](); + this.#tokenManagerDisposable = null; } } }; } - private async startTokenManagerAndObtainInitialToken(): Promise> { + async #startTokenManagerAndObtainInitialToken(): Promise> { const initialResponse = await this.idp.requestToken(); const token = this.tokenManager.wrapAndSetCurrentToken(initialResponse.token, initialResponse.ttlMs); - this.tokenManagerDisposable = this.tokenManager.start( - this.createTokenManagerListener(this.listeners), + this.#tokenManagerDisposable = this.tokenManager.start( + this.#createTokenManagerListener(this.#listeners), this.tokenManager.calculateRefreshTime(token) ); return token; } public hasActiveSubscriptions(): boolean { - return this.tokenManagerDisposable !== null && this.listeners.size > 0; + return this.#tokenManagerDisposable !== null && this.#listeners.size > 0; } public getSubscriptionsCount(): number { - return this.listeners.size; + return this.#listeners.size; + } + + public getTokenManager() { + return this.tokenManager; + } + + public getCurrentCredentials(): BasicAuth | null { + const currentToken = this.tokenManager.getCurrentToken(); + return currentToken ? this.#credentialsMapper(currentToken.value) : null; } -} \ No newline at end of file +} + +const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResult): BasicAuth => ({ + username: token.uniqueId, + password: token.accessToken +}); + +const DEFAULT_ERROR_HANDLER = (error: ReAuthenticationError) => + console.error('ReAuthenticationError', error); \ No newline at end of file diff --git a/packages/entraid/lib/msal-identity-provider.ts b/packages/entraid/lib/msal-identity-provider.ts index 1b44549fe3..f959225e17 100644 --- a/packages/entraid/lib/msal-identity-provider.ts +++ b/packages/entraid/lib/msal-identity-provider.ts @@ -22,7 +22,6 @@ export class MSALIdentityProvider implements IdentityProvider { app.get('/redirect', async (req: AuthRequest, res: Response) => { try { - // Debug log to see exactly what we're receiving - console.log('Full request query:', req.query); - console.log('Code from request:', req.query.code); - console.log('Session state:', req.session); // The authorization code is in req.query.code const { code, client_info } = req.query; diff --git a/packages/entraid/tsconfig.integration-tests.json b/packages/entraid/tsconfig.integration-tests.json new file mode 100644 index 0000000000..5d15f4f275 --- /dev/null +++ b/packages/entraid/tsconfig.integration-tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./integration-tests/**/*.ts", + "./lib/**/*.ts" + ], + "compilerOptions": { + "noEmit": true + }, +} \ No newline at end of file diff --git a/packages/entraid/tsconfig.json b/packages/entraid/tsconfig.json index 3efd3ae061..414dc1fe75 100644 --- a/packages/entraid/tsconfig.json +++ b/packages/entraid/tsconfig.json @@ -4,17 +4,14 @@ "outDir": "./dist" }, "include": [ - "./samples/**/*.ts", "./lib/**/*.ts" ], "exclude": [ - "./lib/test-utils.ts", "./lib/**/*.spec.ts", - "./lib/sentinel/test-util.ts" + "./lib/test-util.ts", ], "typedocOptions": { "entryPoints": [ - "./index.ts", "./lib" ], "entryPointStrategy": "expand", diff --git a/packages/entraid/tsconfig.samples.json b/packages/entraid/tsconfig.samples.json new file mode 100644 index 0000000000..0eb936369f --- /dev/null +++ b/packages/entraid/tsconfig.samples.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./samples/**/*.ts", + "./lib/**/*.ts" + ], + "compilerOptions": { + "noEmit": true + } +} \ No newline at end of file diff --git a/packages/test-utils/lib/cae-client-testing.ts b/packages/test-utils/lib/cae-client-testing.ts new file mode 100644 index 0000000000..92b846dd37 --- /dev/null +++ b/packages/test-utils/lib/cae-client-testing.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; + +interface RawRedisEndpoint { + username?: string; + password?: string; + tls: boolean; + endpoints: string[]; +} + +export type RedisEndpointsConfig = Record; + +export function loadFromJson(jsonString: string): RedisEndpointsConfig { + try { + return JSON.parse(jsonString) as RedisEndpointsConfig; + } catch (error) { + throw new Error(`Invalid JSON configuration: ${error}`); + } +} + +export async function loadFromFile(path: string): Promise { + try { + const configFile = await readFile(path, 'utf-8'); + return loadFromJson(configFile); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new Error(`Config file not found at path: ${path}`); + } + throw error; + } +} \ No newline at end of file