Skip to content

Commit 2d82e6b

Browse files
committed
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
1 parent ac972bd commit 2d82e6b

9 files changed

+199
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { BasicAuth } from '@redis/authx';
2+
import { createClient } from '@redis/client';
3+
import { EntraIdCredentialsProviderFactory } from '../lib/entra-id-credentials-provider-factory';
4+
import { strict as assert } from 'node:assert';
5+
import { spy, SinonSpy } from 'sinon';
6+
import { randomUUID } from 'crypto';
7+
import { loadFromJson, RedisEndpointsConfig } from '@redis/test-utils/lib/cae-client-testing'
8+
9+
describe('EntraID Integration Tests', () => {
10+
11+
interface TestConfig {
12+
clientId: string;
13+
clientSecret: string;
14+
authority: string;
15+
tenantId: string;
16+
redisScopes: string;
17+
cert: string;
18+
privateKey: string;
19+
userAssignedManagedId: string
20+
endpoints: RedisEndpointsConfig
21+
}
22+
23+
const readConfigFromEnv = (): TestConfig => {
24+
const requiredEnvVars = {
25+
AZURE_CLIENT_ID: process.env.AZURE_CLIENT_ID,
26+
AZURE_CLIENT_SECRET: process.env.AZURE_CLIENT_SECRET,
27+
AZURE_AUTHORITY: process.env.AZURE_AUTHORITY,
28+
AZURE_TENANT_ID: process.env.AZURE_TENANT_ID,
29+
AZURE_REDIS_SCOPES: process.env.AZURE_REDIS_SCOPES,
30+
AZURE_CERT: process.env.AZURE_CERT,
31+
AZURE_PRIVATE_KEY: process.env.AZURE_PRIVATE_KEY,
32+
AZURE_USER_ASSIGNED_MANAGED_ID: process.env.AZURE_USER_ASSIGNED_MANAGED_ID,
33+
REDIS_ENDPOINTS_CONFIG_PATH: process.env.REDIS_ENDPOINTS_CONFIG_PATH
34+
};
35+
36+
Object.entries(requiredEnvVars).forEach(([key, value]) => {
37+
console.log(`key: ${key}, value: ${value}`);
38+
if (value == undefined) {
39+
throw new Error(`${key} environment variable must be set`);
40+
}
41+
});
42+
43+
return {
44+
endpoints: loadFromJson(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH),
45+
clientId: requiredEnvVars.AZURE_CLIENT_ID,
46+
clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET,
47+
authority: requiredEnvVars.AZURE_AUTHORITY,
48+
tenantId: requiredEnvVars.AZURE_TENANT_ID,
49+
redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES,
50+
cert: requiredEnvVars.AZURE_CERT,
51+
privateKey: requiredEnvVars.AZURE_PRIVATE_KEY,
52+
userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID
53+
};
54+
};
55+
56+
it('client configured with with a client secret should be able to authenticate/re-authenticate', async () => {
57+
58+
const { clientId, clientSecret, tenantId, endpoints } = readConfigFromEnv();
59+
60+
const entraidCredentialsProvider = EntraIdCredentialsProviderFactory.createForClientCredentials({
61+
clientId: clientId,
62+
clientSecret: clientSecret,
63+
authorityConfig: { type: 'multi-tenant', tenantId: tenantId },
64+
tokenManagerConfig: {
65+
expirationRefreshRatio: 0.0001
66+
}
67+
});
68+
69+
const client = createClient({
70+
url: endpoints['standalone-entraid-acl'].endpoints[0],
71+
credentialsProvider: entraidCredentialsProvider
72+
});
73+
74+
const clientInstance = (client as any)._self;
75+
const reAuthSpy: SinonSpy = spy(clientInstance, <any>'reAuthenticate');
76+
77+
try {
78+
await client.connect();
79+
80+
const startTime = Date.now();
81+
while (Date.now() - startTime < 1000) {
82+
const key = randomUUID();
83+
await client.set(key, 'value');
84+
const value = await client.get(key);
85+
assert.equal(value, 'value');
86+
await client.del(key);
87+
}
88+
89+
assert(reAuthSpy.callCount >= 1, `reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`);
90+
91+
const tokenDetails = reAuthSpy.getCalls().map(call => {
92+
const creds = call.args[0] as BasicAuth;
93+
const tokenPayload = JSON.parse(
94+
Buffer.from(creds.password.split('.')[1], 'base64').toString()
95+
);
96+
97+
return {
98+
token: creds.password,
99+
exp: tokenPayload.exp,
100+
iat: tokenPayload.iat,
101+
lifetime: tokenPayload.exp - tokenPayload.iat,
102+
uti: tokenPayload.uti
103+
};
104+
});
105+
106+
// Verify unique tokens
107+
const uniqueTokens = new Set(tokenDetails.map(detail => detail.token));
108+
assert.equal(
109+
uniqueTokens.size,
110+
reAuthSpy.callCount,
111+
`Expected ${reAuthSpy.callCount} different tokens, but got ${uniqueTokens.size} unique tokens`
112+
);
113+
114+
// Verify all tokens are not cached (i.e. have the same lifetime)
115+
const uniqueLifetimes = new Set(tokenDetails.map(detail => detail.lifetime));
116+
assert.equal(
117+
uniqueLifetimes.size,
118+
1,
119+
`Expected all tokens to have the same lifetime, but found ${uniqueLifetimes.size} different lifetimes: ${[uniqueLifetimes].join(', ')} seconds`
120+
);
121+
122+
// Verify that all tokens have different uti ( unique token identifier)
123+
const uniqueUti = new Set(tokenDetails.map(detail => detail.uti));
124+
assert.equal(
125+
uniqueUti.size,
126+
reAuthSpy.callCount,
127+
`Expected all tokens to have different uti, but found ${uniqueUti.size} different uti in: ${[uniqueUti].join(', ')}`
128+
);
129+
130+
} finally {
131+
await client.destroy();
132+
}
133+
});
134+
});

packages/entraid/lib/entra-id-credentials-provider-factory.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,22 @@ export class EntraIdCredentialsProviderFactory {
101101
);
102102

103103
return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp,
104-
{ onReAuthenticationError: params.onReAuthenticationError });
104+
{
105+
onReAuthenticationError: params.onReAuthenticationError,
106+
credentialsMapper: (token) => {
107+
108+
// Client credentials flow is app-only authentication (no user context),
109+
// so only access token is provided without user-specific claims (uniqueId, idToken, ...)
110+
// this means that we need to extract the oid from the access token manually
111+
const accessToken = JSON.parse(Buffer.from(token.accessToken.split('.')[1], 'base64').toString());
112+
113+
return ({
114+
username: accessToken.oid,
115+
password: token.accessToken
116+
})
117+
118+
}
119+
});
105120
}
106121

107122
/**

packages/entraid/lib/entraid-credentials-provider.spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,15 @@ describe('EntraID CredentialsProvider Subscription Behavior', () => {
134134
private readonly tokenSequence: AuthenticationResult[] = [
135135
{
136136
accessToken: 'initial-token',
137-
account: { username: 'test-user' }
137+
uniqueId: 'test-user'
138138
} as AuthenticationResult,
139139
{
140140
accessToken: 'refresh-token-1',
141-
account: { username: 'test-user' }
141+
uniqueId: 'test-user'
142142
} as AuthenticationResult,
143143
{
144144
accessToken: 'refresh-token-2',
145-
account: { username: 'test-user' }
145+
uniqueId: 'test-user'
146146
} as AuthenticationResult
147147
]
148148
) {}

packages/entraid/lib/entraid-credentials-provider.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
2424
}> = [];
2525

2626
constructor(
27-
private readonly tokenManager: TokenManager<AuthenticationResult>,
28-
private readonly idp: IdentityProvider<AuthenticationResult>,
27+
public readonly tokenManager: TokenManager<AuthenticationResult>,
28+
public readonly idp: IdentityProvider<AuthenticationResult>,
2929
options: {
3030
onReAuthenticationError?: (error: ReAuthenticationError) => void
3131
credentialsMapper?: (token: AuthenticationResult) => BasicAuth
@@ -34,7 +34,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
3434
this.onReAuthenticationError = options.onReAuthenticationError ??
3535
((error) => console.error('ReAuthenticationError', error));
3636
this.credentialsMapper = options.credentialsMapper ?? ((token) => ({
37-
username: token.account?.username ?? undefined,
37+
username: token.uniqueId,
3838
password: token.accessToken
3939
}));
4040

@@ -124,4 +124,8 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
124124
return this.listeners.size;
125125
}
126126

127+
public getTokenManager() {
128+
return this.tokenManager;
129+
}
130+
127131
}

packages/entraid/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"scripts": {
1212
"clean": "rimraf dist",
1313
"build": "npm run clean && tsc",
14-
"start:auth-pkce": "npm run build && node dist/samples/auth-code-pkce/index.js",
14+
"start:auth-pkce": "tsx --tsconfig tsconfig.samples.json ./samples/auth-code-pkce/index.ts",
15+
"test-integration": "mocha -r tsx --tsconfig tsconfig.integration-tests.json './integration-tests/**/*.spec.ts'",
1516
"test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'"
1617
},
1718
"dependencies": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": [
4+
"./integration-tests/**/*.ts",
5+
"./lib/**/*.ts"
6+
],
7+
"compilerOptions": {
8+
"noEmit": true
9+
},
10+
}

packages/entraid/tsconfig.json

+1-4
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@
44
"outDir": "./dist"
55
},
66
"include": [
7-
"./samples/**/*.ts",
87
"./lib/**/*.ts"
98
],
109
"exclude": [
11-
"./lib/test-utils.ts",
1210
"./lib/**/*.spec.ts",
13-
"./lib/sentinel/test-util.ts"
11+
"./lib/test-util.ts",
1412
],
1513
"typedocOptions": {
1614
"entryPoints": [
17-
"./index.ts",
1815
"./lib"
1916
],
2017
"entryPointStrategy": "expand",
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": [
4+
"./samples/**/*.ts",
5+
"./lib/**/*.ts"
6+
],
7+
"compilerOptions": {
8+
"noEmit": true
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
interface RawRedisEndpoint {
2+
username?: string;
3+
password?: string;
4+
tls: boolean;
5+
endpoints: string[];
6+
}
7+
8+
export type RedisEndpointsConfig = Record<string, RawRedisEndpoint>;
9+
10+
export function loadFromJson(jsonString: string): RedisEndpointsConfig {
11+
try {
12+
return JSON.parse(jsonString) as RedisEndpointsConfig;
13+
} catch (error) {
14+
throw new Error(`Invalid JSON configuration: ${error}`);
15+
}
16+
}

0 commit comments

Comments
 (0)