Skip to content

Commit 32fbf6f

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 - Remove `@redis/authx` package and nest it under `@`
1 parent ac972bd commit 32fbf6f

26 files changed

+623
-211
lines changed

packages/authx/package.json

-39
This file was deleted.

packages/authx/tsconfig.json

-21
This file was deleted.

packages/authx/lib/credentials-provider.ts packages/client/lib/authx/credentials-provider.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
import { Disposable } from './disposable';
22
/**
33
* Provides credentials asynchronously.
44
*/
+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Represents a resource that can be disposed.
3+
*/
4+
export interface Disposable {
5+
dispose(): void;
6+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './lib/token-manager';
1+
export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './token-manager';
22
export {
33
CredentialsProvider,
44
StreamingCredentialsProvider,
@@ -8,6 +8,8 @@ export {
88
AsyncCredentialsProvider,
99
ReAuthenticationError,
1010
BasicAuth
11-
} from './lib/credentials-provider';
12-
export { Token } from './lib/token';
13-
export { IdentityProvider, TokenResponse } from './lib/identity-provider';
11+
} from './credentials-provider';
12+
export { Token } from './token';
13+
export { IdentityProvider, TokenResponse } from './identity-provider';
14+
15+
export { Disposable } from './disposable'

packages/authx/lib/token-manager.spec.ts packages/client/lib/authx/token-manager.spec.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ describe('TokenManager', () => {
278278
assert.equal(listener.errors.length, 0, 'Should not have any errors');
279279
assert.equal(manager.getCurrentToken().value, 'token3', 'Should have current token');
280280

281-
disposable?.[Symbol.dispose]();
281+
disposable?.dispose();
282282
});
283283
});
284284
});
@@ -328,7 +328,7 @@ describe('TokenManager', () => {
328328
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after failure');
329329
assert.equal(listener.errors.length, 1, 'Should receive error');
330330
assert.equal(listener.errors[0].message, 'Fatal error', 'Should have correct error message');
331-
assert.equal(listener.errors[0].isFatal, true, 'Should be a fatal error');
331+
assert.equal(listener.errors[0].isRetryable, false, 'Should be a fatal error');
332332

333333
// verify that the token manager is stopped and no more requests are made after the error and expected refresh time
334334
await delay(80);
@@ -338,7 +338,7 @@ describe('TokenManager', () => {
338338
assert.equal(listener.errors.length, 1, 'Should not receive more errors after error');
339339
assert.equal(manager.isRunning(), false, 'Should stop token manager after error');
340340

341-
disposable?.[Symbol.dispose]();
341+
disposable?.dispose();
342342
});
343343

344344
it('should handle retries with exponential backoff', async () => {
@@ -352,7 +352,7 @@ describe('TokenManager', () => {
352352
initialDelayMs: 100,
353353
maxDelayMs: 1000,
354354
backoffMultiplier: 2,
355-
shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
355+
isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
356356
}
357357
};
358358

@@ -389,7 +389,7 @@ describe('TokenManager', () => {
389389
// Should have first error but not stop due to retry config
390390
assert.equal(listener.errors.length, 1, 'Should have first error');
391391
assert.ok(listener.errors[0].message.includes('attempt 1'), 'Error should indicate first attempt');
392-
assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error');
392+
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
393393
assert.equal(manager.isRunning(), true, 'Should continue running during retries');
394394

395395
// Advance past first retry (delay: 100ms due to backoff)
@@ -401,7 +401,7 @@ describe('TokenManager', () => {
401401

402402
assert.equal(listener.errors.length, 2, 'Should have second error');
403403
assert.ok(listener.errors[1].message.includes('attempt 2'), 'Error should indicate second attempt');
404-
assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error');
404+
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
405405
assert.equal(manager.isRunning(), true, 'Should continue running during retries');
406406

407407
// Advance past second retry (delay: 200ms due to backoff)
@@ -420,7 +420,7 @@ describe('TokenManager', () => {
420420
assert.equal(manager.isRunning(), true, 'Should continue running after recovery');
421421
assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests');
422422

423-
disposable?.[Symbol.dispose]();
423+
disposable?.dispose();
424424
});
425425

426426
it('should stop after max retries exceeded', async () => {
@@ -435,7 +435,7 @@ describe('TokenManager', () => {
435435
maxDelayMs: 1000,
436436
backoffMultiplier: 2,
437437
jitterPercentage: 0,
438-
shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
438+
isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
439439
}
440440
};
441441

@@ -470,7 +470,7 @@ describe('TokenManager', () => {
470470
// First error
471471
assert.equal(listener.errors.length, 1, 'Should have first error');
472472
assert.equal(manager.isRunning(), true, 'Should continue running after first error');
473-
assert.equal(listener.errors[0].isFatal, false, 'Should not be a fatal error');
473+
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
474474

475475
// Advance past first retry
476476
await delay(100);
@@ -483,7 +483,7 @@ describe('TokenManager', () => {
483483
// Second error
484484
assert.equal(listener.errors.length, 2, 'Should have second error');
485485
assert.equal(manager.isRunning(), true, 'Should continue running after second error');
486-
assert.equal(listener.errors[1].isFatal, false, 'Should not be a fatal error');
486+
assert.equal(listener.errors[1].isRetryable, true, 'Should not be a fatal error');
487487

488488
// Advance past second retry
489489
await delay(200);
@@ -495,11 +495,11 @@ describe('TokenManager', () => {
495495

496496
// Should stop after max retries
497497
assert.equal(listener.errors.length, 3, 'Should have final error');
498-
assert.equal(listener.errors[2].isFatal, true, 'Should not be a fatal error');
498+
assert.equal(listener.errors[2].isRetryable, false, 'Should be a fatal error');
499499
assert.equal(manager.isRunning(), false, 'Should stop after max retries exceeded');
500500
assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests');
501501

502-
disposable?.[Symbol.dispose]();
502+
disposable?.dispose();
503503

504504
});
505505
});

packages/authx/lib/token-manager.ts packages/client/lib/authx/token-manager.ts

+72-21
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,75 @@
11
import { IdentityProvider, TokenResponse } from './identity-provider';
22
import { Token } from './token';
3+
import {Disposable} from './disposable';
34

45
/**
56
* The configuration for retrying token refreshes.
67
*/
78
export interface RetryPolicy {
8-
// The maximum number of attempts to retry token refreshes.
9+
/**
10+
* The maximum number of attempts to retry token refreshes.
11+
*/
912
maxAttempts: number;
10-
// The initial delay in milliseconds before the first retry.
13+
14+
/**
15+
* The initial delay in milliseconds before the first retry.
16+
*/
1117
initialDelayMs: number;
12-
// The maximum delay in milliseconds between retries (the calculated delay will be capped at this value).
18+
19+
/**
20+
* The maximum delay in milliseconds between retries.
21+
* The calculated delay will be capped at this value.
22+
*/
1323
maxDelayMs: number;
14-
// The multiplier for exponential backoff between retries. e.g. 2 will double the delay each time.
24+
25+
/**
26+
* The multiplier for exponential backoff between retries.
27+
* @example
28+
* A value of 2 will double the delay each time:
29+
* - 1st retry: initialDelayMs
30+
* - 2nd retry: initialDelayMs * 2
31+
* - 3rd retry: initialDelayMs * 4
32+
*/
1533
backoffMultiplier: number;
16-
// The percentage of jitter to apply to the delay. e.g. 0.1 will add or subtract up to 10% of the delay.
34+
35+
/**
36+
* The percentage of jitter to apply to the delay.
37+
* @example
38+
* A value of 0.1 will add or subtract up to 10% of the delay.
39+
*/
1740
jitterPercentage?: number;
18-
// A custom function to determine if a retry should be attempted based on the error and attempt number.
19-
shouldRetry?: (error: unknown, attempt: number) => boolean;
41+
42+
/**
43+
* Function to classify errors from the identity provider as retryable or non-retryable.
44+
* Used to determine if a token refresh failure should be retried based on the type of error.
45+
*
46+
* The default behavior is to retry all types of errors if no function is provided.
47+
*
48+
* Common use cases:
49+
* - Network errors that may be transient (should retry)
50+
* - Invalid credentials (should not retry)
51+
* - Rate limiting responses (should retry)
52+
*
53+
* @param error - The error from the identity provider3
54+
* @param attempt - Current retry attempt (0-based)
55+
* @returns `true` if the error is considered transient and the operation should be retried
56+
*
57+
* @example
58+
* ```typescript
59+
* const retryPolicy: RetryPolicy = {
60+
* maxAttempts: 3,
61+
* initialDelayMs: 1000,
62+
* maxDelayMs: 5000,
63+
* backoffMultiplier: 2,
64+
* isRetryable: (error) => {
65+
* // Retry on network errors or rate limiting
66+
* return error instanceof NetworkError ||
67+
* error instanceof RateLimitError;
68+
* }
69+
* };
70+
* ```
71+
*/
72+
isRetryable?: (error: unknown, attempt: number) => boolean;
2073
}
2174

2275
/**
@@ -36,14 +89,13 @@ export interface TokenManagerConfig {
3689
}
3790

3891
/**
39-
* IDPError is an error that occurs while calling the underlying IdentityProvider.
92+
* IDPError indicates a failure from the identity provider.
4093
*
41-
* It can be transient and if retry policy is configured, the token manager will attempt to obtain a token again.
42-
* This means that receiving non-fatal error is not a stream termination event.
43-
* The stream will be terminated only if the error is fatal.
94+
* The `isRetryable` flag is determined by the RetryPolicy's error classification function - if an error is
95+
* classified as retryable, it will be marked as transient and the token manager will attempt to recover.
4496
*/
4597
export class IDPError extends Error {
46-
constructor(public readonly message: string, public readonly isFatal: boolean) {
98+
constructor(public readonly message: string, public readonly isRetryable: boolean) {
4799
super(message);
48100
this.name = 'IDPError';
49101
}
@@ -105,7 +157,6 @@ export class TokenManager<T> {
105157
*/
106158
public start(listener: TokenStreamListener<T>, initialDelayMs: number = 0): Disposable {
107159
if (this.listener) {
108-
console.log('TokenManager is already running, stopping the previous instance');
109160
this.stop();
110161
}
111162

@@ -115,7 +166,7 @@ export class TokenManager<T> {
115166
this.scheduleNextRefresh(initialDelayMs);
116167

117168
return {
118-
[Symbol.dispose]: () => this.stop()
169+
dispose: () => this.stop()
119170
};
120171
}
121172

@@ -142,14 +193,14 @@ export class TokenManager<T> {
142193
private shouldRetry(error: unknown): boolean {
143194
if (!this.config.retry) return false;
144195

145-
const { maxAttempts, shouldRetry } = this.config.retry;
196+
const { maxAttempts, isRetryable } = this.config.retry;
146197

147198
if (this.retryAttempt >= maxAttempts) {
148199
return false;
149200
}
150201

151-
if (shouldRetry) {
152-
return shouldRetry(error, this.retryAttempt);
202+
if (isRetryable) {
203+
return isRetryable(error, this.retryAttempt);
153204
}
154205

155206
return false;
@@ -172,10 +223,10 @@ export class TokenManager<T> {
172223
if (this.shouldRetry(error)) {
173224
this.retryAttempt++;
174225
const retryDelay = this.calculateRetryDelay();
175-
this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, false)
226+
this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true)
176227
this.scheduleNextRefresh(retryDelay);
177228
} else {
178-
this.notifyError(error, true);
229+
this.notifyError(error, false);
179230
this.stop();
180231
}
181232
}
@@ -255,13 +306,13 @@ export class TokenManager<T> {
255306
return this.currentToken;
256307
}
257308

258-
private notifyError = (error: unknown, isFatal: boolean): void => {
309+
private notifyError(error: unknown, isRetryable: boolean): void {
259310
const errorMessage = error instanceof Error ? error.message : String(error);
260311

261312
if (!this.listener) {
262313
throw new Error(`TokenManager is not running but received an error: ${errorMessage}`);
263314
}
264315

265-
this.listener.onError(new IDPError(errorMessage, isFatal));
316+
this.listener.onError(new IDPError(errorMessage, isRetryable));
266317
}
267318
}
File renamed without changes.

0 commit comments

Comments
 (0)