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..4cc6fd5a36 --- /dev/null +++ b/packages/entraid/integration-tests/entraid-integration.spec.ts @@ -0,0 +1,134 @@ +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 { loadFromJson, RedisEndpointsConfig } from '@redis/test-utils/lib/cae-client-testing' + +describe('EntraID Integration Tests', () => { + + interface TestConfig { + clientId: string; + clientSecret: string; + authority: string; + tenantId: string; + redisScopes: string; + cert: string; + privateKey: string; + userAssignedManagedId: string + endpoints: RedisEndpointsConfig + } + + const readConfigFromEnv = (): TestConfig => { + 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]) => { + console.log(`key: ${key}, value: ${value}`); + if (value == undefined) { + throw new Error(`${key} environment variable must be set`); + } + }); + + return { + endpoints: loadFromJson(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 + }; + }; + + it('client configured with with a client secret should be able to authenticate/re-authenticate', async () => { + + const { clientId, clientSecret, tenantId, endpoints } = readConfigFromEnv(); + + const entraidCredentialsProvider = EntraIdCredentialsProviderFactory.createForClientCredentials({ + clientId: clientId, + clientSecret: clientSecret, + authorityConfig: { type: 'multi-tenant', tenantId: tenantId }, + tokenManagerConfig: { + expirationRefreshRatio: 0.0001 + } + }); + + const client = createClient({ + url: endpoints['standalone-entraid-acl'].endpoints[0], + credentialsProvider: entraidCredentialsProvider + }); + + const clientInstance = (client as any)._self; + const reAuthSpy: SinonSpy = spy(clientInstance, 'reAuthenticate'); + + try { + await client.connect(); + + 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); + } + + assert(reAuthSpy.callCount >= 1, `reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`); + + const tokenDetails = 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(', ')}` + ); + + } finally { + await client.destroy(); + } + }); +}); \ No newline at end of file diff --git a/packages/entraid/lib/entra-id-credentials-provider-factory.ts b/packages/entraid/lib/entra-id-credentials-provider-factory.ts index 1eb32c2195..40a587d945 100644 --- a/packages/entraid/lib/entra-id-credentials-provider-factory.ts +++ b/packages/entraid/lib/entra-id-credentials-provider-factory.ts @@ -101,7 +101,22 @@ export class EntraIdCredentialsProviderFactory { ); return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp, - { onReAuthenticationError: params.onReAuthenticationError }); + { + onReAuthenticationError: params.onReAuthenticationError, + credentialsMapper: (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 + }) + + } + }); } /** 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..d4fa1cc604 100644 --- a/packages/entraid/lib/entraid-credentials-provider.ts +++ b/packages/entraid/lib/entraid-credentials-provider.ts @@ -24,8 +24,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider }> = []; constructor( - private readonly tokenManager: TokenManager, - private readonly idp: IdentityProvider, + public readonly tokenManager: TokenManager, + public readonly idp: IdentityProvider, options: { onReAuthenticationError?: (error: ReAuthenticationError) => void credentialsMapper?: (token: AuthenticationResult) => BasicAuth @@ -34,7 +34,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider this.onReAuthenticationError = options.onReAuthenticationError ?? ((error) => console.error('ReAuthenticationError', error)); this.credentialsMapper = options.credentialsMapper ?? ((token) => ({ - username: token.account?.username ?? undefined, + username: token.uniqueId, password: token.accessToken })); @@ -124,4 +124,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider return this.listeners.size; } + public getTokenManager() { + return this.tokenManager; + } + } \ No newline at end of file diff --git a/packages/entraid/package.json b/packages/entraid/package.json index f57ad09f46..9bb8aec189 100644 --- a/packages/entraid/package.json +++ b/packages/entraid/package.json @@ -11,7 +11,8 @@ "scripts": { "clean": "rimraf dist", "build": "npm run clean && tsc", - "start:auth-pkce": "npm run build && node dist/samples/auth-code-pkce/index.js", + "start:auth-pkce": "tsx --tsconfig tsconfig.samples.json ./samples/auth-code-pkce/index.ts", + "test-integration": "mocha -r tsx --tsconfig tsconfig.integration-tests.json './integration-tests/**/*.spec.ts'", "test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'" }, "dependencies": { 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..e229bbee28 --- /dev/null +++ b/packages/test-utils/lib/cae-client-testing.ts @@ -0,0 +1,16 @@ +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}`); + } +} \ No newline at end of file