You've already forked node-redis
mirror of
https://github.com/redis/node-redis.git
synced 2025-08-04 15:02:09 +03:00
* 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 `@`
199 lines
7.7 KiB
TypeScript
199 lines
7.7 KiB
TypeScript
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);
|
|
};
|
|
}); |