Skip to content

Commit

Permalink
feat(auth): add EntraId integration tests
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
bobymicroby committed Jan 16, 2025
1 parent ac972bd commit 2d82e6b
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 12 deletions.
134 changes: 134 additions & 0 deletions packages/entraid/integration-tests/entraid-integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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, <any>'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();
}
});
});
17 changes: 16 additions & 1 deletion packages/entraid/lib/entra-id-credentials-provider-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

}
});
}

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/entraid/lib/entraid-credentials-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
) {}
Expand Down
10 changes: 7 additions & 3 deletions packages/entraid/lib/entraid-credentials-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
}> = [];

constructor(
private readonly tokenManager: TokenManager<AuthenticationResult>,
private readonly idp: IdentityProvider<AuthenticationResult>,
public readonly tokenManager: TokenManager<AuthenticationResult>,
public readonly idp: IdentityProvider<AuthenticationResult>,
options: {
onReAuthenticationError?: (error: ReAuthenticationError) => void
credentialsMapper?: (token: AuthenticationResult) => BasicAuth
Expand All @@ -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
}));

Expand Down Expand Up @@ -124,4 +124,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
return this.listeners.size;
}

public getTokenManager() {
return this.tokenManager;
}

}
3 changes: 2 additions & 1 deletion packages/entraid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions packages/entraid/tsconfig.integration-tests.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"include": [
"./integration-tests/**/*.ts",
"./lib/**/*.ts"
],
"compilerOptions": {
"noEmit": true
},
}
5 changes: 1 addition & 4 deletions packages/entraid/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/entraid/tsconfig.samples.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"include": [
"./samples/**/*.ts",
"./lib/**/*.ts"
],
"compilerOptions": {
"noEmit": true
}
}
16 changes: 16 additions & 0 deletions packages/test-utils/lib/cae-client-testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface RawRedisEndpoint {
username?: string;
password?: string;
tls: boolean;
endpoints: string[];
}

export type RedisEndpointsConfig = Record<string, RawRedisEndpoint>;

export function loadFromJson(jsonString: string): RedisEndpointsConfig {
try {
return JSON.parse(jsonString) as RedisEndpointsConfig;
} catch (error) {
throw new Error(`Invalid JSON configuration: ${error}`);
}
}

0 comments on commit 2d82e6b

Please sign in to comment.