Skip to content

Commit b326302

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 b326302

9 files changed

+240
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
import { EntraidCredentialsProvider } from '../lib/entraid-credentials-provider';
9+
10+
describe('EntraID Integration Tests', () => {
11+
12+
it('client configured with client secret should be able to authenticate/re-authenticate', async () => {
13+
const config = readConfigFromEnv();
14+
await runAuthenticationTest(() =>
15+
EntraIdCredentialsProviderFactory.createForClientCredentials({
16+
clientId: config.clientId,
17+
clientSecret: config.clientSecret,
18+
authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId },
19+
tokenManagerConfig: {
20+
expirationRefreshRatio: 0.0001
21+
}
22+
})
23+
);
24+
});
25+
26+
it('client configured with client certificate should be able to authenticate/re-authenticate', async () => {
27+
const config = readConfigFromEnv();
28+
await runAuthenticationTest(() =>
29+
EntraIdCredentialsProviderFactory.createForClientCredentialsWithCertificate({
30+
clientId: config.clientId,
31+
certificate: {
32+
privateKey: config.privateKey,
33+
thumbprint: config.cert
34+
},
35+
authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId },
36+
tokenManagerConfig: {
37+
expirationRefreshRatio: 0.0001
38+
}
39+
})
40+
);
41+
});
42+
43+
interface TestConfig {
44+
clientId: string;
45+
clientSecret: string;
46+
authority: string;
47+
tenantId: string;
48+
redisScopes: string;
49+
cert: string;
50+
privateKey: string;
51+
userAssignedManagedId: string;
52+
endpoints: RedisEndpointsConfig;
53+
}
54+
55+
const readConfigFromEnv = (): TestConfig => {
56+
const requiredEnvVars = {
57+
AZURE_CLIENT_ID: process.env.AZURE_CLIENT_ID,
58+
AZURE_CLIENT_SECRET: process.env.AZURE_CLIENT_SECRET,
59+
AZURE_AUTHORITY: process.env.AZURE_AUTHORITY,
60+
AZURE_TENANT_ID: process.env.AZURE_TENANT_ID,
61+
AZURE_REDIS_SCOPES: process.env.AZURE_REDIS_SCOPES,
62+
AZURE_CERT: process.env.AZURE_CERT,
63+
AZURE_PRIVATE_KEY: process.env.AZURE_PRIVATE_KEY,
64+
AZURE_USER_ASSIGNED_MANAGED_ID: process.env.AZURE_USER_ASSIGNED_MANAGED_ID,
65+
REDIS_ENDPOINTS_CONFIG_PATH: process.env.REDIS_ENDPOINTS_CONFIG_PATH
66+
};
67+
68+
Object.entries(requiredEnvVars).forEach(([key, value]) => {
69+
if (value == undefined) {
70+
throw new Error(`${key} environment variable must be set`);
71+
}
72+
});
73+
74+
return {
75+
endpoints: loadFromJson(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH),
76+
clientId: requiredEnvVars.AZURE_CLIENT_ID,
77+
clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET,
78+
authority: requiredEnvVars.AZURE_AUTHORITY,
79+
tenantId: requiredEnvVars.AZURE_TENANT_ID,
80+
redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES,
81+
cert: requiredEnvVars.AZURE_CERT,
82+
privateKey: requiredEnvVars.AZURE_PRIVATE_KEY,
83+
userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID
84+
};
85+
};
86+
87+
interface TokenDetail {
88+
token: string;
89+
exp: number;
90+
iat: number;
91+
lifetime: number;
92+
uti: string;
93+
}
94+
95+
const setupTestClient = (credentialsProvider: EntraidCredentialsProvider) => {
96+
const config = readConfigFromEnv();
97+
const client = createClient({
98+
url: config.endpoints['standalone-entraid-acl'].endpoints[0],
99+
credentialsProvider
100+
});
101+
102+
const clientInstance = (client as any)._self;
103+
const reAuthSpy: SinonSpy = spy(clientInstance, 'reAuthenticate');
104+
105+
return { client, reAuthSpy };
106+
};
107+
108+
const runClientOperations = async (client: any) => {
109+
const startTime = Date.now();
110+
while (Date.now() - startTime < 1000) {
111+
const key = randomUUID();
112+
await client.set(key, 'value');
113+
const value = await client.get(key);
114+
assert.equal(value, 'value');
115+
await client.del(key);
116+
}
117+
};
118+
119+
const validateTokens = (reAuthSpy: SinonSpy) => {
120+
assert(reAuthSpy.callCount >= 1,
121+
`reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`);
122+
123+
const tokenDetails: TokenDetail[] = reAuthSpy.getCalls().map(call => {
124+
const creds = call.args[0] as BasicAuth;
125+
const tokenPayload = JSON.parse(
126+
Buffer.from(creds.password.split('.')[1], 'base64').toString()
127+
);
128+
129+
return {
130+
token: creds.password,
131+
exp: tokenPayload.exp,
132+
iat: tokenPayload.iat,
133+
lifetime: tokenPayload.exp - tokenPayload.iat,
134+
uti: tokenPayload.uti
135+
};
136+
});
137+
138+
// Verify unique tokens
139+
const uniqueTokens = new Set(tokenDetails.map(detail => detail.token));
140+
assert.equal(
141+
uniqueTokens.size,
142+
reAuthSpy.callCount,
143+
`Expected ${reAuthSpy.callCount} different tokens, but got ${uniqueTokens.size} unique tokens`
144+
);
145+
146+
// Verify all tokens are not cached (i.e. have the same lifetime)
147+
const uniqueLifetimes = new Set(tokenDetails.map(detail => detail.lifetime));
148+
assert.equal(
149+
uniqueLifetimes.size,
150+
1,
151+
`Expected all tokens to have the same lifetime, but found ${uniqueLifetimes.size} different lifetimes: ${[uniqueLifetimes].join(', ')} seconds`
152+
);
153+
154+
// Verify that all tokens have different uti (unique token identifier)
155+
const uniqueUti = new Set(tokenDetails.map(detail => detail.uti));
156+
assert.equal(
157+
uniqueUti.size,
158+
reAuthSpy.callCount,
159+
`Expected all tokens to have different uti, but found ${uniqueUti.size} different uti in: ${[uniqueUti].join(', ')}`
160+
);
161+
};
162+
163+
const runAuthenticationTest = async (setupCredentialsProvider: () => any) => {
164+
const { client, reAuthSpy } = setupTestClient(setupCredentialsProvider());
165+
166+
try {
167+
await client.connect();
168+
await runClientOperations(client);
169+
validateTokens(reAuthSpy);
170+
} finally {
171+
await client.destroy();
172+
}
173+
};
174+
175+
});

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)