1
0
mirror of https://github.com/redis/node-redis.git synced 2025-08-07 13:22:56 +03:00

feat(auth): add Entra ID identity provider integration for Redis client authentication (#2877)

* feat(auth): refactor authentication mechanism to use CredentialsProvider

- Introduce new credential providers: AsyncCredentialsProvider, StreamingCredentialsProvider
- Update client handshake process to use the new CredentialsProviders and to support async credentials fetch / credentials refresh
- Internal conversion of username/password to a CredentialsProvider
- Modify URL parsing to accommodate the new authentication structure
- Tests

* feat(auth): auth extensions

Introduces TokenManager and supporting classes to handle token acquisition, automatic
refresh, and updates via identity providers. This foundation enables consistent
authentication token management across different identity provider implementations.

Key additions:
- Add TokenManager to obtain and maintain auth tokens from identity providers
  with automated refresh scheduling based on TTL and configurable thresholds
- Add IdentityProvider interface for token acquisition from auth providers
- Implement Token class for managing token state and TTL tracking
- Include configurable retry mechanism with exponential backoff and jitter
- Add comprehensive test suite covering refresh cycles and error handling

This change establishes the core infrastructure needed for reliable token
lifecycle management across different authentication providers.

* feat(auth): add Entra ID identity provider integration

Introduces Entra ID (former Azure AD) authentication support with multiple authentication flows
and automated token lifecycle management.

Key additions:
- Add EntraIdCredentialsProvider for handling Entra ID authentication flows
- Implement MSALIdentityProvider to integrate with MSAL/EntraID authentication library
- Add support for multiple authentication methods:
  - Managed identities (system and user-assigned)
  - Client credentials with certificate
  - Client credentials with secret
  - Authorization Code flow with PKCE
- Add factory class with builder methods for each authentication flow
- Include sample Express server implementation for Authorization Code flow
- Add comprehensive configuration options for authority and token management

* feat(test-utils): improve cluster testing

- Add support for configuring replica authentication with 'masterauth'
- Allow default client configuration during test cluster creation

This improves the testing framework's flexibility by automatically
configuring replica authentication when '--requirepass' is used and
enabling custom client configurations across cluster nodes.

* 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 `@`
This commit is contained in:
Bobby I.
2025-01-30 10:29:19 +02:00
committed by GitHub
parent ae89341780
commit 6d21de3f31
32 changed files with 3991 additions and 103 deletions

View File

@@ -0,0 +1,102 @@
import { Disposable } from './disposable';
/**
* Provides credentials asynchronously.
*/
export interface AsyncCredentialsProvider {
readonly type: 'async-credentials-provider';
credentials: () => Promise<BasicAuth>
}
/**
* Provides credentials asynchronously with support for continuous updates via a subscription model.
* This is useful for environments where credentials are frequently rotated or updated or can be revoked.
*/
export interface StreamingCredentialsProvider {
readonly type: 'streaming-credentials-provider';
/**
* Provides initial credentials and subscribes to subsequent updates. This is used internally by the node-redis client
* to handle credential rotation and re-authentication.
*
* Note: The node-redis client manages the subscription lifecycle automatically. Users only need to implement
* onReAuthenticationError if they want to be notified about authentication failures.
*
* Error handling:
* - Errors received via onError indicate a fatal issue with the credentials stream
* - The stream is automatically closed(disposed) when onError occurs
* - onError typically mean the provider failed to fetch new credentials after retrying
*
* @example
* ```ts
* const provider = getStreamingProvider();
* const [initialCredentials, disposable] = await provider.subscribe({
* onNext: (newCredentials) => {
* // Handle credential update
* },
* onError: (error) => {
* // Handle fatal stream error
* }
* });
*
* @param listener - Callbacks to handle credential updates and errors
* @returns A Promise resolving to [initial credentials, cleanup function]
*/
subscribe: (listener: StreamingCredentialsListener<BasicAuth>) => Promise<[BasicAuth, Disposable]>
/**
* Called when authentication fails or credentials cannot be renewed in time.
* Implement this to handle authentication errors in your application.
*
* @param error - Either a CredentialsError (invalid/expired credentials) or
* UnableToObtainNewCredentialsError (failed to fetch new credentials on time)
*/
onReAuthenticationError: (error: ReAuthenticationError) => void;
}
/**
* Type representing basic authentication credentials.
*/
export type BasicAuth = { username?: string, password?: string }
/**
* Callback to handle credential updates and errors.
*/
export type StreamingCredentialsListener<T> = {
onNext: (credentials: T) => void;
onError: (e: Error) => void;
}
/**
* Providers that can supply authentication credentials
*/
export type CredentialsProvider = AsyncCredentialsProvider | StreamingCredentialsProvider
/**
* Errors that can occur during re-authentication.
*/
export type ReAuthenticationError = CredentialsError | UnableToObtainNewCredentialsError
/**
* Thrown when re-authentication fails with provided credentials .
* e.g. when the credentials are invalid, expired or revoked.
*
*/
export class CredentialsError extends Error {
constructor(message: string) {
super(`Re-authentication with latest credentials failed: ${message}`);
this.name = 'CredentialsError';
}
}
/**
* Thrown when new credentials cannot be obtained before current ones expire
*/
export class UnableToObtainNewCredentialsError extends Error {
constructor(message: string) {
super(`Unable to obtain new credentials : ${message}`);
this.name = 'UnableToObtainNewCredentialsError';
}
}

View File

@@ -0,0 +1,6 @@
/**
* Represents a resource that can be disposed.
*/
export interface Disposable {
dispose(): void;
}

View File

@@ -0,0 +1,22 @@
/**
* An identity provider is responsible for providing a token that can be used to authenticate with a service.
*/
/**
* The response from an identity provider when requesting a token.
*
* note: "native" refers to the type of the token that the actual identity provider library is using.
*
* @type T The type of the native idp token.
* @property token The token.
* @property ttlMs The time-to-live of the token in epoch milliseconds extracted from the native token in local time.
*/
export type TokenResponse<T> = { token: T, ttlMs: number };
export interface IdentityProvider<T> {
/**
* Request a token from the identity provider.
* @returns A promise that resolves to an object containing the token and the time-to-live in epoch milliseconds.
*/
requestToken(): Promise<TokenResponse<T>>;
}

View File

@@ -0,0 +1,15 @@
export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './token-manager';
export {
CredentialsProvider,
StreamingCredentialsProvider,
UnableToObtainNewCredentialsError,
CredentialsError,
StreamingCredentialsListener,
AsyncCredentialsProvider,
ReAuthenticationError,
BasicAuth
} from './credentials-provider';
export { Token } from './token';
export { IdentityProvider, TokenResponse } from './identity-provider';
export { Disposable } from './disposable'

View File

@@ -0,0 +1,588 @@
import { strict as assert } from 'node:assert';
import { Token } from './token';
import { IDPError, RetryPolicy, TokenManager, TokenManagerConfig, TokenStreamListener } from './token-manager';
import { IdentityProvider, TokenResponse } from './identity-provider';
import { setTimeout } from 'timers/promises';
describe('TokenManager', () => {
/**
* Helper function to delay execution for a given number of milliseconds.
* @param ms
*/
const delay = (ms: number) => {
return setTimeout(ms);
}
/**
* IdentityProvider that returns a fixed test token for testing and doesn't handle TTL.
*/
class TestIdentityProvider implements IdentityProvider<string> {
requestToken(): Promise<TokenResponse<string>> {
return Promise.resolve({ token: 'test-token 1', ttlMs: 1000 });
}
}
/**
* Helper function to create a test token with a given TTL .
* @param ttlMs Time-to-live in milliseconds
*/
const createToken = (ttlMs: number): Token<string> => {
return new Token('test-token', ttlMs, 0);
};
/**
* Listener that records received tokens and errors for testing.
*/
class TestListener implements TokenStreamListener<string> {
public readonly receivedTokens: Token<string>[] = [];
public readonly errors: IDPError[] = [];
onNext(token: Token<string>): void {
this.receivedTokens.push(token);
}
onError(error: IDPError): void {
this.errors.push(error);
}
}
/**
* IdentityProvider that returns a sequence of tokens with a fixed delay simulating network latency.
* Used for testing token refresh scenarios.
*/
class ControlledIdentityProvider implements IdentityProvider<string> {
private tokenIndex = 0;
private readonly delayMs: number;
private readonly ttlMs: number;
constructor(
private readonly tokens: string[],
delayMs: number = 0,
tokenTTlMs: number = 100
) {
this.delayMs = delayMs;
this.ttlMs = tokenTTlMs;
}
async requestToken(): Promise<TokenResponse<string>> {
if (this.tokenIndex >= this.tokens.length) {
throw new Error('No more test tokens available');
}
if (this.delayMs > 0) {
await setTimeout(this.delayMs);
}
return { token: this.tokens[this.tokenIndex++], ttlMs: this.ttlMs };
}
}
/**
* IdentityProvider that simulates various error scenarios with configurable behavior
*/
class ErrorSimulatingProvider implements IdentityProvider<string> {
private requestCount = 0;
constructor(
private readonly errorSequence: Array<Error | string>,
private readonly delayMs: number = 0,
private readonly ttlMs: number = 100
) {}
async requestToken(): Promise<TokenResponse<string>> {
if (this.delayMs > 0) {
await delay(this.delayMs);
}
const result = this.errorSequence[this.requestCount];
this.requestCount++;
if (result instanceof Error) {
throw result;
} else if (typeof result === 'string') {
return { token: result, ttlMs: this.ttlMs };
} else {
throw new Error('No more responses configured');
}
}
getRequestCount(): number {
return this.requestCount;
}
}
describe('constructor validation', () => {
it('should throw error if ratio is greater than 1', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 1.1
};
assert.throws(
() => new TokenManager(new TestIdentityProvider(), config),
/expirationRefreshRatio must be less than or equal to 1/
);
});
it('should throw error if ratio is negative', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: -0.1
};
assert.throws(
() => new TokenManager(new TestIdentityProvider(), config),
/expirationRefreshRatio must be greater or equal to 0/
);
});
it('should accept ratio of 1', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 1
};
assert.doesNotThrow(
() => new TokenManager(new TestIdentityProvider(), config)
);
});
it('should accept ratio of 0', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0
};
assert.doesNotThrow(
() => new TokenManager(new TestIdentityProvider(), config)
);
});
});
describe('calculateRefreshTime', () => {
it('should calculate correct refresh time with 0.8 ratio', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8
};
const manager = new TokenManager(new TestIdentityProvider(), config);
const token = createToken(1000);
const refreshTime = manager.calculateRefreshTime(token, 0);
// With 1000s TTL and 0.8 ratio, should refresh at 800s
assert.equal(refreshTime, 800);
});
it('should return 0 for ratio of 0', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0
};
const manager = new TokenManager(new TestIdentityProvider(), config);
const token = createToken(1000);
const refreshTime = manager.calculateRefreshTime(token, 0);
assert.equal(refreshTime, 0);
});
it('should refresh at expiration time with ratio of 1', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 1
};
const manager = new TokenManager(new TestIdentityProvider(), config);
const token = createToken(1000);
const refreshTime = manager.calculateRefreshTime(token, 0);
assert.equal(refreshTime, 1000);
});
it('should handle short TTL tokens', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8
};
const manager = new TokenManager(new TestIdentityProvider(), config);
const token = createToken(5);
const refreshTime = manager.calculateRefreshTime(token, 0);
assert.equal(refreshTime, 4);
});
it('should handle expired tokens', () => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8
};
const manager = new TokenManager(new TestIdentityProvider(), config);
// Create token that expired 100s ago
const token = createToken(-100);
const refreshTime = manager.calculateRefreshTime(token, 0);
// Should return refresh time of 0 for expired tokens
assert.equal(refreshTime, 0);
});
describe('token refresh scenarios', () => {
describe('token refresh', () => {
it('should handle token refresh', async () => {
const networkDelay = 20;
const tokenTtl = 100;
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8
};
const identityProvider = new ControlledIdentityProvider(['token1', 'token2', 'token3'], networkDelay, tokenTtl);
const manager = new TokenManager(identityProvider, config);
const listener = new TestListener();
const disposable = manager.start(listener);
assert.equal(manager.getCurrentToken(), null, 'Should not have token yet');
// Wait for the first token request to complete ( it should be immediate, and we should wait only for the network delay)
await delay(networkDelay)
assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token');
assert.equal(listener.receivedTokens[0].value, 'token1', 'Should have correct token value');
assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(listener.errors.length, 0, 'Should not have any errors: ' + listener.errors);
assert.equal(manager.getCurrentToken().value, 'token1', 'Should have current token');
await delay(80);
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 2, 'Should receive second token');
assert.equal(listener.receivedTokens[1].value, 'token2', 'Should have correct token value');
assert.equal(listener.receivedTokens[1].expiresAtMs - listener.receivedTokens[1].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
assert.equal(manager.getCurrentToken().value, 'token2', 'Should have current token');
await delay(80);
assert.equal(listener.receivedTokens.length, 2, 'Should not receive new token yet');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 3, 'Should receive third token');
assert.equal(listener.receivedTokens[2].value, 'token3', 'Should have correct token value');
assert.equal(listener.receivedTokens[2].expiresAtMs - listener.receivedTokens[2].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
assert.equal(manager.getCurrentToken().value, 'token3', 'Should have current token');
disposable?.dispose();
});
});
});
});
describe('TokenManager error handling', () => {
describe('error scenarios', () => {
it('should not recover if retries are not enabled', async () => {
const networkDelay = 20;
const tokenTtl = 100;
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8
};
const identityProvider = new ErrorSimulatingProvider(
[
'token1',
new Error('Fatal error'),
'token3'
],
networkDelay,
tokenTtl
);
const manager = new TokenManager(identityProvider, config);
const listener = new TestListener();
const disposable = manager.start(listener);
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token');
assert.equal(listener.receivedTokens[0].value, 'token1', 'Should have correct initial token');
assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(listener.errors.length, 0, 'Should not have errors yet');
await delay(80);
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after failure');
assert.equal(listener.errors.length, 1, 'Should receive error');
assert.equal(listener.errors[0].message, 'Fatal error', 'Should have correct error message');
assert.equal(listener.errors[0].isRetryable, false, 'Should be a fatal error');
// verify that the token manager is stopped and no more requests are made after the error and expected refresh time
await delay(80);
assert.equal(identityProvider.getRequestCount(), 2, 'Should not make more requests after error');
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token after error');
assert.equal(listener.errors.length, 1, 'Should not receive more errors after error');
assert.equal(manager.isRunning(), false, 'Should stop token manager after error');
disposable?.dispose();
});
it('should handle retries with exponential backoff', async () => {
const networkDelay = 20;
const tokenTtl = 100;
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8,
retry: {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 1000,
backoffMultiplier: 2,
isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
}
};
const identityProvider = new ErrorSimulatingProvider(
[
'initial-token',
new Error('Temporary failure'), // First attempt fails
new Error('Temporary failure'), // First retry fails
'recovery-token' // Second retry succeeds
],
networkDelay,
tokenTtl
);
const manager = new TokenManager(identityProvider, config);
const listener = new TestListener();
const disposable = manager.start(listener);
// Wait for initial token
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token');
assert.equal(listener.receivedTokens[0].value, 'initial-token', 'Should have correct initial token');
assert.equal(listener.receivedTokens[0].expiresAtMs - listener.receivedTokens[0].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(listener.errors.length, 0, 'Should not have errors yet');
await delay(80);
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
await delay(networkDelay);
// Should have first error but not stop due to retry config
assert.equal(listener.errors.length, 1, 'Should have first error');
assert.ok(listener.errors[0].message.includes('attempt 1'), 'Error should indicate first attempt');
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
assert.equal(manager.isRunning(), true, 'Should continue running during retries');
// Advance past first retry (delay: 100ms due to backoff)
await delay(100);
assert.equal(listener.errors.length, 1, 'Should not have the second error yet');
await delay(networkDelay);
assert.equal(listener.errors.length, 2, 'Should have second error');
assert.ok(listener.errors[1].message.includes('attempt 2'), 'Error should indicate second attempt');
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
assert.equal(manager.isRunning(), true, 'Should continue running during retries');
// Advance past second retry (delay: 200ms due to backoff)
await delay(200);
assert.equal(listener.errors.length, 2, 'Should not have another error');
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet');
await delay(networkDelay);
// Should have recovered
assert.equal(listener.receivedTokens.length, 2, 'Should receive recovery token');
assert.equal(listener.receivedTokens[1].value, 'recovery-token', 'Should have correct recovery token');
assert.equal(listener.receivedTokens[1].expiresAtMs - listener.receivedTokens[1].receivedAtMs,
tokenTtl, 'Should have correct TTL');
assert.equal(manager.isRunning(), true, 'Should continue running after recovery');
assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests');
disposable?.dispose();
});
it('should stop after max retries exceeded', async () => {
const networkDelay = 20;
const tokenTtl = 100;
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8,
retry: {
maxAttempts: 2, // Only allow 2 retries
initialDelayMs: 100,
maxDelayMs: 1000,
backoffMultiplier: 2,
jitterPercentage: 0,
isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure'
}
};
// All attempts must fail
const identityProvider = new ErrorSimulatingProvider(
[
'initial-token',
new Error('Temporary failure'),
new Error('Temporary failure'),
new Error('Temporary failure')
],
networkDelay,
tokenTtl
);
const manager = new TokenManager(identityProvider, config);
const listener = new TestListener();
const disposable = manager.start(listener);
// Wait for initial token
await delay(networkDelay);
assert.equal(listener.receivedTokens.length, 1, 'Should receive initial token');
await delay(80);
assert.equal(listener.receivedTokens.length, 1, 'Should not receive new token yet');
assert.equal(listener.errors.length, 0, 'Should not have any errors');
//wait for the "network call" to complete
await delay(networkDelay);
// First error
assert.equal(listener.errors.length, 1, 'Should have first error');
assert.equal(manager.isRunning(), true, 'Should continue running after first error');
assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error');
// Advance past first retry
await delay(100);
assert.equal(listener.errors.length, 1, 'Should not have second error yet');
//wait for the "network call" to complete
await delay(networkDelay);
// Second error
assert.equal(listener.errors.length, 2, 'Should have second error');
assert.equal(manager.isRunning(), true, 'Should continue running after second error');
assert.equal(listener.errors[1].isRetryable, true, 'Should not be a fatal error');
// Advance past second retry
await delay(200);
assert.equal(listener.errors.length, 2, 'Should not have third error yet');
//wait for the "network call" to complete
await delay(networkDelay);
// Should stop after max retries
assert.equal(listener.errors.length, 3, 'Should have final error');
assert.equal(listener.errors[2].isRetryable, false, 'Should be a fatal error');
assert.equal(manager.isRunning(), false, 'Should stop after max retries exceeded');
assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests');
disposable?.dispose();
});
});
});
describe('TokenManager retry delay calculations', () => {
const createManager = (retryConfig: Partial<RetryPolicy>) => {
const config: TokenManagerConfig = {
expirationRefreshRatio: 0.8,
retry: {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 1000,
backoffMultiplier: 2,
...retryConfig
}
};
return new TokenManager(new TestIdentityProvider(), config);
};
describe('calculateRetryDelay', () => {
it('should apply exponential backoff', () => {
const manager = createManager({
initialDelayMs: 100,
backoffMultiplier: 2,
jitterPercentage: 0
});
// Test multiple retry attempts
const expectedDelays = [
[1, 100], // First attempt: initialDelay * (2^0) = 100
[2, 200], // Second attempt: initialDelay * (2^1) = 200
[3, 400], // Third attempt: initialDelay * (2^2) = 400
[4, 800], // Fourth attempt: initialDelay * (2^3) = 800
[5, 1000] // Fifth attempt: would be 1600, but capped at maxDelay (1000)
];
for (const [attempt, expectedDelay] of expectedDelays) {
manager['retryAttempt'] = attempt;
assert.equal(
manager.calculateRetryDelay(),
expectedDelay,
`Incorrect delay for attempt ${attempt}`
);
}
});
it('should respect maxDelayMs', () => {
const manager = createManager({
initialDelayMs: 100,
maxDelayMs: 300,
backoffMultiplier: 2,
jitterPercentage: 0
});
// Test that delays are capped at maxDelayMs
const expectedDelays = [
[1, 100], // First attempt: 100
[2, 200], // Second attempt: 200
[3, 300], // Third attempt: would be 400, capped at 300
[4, 300], // Fourth attempt: would be 800, capped at 300
[5, 300] // Fifth attempt: would be 1600, capped at 300
];
for (const [attempt, expectedDelay] of expectedDelays) {
manager['retryAttempt'] = attempt;
assert.equal(
manager.calculateRetryDelay(),
expectedDelay,
`Incorrect delay for attempt ${attempt}`
);
}
});
it('should return 0 when no retry config is present', () => {
const manager = new TokenManager(new TestIdentityProvider(), {
expirationRefreshRatio: 0.8
});
manager['retryAttempt'] = 1;
assert.equal(manager.calculateRetryDelay(), 0);
});
});
});
});

View File

@@ -0,0 +1,318 @@
import { IdentityProvider, TokenResponse } from './identity-provider';
import { Token } from './token';
import {Disposable} from './disposable';
/**
* The configuration for retrying token refreshes.
*/
export interface RetryPolicy {
/**
* The maximum number of attempts to retry token refreshes.
*/
maxAttempts: number;
/**
* The initial delay in milliseconds before the first retry.
*/
initialDelayMs: number;
/**
* The maximum delay in milliseconds between retries.
* The calculated delay will be capped at this value.
*/
maxDelayMs: number;
/**
* The multiplier for exponential backoff between retries.
* @example
* A value of 2 will double the delay each time:
* - 1st retry: initialDelayMs
* - 2nd retry: initialDelayMs * 2
* - 3rd retry: initialDelayMs * 4
*/
backoffMultiplier: number;
/**
* The percentage of jitter to apply to the delay.
* @example
* A value of 0.1 will add or subtract up to 10% of the delay.
*/
jitterPercentage?: number;
/**
* Function to classify errors from the identity provider as retryable or non-retryable.
* Used to determine if a token refresh failure should be retried based on the type of error.
*
* The default behavior is to retry all types of errors if no function is provided.
*
* Common use cases:
* - Network errors that may be transient (should retry)
* - Invalid credentials (should not retry)
* - Rate limiting responses (should retry)
*
* @param error - The error from the identity provider3
* @param attempt - Current retry attempt (0-based)
* @returns `true` if the error is considered transient and the operation should be retried
*
* @example
* ```typescript
* const retryPolicy: RetryPolicy = {
* maxAttempts: 3,
* initialDelayMs: 1000,
* maxDelayMs: 5000,
* backoffMultiplier: 2,
* isRetryable: (error) => {
* // Retry on network errors or rate limiting
* return error instanceof NetworkError ||
* error instanceof RateLimitError;
* }
* };
* ```
*/
isRetryable?: (error: unknown, attempt: number) => boolean;
}
/**
* the configuration for the TokenManager.
*/
export interface TokenManagerConfig {
/**
* Represents the ratio of a token's lifetime at which a refresh should be triggered.
* For example, a value of 0.75 means the token should be refreshed when 75% of its lifetime has elapsed (or when
* 25% of its lifetime remains).
*/
expirationRefreshRatio: number;
// The retry policy for token refreshes. If not provided, no retries will be attempted.
retry?: RetryPolicy;
}
/**
* IDPError indicates a failure from the identity provider.
*
* The `isRetryable` flag is determined by the RetryPolicy's error classification function - if an error is
* classified as retryable, it will be marked as transient and the token manager will attempt to recover.
*/
export class IDPError extends Error {
constructor(public readonly message: string, public readonly isRetryable: boolean) {
super(message);
this.name = 'IDPError';
}
}
/**
* TokenStreamListener is an interface for objects that listen to token changes.
*/
export type TokenStreamListener<T> = {
/**
* Called each time a new token is received.
* @param token
*/
onNext: (token: Token<T>) => void;
/**
* Called when an error occurs while calling the underlying IdentityProvider. The error can be
* transient and the token manager will attempt to obtain a token again if retry policy is configured.
*
* Only fatal errors will terminate the stream and stop the token manager.
*
* @param error
*/
onError: (error: IDPError) => void;
}
/**
* TokenManager is responsible for obtaining/refreshing tokens and notifying listeners about token changes.
* It uses an IdentityProvider to request tokens. The token refresh is scheduled based on the token's TTL and
* the expirationRefreshRatio configuration.
*
* The TokenManager should be disposed when it is no longer needed by calling the dispose method on the Disposable
* returned by start.
*/
export class TokenManager<T> {
private currentToken: Token<T> | null = null;
private refreshTimeout: NodeJS.Timeout | null = null;
private listener: TokenStreamListener<T> | null = null;
private retryAttempt: number = 0;
constructor(
private readonly identityProvider: IdentityProvider<T>,
private readonly config: TokenManagerConfig
) {
if (this.config.expirationRefreshRatio > 1) {
throw new Error('expirationRefreshRatio must be less than or equal to 1');
}
if (this.config.expirationRefreshRatio < 0) {
throw new Error('expirationRefreshRatio must be greater or equal to 0');
}
}
/**
* Starts the token manager and returns a Disposable that can be used to stop the token manager.
*
* @param listener The listener that will receive token updates.
* @param initialDelayMs The initial delay in milliseconds before the first token refresh.
*/
public start(listener: TokenStreamListener<T>, initialDelayMs: number = 0): Disposable {
if (this.listener) {
this.stop();
}
this.listener = listener;
this.retryAttempt = 0;
this.scheduleNextRefresh(initialDelayMs);
return {
dispose: () => this.stop()
};
}
public calculateRetryDelay(): number {
if (!this.config.retry) return 0;
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitterPercentage } = this.config.retry;
let delay = initialDelayMs * Math.pow(backoffMultiplier, this.retryAttempt - 1);
delay = Math.min(delay, maxDelayMs);
if (jitterPercentage) {
const jitterRange = delay * (jitterPercentage / 100);
const jitterAmount = Math.random() * jitterRange - (jitterRange / 2);
delay += jitterAmount;
}
let result = Math.max(0, Math.floor(delay));
return result;
}
private shouldRetry(error: unknown): boolean {
if (!this.config.retry) return false;
const { maxAttempts, isRetryable } = this.config.retry;
if (this.retryAttempt >= maxAttempts) {
return false;
}
if (isRetryable) {
return isRetryable(error, this.retryAttempt);
}
return false;
}
public isRunning(): boolean {
return this.listener !== null;
}
private async refresh(): Promise<void> {
if (!this.listener) {
throw new Error('TokenManager is not running, but refresh was called');
}
try {
await this.identityProvider.requestToken().then(this.handleNewToken);
this.retryAttempt = 0;
} catch (error) {
if (this.shouldRetry(error)) {
this.retryAttempt++;
const retryDelay = this.calculateRetryDelay();
this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true)
this.scheduleNextRefresh(retryDelay);
} else {
this.notifyError(error, false);
this.stop();
}
}
}
private handleNewToken = async ({ token: nativeToken, ttlMs }: TokenResponse<T>): Promise<void> => {
if (!this.listener) {
throw new Error('TokenManager is not running, but a new token was received');
}
const token = this.wrapAndSetCurrentToken(nativeToken, ttlMs);
this.listener.onNext(token);
this.scheduleNextRefresh(this.calculateRefreshTime(token));
}
/**
* Creates a Token object from a native token and sets it as the current token.
*
* @param nativeToken - The raw token received from the identity provider
* @param ttlMs - Time-to-live in milliseconds for the token
*
* @returns A new Token instance containing the wrapped native token and expiration details
*
*/
public wrapAndSetCurrentToken(nativeToken: T, ttlMs: number): Token<T> {
const now = Date.now();
const token = new Token(
nativeToken,
now + ttlMs,
now
);
this.currentToken = token;
return token;
}
private scheduleNextRefresh(delayMs: number): void {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = null;
}
if (delayMs === 0) {
this.refresh();
} else {
this.refreshTimeout = setTimeout(() => this.refresh(), delayMs);
}
}
/**
* Calculates the time in milliseconds when the token should be refreshed
* based on the token's TTL and the expirationRefreshRatio configuration.
*
* @param token The token to calculate the refresh time for.
* @param now The current time in milliseconds. Defaults to Date.now().
*/
public calculateRefreshTime(token: Token<T>, now: number = Date.now()): number {
const ttlMs = token.getTtlMs(now);
return Math.floor(ttlMs * this.config.expirationRefreshRatio);
}
private stop(): void {
if (this.refreshTimeout) {
clearTimeout(this.refreshTimeout);
this.refreshTimeout = null;
}
this.listener = null;
this.currentToken = null;
this.retryAttempt = 0;
}
/**
* Returns the current token or null if no token is available.
*/
public getCurrentToken(): Token<T> | null {
return this.currentToken;
}
private notifyError(error: unknown, isRetryable: boolean): void {
const errorMessage = error instanceof Error ? error.message : String(error);
if (!this.listener) {
throw new Error(`TokenManager is not running but received an error: ${errorMessage}`);
}
this.listener.onError(new IDPError(errorMessage, isRetryable));
}
}

View File

@@ -0,0 +1,23 @@
/**
* A token that can be used to authenticate with a service.
*/
export class Token<T> {
constructor(
public readonly value: T,
//represents the token deadline - the time in milliseconds since the Unix epoch at which the token expires
public readonly expiresAtMs: number,
//represents the time in milliseconds since the Unix epoch at which the token was received
public readonly receivedAtMs: number
) {}
/**
* Returns the time-to-live of the token in milliseconds.
* @param now The current time in milliseconds since the Unix epoch.
*/
getTtlMs(now: number): number {
if (this.expiresAtMs < now) {
return 0;
}
return this.expiresAtMs - now;
}
}