You've already forked node-redis
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:
50
.github/release-drafter/entraid-config.yml
vendored
Normal file
50
.github/release-drafter/entraid-config.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name-template: 'entraid@$NEXT_PATCH_VERSION'
|
||||
tag-template: 'entraid@$NEXT_PATCH_VERSION'
|
||||
autolabeler:
|
||||
- label: 'chore'
|
||||
files:
|
||||
- '*.md'
|
||||
- '.github/*'
|
||||
- label: 'bug'
|
||||
branch:
|
||||
- '/bug-.+'
|
||||
- label: 'chore'
|
||||
branch:
|
||||
- '/chore-.+'
|
||||
- label: 'feature'
|
||||
branch:
|
||||
- '/feature-.+'
|
||||
categories:
|
||||
- title: 'Breaking Changes'
|
||||
labels:
|
||||
- 'breakingchange'
|
||||
- title: '🚀 New Features'
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'enhancement'
|
||||
- title: '🐛 Bug Fixes'
|
||||
labels:
|
||||
- 'fix'
|
||||
- 'bugfix'
|
||||
- 'bug'
|
||||
- title: '🧰 Maintenance'
|
||||
label:
|
||||
- 'chore'
|
||||
- 'maintenance'
|
||||
- 'documentation'
|
||||
- 'docs'
|
||||
|
||||
change-template: '- $TITLE (#$NUMBER)'
|
||||
include-paths:
|
||||
- 'packages/entraid'
|
||||
exclude-labels:
|
||||
- 'skip-changelog'
|
||||
template: |
|
||||
## Changes
|
||||
|
||||
$CHANGES
|
||||
|
||||
## Contributors
|
||||
We'd like to thank all the contributors who worked on this release!
|
||||
|
||||
$CONTRIBUTORS
|
24
.github/workflows/release-drafter-entraid.yml
vendored
Normal file
24
.github/workflows/release-drafter-entraid.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Release Drafter
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches to consider in the event; optional, defaults to all
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
|
||||
update_release_draft:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Drafts your next Release notes as Pull Requests are merged into "master"
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
with:
|
||||
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
|
||||
config-name: release-drafter/entraid-config.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
1124
package-lock.json
generated
1124
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
102
packages/client/lib/authx/credentials-provider.ts
Normal file
102
packages/client/lib/authx/credentials-provider.ts
Normal 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';
|
||||
}
|
||||
}
|
6
packages/client/lib/authx/disposable.ts
Normal file
6
packages/client/lib/authx/disposable.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Represents a resource that can be disposed.
|
||||
*/
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
22
packages/client/lib/authx/identity-provider.ts
Normal file
22
packages/client/lib/authx/identity-provider.ts
Normal 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>>;
|
||||
}
|
15
packages/client/lib/authx/index.ts
Normal file
15
packages/client/lib/authx/index.ts
Normal 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'
|
588
packages/client/lib/authx/token-manager.spec.ts
Normal file
588
packages/client/lib/authx/token-manager.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
318
packages/client/lib/authx/token-manager.ts
Normal file
318
packages/client/lib/authx/token-manager.ts
Normal 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));
|
||||
}
|
||||
}
|
23
packages/client/lib/authx/token.ts
Normal file
23
packages/client/lib/authx/token.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
|
||||
import RedisClient, { RedisClientType } from '.';
|
||||
import RedisClient, { RedisClientOptions, RedisClientType } from '.';
|
||||
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors';
|
||||
import { defineScript } from '../lua-script';
|
||||
import { spy } from 'sinon';
|
||||
@@ -25,36 +25,87 @@ export const SQUARE_SCRIPT = defineScript({
|
||||
|
||||
describe('Client', () => {
|
||||
describe('parseURL', () => {
|
||||
it('redis://user:secret@localhost:6379/0', () => {
|
||||
assert.deepEqual(
|
||||
RedisClient.parseURL('redis://user:secret@localhost:6379/0'),
|
||||
{
|
||||
socket: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
},
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
database: 0
|
||||
it('redis://user:secret@localhost:6379/0', async () => {
|
||||
const result = RedisClient.parseURL('redis://user:secret@localhost:6379/0');
|
||||
const expected : RedisClientOptions = {
|
||||
socket: {
|
||||
host: 'localhost',
|
||||
port: 6379
|
||||
},
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
database: 0,
|
||||
credentialsProvider: {
|
||||
type: 'async-credentials-provider',
|
||||
credentials: async () => ({
|
||||
password: 'secret',
|
||||
username: 'user'
|
||||
})
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Compare everything except the credentials function
|
||||
const { credentialsProvider: resultCredProvider, ...resultRest } = result;
|
||||
const { credentialsProvider: expectedCredProvider, ...expectedRest } = expected;
|
||||
|
||||
// Compare non-function properties
|
||||
assert.deepEqual(resultRest, expectedRest);
|
||||
|
||||
if(result.credentialsProvider.type === 'async-credentials-provider'
|
||||
&& expected.credentialsProvider.type === 'async-credentials-provider') {
|
||||
|
||||
// Compare the actual output of the credentials functions
|
||||
const resultCreds = await result.credentialsProvider.credentials();
|
||||
const expectedCreds = await expected.credentialsProvider.credentials();
|
||||
assert.deepEqual(resultCreds, expectedCreds);
|
||||
} else {
|
||||
assert.fail('Credentials provider type mismatch');
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
it('rediss://user:secret@localhost:6379/0', () => {
|
||||
assert.deepEqual(
|
||||
RedisClient.parseURL('rediss://user:secret@localhost:6379/0'),
|
||||
{
|
||||
socket: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
tls: true
|
||||
},
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
database: 0
|
||||
it('rediss://user:secret@localhost:6379/0', async () => {
|
||||
const result = RedisClient.parseURL('rediss://user:secret@localhost:6379/0');
|
||||
const expected: RedisClientOptions = {
|
||||
socket: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
tls: true
|
||||
},
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
database: 0,
|
||||
credentialsProvider: {
|
||||
credentials: async () => ({
|
||||
password: 'secret',
|
||||
username: 'user'
|
||||
}),
|
||||
type: 'async-credentials-provider'
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Compare everything except the credentials function
|
||||
const { credentialsProvider: resultCredProvider, ...resultRest } = result;
|
||||
const { credentialsProvider: expectedCredProvider, ...expectedRest } = expected;
|
||||
|
||||
// Compare non-function properties
|
||||
assert.deepEqual(resultRest, expectedRest);
|
||||
assert.equal(resultCredProvider.type, expectedCredProvider.type);
|
||||
|
||||
if (result.credentialsProvider.type === 'async-credentials-provider' &&
|
||||
expected.credentialsProvider.type === 'async-credentials-provider') {
|
||||
|
||||
// Compare the actual output of the credentials functions
|
||||
const resultCreds = await result.credentialsProvider.credentials();
|
||||
const expectedCreds = await expected.credentialsProvider.credentials();
|
||||
assert.deepEqual(resultCreds, expectedCreds);
|
||||
|
||||
} else {
|
||||
assert.fail('Credentials provider type mismatch');
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
it('Invalid protocol', () => {
|
||||
assert.throws(
|
||||
@@ -90,6 +141,21 @@ describe('Client', () => {
|
||||
);
|
||||
}, GLOBAL.SERVERS.PASSWORD);
|
||||
|
||||
testUtils.testWithClient('Client can authenticate asynchronously ', async client => {
|
||||
assert.equal(
|
||||
await client.ping(),
|
||||
'PONG'
|
||||
);
|
||||
}, GLOBAL.SERVERS.ASYNC_BASIC_AUTH);
|
||||
|
||||
testUtils.testWithClient('Client can authenticate using the streaming credentials provider for initial token acquisition',
|
||||
async client => {
|
||||
assert.equal(
|
||||
await client.ping(),
|
||||
'PONG'
|
||||
);
|
||||
}, GLOBAL.SERVERS.STREAMING_AUTH);
|
||||
|
||||
testUtils.testWithClient('should execute AUTH before SELECT', async client => {
|
||||
assert.equal(
|
||||
(await client.clientInfo()).db,
|
||||
@@ -294,6 +360,7 @@ describe('Client', () => {
|
||||
assert.equal(err.replies.length, 2);
|
||||
assert.deepEqual(err.errorIndexes, [1]);
|
||||
assert.ok(err.replies[1] instanceof ErrorReply);
|
||||
// @ts-ignore TS2802
|
||||
assert.deepEqual([...err.errors()], [err.replies[1]]);
|
||||
return true;
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import COMMANDS from '../commands';
|
||||
import RedisSocket, { RedisSocketOptions } from './socket';
|
||||
import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, Disposable } from '../authx';
|
||||
import RedisCommandsQueue, { CommandOptions } from './commands-queue';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander';
|
||||
@@ -42,6 +43,13 @@ export interface RedisClientOptions<
|
||||
* ACL password or the old "--requirepass" password
|
||||
*/
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* Provides credentials for authentication. Can be set directly or will be created internally
|
||||
* if username/password are provided instead. If both are supplied, this credentialsProvider
|
||||
* takes precedence over username/password.
|
||||
*/
|
||||
credentialsProvider?: CredentialsProvider;
|
||||
/**
|
||||
* Client name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname))
|
||||
*/
|
||||
@@ -261,6 +269,17 @@ export default class RedisClient<
|
||||
parsed.password = decodeURIComponent(password);
|
||||
}
|
||||
|
||||
if (username || password) {
|
||||
parsed.credentialsProvider = {
|
||||
type: 'async-credentials-provider',
|
||||
credentials: async () => (
|
||||
{
|
||||
username: username ? decodeURIComponent(username) : undefined,
|
||||
password: password ? decodeURIComponent(password) : undefined
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.length > 1) {
|
||||
const database = Number(pathname.substring(1));
|
||||
if (isNaN(database)) {
|
||||
@@ -284,6 +303,8 @@ export default class RedisClient<
|
||||
#epoch: number;
|
||||
#watchEpoch?: number;
|
||||
|
||||
#credentialsSubscription: Disposable | null = null;
|
||||
|
||||
get options(): RedisClientOptions<M, F, S, RESP> | undefined {
|
||||
return this._self.#options;
|
||||
}
|
||||
@@ -317,6 +338,19 @@ export default class RedisClient<
|
||||
}
|
||||
|
||||
#initiateOptions(options?: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING>): RedisClientOptions<M, F, S, RESP, TYPE_MAPPING> | undefined {
|
||||
|
||||
// Convert username/password to credentialsProvider if no credentialsProvider is already in place
|
||||
if (!options?.credentialsProvider && (options?.username || options?.password)) {
|
||||
|
||||
options.credentialsProvider = {
|
||||
type: 'async-credentials-provider',
|
||||
credentials: async () => ({
|
||||
username: options.username,
|
||||
password: options.password
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.url) {
|
||||
const parsed = RedisClient.parseURL(options.url);
|
||||
if (options.socket) {
|
||||
@@ -345,17 +379,65 @@ export default class RedisClient<
|
||||
);
|
||||
}
|
||||
|
||||
#handshake(selectedDB: number) {
|
||||
/**
|
||||
* @param credentials
|
||||
*/
|
||||
private reAuthenticate = async (credentials: BasicAuth) => {
|
||||
// Re-authentication is not supported on RESP2 with PubSub active
|
||||
if (!(this.isPubSubActive && !this.#options?.RESP)) {
|
||||
await this.sendCommand(
|
||||
parseArgs(COMMANDS.AUTH, {
|
||||
username: credentials.username,
|
||||
password: credentials.password ?? ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#subscribeForStreamingCredentials(cp: StreamingCredentialsProvider): Promise<[BasicAuth, Disposable]> {
|
||||
return cp.subscribe({
|
||||
onNext: credentials => {
|
||||
this.reAuthenticate(credentials).catch(error => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
cp.onReAuthenticationError(new CredentialsError(errorMessage));
|
||||
});
|
||||
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const errorMessage = `Error from streaming credentials provider: ${e.message}`;
|
||||
cp.onReAuthenticationError(new UnableToObtainNewCredentialsError(errorMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #handshake(selectedDB: number) {
|
||||
const commands = [];
|
||||
const cp = this.#options?.credentialsProvider;
|
||||
|
||||
if (this.#options?.RESP) {
|
||||
const hello: HelloOptions = {};
|
||||
|
||||
if (this.#options.password) {
|
||||
hello.AUTH = {
|
||||
username: this.#options.username ?? 'default',
|
||||
password: this.#options.password
|
||||
};
|
||||
if (cp && cp.type === 'async-credentials-provider') {
|
||||
const credentials = await cp.credentials();
|
||||
if (credentials.password) {
|
||||
hello.AUTH = {
|
||||
username: credentials.username ?? 'default',
|
||||
password: credentials.password
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (cp && cp.type === 'streaming-credentials-provider') {
|
||||
|
||||
const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp)
|
||||
this.#credentialsSubscription = disposable;
|
||||
|
||||
if (credentials.password) {
|
||||
hello.AUTH = {
|
||||
username: credentials.username ?? 'default',
|
||||
password: credentials.password
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#options.name) {
|
||||
@@ -366,13 +448,34 @@ export default class RedisClient<
|
||||
parseArgs(HELLO, this.#options.RESP, hello)
|
||||
);
|
||||
} else {
|
||||
if (this.#options?.username || this.#options?.password) {
|
||||
commands.push(
|
||||
parseArgs(COMMANDS.AUTH, {
|
||||
username: this.#options.username,
|
||||
password: this.#options.password ?? ''
|
||||
})
|
||||
);
|
||||
|
||||
if (cp && cp.type === 'async-credentials-provider') {
|
||||
|
||||
const credentials = await cp.credentials();
|
||||
|
||||
if (credentials.username || credentials.password) {
|
||||
commands.push(
|
||||
parseArgs(COMMANDS.AUTH, {
|
||||
username: credentials.username,
|
||||
password: credentials.password ?? ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cp && cp.type === 'streaming-credentials-provider') {
|
||||
|
||||
const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp)
|
||||
this.#credentialsSubscription = disposable;
|
||||
|
||||
if (credentials.username || credentials.password) {
|
||||
commands.push(
|
||||
parseArgs(COMMANDS.AUTH, {
|
||||
username: credentials.username,
|
||||
password: credentials.password ?? ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#options?.name) {
|
||||
@@ -396,7 +499,7 @@ export default class RedisClient<
|
||||
}
|
||||
|
||||
#initiateSocket(): RedisSocket {
|
||||
const socketInitiator = () => {
|
||||
const socketInitiator = async () => {
|
||||
const promises = [],
|
||||
chainId = Symbol('Socket Initiator');
|
||||
|
||||
@@ -418,7 +521,7 @@ export default class RedisClient<
|
||||
);
|
||||
}
|
||||
|
||||
const commands = this.#handshake(this.#selectedDB);
|
||||
const commands = await this.#handshake(this.#selectedDB);
|
||||
for (let i = commands.length - 1; i >= 0; --i) {
|
||||
promises.push(
|
||||
this.#queue.addCommand(commands[i], {
|
||||
@@ -1000,7 +1103,9 @@ export default class RedisClient<
|
||||
const chainId = Symbol('Reset Chain'),
|
||||
promises = [this._self.#queue.reset(chainId)],
|
||||
selectedDB = this._self.#options?.database ?? 0;
|
||||
for (const command of this._self.#handshake(selectedDB)) {
|
||||
this._self.#credentialsSubscription?.dispose();
|
||||
this._self.#credentialsSubscription = null;
|
||||
for (const command of (await this._self.#handshake(selectedDB))) {
|
||||
promises.push(
|
||||
this._self.#queue.addCommand(command, {
|
||||
chainId
|
||||
@@ -1051,6 +1156,8 @@ export default class RedisClient<
|
||||
* @deprecated use .close instead
|
||||
*/
|
||||
QUIT(): Promise<string> {
|
||||
this._self.#credentialsSubscription?.dispose();
|
||||
this._self.#credentialsSubscription = null;
|
||||
return this._self.#socket.quit(async () => {
|
||||
clearTimeout(this._self.#pingTimer);
|
||||
const quitPromise = this._self.#queue.addCommand<string>(['QUIT']);
|
||||
@@ -1089,6 +1196,8 @@ export default class RedisClient<
|
||||
resolve();
|
||||
};
|
||||
this._self.#socket.on('data', maybeClose);
|
||||
this._self.#credentialsSubscription?.dispose();
|
||||
this._self.#credentialsSubscription = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1099,6 +1208,8 @@ export default class RedisClient<
|
||||
clearTimeout(this._self.#pingTimer);
|
||||
this._self.#queue.flushAll(new DisconnectsClientError());
|
||||
this._self.#socket.destroy();
|
||||
this._self.#credentialsSubscription?.dispose();
|
||||
this._self.#credentialsSubscription = null;
|
||||
}
|
||||
|
||||
ref() {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import TestUtils from '@redis/test-utils';
|
||||
import { SinonSpy } from 'sinon';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { CredentialsProvider } from './authx';
|
||||
import { Command } from './RESP/types';
|
||||
import { BasicCommandParser } from './client/parser';
|
||||
|
||||
@@ -16,6 +17,31 @@ const DEBUG_MODE_ARGS = utils.isVersionGreaterThan([7]) ?
|
||||
['--enable-debug-command', 'yes'] :
|
||||
[];
|
||||
|
||||
const asyncBasicAuthCredentialsProvider: CredentialsProvider =
|
||||
{
|
||||
type: 'async-credentials-provider',
|
||||
credentials: async () => ({ password: 'password' })
|
||||
} as const;
|
||||
|
||||
const streamingCredentialsProvider: CredentialsProvider =
|
||||
{
|
||||
type: 'streaming-credentials-provider',
|
||||
|
||||
subscribe : (observable) => ( Promise.resolve([
|
||||
{ password: 'password' },
|
||||
{
|
||||
dispose: () => {
|
||||
console.log('disposing credentials provider subscription');
|
||||
}
|
||||
}
|
||||
])),
|
||||
|
||||
onReAuthenticationError: (error) => {
|
||||
console.error('re-authentication error', error);
|
||||
}
|
||||
|
||||
} as const;
|
||||
|
||||
export const GLOBAL = {
|
||||
SERVERS: {
|
||||
OPEN: {
|
||||
@@ -26,6 +52,18 @@ export const GLOBAL = {
|
||||
clientOptions: {
|
||||
password: 'password'
|
||||
}
|
||||
},
|
||||
ASYNC_BASIC_AUTH: {
|
||||
serverArguments: ['--requirepass', 'password', ...DEBUG_MODE_ARGS],
|
||||
clientOptions: {
|
||||
credentialsProvider: asyncBasicAuthCredentialsProvider
|
||||
}
|
||||
},
|
||||
STREAMING_AUTH: {
|
||||
serverArguments: ['--requirepass', 'password', ...DEBUG_MODE_ARGS],
|
||||
clientOptions: {
|
||||
credentialsProvider: streamingCredentialsProvider
|
||||
}
|
||||
}
|
||||
},
|
||||
CLUSTERS: {
|
||||
|
10
packages/entraid/.nycrc.json
Normal file
10
packages/entraid/.nycrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@istanbuljs/nyc-config-typescript",
|
||||
"exclude": [
|
||||
"integration-tests",
|
||||
"samples",
|
||||
"dist",
|
||||
"**/*.spec.ts",
|
||||
"lib/test-utils.ts"
|
||||
]
|
||||
}
|
11
packages/entraid/.release-it.json
Normal file
11
packages/entraid/.release-it.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"git": {
|
||||
"tagName": "entraid@${version}",
|
||||
"commitMessage": "Release ${tagName}",
|
||||
"tagAnnotation": "Release ${tagName}"
|
||||
},
|
||||
"npm": {
|
||||
"versionArgs": ["--workspaces-update=false"],
|
||||
"publishArgs": ["--access", "public"]
|
||||
}
|
||||
}
|
137
packages/entraid/README.md
Normal file
137
packages/entraid/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# @redis/entraid
|
||||
|
||||
Secure token-based authentication for Redis clients using Microsoft Entra ID (formerly Azure Active Directory).
|
||||
|
||||
## Features
|
||||
|
||||
- Token-based authentication using Microsoft Entra ID
|
||||
- Automatic token refresh before expiration
|
||||
- Automatic re-authentication of all connections after token refresh
|
||||
- Support for multiple authentication flows:
|
||||
- Managed identities (system-assigned and user-assigned)
|
||||
- Service principals (with or without certificates)
|
||||
- Authorization Code with PKCE flow
|
||||
- Built-in retry mechanisms for transient failures
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @redis/client
|
||||
npm install @redis/entraid
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
The first step to using @redis/entraid is choosing the right credentials provider for your authentication needs. The `EntraIdCredentialsProviderFactory` class provides several factory methods to create the appropriate provider:
|
||||
|
||||
- `createForSystemAssignedManagedIdentity`: Use when your application runs in Azure with a system-assigned managed identity
|
||||
- `createForUserAssignedManagedIdentity`: Use when your application runs in Azure with a user-assigned managed identity
|
||||
- `createForClientCredentials`: Use when authenticating with a service principal using client secret
|
||||
- `createForClientCredentialsWithCertificate`: Use when authenticating with a service principal using a certificate
|
||||
- `createForAuthorizationCodeWithPKCE`: Use for interactive authentication flows in user applications
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Service Principal Authentication
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@redis/client';
|
||||
import { EntraIdCredentialsProviderFactory } from '@redis/entraid';
|
||||
|
||||
const provider = EntraIdCredentialsProviderFactory.createForClientCredentials({
|
||||
clientId: 'your-client-id',
|
||||
clientSecret: 'your-client-secret',
|
||||
authorityConfig: {
|
||||
type: 'multi-tenant',
|
||||
tenantId: 'your-tenant-id'
|
||||
},
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.8 // Refresh token after 80% of its lifetime
|
||||
}
|
||||
});
|
||||
|
||||
const client = createClient({
|
||||
url: 'redis://your-host',
|
||||
credentialsProvider: provider
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
```
|
||||
|
||||
### System-Assigned Managed Identity
|
||||
|
||||
```typescript
|
||||
const provider = EntraIdCredentialsProviderFactory.createForSystemAssignedManagedIdentity({
|
||||
clientId: 'your-client-id',
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.8
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### User-Assigned Managed Identity
|
||||
|
||||
```typescript
|
||||
const provider = EntraIdCredentialsProviderFactory.createForUserAssignedManagedIdentity({
|
||||
clientId: 'your-client-id',
|
||||
userAssignedClientId: 'your-user-assigned-client-id',
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.8
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Important Limitations
|
||||
|
||||
### RESP2 PUB/SUB Limitations
|
||||
|
||||
When using RESP2 (Redis Serialization Protocol 2), there are important limitations with PUB/SUB:
|
||||
|
||||
- **No Re-Authentication in PUB/SUB Mode**: In RESP2, once a connection enters PUB/SUB mode, the socket is blocked and cannot process out-of-band commands like AUTH. This means that connections in PUB/SUB mode cannot be re-authenticated when tokens are refreshed.
|
||||
- **Connection Eviction**: As a result, PUB/SUB connections will be evicted by the Redis proxy when their tokens expire. The client will need to establish new connections with fresh tokens.
|
||||
|
||||
### Transaction Safety
|
||||
|
||||
When using token-based authentication, special care must be taken with Redis transactions. The token manager runs in the background and may attempt to re-authenticate connections at any time by sending AUTH commands. This can interfere with manually constructed transactions.
|
||||
|
||||
#### ✅ Recommended: Use the Official Transaction API
|
||||
|
||||
Always use the official transaction API provided by the client:
|
||||
|
||||
```typescript
|
||||
// Correct way to handle transactions
|
||||
const multi = client.multi();
|
||||
multi.set('key1', 'value1');
|
||||
multi.set('key2', 'value2');
|
||||
await multi.exec();
|
||||
```
|
||||
|
||||
#### ❌ Avoid: Manual Transaction Construction
|
||||
|
||||
Do not manually construct transactions by sending individual MULTI/EXEC commands:
|
||||
|
||||
```typescript
|
||||
// Incorrect and potentially dangerous
|
||||
await client.sendCommand(['MULTI']);
|
||||
await client.sendCommand(['SET', 'key1', 'value1']);
|
||||
await client.sendCommand(['SET', 'key2', 'value2']);
|
||||
await client.sendCommand(['EXEC']); // Risk of AUTH command being injected before EXEC
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The provider includes built-in retry mechanisms for transient errors:
|
||||
|
||||
```typescript
|
||||
const provider = EntraIdCredentialsProviderFactory.createForClientCredentials({
|
||||
// ... other config ...
|
||||
tokenManagerConfig: {
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
backoffMultiplier: 2
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
217
packages/entraid/integration-tests/entraid-integration.spec.ts
Normal file
217
packages/entraid/integration-tests/entraid-integration.spec.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { BasicAuth } from '@redis/client/dist/lib/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 { loadFromFile, RedisEndpointsConfig } from '@redis/test-utils/lib/cae-client-testing';
|
||||
import { EntraidCredentialsProvider } from '../lib/entraid-credentials-provider';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
describe('EntraID Integration Tests', () => {
|
||||
|
||||
it('client configured with client secret should be able to authenticate/re-authenticate', async () => {
|
||||
const config = await readConfigFromEnv();
|
||||
await runAuthenticationTest(() =>
|
||||
EntraIdCredentialsProviderFactory.createForClientCredentials({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId },
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.0001
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('client configured with client certificate should be able to authenticate/re-authenticate', async () => {
|
||||
const config = await readConfigFromEnv();
|
||||
await runAuthenticationTest(() =>
|
||||
EntraIdCredentialsProviderFactory.createForClientCredentialsWithCertificate({
|
||||
clientId: config.clientId,
|
||||
certificate: convertCertsForMSAL(config.cert, config.privateKey),
|
||||
authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId },
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.0001
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('client with system managed identity should be able to authenticate/re-authenticate', async () => {
|
||||
const config = await readConfigFromEnv();
|
||||
await runAuthenticationTest(() =>
|
||||
EntraIdCredentialsProviderFactory.createForSystemAssignedManagedIdentity({
|
||||
clientId: config.clientId,
|
||||
authorityConfig: { type: 'multi-tenant', tenantId: config.tenantId },
|
||||
tokenManagerConfig: {
|
||||
expirationRefreshRatio: 0.00001
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
interface TestConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authority: string;
|
||||
tenantId: string;
|
||||
redisScopes: string;
|
||||
cert: string;
|
||||
privateKey: string;
|
||||
userAssignedManagedId: string;
|
||||
endpoints: RedisEndpointsConfig;
|
||||
}
|
||||
|
||||
const readConfigFromEnv = async (): Promise<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]) => {
|
||||
if (value == undefined) {
|
||||
throw new Error(`${key} environment variable must be set`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
endpoints: await loadFromFile(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
|
||||
};
|
||||
};
|
||||
|
||||
interface TokenDetail {
|
||||
token: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
lifetime: number;
|
||||
uti: string;
|
||||
}
|
||||
|
||||
const setupTestClient = async (credentialsProvider: EntraidCredentialsProvider) => {
|
||||
const config = await readConfigFromEnv();
|
||||
const client = createClient({
|
||||
url: config.endpoints['standalone-entraid-acl'].endpoints[0],
|
||||
credentialsProvider
|
||||
});
|
||||
|
||||
const clientInstance = (client as any)._self;
|
||||
const reAuthSpy: SinonSpy = spy(clientInstance, 'reAuthenticate');
|
||||
|
||||
return { client, reAuthSpy };
|
||||
};
|
||||
|
||||
const runClientOperations = async (client: any) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const validateTokens = (reAuthSpy: SinonSpy) => {
|
||||
assert(reAuthSpy.callCount >= 1,
|
||||
`reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`);
|
||||
|
||||
const tokenDetails: TokenDetail[] = 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(', ')}`
|
||||
);
|
||||
};
|
||||
|
||||
const runAuthenticationTest = async (setupCredentialsProvider: () => any) => {
|
||||
const { client, reAuthSpy } = await setupTestClient(setupCredentialsProvider());
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await runClientOperations(client);
|
||||
validateTokens(reAuthSpy);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
function getCertificate(certBase64) {
|
||||
try {
|
||||
const decodedCert = Buffer.from(certBase64, 'base64');
|
||||
const cert = new crypto.X509Certificate(decodedCert);
|
||||
return cert;
|
||||
} catch (error) {
|
||||
console.error('Error parsing certificate:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getCertificateThumbprint(certBase64) {
|
||||
const cert = getCertificate(certBase64);
|
||||
return cert.fingerprint.replace(/:/g, '');
|
||||
}
|
||||
|
||||
function convertCertsForMSAL(certBase64, privateKeyBase64) {
|
||||
const thumbprint = getCertificateThumbprint(certBase64);
|
||||
|
||||
const privateKeyPEM = `-----BEGIN PRIVATE KEY-----\n${privateKeyBase64}\n-----END PRIVATE KEY-----`;
|
||||
|
||||
return {
|
||||
thumbprint: thumbprint,
|
||||
privateKey: privateKeyPEM,
|
||||
x5c: certBase64
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
371
packages/entraid/lib/entra-id-credentials-provider-factory.ts
Normal file
371
packages/entraid/lib/entra-id-credentials-provider-factory.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { NetworkError } from '@azure/msal-common';
|
||||
import {
|
||||
LogLevel,
|
||||
ManagedIdentityApplication,
|
||||
ManagedIdentityConfiguration,
|
||||
AuthenticationResult,
|
||||
PublicClientApplication,
|
||||
ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo
|
||||
} from '@azure/msal-node';
|
||||
import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } from '@redis/client/dist/lib/authx';
|
||||
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
|
||||
import { MSALIdentityProvider } from './msal-identity-provider';
|
||||
|
||||
/**
|
||||
* This class is used to create credentials providers for different types of authentication flows.
|
||||
*/
|
||||
export class EntraIdCredentialsProviderFactory {
|
||||
|
||||
/**
|
||||
* This method is used to create a ManagedIdentityProvider for both system-assigned and user-assigned managed identities.
|
||||
*
|
||||
* @param params
|
||||
* @param userAssignedClientId For user-assigned managed identities, the developer needs to pass either the client ID,
|
||||
* full resource identifier, or the object ID of the managed identity when creating ManagedIdentityApplication.
|
||||
*
|
||||
*/
|
||||
public static createManagedIdentityProvider(
|
||||
params: CredentialParams, userAssignedClientId?: string
|
||||
): EntraidCredentialsProvider {
|
||||
const config: ManagedIdentityConfiguration = {
|
||||
// For user-assigned identity, include the client ID
|
||||
...(userAssignedClientId && {
|
||||
managedIdentityIdParams: {
|
||||
userAssignedClientId
|
||||
}
|
||||
}),
|
||||
system: {
|
||||
loggerOptions
|
||||
}
|
||||
};
|
||||
|
||||
const client = new ManagedIdentityApplication(config);
|
||||
|
||||
const idp = new MSALIdentityProvider(
|
||||
() => client.acquireToken({
|
||||
resource: params.scopes?.[0] ?? REDIS_SCOPE,
|
||||
forceRefresh: true
|
||||
}).then(x => x === null ? Promise.reject('Token is null') : x)
|
||||
);
|
||||
|
||||
return new EntraidCredentialsProvider(
|
||||
new TokenManager(idp, params.tokenManagerConfig),
|
||||
idp,
|
||||
{ onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: OID_CREDENTIALS_MAPPER }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a credentials provider for system-assigned managed identities.
|
||||
* @param params
|
||||
*/
|
||||
static createForSystemAssignedManagedIdentity(
|
||||
params: CredentialParams
|
||||
): EntraidCredentialsProvider {
|
||||
return this.createManagedIdentityProvider(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a credentials provider for user-assigned managed identities.
|
||||
* It will include the client ID as the userAssignedClientId in the ManagedIdentityConfiguration.
|
||||
* @param params
|
||||
*/
|
||||
static createForUserAssignedManagedIdentity(
|
||||
params: CredentialParams & { userAssignedClientId: string }
|
||||
): EntraidCredentialsProvider {
|
||||
return this.createManagedIdentityProvider(params, params.userAssignedClientId);
|
||||
}
|
||||
|
||||
static #createForClientCredentials(
|
||||
authConfig: NodeAuthOptions,
|
||||
params: CredentialParams
|
||||
): EntraidCredentialsProvider {
|
||||
const config: Configuration = {
|
||||
auth: {
|
||||
...authConfig,
|
||||
authority: this.getAuthority(params.authorityConfig ?? { type: 'default' })
|
||||
},
|
||||
system: {
|
||||
loggerOptions
|
||||
}
|
||||
};
|
||||
|
||||
const client = new ConfidentialClientApplication(config);
|
||||
|
||||
const idp = new MSALIdentityProvider(
|
||||
() => client.acquireTokenByClientCredential({
|
||||
skipCache: true,
|
||||
scopes: params.scopes ?? [REDIS_SCOPE_DEFAULT]
|
||||
}).then(x => x === null ? Promise.reject('Token is null') : x)
|
||||
);
|
||||
|
||||
return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp,
|
||||
{
|
||||
onReAuthenticationError: params.onReAuthenticationError,
|
||||
credentialsMapper: OID_CREDENTIALS_MAPPER
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a credentials provider for service principals using certificate.
|
||||
* @param params
|
||||
*/
|
||||
static createForClientCredentialsWithCertificate(
|
||||
params: ClientCredentialsWithCertificateParams
|
||||
): EntraidCredentialsProvider {
|
||||
return this.#createForClientCredentials(
|
||||
{
|
||||
clientId: params.clientId,
|
||||
clientCertificate: params.certificate
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a credentials provider for service principals using client secret.
|
||||
* @param params
|
||||
*/
|
||||
static createForClientCredentials(
|
||||
params: ClientSecretCredentialsParams
|
||||
): EntraidCredentialsProvider {
|
||||
return this.#createForClientCredentials(
|
||||
{
|
||||
clientId: params.clientId,
|
||||
clientSecret: params.clientSecret
|
||||
},
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to create a credentials provider for the Authorization Code Flow with PKCE.
|
||||
* @param params
|
||||
*/
|
||||
static createForAuthorizationCodeWithPKCE(
|
||||
params: AuthCodePKCEParams
|
||||
): {
|
||||
getPKCECodes: () => Promise<{
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
challengeMethod: string;
|
||||
}>;
|
||||
getAuthCodeUrl: (
|
||||
pkceCodes: { challenge: string; challengeMethod: string }
|
||||
) => Promise<string>;
|
||||
createCredentialsProvider: (
|
||||
params: PKCEParams
|
||||
) => EntraidCredentialsProvider;
|
||||
} {
|
||||
|
||||
const requiredScopes = ['user.read', 'offline_access'];
|
||||
const scopes = [...new Set([...(params.scopes || []), ...requiredScopes])];
|
||||
|
||||
const authFlow = AuthCodeFlowHelper.create({
|
||||
clientId: params.clientId,
|
||||
redirectUri: params.redirectUri,
|
||||
scopes: scopes,
|
||||
authorityConfig: params.authorityConfig
|
||||
});
|
||||
|
||||
return {
|
||||
getPKCECodes: AuthCodeFlowHelper.generatePKCE,
|
||||
getAuthCodeUrl: (pkceCodes) => authFlow.getAuthCodeUrl(pkceCodes),
|
||||
createCredentialsProvider: (pkceParams) => {
|
||||
|
||||
// This is used to store the initial credentials account to be used
|
||||
// for silent token acquisition after the initial token acquisition.
|
||||
let initialCredentialsAccount: AccountInfo | null = null;
|
||||
|
||||
const idp = new MSALIdentityProvider(
|
||||
async () => {
|
||||
if (!initialCredentialsAccount) {
|
||||
let authResult = await authFlow.acquireTokenByCode(pkceParams);
|
||||
initialCredentialsAccount = authResult.account;
|
||||
return authResult;
|
||||
} else {
|
||||
return authFlow.client.acquireTokenSilent({
|
||||
forceRefresh: true,
|
||||
account: initialCredentialsAccount,
|
||||
scopes
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
const tm = new TokenManager(idp, params.tokenManagerConfig);
|
||||
return new EntraidCredentialsProvider(tm, idp, { onReAuthenticationError: params.onReAuthenticationError });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getAuthority(config: AuthorityConfig): string {
|
||||
switch (config.type) {
|
||||
case 'multi-tenant':
|
||||
return `https://login.microsoftonline.com/${config.tenantId}`;
|
||||
case 'custom':
|
||||
return config.authorityUrl;
|
||||
case 'default':
|
||||
return 'https://login.microsoftonline.com/common';
|
||||
default:
|
||||
throw new Error('Invalid authority configuration');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default';
|
||||
const REDIS_SCOPE = 'https://redis.azure.com'
|
||||
|
||||
export type AuthorityConfig =
|
||||
| { type: 'multi-tenant'; tenantId: string }
|
||||
| { type: 'custom'; authorityUrl: string }
|
||||
| { type: 'default' };
|
||||
|
||||
export type PKCEParams = {
|
||||
code: string;
|
||||
verifier: string;
|
||||
clientInfo?: string;
|
||||
}
|
||||
|
||||
export type CredentialParams = {
|
||||
clientId: string;
|
||||
scopes?: string[];
|
||||
authorityConfig?: AuthorityConfig;
|
||||
|
||||
tokenManagerConfig: TokenManagerConfig
|
||||
onReAuthenticationError?: (error: ReAuthenticationError) => void;
|
||||
}
|
||||
|
||||
export type AuthCodePKCEParams = CredentialParams & {
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
export type ClientSecretCredentialsParams = CredentialParams & {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type ClientCredentialsWithCertificateParams = CredentialParams & {
|
||||
certificate: {
|
||||
thumbprint: string;
|
||||
privateKey: string;
|
||||
x5c?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const loggerOptions = {
|
||||
loggerCallback(loglevel: LogLevel, message: string, containsPii: boolean) {
|
||||
if (!containsPii) console.log(message);
|
||||
},
|
||||
piiLoggingEnabled: false,
|
||||
logLevel: LogLevel.Error
|
||||
}
|
||||
|
||||
/**
|
||||
* The most important part of the RetryPolicy is the `isRetryable` function. This function is used to determine if a request should be retried based
|
||||
* on the error returned from the identity provider. The default for is to retry on network errors only.
|
||||
*/
|
||||
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
||||
// currently only retry on network errors
|
||||
isRetryable: (error: unknown) => error instanceof NetworkError,
|
||||
maxAttempts: 10,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 100000,
|
||||
backoffMultiplier: 2,
|
||||
jitterPercentage: 0.1
|
||||
|
||||
};
|
||||
|
||||
export const DEFAULT_TOKEN_MANAGER_CONFIG: TokenManagerConfig = {
|
||||
retry: DEFAULT_RETRY_POLICY,
|
||||
expirationRefreshRatio: 0.7 // Refresh token when 70% of the token has expired
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is used to help with the Authorization Code Flow with PKCE.
|
||||
* It provides methods to generate PKCE codes, get the authorization URL, and create the credential provider.
|
||||
*/
|
||||
export class AuthCodeFlowHelper {
|
||||
private constructor(
|
||||
readonly client: PublicClientApplication,
|
||||
readonly scopes: string[],
|
||||
readonly redirectUri: string
|
||||
) {}
|
||||
|
||||
async getAuthCodeUrl(pkceCodes: {
|
||||
challenge: string;
|
||||
challengeMethod: string;
|
||||
}): Promise<string> {
|
||||
const authCodeUrlParameters: AuthorizationUrlRequest = {
|
||||
scopes: this.scopes,
|
||||
redirectUri: this.redirectUri,
|
||||
codeChallenge: pkceCodes.challenge,
|
||||
codeChallengeMethod: pkceCodes.challengeMethod
|
||||
};
|
||||
|
||||
return this.client.getAuthCodeUrl(authCodeUrlParameters);
|
||||
}
|
||||
|
||||
async acquireTokenByCode(params: PKCEParams): Promise<AuthenticationResult> {
|
||||
const tokenRequest: AuthorizationCodeRequest = {
|
||||
code: params.code,
|
||||
scopes: this.scopes,
|
||||
redirectUri: this.redirectUri,
|
||||
codeVerifier: params.verifier,
|
||||
clientInfo: params.clientInfo
|
||||
};
|
||||
|
||||
return this.client.acquireTokenByCode(tokenRequest);
|
||||
}
|
||||
|
||||
static async generatePKCE(): Promise<{
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
challengeMethod: string;
|
||||
}> {
|
||||
const cryptoProvider = new CryptoProvider();
|
||||
const { verifier, challenge } = await cryptoProvider.generatePkceCodes();
|
||||
return {
|
||||
verifier,
|
||||
challenge,
|
||||
challengeMethod: 'S256'
|
||||
};
|
||||
}
|
||||
|
||||
static create(params: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes?: string[];
|
||||
authorityConfig?: AuthorityConfig;
|
||||
}): AuthCodeFlowHelper {
|
||||
const config = {
|
||||
auth: {
|
||||
clientId: params.clientId,
|
||||
authority: EntraIdCredentialsProviderFactory.getAuthority(params.authorityConfig ?? { type: 'default' })
|
||||
},
|
||||
system: {
|
||||
loggerOptions
|
||||
}
|
||||
};
|
||||
|
||||
return new AuthCodeFlowHelper(
|
||||
new PublicClientApplication(config),
|
||||
params.scopes ?? ['user.read'],
|
||||
params.redirectUri
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const OID_CREDENTIALS_MAPPER = (token: AuthenticationResult) => {
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
}
|
199
packages/entraid/lib/entraid-credentials-provider.spec.ts
Normal file
199
packages/entraid/lib/entraid-credentials-provider.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { AuthenticationResult } from '@azure/msal-node';
|
||||
import { IdentityProvider, TokenManager, TokenResponse, BasicAuth } from '@redis/client/dist/lib/authx';
|
||||
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { GLOBAL, testUtils } from './test-utils'
|
||||
|
||||
|
||||
describe('EntraID authentication in cluster mode', () => {
|
||||
|
||||
testUtils.testWithCluster('sendCommand', async cluster => {
|
||||
assert.equal(
|
||||
await cluster.sendCommand(undefined, true, ['PING']),
|
||||
'PONG'
|
||||
);
|
||||
}, GLOBAL.CLUSTERS.PASSWORD_WITH_REPLICAS);
|
||||
})
|
||||
|
||||
describe('EntraID CredentialsProvider Subscription Behavior', () => {
|
||||
|
||||
it('should properly handle token refresh sequence for multiple subscribers', async () => {
|
||||
const networkDelay = 20;
|
||||
const tokenTTL = 100;
|
||||
const refreshRatio = 0.5; // Refresh at 50% of TTL
|
||||
|
||||
const idp = new SequenceEntraIDProvider(tokenTTL, networkDelay);
|
||||
const tokenManager = new TokenManager<AuthenticationResult>(idp, {
|
||||
expirationRefreshRatio: refreshRatio
|
||||
});
|
||||
const entraid = new EntraidCredentialsProvider(tokenManager, idp);
|
||||
|
||||
// Create two initial subscribers
|
||||
const subscriber1 = new TestSubscriber('subscriber1');
|
||||
const subscriber2 = new TestSubscriber('subscriber2');
|
||||
|
||||
assert.equal(entraid.hasActiveSubscriptions(), false, 'There should be no active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 0, 'There should be 0 subscriptions');
|
||||
|
||||
// Start the first two subscriptions almost simultaneously
|
||||
const [sub1Initial, sub2Initial] = await Promise.all([
|
||||
entraid.subscribe(subscriber1),
|
||||
entraid.subscribe(subscriber2)]
|
||||
);
|
||||
|
||||
assertCredentials(sub1Initial[0], 'initial-token', 'Subscriber 1 should receive initial token');
|
||||
assertCredentials(sub2Initial[0], 'initial-token', 'Subscriber 2 should receive initial token');
|
||||
|
||||
assert.equal(entraid.hasActiveSubscriptions(), true, 'There should be active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 2, 'There should be 2 subscriptions');
|
||||
|
||||
// add a third subscriber after a very short delay
|
||||
const subscriber3 = new TestSubscriber('subscriber3');
|
||||
await setTimeout(1);
|
||||
const sub3Initial = await entraid.subscribe(subscriber3)
|
||||
|
||||
assert.equal(entraid.hasActiveSubscriptions(), true, 'There should be active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 3, 'There should be 3 subscriptions');
|
||||
|
||||
// make sure the third subscriber gets the initial token as well
|
||||
assertCredentials(sub3Initial[0], 'initial-token', 'Subscriber 3 should receive initial token');
|
||||
|
||||
// Wait for first refresh (50% of TTL + network delay + small buffer)
|
||||
await setTimeout((tokenTTL * refreshRatio) + networkDelay + 15);
|
||||
|
||||
// All 3 subscribers should receive refresh-token-1
|
||||
assertCredentials(subscriber1.credentials[0], 'refresh-token-1', 'Subscriber 1 should receive first refresh token');
|
||||
assertCredentials(subscriber2.credentials[0], 'refresh-token-1', 'Subscriber 2 should receive first refresh token');
|
||||
assertCredentials(subscriber3.credentials[0], 'refresh-token-1', 'Subscriber 3 should receive first refresh token');
|
||||
|
||||
// Add a late subscriber - should immediately get refresh-token-1
|
||||
const subscriber4 = new TestSubscriber('subscriber4');
|
||||
const sub4Initial = await entraid.subscribe(subscriber4);
|
||||
|
||||
assert.equal(entraid.hasActiveSubscriptions(), true, 'There should be active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 4, 'There should be 4 subscriptions');
|
||||
|
||||
assertCredentials(sub4Initial[0], 'refresh-token-1', 'Late subscriber should receive refresh-token-1');
|
||||
|
||||
// Wait for second refresh
|
||||
await setTimeout((tokenTTL * refreshRatio) + networkDelay + 15);
|
||||
|
||||
assertCredentials(subscriber1.credentials[1], 'refresh-token-2', 'Subscriber 1 should receive second refresh token');
|
||||
assertCredentials(subscriber2.credentials[1], 'refresh-token-2', 'Subscriber 2 should receive second refresh token');
|
||||
assertCredentials(subscriber3.credentials[1], 'refresh-token-2', 'Subscriber 3 should receive second refresh token');
|
||||
|
||||
assertCredentials(subscriber4.credentials[0], 'refresh-token-2', 'Subscriber 4 should receive second refresh token');
|
||||
|
||||
// Verify refreshes happen after minimum expected time
|
||||
const minimumRefreshInterval = tokenTTL * 0.4; // 40% of TTL as safety margin
|
||||
|
||||
verifyRefreshTiming(subscriber1, minimumRefreshInterval);
|
||||
verifyRefreshTiming(subscriber2, minimumRefreshInterval);
|
||||
verifyRefreshTiming(subscriber3, minimumRefreshInterval);
|
||||
verifyRefreshTiming(subscriber4, minimumRefreshInterval);
|
||||
|
||||
// Cleanup
|
||||
|
||||
assert.equal(tokenManager.isRunning(), true);
|
||||
sub1Initial[1].dispose();
|
||||
sub2Initial[1].dispose();
|
||||
sub3Initial[1].dispose();
|
||||
assert.equal(entraid.hasActiveSubscriptions(), true, 'There should be active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 1, 'There should be 1 subscriptions');
|
||||
sub4Initial[1].dispose();
|
||||
assert.equal(entraid.hasActiveSubscriptions(), false, 'There should be no active subscriptions');
|
||||
assert.equal(entraid.getSubscriptionsCount(), 0, 'There should be 0 subscriptions');
|
||||
assert.equal(tokenManager.isRunning(), false)
|
||||
});
|
||||
|
||||
const verifyRefreshTiming = (
|
||||
subscriber: TestSubscriber,
|
||||
expectedMinimumInterval: number,
|
||||
message?: string
|
||||
) => {
|
||||
const intervals = [];
|
||||
for (let i = 1; i < subscriber.timestamps.length; i++) {
|
||||
intervals.push(subscriber.timestamps[i] - subscriber.timestamps[i - 1]);
|
||||
}
|
||||
|
||||
intervals.forEach((interval, index) => {
|
||||
assert.ok(
|
||||
interval > expectedMinimumInterval,
|
||||
message || `Refresh ${index + 1} for ${subscriber.name} should happen after minimum interval of ${expectedMinimumInterval}ms`
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
class SequenceEntraIDProvider implements IdentityProvider<AuthenticationResult> {
|
||||
private currentIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly tokenTTL: number = 100,
|
||||
private tokenDeliveryDelayMs: number = 0,
|
||||
private readonly tokenSequence: AuthenticationResult[] = [
|
||||
{
|
||||
accessToken: 'initial-token',
|
||||
uniqueId: 'test-user'
|
||||
} as AuthenticationResult,
|
||||
{
|
||||
accessToken: 'refresh-token-1',
|
||||
uniqueId: 'test-user'
|
||||
} as AuthenticationResult,
|
||||
{
|
||||
accessToken: 'refresh-token-2',
|
||||
uniqueId: 'test-user'
|
||||
} as AuthenticationResult
|
||||
]
|
||||
) {}
|
||||
|
||||
setTokenDeliveryDelay(delayMs: number): void {
|
||||
this.tokenDeliveryDelayMs = delayMs;
|
||||
}
|
||||
|
||||
async requestToken(): Promise<TokenResponse<AuthenticationResult>> {
|
||||
if (this.tokenDeliveryDelayMs > 0) {
|
||||
await setTimeout(this.tokenDeliveryDelayMs);
|
||||
}
|
||||
|
||||
if (this.currentIndex >= this.tokenSequence.length) {
|
||||
throw new Error('No more tokens in sequence');
|
||||
}
|
||||
|
||||
return {
|
||||
token: this.tokenSequence[this.currentIndex++],
|
||||
ttlMs: this.tokenTTL
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TestSubscriber {
|
||||
public readonly credentials: Array<BasicAuth> = [];
|
||||
public readonly errors: Error[] = [];
|
||||
public readonly timestamps: number[] = [];
|
||||
|
||||
constructor(public readonly name: string = 'unnamed') {}
|
||||
|
||||
onNext = (creds: BasicAuth) => {
|
||||
this.credentials.push(creds);
|
||||
this.timestamps.push(Date.now());
|
||||
}
|
||||
|
||||
onError = (error: Error) => {
|
||||
this.errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the actual credentials match the expected token
|
||||
* @param actual
|
||||
* @param expectedToken
|
||||
* @param message
|
||||
*/
|
||||
const assertCredentials = (actual: BasicAuth, expectedToken: string, message: string) => {
|
||||
assert.deepEqual(actual, {
|
||||
username: 'test-user',
|
||||
password: expectedToken
|
||||
}, message);
|
||||
};
|
||||
});
|
140
packages/entraid/lib/entraid-credentials-provider.ts
Normal file
140
packages/entraid/lib/entraid-credentials-provider.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { AuthenticationResult } from '@azure/msal-common/node';
|
||||
import {
|
||||
BasicAuth, StreamingCredentialsProvider, IdentityProvider, TokenManager,
|
||||
ReAuthenticationError, StreamingCredentialsListener, IDPError, Token, Disposable
|
||||
} from '@redis/client/dist/lib/authx';
|
||||
|
||||
/**
|
||||
* A streaming credentials provider that uses the Entraid identity provider to provide credentials.
|
||||
* Please use one of the factory functions in `entraid-credetfactories.ts` to create an instance of this class for the different
|
||||
* type of authentication flows.
|
||||
*/
|
||||
export class EntraidCredentialsProvider implements StreamingCredentialsProvider {
|
||||
readonly type = 'streaming-credentials-provider';
|
||||
|
||||
readonly #listeners: Set<StreamingCredentialsListener<BasicAuth>> = new Set();
|
||||
|
||||
#tokenManagerDisposable: Disposable | null = null;
|
||||
#isStarting: boolean = false;
|
||||
|
||||
#pendingSubscribers: Array<{
|
||||
resolve: (value: [BasicAuth, Disposable]) => void;
|
||||
reject: (error: Error) => void;
|
||||
pendingListener: StreamingCredentialsListener<BasicAuth>;
|
||||
}> = [];
|
||||
|
||||
constructor(
|
||||
public readonly tokenManager: TokenManager<AuthenticationResult>,
|
||||
public readonly idp: IdentityProvider<AuthenticationResult>,
|
||||
private readonly options: {
|
||||
onReAuthenticationError?: (error: ReAuthenticationError) => void;
|
||||
credentialsMapper?: (token: AuthenticationResult) => BasicAuth;
|
||||
onRetryableError?: (error: string) => void;
|
||||
} = {}
|
||||
) {
|
||||
this.onReAuthenticationError = options.onReAuthenticationError ?? DEFAULT_ERROR_HANDLER;
|
||||
this.#credentialsMapper = options.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER;
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
listener: StreamingCredentialsListener<BasicAuth>
|
||||
): Promise<[BasicAuth, Disposable]> {
|
||||
|
||||
const currentToken = this.tokenManager.getCurrentToken();
|
||||
|
||||
if (currentToken) {
|
||||
return [this.#credentialsMapper(currentToken.value), this.#createDisposable(listener)];
|
||||
}
|
||||
|
||||
if (this.#isStarting) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pendingSubscribers.push({ resolve, reject, pendingListener: listener });
|
||||
});
|
||||
}
|
||||
|
||||
this.#isStarting = true;
|
||||
try {
|
||||
const initialToken = await this.#startTokenManagerAndObtainInitialToken();
|
||||
|
||||
this.#pendingSubscribers.forEach(({ resolve, pendingListener }) => {
|
||||
resolve([this.#credentialsMapper(initialToken.value), this.#createDisposable(pendingListener)]);
|
||||
});
|
||||
this.#pendingSubscribers = [];
|
||||
|
||||
return [this.#credentialsMapper(initialToken.value), this.#createDisposable(listener)];
|
||||
} finally {
|
||||
this.#isStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
onReAuthenticationError: (error: ReAuthenticationError) => void;
|
||||
|
||||
#credentialsMapper: (token: AuthenticationResult) => BasicAuth;
|
||||
|
||||
#createTokenManagerListener(subscribers: Set<StreamingCredentialsListener<BasicAuth>>) {
|
||||
return {
|
||||
onError: (error: IDPError): void => {
|
||||
if (!error.isRetryable) {
|
||||
subscribers.forEach(listener => listener.onError(error));
|
||||
} else {
|
||||
this.options.onRetryableError?.(error.message);
|
||||
}
|
||||
},
|
||||
onNext: (token: { value: AuthenticationResult }): void => {
|
||||
const credentials = this.#credentialsMapper(token.value);
|
||||
subscribers.forEach(listener => listener.onNext(credentials));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#createDisposable(listener: StreamingCredentialsListener<BasicAuth>): Disposable {
|
||||
this.#listeners.add(listener);
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this.#listeners.delete(listener);
|
||||
if (this.#listeners.size === 0 && this.#tokenManagerDisposable) {
|
||||
this.#tokenManagerDisposable.dispose();
|
||||
this.#tokenManagerDisposable = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async #startTokenManagerAndObtainInitialToken(): Promise<Token<AuthenticationResult>> {
|
||||
const initialResponse = await this.idp.requestToken();
|
||||
const token = this.tokenManager.wrapAndSetCurrentToken(initialResponse.token, initialResponse.ttlMs);
|
||||
|
||||
this.#tokenManagerDisposable = this.tokenManager.start(
|
||||
this.#createTokenManagerListener(this.#listeners),
|
||||
this.tokenManager.calculateRefreshTime(token)
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
public hasActiveSubscriptions(): boolean {
|
||||
return this.#tokenManagerDisposable !== null && this.#listeners.size > 0;
|
||||
}
|
||||
|
||||
public getSubscriptionsCount(): number {
|
||||
return this.#listeners.size;
|
||||
}
|
||||
|
||||
public getTokenManager() {
|
||||
return this.tokenManager;
|
||||
}
|
||||
|
||||
public getCurrentCredentials(): BasicAuth | null {
|
||||
const currentToken = this.tokenManager.getCurrentToken();
|
||||
return currentToken ? this.#credentialsMapper(currentToken.value) : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResult): BasicAuth => ({
|
||||
username: token.uniqueId,
|
||||
password: token.accessToken
|
||||
});
|
||||
|
||||
const DEFAULT_ERROR_HANDLER = (error: ReAuthenticationError) =>
|
||||
console.error('ReAuthenticationError', error);
|
3
packages/entraid/lib/index.ts
Normal file
3
packages/entraid/lib/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './entra-id-credentials-provider-factory';
|
||||
export * from './entraid-credentials-provider';
|
||||
export * from './msal-identity-provider';
|
31
packages/entraid/lib/msal-identity-provider.ts
Normal file
31
packages/entraid/lib/msal-identity-provider.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
AuthenticationResult
|
||||
} from '@azure/msal-node';
|
||||
import { IdentityProvider, TokenResponse } from '@redis/client/dist/lib/authx';
|
||||
|
||||
export class MSALIdentityProvider implements IdentityProvider<AuthenticationResult> {
|
||||
private readonly getToken: () => Promise<AuthenticationResult>;
|
||||
|
||||
constructor(getToken: () => Promise<AuthenticationResult>) {
|
||||
this.getToken = getToken;
|
||||
}
|
||||
|
||||
async requestToken(): Promise<TokenResponse<AuthenticationResult>> {
|
||||
try {
|
||||
const result = await this.getToken();
|
||||
|
||||
if (!result?.accessToken || !result?.expiresOn) {
|
||||
throw new Error('Invalid token response');
|
||||
}
|
||||
return {
|
||||
token: result,
|
||||
ttlMs: result.expiresOn.getTime() - Date.now()
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
46
packages/entraid/lib/test-utils.ts
Normal file
46
packages/entraid/lib/test-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AuthenticationResult } from '@azure/msal-node';
|
||||
import { IdentityProvider, StreamingCredentialsProvider, TokenManager, TokenResponse } from '@redis/client/dist/lib/authx';
|
||||
import TestUtils from '@redis/test-utils';
|
||||
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
|
||||
|
||||
export const testUtils = new TestUtils({
|
||||
dockerImageName: 'redis/redis-stack',
|
||||
dockerImageVersionArgument: 'redis-version',
|
||||
defaultDockerVersion: '7.4.0-v1'
|
||||
});
|
||||
|
||||
const DEBUG_MODE_ARGS = testUtils.isVersionGreaterThan([7]) ?
|
||||
['--enable-debug-command', 'yes'] :
|
||||
[];
|
||||
|
||||
const idp: IdentityProvider<AuthenticationResult> = {
|
||||
requestToken(): Promise<TokenResponse<AuthenticationResult>> {
|
||||
// @ts-ignore
|
||||
return Promise.resolve({
|
||||
ttlMs: 100000,
|
||||
token: {
|
||||
accessToken: 'password'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tokenManager = new TokenManager<AuthenticationResult>(idp, { expirationRefreshRatio: 0.8 });
|
||||
const entraIdCredentialsProvider: StreamingCredentialsProvider = new EntraidCredentialsProvider(tokenManager, idp)
|
||||
|
||||
const PASSWORD_WITH_REPLICAS = {
|
||||
serverArguments: ['--requirepass', 'password', ...DEBUG_MODE_ARGS],
|
||||
numberOfMasters: 2,
|
||||
numberOfReplicas: 1,
|
||||
clusterConfiguration: {
|
||||
defaults: {
|
||||
credentialsProvider: entraIdCredentialsProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GLOBAL = {
|
||||
CLUSTERS: {
|
||||
PASSWORD_WITH_REPLICAS
|
||||
}
|
||||
}
|
47
packages/entraid/package.json
Normal file
47
packages/entraid/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@redis/entraid",
|
||||
"version": "5.0.0-next.5",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/",
|
||||
"!dist/tsconfig.tsbuildinfo"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && tsc",
|
||||
"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": {
|
||||
"@azure/msal-node": "^2.16.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^5.0.0-next.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.21.1",
|
||||
"express-session": "^1.18.1",
|
||||
"@redis/test-utils": "*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/redis/node-redis.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/redis/node-redis/issues"
|
||||
},
|
||||
"homepage": "https://github.com/redis/node-redis/tree/master/packages/entraid",
|
||||
"keywords": [
|
||||
"redis"
|
||||
]
|
||||
}
|
153
packages/entraid/samples/auth-code-pkce/index.ts
Normal file
153
packages/entraid/samples/auth-code-pkce/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import session from 'express-session';
|
||||
import dotenv from 'dotenv';
|
||||
import { DEFAULT_TOKEN_MANAGER_CONFIG, EntraIdCredentialsProviderFactory } from '../../lib/entra-id-credentials-provider-factory';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
if (!process.env.SESSION_SECRET) {
|
||||
throw new Error('SESSION_SECRET environment variable must be set');
|
||||
}
|
||||
|
||||
interface PKCESession extends session.Session {
|
||||
pkceCodes?: {
|
||||
verifier: string;
|
||||
challenge: string;
|
||||
challengeMethod: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
session: PKCESession;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
const sessionConfig = {
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // Only use secure in production
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 3600000 // 1 hour
|
||||
}
|
||||
} as const;
|
||||
|
||||
app.use(session(sessionConfig));
|
||||
|
||||
if (!process.env.MSAL_CLIENT_ID || !process.env.MSAL_TENANT_ID) {
|
||||
throw new Error('MSAL_CLIENT_ID and MSAL_TENANT_ID environment variables must be set');
|
||||
}
|
||||
|
||||
// Initialize MSAL provider with authorization code PKCE flow
|
||||
const {
|
||||
getPKCECodes,
|
||||
createCredentialsProvider,
|
||||
getAuthCodeUrl
|
||||
} = EntraIdCredentialsProviderFactory.createForAuthorizationCodeWithPKCE({
|
||||
clientId: process.env.MSAL_CLIENT_ID,
|
||||
redirectUri: process.env.REDIRECT_URI || 'http://localhost:3000/redirect',
|
||||
authorityConfig: { type: 'multi-tenant', tenantId: process.env.MSAL_TENANT_ID },
|
||||
tokenManagerConfig: DEFAULT_TOKEN_MANAGER_CONFIG
|
||||
});
|
||||
|
||||
app.get('/login', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// Generate PKCE Codes before starting the authorization flow
|
||||
const pkceCodes = await getPKCECodes();
|
||||
|
||||
// Store PKCE codes in session
|
||||
req.session.pkceCodes = pkceCodes
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.save((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const authUrl = await getAuthCodeUrl({
|
||||
challenge: pkceCodes.challenge,
|
||||
challengeMethod: pkceCodes.challengeMethod
|
||||
});
|
||||
|
||||
res.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('Login flow failed:', error);
|
||||
res.status(500).send('Authentication failed');
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/redirect', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
|
||||
// The authorization code is in req.query.code
|
||||
const { code, client_info } = req.query;
|
||||
const { pkceCodes } = req.session;
|
||||
|
||||
if (!pkceCodes) {
|
||||
console.error('Session state:', {
|
||||
hasSession: !!req.session,
|
||||
sessionID: req.sessionID,
|
||||
pkceCodes: req.session.pkceCodes
|
||||
});
|
||||
return res.status(400).send('PKCE codes not found in session');
|
||||
}
|
||||
|
||||
// Check both possible error scenarios
|
||||
if (req.query.error) {
|
||||
console.error('OAuth error:', req.query.error, req.query.error_description);
|
||||
return res.status(400).send(`OAuth error: ${req.query.error_description || req.query.error}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('Missing authorization code. Query parameters received:', req.query);
|
||||
return res.status(400).send('Authorization code not found in request. Query params: ' + JSON.stringify(req.query));
|
||||
}
|
||||
|
||||
// Configure with the received code
|
||||
const entraidCredentialsProvider = createCredentialsProvider(
|
||||
{
|
||||
code: code as string,
|
||||
verifier: pkceCodes.verifier,
|
||||
clientInfo: client_info as string | undefined
|
||||
},
|
||||
);
|
||||
|
||||
const initialCredentials = entraidCredentialsProvider.subscribe({
|
||||
onNext: (token) => {
|
||||
console.log('Token acquired:', token);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Token acquisition failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const [credentials] = await initialCredentials;
|
||||
|
||||
console.log('Credentials acquired:', credentials)
|
||||
|
||||
// Clear sensitive data
|
||||
delete req.session.pkceCodes;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.save((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ message: 'Authentication successful' });
|
||||
} catch (error) {
|
||||
console.error('Token acquisition failed:', error);
|
||||
res.status(500).send('Failed to acquire token');
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Login URL: http://localhost:${PORT}/login`);
|
||||
});
|
10
packages/entraid/tsconfig.integration-tests.json
Normal file
10
packages/entraid/tsconfig.integration-tests.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"./integration-tests/**/*.ts",
|
||||
"./lib/**/*.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
}
|
20
packages/entraid/tsconfig.json
Normal file
20
packages/entraid/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./lib/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"./lib/**/*.spec.ts",
|
||||
"./lib/test-util.ts",
|
||||
],
|
||||
"typedocOptions": {
|
||||
"entryPoints": [
|
||||
"./lib"
|
||||
],
|
||||
"entryPointStrategy": "expand",
|
||||
"out": "../../documentation/entraid"
|
||||
}
|
||||
}
|
10
packages/entraid/tsconfig.samples.json
Normal file
10
packages/entraid/tsconfig.samples.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"./samples/**/*.ts",
|
||||
"./lib/**/*.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
30
packages/test-utils/lib/cae-client-testing.ts
Normal file
30
packages/test-utils/lib/cae-client-testing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFromFile(path: string): Promise<RedisEndpointsConfig> {
|
||||
try {
|
||||
const configFile = await readFile(path, 'utf-8');
|
||||
return loadFromJson(configFile);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
throw new Error(`Config file not found at path: ${path}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
@@ -1,3 +1,4 @@
|
||||
import { RedisClusterClientOptions } from '@redis/client/dist/lib/cluster';
|
||||
import { createConnection } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { createClient } from '@redis/client/index';
|
||||
@@ -102,7 +103,8 @@ async function spawnRedisClusterNodeDockers(
|
||||
dockersConfig: RedisClusterDockersConfig,
|
||||
serverArguments: Array<string>,
|
||||
fromSlot: number,
|
||||
toSlot: number
|
||||
toSlot: number,
|
||||
clientConfig?: Partial<RedisClusterClientOptions>
|
||||
) {
|
||||
const range: Array<number> = [];
|
||||
for (let i = fromSlot; i < toSlot; i++) {
|
||||
@@ -111,7 +113,8 @@ async function spawnRedisClusterNodeDockers(
|
||||
|
||||
const master = await spawnRedisClusterNodeDocker(
|
||||
dockersConfig,
|
||||
serverArguments
|
||||
serverArguments,
|
||||
clientConfig
|
||||
);
|
||||
|
||||
await master.client.clusterAddSlots(range);
|
||||
@@ -127,7 +130,13 @@ async function spawnRedisClusterNodeDockers(
|
||||
'yes',
|
||||
'--cluster-node-timeout',
|
||||
'5000'
|
||||
]).then(async replica => {
|
||||
], clientConfig).then(async replica => {
|
||||
|
||||
const requirePassIndex = serverArguments.findIndex((x)=>x==='--requirepass');
|
||||
if(requirePassIndex!==-1) {
|
||||
const password = serverArguments[requirePassIndex+1];
|
||||
await replica.client.configSet({'masterauth': password})
|
||||
}
|
||||
await replica.client.clusterMeet('127.0.0.1', master.docker.port);
|
||||
|
||||
while ((await replica.client.clusterSlots()).length === 0) {
|
||||
@@ -151,7 +160,8 @@ async function spawnRedisClusterNodeDockers(
|
||||
|
||||
async function spawnRedisClusterNodeDocker(
|
||||
dockersConfig: RedisClusterDockersConfig,
|
||||
serverArguments: Array<string>
|
||||
serverArguments: Array<string>,
|
||||
clientConfig?: Partial<RedisClusterClientOptions>
|
||||
) {
|
||||
const docker = await spawnRedisServerDocker(dockersConfig, [
|
||||
...serverArguments,
|
||||
@@ -163,7 +173,8 @@ async function spawnRedisClusterNodeDocker(
|
||||
client = createClient({
|
||||
socket: {
|
||||
port: docker.port
|
||||
}
|
||||
},
|
||||
...clientConfig
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
@@ -178,7 +189,8 @@ const SLOTS = 16384;
|
||||
|
||||
async function spawnRedisClusterDockers(
|
||||
dockersConfig: RedisClusterDockersConfig,
|
||||
serverArguments: Array<string>
|
||||
serverArguments: Array<string>,
|
||||
clientConfig?: Partial<RedisClusterClientOptions>
|
||||
): Promise<Array<RedisServerDocker>> {
|
||||
const numberOfMasters = dockersConfig.numberOfMasters ?? 2,
|
||||
slotsPerNode = Math.floor(SLOTS / numberOfMasters),
|
||||
@@ -191,7 +203,8 @@ async function spawnRedisClusterDockers(
|
||||
dockersConfig,
|
||||
serverArguments,
|
||||
fromSlot,
|
||||
toSlot
|
||||
toSlot,
|
||||
clientConfig
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -234,13 +247,18 @@ function totalNodes(slots: any) {
|
||||
|
||||
const RUNNING_CLUSTERS = new Map<Array<string>, ReturnType<typeof spawnRedisClusterDockers>>();
|
||||
|
||||
export function spawnRedisCluster(dockersConfig: RedisClusterDockersConfig, serverArguments: Array<string>): Promise<Array<RedisServerDocker>> {
|
||||
export function spawnRedisCluster(
|
||||
dockersConfig: RedisClusterDockersConfig,
|
||||
serverArguments: Array<string>,
|
||||
clientConfig?: Partial<RedisClusterClientOptions>): Promise<Array<RedisServerDocker>> {
|
||||
|
||||
const runningCluster = RUNNING_CLUSTERS.get(serverArguments);
|
||||
if (runningCluster) {
|
||||
return runningCluster;
|
||||
}
|
||||
|
||||
const dockersPromise = spawnRedisClusterDockers(dockersConfig, serverArguments);
|
||||
const dockersPromise = spawnRedisClusterDockers(dockersConfig, serverArguments,clientConfig);
|
||||
|
||||
RUNNING_CLUSTERS.set(serverArguments, dockersPromise);
|
||||
return dockersPromise;
|
||||
}
|
||||
|
@@ -290,7 +290,8 @@ export default class TestUtils {
|
||||
...dockerImage,
|
||||
numberOfMasters: options.numberOfMasters,
|
||||
numberOfReplicas: options.numberOfReplicas
|
||||
}, options.serverArguments);
|
||||
}, options.serverArguments,
|
||||
options.clusterConfiguration?.defaults);
|
||||
return dockersPromise;
|
||||
});
|
||||
}
|
||||
|
@@ -1,20 +1,32 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{
|
||||
"path": "./packages/client"
|
||||
}, {
|
||||
"path": "./packages/test-utils"
|
||||
}, {
|
||||
"path": "./packages/bloom"
|
||||
}, {
|
||||
"path": "./packages/graph"
|
||||
}, {
|
||||
"path": "./packages/json"
|
||||
}, {
|
||||
"path": "./packages/search"
|
||||
}, {
|
||||
"path": "./packages/time-series"
|
||||
}, {
|
||||
"path": "./packages/redis"
|
||||
}]
|
||||
"references": [
|
||||
{
|
||||
"path": "./packages/client"
|
||||
},
|
||||
{
|
||||
"path": "./packages/test-utils"
|
||||
},
|
||||
{
|
||||
"path": "./packages/bloom"
|
||||
},
|
||||
{
|
||||
"path": "./packages/graph"
|
||||
},
|
||||
{
|
||||
"path": "./packages/json"
|
||||
},
|
||||
{
|
||||
"path": "./packages/search"
|
||||
},
|
||||
{
|
||||
"path": "./packages/time-series"
|
||||
},
|
||||
{
|
||||
"path": "./packages/entraid"
|
||||
},
|
||||
{
|
||||
"path": "./packages/redis"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user