From a0c324b9a0e9e11f82256748b93a0f97816f482b Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Wed, 4 Dec 2024 13:50:58 +0200 Subject: [PATCH 1/5] 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 --- .../lib/client/authx/credentials-provider.ts | 107 ++++++++++++++ packages/client/lib/client/index.spec.ts | 121 +++++++++++---- packages/client/lib/client/index.ts | 138 ++++++++++++++++-- packages/client/lib/test-utils.ts | 38 +++++ 4 files changed, 361 insertions(+), 43 deletions(-) create mode 100644 packages/client/lib/client/authx/credentials-provider.ts diff --git a/packages/client/lib/client/authx/credentials-provider.ts b/packages/client/lib/client/authx/credentials-provider.ts new file mode 100644 index 00000000000..319a95b4c93 --- /dev/null +++ b/packages/client/lib/client/authx/credentials-provider.ts @@ -0,0 +1,107 @@ +/** + * Provides credentials asynchronously. + */ +export interface AsyncCredentialsProvider { + readonly type: 'async-credentials-provider'; + credentials: () => Promise +} + +/** + * 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) => 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 = { + onNext: (credentials: T) => void; + onError: (e: Error) => void; +} + +/** + * Disposable is an interface for objects that hold resources that should be released when they are no longer needed. + */ +export type Disposable = { + dispose: () => 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'; + } +} \ No newline at end of file diff --git a/packages/client/lib/client/index.spec.ts b/packages/client/lib/client/index.spec.ts index cd2040ec97f..c71cf1a1fad 100644 --- a/packages/client/lib/client/index.spec.ts +++ b/packages/client/lib/client/index.spec.ts @@ -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; } diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 55355a133dd..88f4ce23f21 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -1,4 +1,5 @@ import COMMANDS from '../commands'; +import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, Disposable } from './authx/credentials-provider'; import RedisSocket, { RedisSocketOptions } from './socket'; import RedisCommandsQueue, { CommandOptions } from './commands-queue'; import { EventEmitter } from 'node:events'; @@ -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; + private credentialsSubscription: Disposable | null = null; + get options(): RedisClientOptions | undefined { return this._self.#options; } @@ -317,6 +338,19 @@ export default class RedisClient< } #initiateOptions(options?: RedisClientOptions): RedisClientOptions | 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,60 @@ export default class RedisClient< ); } - #handshake(selectedDB: number) { + /** + * TODO: Implement re-authentication to support refreshing credentials without reconnecting + * @param credentials + */ + private reAuthenticate = async (credentials: BasicAuth) => { + throw new Error('Not implemented'); + } + + private 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); + console.error('Error during re-authentication', errorMessage); + cp.onReAuthenticationError(new CredentialsError(errorMessage)); + }); + + }, + onError: (e: Error) => { + const errorMessage = `Error from streaming credentials provider: ${e.message}`; + console.error(errorMessage); + 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 +443,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 +494,7 @@ export default class RedisClient< } #initiateSocket(): RedisSocket { - const socketInitiator = () => { + const socketInitiator = async () => { const promises = [], chainId = Symbol('Socket Initiator'); @@ -418,7 +516,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 +1098,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.credentialsSubscription?.dispose(); + this.credentialsSubscription = null; + for (const command of (await this._self.#handshake(selectedDB))) { promises.push( this._self.#queue.addCommand(command, { chainId @@ -1051,6 +1151,8 @@ export default class RedisClient< * @deprecated use .close instead */ QUIT(): Promise { + this.credentialsSubscription?.dispose(); + this.credentialsSubscription = null; return this._self.#socket.quit(async () => { clearTimeout(this._self.#pingTimer); const quitPromise = this._self.#queue.addCommand(['QUIT']); @@ -1089,6 +1191,8 @@ export default class RedisClient< resolve(); }; this._self.#socket.on('data', maybeClose); + this.credentialsSubscription?.dispose(); + this.credentialsSubscription = null; }); } @@ -1099,6 +1203,8 @@ export default class RedisClient< clearTimeout(this._self.#pingTimer); this._self.#queue.flushAll(new DisconnectsClientError()); this._self.#socket.destroy(); + this.credentialsSubscription?.dispose(); + this.credentialsSubscription = null; } ref() { diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 083c9127e5b..55a11d6ea82 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -1,6 +1,7 @@ import TestUtils from '@redis/test-utils'; import { SinonSpy } from 'sinon'; import { setTimeout } from 'node:timers/promises'; +import { CredentialsProvider } from './client/authx/credentials-provider'; 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: { From e7d6f5b780969a442d5f78bb5c0582c9f298f2e4 Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Wed, 11 Dec 2024 13:39:07 +0200 Subject: [PATCH 2/5] 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. --- package-lock.json | 150 ++++- packages/authx/index.ts | 13 + .../lib}/credentials-provider.ts | 7 +- packages/authx/lib/identity-provider.ts | 22 + packages/authx/lib/token-manager.spec.ts | 588 ++++++++++++++++++ packages/authx/lib/token-manager.ts | 264 ++++++++ packages/authx/lib/token.ts | 23 + packages/authx/package.json | 39 ++ packages/authx/tsconfig.json | 21 + packages/client/lib/client/index.ts | 10 +- packages/client/lib/test-utils.ts | 4 +- packages/client/package.json | 3 + tsconfig.json | 47 +- 13 files changed, 1156 insertions(+), 35 deletions(-) create mode 100644 packages/authx/index.ts rename packages/{client/lib/client/authx => authx/lib}/credentials-provider.ts (95%) create mode 100644 packages/authx/lib/identity-provider.ts create mode 100644 packages/authx/lib/token-manager.spec.ts create mode 100644 packages/authx/lib/token-manager.ts create mode 100644 packages/authx/lib/token.ts create mode 100644 packages/authx/package.json create mode 100644 packages/authx/tsconfig.json diff --git a/package-lock.json b/package-lock.json index ba18a98b6a5..a144e852d9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,29 @@ "node": ">=6.0.0" } }, + "node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.2.tgz", + "integrity": "sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "dev": true, @@ -824,6 +847,10 @@ "node": ">=12" } }, + "node_modules/@redis/authx": { + "resolved": "packages/authx", + "link": true + }, "node_modules/@redis/bloom": { "resolved": "packages/bloom", "link": true @@ -1466,6 +1493,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bundle-name": { "version": "4.1.0", "dev": true, @@ -2087,6 +2120,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.656", "dev": true, @@ -4048,11 +4090,66 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/just-extend": { "version": "6.2.0", "dev": true, "license": "MIT" }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -4119,14 +4216,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "dev": true, "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, "node_modules/lodash.uniqby": { @@ -4384,7 +4509,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/mute-stream": { @@ -5880,7 +6004,6 @@ }, "node_modules/safe-buffer": { "version": "5.2.1", - "dev": true, "funding": [ { "type": "github", @@ -6752,7 +6875,6 @@ }, "node_modules/uuid": { "version": "8.3.2", - "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -7127,6 +7249,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/authx": { + "name": "@redis/authx", + "version": "5.0.0-next.5", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.16.1" + }, + "devDependencies": {}, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.0.0-next.5" + } + }, "packages/bloom": { "name": "@redis/bloom", "version": "5.0.0-next.5", @@ -7155,6 +7292,9 @@ }, "engines": { "node": ">= 18" + }, + "peerDependencies": { + "@redis/authx": "^5.0.0-next.5" } }, "packages/graph": { diff --git a/packages/authx/index.ts b/packages/authx/index.ts new file mode 100644 index 00000000000..a9ad4e50e1e --- /dev/null +++ b/packages/authx/index.ts @@ -0,0 +1,13 @@ +export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './lib/token-manager'; +export { + CredentialsProvider, + StreamingCredentialsProvider, + UnableToObtainNewCredentialsError, + CredentialsError, + StreamingCredentialsListener, + AsyncCredentialsProvider, + ReAuthenticationError, + BasicAuth +} from './lib/credentials-provider'; +export { Token } from './lib/token'; +export { IdentityProvider, TokenResponse } from './lib/identity-provider'; diff --git a/packages/client/lib/client/authx/credentials-provider.ts b/packages/authx/lib/credentials-provider.ts similarity index 95% rename from packages/client/lib/client/authx/credentials-provider.ts rename to packages/authx/lib/credentials-provider.ts index 319a95b4c93..06e2422976c 100644 --- a/packages/client/lib/client/authx/credentials-provider.ts +++ b/packages/authx/lib/credentials-provider.ts @@ -1,3 +1,4 @@ + /** * Provides credentials asynchronously. */ @@ -66,12 +67,6 @@ export type StreamingCredentialsListener = { onError: (e: Error) => void; } -/** - * Disposable is an interface for objects that hold resources that should be released when they are no longer needed. - */ -export type Disposable = { - dispose: () => void; -} /** * Providers that can supply authentication credentials diff --git a/packages/authx/lib/identity-provider.ts b/packages/authx/lib/identity-provider.ts new file mode 100644 index 00000000000..a2d25c8f9db --- /dev/null +++ b/packages/authx/lib/identity-provider.ts @@ -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 = { token: T, ttlMs: number }; + +export interface IdentityProvider { + /** + * 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>; +} \ No newline at end of file diff --git a/packages/authx/lib/token-manager.spec.ts b/packages/authx/lib/token-manager.spec.ts new file mode 100644 index 00000000000..832d10f9f3b --- /dev/null +++ b/packages/authx/lib/token-manager.spec.ts @@ -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 { + requestToken(): Promise> { + 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 => { + return new Token('test-token', ttlMs, 0); + }; + + /** + * Listener that records received tokens and errors for testing. + */ + class TestListener implements TokenStreamListener { + + public readonly receivedTokens: Token[] = []; + public readonly errors: IDPError[] = []; + + onNext(token: Token): 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 { + 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> { + + 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 { + private requestCount = 0; + + constructor( + private readonly errorSequence: Array, + private readonly delayMs: number = 0, + private readonly ttlMs: number = 100 + ) {} + + async requestToken(): Promise> { + + 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?.[Symbol.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].isFatal, true, '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?.[Symbol.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, + shouldRetry: (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].isFatal, false, '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].isFatal, false, '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?.[Symbol.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, + shouldRetry: (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].isFatal, false, '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].isFatal, false, '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].isFatal, true, 'Should not 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?.[Symbol.dispose](); + + }); + }); + }); + + describe('TokenManager retry delay calculations', () => { + const createManager = (retryConfig: Partial) => { + 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); + }); + }); + }); +}); + diff --git a/packages/authx/lib/token-manager.ts b/packages/authx/lib/token-manager.ts new file mode 100644 index 00000000000..b91a4c5c12d --- /dev/null +++ b/packages/authx/lib/token-manager.ts @@ -0,0 +1,264 @@ +import { IdentityProvider, TokenResponse } from './identity-provider'; +import { Token } from './token'; + +/** + * 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. e.g. 2 will double the delay each time. + backoffMultiplier: number; + // The percentage of jitter to apply to the delay. e.g. 0.1 will add or subtract up to 10% of the delay. + jitterPercentage?: number; + // A custom function to determine if a retry should be attempted based on the error and attempt number. + shouldRetry?: (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 is an error that occurs while calling the underlying IdentityProvider. + * + * It can be transient and if retry policy is configured, the token manager will attempt to obtain a token again. + * This means that receiving non-fatal error is not a stream termination event. + * The stream will be terminated only if the error is fatal. + */ +export class IDPError extends Error { + constructor(public readonly message: string, public readonly isFatal: boolean) { + super(message); + this.name = 'IDPError'; + } +} + +/** + * TokenStreamListener is an interface for objects that listen to token changes. + */ +export type TokenStreamListener = { + /** + * Called each time a new token is received. + * @param token + */ + onNext: (token: Token) => 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 { + private currentToken: Token | null = null; + private refreshTimeout: NodeJS.Timeout | null = null; + private listener: TokenStreamListener | null = null; + private retryAttempt: number = 0; + + constructor( + private readonly identityProvider: IdentityProvider, + 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, initialDelayMs: number = 0): Disposable { + if (this.listener) { + console.log('TokenManager is already running, stopping the previous instance'); + this.stop(); + } + + this.listener = listener; + this.retryAttempt = 0; + + this.scheduleNextRefresh(initialDelayMs); + + return { + [Symbol.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, shouldRetry } = this.config.retry; + + if (this.retryAttempt >= maxAttempts) { + return false; + } + + if (shouldRetry) { + return shouldRetry(error, this.retryAttempt); + } + + return false; + } + + public isRunning(): boolean { + return this.listener !== null; + } + + private async refresh(): Promise { + 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}`, false) + this.scheduleNextRefresh(retryDelay); + } else { + this.notifyError(error, true); + this.stop(); + } + } + } + + private handleNewToken = async ({ token: nativeToken, ttlMs }: TokenResponse): Promise => { + if (!this.listener) { + throw new Error('TokenManager is not running, but a new token was received'); + } + const token = this.wrapNativeToken(nativeToken, ttlMs); + this.currentToken = token; + this.listener.onNext(token); + + this.scheduleNextRefresh(this.calculateRefreshTime(token)); + } + + /** + * Wraps a native token obtained from identity provider. + * @param nativeToken + * @param ttlMs + */ + public wrapNativeToken(nativeToken: T, ttlMs: number): Token { + const now = Date.now(); + const token = new Token( + nativeToken, + now + ttlMs, + now + ); + 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, 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 | null { + return this.currentToken; + } + + private notifyError = (error: unknown, isFatal: 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, isFatal)); + } +} \ No newline at end of file diff --git a/packages/authx/lib/token.ts b/packages/authx/lib/token.ts new file mode 100644 index 00000000000..3d6e6867d84 --- /dev/null +++ b/packages/authx/lib/token.ts @@ -0,0 +1,23 @@ +/** + * A token that can be used to authenticate with a service. + */ +export class Token { + 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; + } +} \ No newline at end of file diff --git a/packages/authx/package.json b/packages/authx/package.json new file mode 100644 index 00000000000..1d8d182f8e8 --- /dev/null +++ b/packages/authx/package.json @@ -0,0 +1,39 @@ +{ + "name": "@redis/authx", + "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", + "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": { + + }, + "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/authx", + "keywords": [ + "redis" + ] +} diff --git a/packages/authx/tsconfig.json b/packages/authx/tsconfig.json new file mode 100644 index 00000000000..1a916a92d9d --- /dev/null +++ b/packages/authx/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "./index.ts", + "./lib/**/*.ts" + ], + "exclude": [ + "./lib/**/*.spec.ts", + ], + "typedocOptions": { + "entryPoints": [ + "./index.ts", + "./lib" + ], + "entryPointStrategy": "expand", + "out": "../../documentation/authx" + } +} diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 88f4ce23f21..380f1360624 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -1,5 +1,5 @@ import COMMANDS from '../commands'; -import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError, Disposable } from './authx/credentials-provider'; +import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError } from '@redis/authx'; import RedisSocket, { RedisSocketOptions } from './socket'; import RedisCommandsQueue, { CommandOptions } from './commands-queue'; import { EventEmitter } from 'node:events'; @@ -1098,7 +1098,7 @@ export default class RedisClient< const chainId = Symbol('Reset Chain'), promises = [this._self.#queue.reset(chainId)], selectedDB = this._self.#options?.database ?? 0; - this.credentialsSubscription?.dispose(); + this.credentialsSubscription?.[Symbol.dispose](); this.credentialsSubscription = null; for (const command of (await this._self.#handshake(selectedDB))) { promises.push( @@ -1151,7 +1151,7 @@ export default class RedisClient< * @deprecated use .close instead */ QUIT(): Promise { - this.credentialsSubscription?.dispose(); + this.credentialsSubscription?.[Symbol.dispose](); this.credentialsSubscription = null; return this._self.#socket.quit(async () => { clearTimeout(this._self.#pingTimer); @@ -1191,7 +1191,7 @@ export default class RedisClient< resolve(); }; this._self.#socket.on('data', maybeClose); - this.credentialsSubscription?.dispose(); + this.credentialsSubscription?.[Symbol.dispose](); this.credentialsSubscription = null; }); } @@ -1203,7 +1203,7 @@ export default class RedisClient< clearTimeout(this._self.#pingTimer); this._self.#queue.flushAll(new DisconnectsClientError()); this._self.#socket.destroy(); - this.credentialsSubscription?.dispose(); + this.credentialsSubscription?.[Symbol.dispose](); this.credentialsSubscription = null; } diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 55a11d6ea82..2074745e7c4 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -1,7 +1,7 @@ import TestUtils from '@redis/test-utils'; import { SinonSpy } from 'sinon'; import { setTimeout } from 'node:timers/promises'; -import { CredentialsProvider } from './client/authx/credentials-provider'; +import { CredentialsProvider } from '@redis/authx'; import { Command } from './RESP/types'; import { BasicCommandParser } from './client/parser'; @@ -30,7 +30,7 @@ const streamingCredentialsProvider: CredentialsProvider = subscribe : (observable) => ( Promise.resolve([ { password: 'password' }, { - dispose: () => { + [Symbol.dispose]: () => { console.log('disposing credentials provider subscription'); } } diff --git a/packages/client/package.json b/packages/client/package.json index 9d028aa2bb2..74aa34b4a08 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -14,6 +14,9 @@ "dependencies": { "cluster-key-slot": "1.1.2" }, + "peerDependencies": { + "@redis/authx": "^5.0.0-next.5" + }, "devDependencies": { "@redis/test-utils": "*", "@types/sinon": "^17.0.3", diff --git a/tsconfig.json b/tsconfig.json index a578fefa54f..2c2cb842704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,33 @@ { "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/redis" + }, + { + "path": "./packages/authx" + } + ] + } From 0ce5e7a462bee36f31d5b66e6ab7c511a4e35721 Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Wed, 11 Dec 2024 13:49:54 +0200 Subject: [PATCH 3/5] 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 --- package-lock.json | 973 +++++++++++++++++- packages/authx/lib/token-manager.ts | 21 +- packages/client/lib/client/index.ts | 11 +- packages/entraid/README.md | 0 .../entra-id-credentials-provider-factory.ts | 357 +++++++ .../lib/entraid-credentials-provider.spec.ts | 187 ++++ .../lib/entraid-credentials-provider.ts | 127 +++ .../entraid/lib/msal-identity-provider.ts | 32 + packages/entraid/package.json | 47 + .../entraid/samples/auth-code-pkce/index.ts | 157 +++ packages/entraid/tsconfig.json | 23 + tsconfig.json | 3 + 12 files changed, 1899 insertions(+), 39 deletions(-) create mode 100644 packages/entraid/README.md create mode 100644 packages/entraid/lib/entra-id-credentials-provider-factory.ts create mode 100644 packages/entraid/lib/entraid-credentials-provider.spec.ts create mode 100644 packages/entraid/lib/entraid-credentials-provider.ts create mode 100644 packages/entraid/lib/msal-identity-provider.ts create mode 100644 packages/entraid/package.json create mode 100644 packages/entraid/samples/auth-code-pkce/index.ts create mode 100644 packages/entraid/tsconfig.json diff --git a/package-lock.json b/package-lock.json index a144e852d9f..1ca16879d6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -859,6 +859,10 @@ "resolved": "packages/client", "link": true }, + "node_modules/@redis/entraid": { + "resolved": "packages/entraid", + "link": true + }, "node_modules/@redis/graph": { "resolved": "packages/graph", "link": true @@ -956,11 +960,82 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-S6TkD/lljxDlQ2u/4A70luD8/ZxZcrU5pQwI1rVXCiaVIywoFgbA+PIUNDjPhQpPdK0dGleLtYc/y7XWBfclBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "dev": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.6", "dev": true, @@ -974,6 +1049,43 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/qs": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", + "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/sinon": { "version": "17.0.3", "dev": true, @@ -1000,6 +1112,20 @@ "dev": true, "license": "MIT" }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agent-base": { "version": "7.1.0", "dev": true, @@ -1139,6 +1265,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, "node_modules/array-union": { "version": "1.0.2", "dev": true, @@ -1287,6 +1420,48 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/boxen": { "version": "7.1.1", "dev": true, @@ -1513,6 +1688,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cacheable-lookup": { "version": "7.0.0", "dev": true, @@ -1564,18 +1749,38 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "dev": true, @@ -1838,11 +2043,51 @@ "url": "https://github.com/yeoman/configstore?sponsor=1" } }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "9.0.0", "dev": true, @@ -2036,16 +2281,21 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -2088,11 +2338,32 @@ "node": ">= 14" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deprecation": { "version": "2.3.1", "dev": true, "license": "ISC" }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/diff": { "version": "5.0.0", "dev": true, @@ -2115,6 +2386,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", + "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "dev": true, @@ -2129,6 +2428,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.4.656", "dev": true, @@ -2144,6 +2450,16 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/env-paths": { "version": "2.2.1", "dev": true, @@ -2217,6 +2533,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "dev": true, @@ -2334,6 +2660,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -2393,6 +2726,16 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "8.0.1", "dev": true, @@ -2437,6 +2780,131 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/external-editor": { "version": "3.1.0", "dev": true, @@ -2567,6 +3035,42 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "dev": true, @@ -2645,6 +3149,26 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fromentries": { "version": "1.3.2", "dev": true, @@ -2743,15 +3267,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.3", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.5.tgz", + "integrity": "sha512-Y4+pKa7XeRUPWFNvOOYHkRYrfzW07oraURSvjDmRVOJ748OrVmeXtpE4+GCEHncjCjkTxPNRt8kEbxDhsn6VTg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.0.0", + "call-bind-apply-helpers": "^1.0.0", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -2976,11 +3505,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3043,11 +3574,13 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3065,7 +3598,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -3105,7 +3640,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3133,6 +3670,23 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.0", "dev": true, @@ -3402,6 +3956,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "dev": true, @@ -4334,6 +4898,26 @@ "node": ">= 12" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -4347,6 +4931,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.5", "dev": true, @@ -4359,6 +4953,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "dev": true, @@ -4530,6 +5137,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { "version": "2.0.2", "dev": true, @@ -4847,6 +5464,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -5302,6 +5942,16 @@ "parse-path": "^7.0.0" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -5489,6 +6139,20 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.3.1", "dev": true, @@ -5534,6 +6198,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -5564,6 +6244,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "dev": true, @@ -5572,6 +6262,32 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/rc": { "version": "1.2.8", "dev": true, @@ -6093,6 +6809,58 @@ "dev": true, "license": "ISC" }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-javascript": { "version": "6.0.0", "dev": true, @@ -6101,21 +6869,40 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "dev": true, "license": "ISC" }, "node_modules/set-function-length": { - "version": "1.2.0", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6134,6 +6921,13 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -6181,13 +6975,19 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6314,6 +7114,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stdin-discarder": { "version": "0.2.2", "dev": true, @@ -6527,6 +7337,16 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-repeated": { "version": "1.0.0", "dev": true, @@ -6585,6 +7405,20 @@ "node": ">=8" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.0", "dev": true, @@ -6708,6 +7542,19 @@ "node": ">=14.17" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dev": true, + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "dev": true, @@ -6765,6 +7612,16 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "dev": true, @@ -6873,6 +7730,16 @@ "dev": true, "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "license": "MIT", @@ -6880,6 +7747,16 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vscode-oniguruma": { "version": "1.7.0", "dev": true, @@ -7297,6 +8174,46 @@ "@redis/authx": "^5.0.0-next.5" } }, + "packages/entraid": { + "name": "@redis/entraid", + "version": "5.0.0-next.5", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.16.1" + }, + "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" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/authx": "^5.0.0-next.5", + "@redis/client": "^5.0.0-next.5" + } + }, + "packages/entraid/node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "packages/entraid/node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "packages/graph": { "name": "@redis/graph", "version": "5.0.0-next.5", diff --git a/packages/authx/lib/token-manager.ts b/packages/authx/lib/token-manager.ts index b91a4c5c12d..fc780f1c4e1 100644 --- a/packages/authx/lib/token-manager.ts +++ b/packages/authx/lib/token-manager.ts @@ -35,7 +35,6 @@ export interface TokenManagerConfig { retry?: RetryPolicy; } - /** * IDPError is an error that occurs while calling the underlying IdentityProvider. * @@ -88,7 +87,7 @@ export class TokenManager { constructor( private readonly identityProvider: IdentityProvider, - private readonly config: TokenManagerConfig, + private readonly config: TokenManagerConfig ) { if (this.config.expirationRefreshRatio > 1) { throw new Error('expirationRefreshRatio must be less than or equal to 1'); @@ -186,25 +185,29 @@ export class TokenManager { if (!this.listener) { throw new Error('TokenManager is not running, but a new token was received'); } - const token = this.wrapNativeToken(nativeToken, ttlMs); - this.currentToken = token; + const token = this.wrapAndSetCurrentToken(nativeToken, ttlMs); this.listener.onNext(token); this.scheduleNextRefresh(this.calculateRefreshTime(token)); } /** - * Wraps a native token obtained from identity provider. - * @param nativeToken - * @param ttlMs + * 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 wrapNativeToken(nativeToken: T, ttlMs: number): Token { + public wrapAndSetCurrentToken(nativeToken: T, ttlMs: number): Token { const now = Date.now(); const token = new Token( nativeToken, now + ttlMs, now ); + this.currentToken = token; return token; } @@ -228,7 +231,7 @@ export class TokenManager { * @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, now:number = Date.now()): number { + public calculateRefreshTime(token: Token, now: number = Date.now()): number { const ttlMs = token.getTtlMs(now); return Math.floor(ttlMs * this.config.expirationRefreshRatio); } diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 380f1360624..76009946e22 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -380,11 +380,18 @@ export default class RedisClient< } /** - * TODO: Implement re-authentication to support refreshing credentials without reconnecting * @param credentials */ private reAuthenticate = async (credentials: BasicAuth) => { - throw new Error('Not implemented'); + // 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 ?? '' + }) + ); + } } private subscribeForStreamingCredentials(cp: StreamingCredentialsProvider): Promise<[BasicAuth, Disposable]> { diff --git a/packages/entraid/README.md b/packages/entraid/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/entraid/lib/entra-id-credentials-provider-factory.ts b/packages/entraid/lib/entra-id-credentials-provider-factory.ts new file mode 100644 index 00000000000..1eb32c21956 --- /dev/null +++ b/packages/entraid/lib/entra-id-credentials-provider-factory.ts @@ -0,0 +1,357 @@ +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/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] ?? FALLBACK_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 } + ); + } + + /** + * 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 + ): EntraidCredentialsProvider { + return this.createManagedIdentityProvider(params, params.clientId); + } + + private 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 ?? [FALLBACK_SCOPE] + }).then(x => x === null ? Promise.reject('Token is null') : x) + ); + + return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp, + { onReAuthenticationError: params.onReAuthenticationError }); + } + + /** + * 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; + 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 FALLBACK_SCOPE = 'https://redis.azure.com/.default'; + +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.Verbose +} + +/** + * The most imporant part of the RetryPolicy is the shouldRetry function. This function is used to determine if a request should be retried based + * on the error returned from the identity provider. The defaultRetryPolicy is used to retry on network errors only. + */ +export const DEFAULT_RETRY_POLICY: RetryPolicy = { + // currently only retry on network errors + shouldRetry: (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 { + 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 { + 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 + ); + } +} + diff --git a/packages/entraid/lib/entraid-credentials-provider.spec.ts b/packages/entraid/lib/entraid-credentials-provider.spec.ts new file mode 100644 index 00000000000..f94cbcc9ad8 --- /dev/null +++ b/packages/entraid/lib/entraid-credentials-provider.spec.ts @@ -0,0 +1,187 @@ +import { IdentityProvider, TokenManager, TokenResponse, TokenManagerConfig, BasicAuth } from '@redis/authx'; +import { EntraidCredentialsProvider } from './entraid-credentials-provider'; +import { strict as assert } from 'node:assert'; +import { setTimeout } from 'timers/promises'; +import { AuthenticationResult } from '@azure/msal-common/node'; + +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(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][Symbol.dispose](); + sub2Initial[1][Symbol.dispose](); + sub3Initial[1][Symbol.dispose](); + assert.equal(entraid.hasActiveSubscriptions(), true, 'There should be active subscriptions'); + assert.equal(entraid.getSubscriptionsCount(), 1, 'There should be 1 subscriptions'); + sub4Initial[1][Symbol.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 { + private currentIndex = 0; + + constructor( + private readonly tokenTTL: number = 100, + private tokenDeliveryDelayMs: number = 0, + private readonly tokenSequence: AuthenticationResult[] = [ + { + accessToken: 'initial-token', + account: { username: 'test-user' } + } as AuthenticationResult, + { + accessToken: 'refresh-token-1', + account: { username: 'test-user' } + } as AuthenticationResult, + { + accessToken: 'refresh-token-2', + account: { username: 'test-user' } + } as AuthenticationResult + ] + ) {} + + setTokenDeliveryDelay(delayMs: number): void { + this.tokenDeliveryDelayMs = delayMs; + } + + async requestToken(): Promise> { + 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 = []; + 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); + }; +}); \ No newline at end of file diff --git a/packages/entraid/lib/entraid-credentials-provider.ts b/packages/entraid/lib/entraid-credentials-provider.ts new file mode 100644 index 00000000000..662954e25f1 --- /dev/null +++ b/packages/entraid/lib/entraid-credentials-provider.ts @@ -0,0 +1,127 @@ +import { AuthenticationResult } from '@azure/msal-common/node'; +import { + BasicAuth, StreamingCredentialsProvider, IdentityProvider, TokenManager, + ReAuthenticationError, StreamingCredentialsListener, IDPError, Token +} from '@redis/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'; + + private readonly listeners: Set> = new Set(); + + private tokenManagerDisposable: Disposable | null = null; + private isStarting: boolean = false; + + private pendingSubscribers: Array<{ + resolve: (value: [BasicAuth, Disposable]) => void; + reject: (error: Error) => void; + pendingListener: StreamingCredentialsListener; + }> = []; + + constructor( + private readonly tokenManager: TokenManager, + private readonly idp: IdentityProvider, + options: { + onReAuthenticationError?: (error: ReAuthenticationError) => void + credentialsMapper?: (token: AuthenticationResult) => BasicAuth + } = {} + ) { + this.onReAuthenticationError = options.onReAuthenticationError ?? + ((error) => console.error('ReAuthenticationError', error)); + this.credentialsMapper = options.credentialsMapper ?? ((token) => ({ + username: token.account?.username ?? undefined, + password: token.accessToken + })); + + } + + async subscribe( + listener: StreamingCredentialsListener + ): 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; + + private credentialsMapper: (token: AuthenticationResult) => BasicAuth ; + + private createTokenManagerListener(subscribers: Set>) { + return { + onError: (error: IDPError): void => { + if (error.isFatal) { + subscribers.forEach(listener => listener.onError(error)); + } else { + console.log('Transient identity provider error', error); + } + }, + onNext: (token: { value: AuthenticationResult }): void => { + const credentials = this.credentialsMapper(token.value); + subscribers.forEach(listener => listener.onNext(credentials)); + } + }; + } + + private createDisposable(listener: StreamingCredentialsListener): Disposable { + this.listeners.add(listener); + + return { + [Symbol.dispose]: () => { + this.listeners.delete(listener); + if (this.listeners.size === 0 && this.tokenManagerDisposable) { + this.tokenManagerDisposable[Symbol.dispose](); + this.tokenManagerDisposable = null; + } + } + }; + } + + private async startTokenManagerAndObtainInitialToken(): Promise> { + 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; + } + +} \ No newline at end of file diff --git a/packages/entraid/lib/msal-identity-provider.ts b/packages/entraid/lib/msal-identity-provider.ts new file mode 100644 index 00000000000..1b44549fe3a --- /dev/null +++ b/packages/entraid/lib/msal-identity-provider.ts @@ -0,0 +1,32 @@ +import { + AuthenticationResult +} from '@azure/msal-node'; +import { IdentityProvider, TokenResponse } from '@redis/authx'; + +export class MSALIdentityProvider implements IdentityProvider { + private readonly getToken: () => Promise; + + constructor(getToken: () => Promise) { + this.getToken = getToken; + } + + async requestToken(): Promise> { + 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) { + console.error('Error acquiring token:', error); + throw error; + } + } + +} + + diff --git a/packages/entraid/package.json b/packages/entraid/package.json new file mode 100644 index 00000000000..867772b24c5 --- /dev/null +++ b/packages/entraid/package.json @@ -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": "npm run build && node dist/samples/auth-code-pkce/index.js", + "test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'" + }, + "dependencies": { + "@azure/msal-node": "^2.16.1" + }, + "peerDependencies": { + "@redis/authx": "^5.0.0-next.5", + "@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" + }, + "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", + "entraid" + ] +} diff --git a/packages/entraid/samples/auth-code-pkce/index.ts b/packages/entraid/samples/auth-code-pkce/index.ts new file mode 100644 index 00000000000..797358e0e2f --- /dev/null +++ b/packages/entraid/samples/auth-code-pkce/index.ts @@ -0,0 +1,157 @@ +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((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 { + // Debug log to see exactly what we're receiving + console.log('Full request query:', req.query); + console.log('Code from request:', req.query.code); + console.log('Session state:', req.session); + + // 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((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`); +}); \ No newline at end of file diff --git a/packages/entraid/tsconfig.json b/packages/entraid/tsconfig.json new file mode 100644 index 00000000000..3efd3ae0614 --- /dev/null +++ b/packages/entraid/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "./samples/**/*.ts", + "./lib/**/*.ts" + ], + "exclude": [ + "./lib/test-utils.ts", + "./lib/**/*.spec.ts", + "./lib/sentinel/test-util.ts" + ], + "typedocOptions": { + "entryPoints": [ + "./index.ts", + "./lib" + ], + "entryPointStrategy": "expand", + "out": "../../documentation/entraid" + } +} diff --git a/tsconfig.json b/tsconfig.json index 2c2cb842704..ec2b133ac2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,9 @@ }, { "path": "./packages/authx" + }, + { + "path": "./packages/entraid" } ] From ac972bdcd542b0c3a09cab2d654948b965ef8ab7 Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Wed, 8 Jan 2025 14:03:24 +0200 Subject: [PATCH 4/5] 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. --- package-lock.json | 1 + .../lib/entraid-credentials-provider.spec.ts | 18 ++++++-- packages/entraid/lib/test-utils.ts | 46 +++++++++++++++++++ packages/entraid/package.json | 3 +- packages/test-utils/lib/dockers.ts | 36 +++++++++++---- packages/test-utils/lib/index.ts | 3 +- 6 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 packages/entraid/lib/test-utils.ts diff --git a/package-lock.json b/package-lock.json index 1ca16879d6f..8fdd049a5b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8182,6 +8182,7 @@ "@azure/msal-node": "^2.16.1" }, "devDependencies": { + "@redis/test-utils": "*", "@types/express": "^4.17.21", "@types/express-session": "^1.18.0", "@types/node": "^22.9.0", diff --git a/packages/entraid/lib/entraid-credentials-provider.spec.ts b/packages/entraid/lib/entraid-credentials-provider.spec.ts index f94cbcc9ad8..8967bd18831 100644 --- a/packages/entraid/lib/entraid-credentials-provider.spec.ts +++ b/packages/entraid/lib/entraid-credentials-provider.spec.ts @@ -1,8 +1,20 @@ -import { IdentityProvider, TokenManager, TokenResponse, TokenManagerConfig, BasicAuth } from '@redis/authx'; +import { AuthenticationResult } from '@azure/msal-node'; +import { IdentityProvider, TokenManager, TokenResponse, BasicAuth } from '@redis/authx'; import { EntraidCredentialsProvider } from './entraid-credentials-provider'; -import { strict as assert } from 'node:assert'; import { setTimeout } from 'timers/promises'; -import { AuthenticationResult } from '@azure/msal-common/node'; +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', () => { diff --git a/packages/entraid/lib/test-utils.ts b/packages/entraid/lib/test-utils.ts new file mode 100644 index 00000000000..04799f66a39 --- /dev/null +++ b/packages/entraid/lib/test-utils.ts @@ -0,0 +1,46 @@ +import { AuthenticationResult } from '@azure/msal-node'; +import { IdentityProvider, StreamingCredentialsProvider, TokenManager, TokenResponse } from '@redis/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 = { + requestToken(): Promise> { + // @ts-ignore + return Promise.resolve({ + ttlMs: 100000, + token: { + accessToken: 'password' + } + }) + } +} + +const tokenManager = new TokenManager(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 + } +} diff --git a/packages/entraid/package.json b/packages/entraid/package.json index 867772b24c5..f57ad09f464 100644 --- a/packages/entraid/package.json +++ b/packages/entraid/package.json @@ -27,7 +27,8 @@ "@types/node": "^22.9.0", "dotenv": "^16.3.1", "express": "^4.21.1", - "express-session": "^1.18.1" + "express-session": "^1.18.1", + "@redis/test-utils": "*" }, "engines": { "node": ">= 18" diff --git a/packages/test-utils/lib/dockers.ts b/packages/test-utils/lib/dockers.ts index a1cb63eb7bf..bfb66603750 100644 --- a/packages/test-utils/lib/dockers.ts +++ b/packages/test-utils/lib/dockers.ts @@ -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, fromSlot: number, - toSlot: number + toSlot: number, + clientConfig?: Partial ) { const range: Array = []; 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 + serverArguments: Array, + clientConfig?: Partial ) { 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 + serverArguments: Array, + clientConfig?: Partial ): Promise> { 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, ReturnType>(); -export function spawnRedisCluster(dockersConfig: RedisClusterDockersConfig, serverArguments: Array): Promise> { +export function spawnRedisCluster( + dockersConfig: RedisClusterDockersConfig, + serverArguments: Array, + clientConfig?: Partial): Promise> { + 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; } diff --git a/packages/test-utils/lib/index.ts b/packages/test-utils/lib/index.ts index 87ba34db7ef..9dee350e31e 100644 --- a/packages/test-utils/lib/index.ts +++ b/packages/test-utils/lib/index.ts @@ -290,7 +290,8 @@ export default class TestUtils { ...dockerImage, numberOfMasters: options.numberOfMasters, numberOfReplicas: options.numberOfReplicas - }, options.serverArguments); + }, options.serverArguments, + options.clusterConfiguration?.defaults); return dockersPromise; }); } From bcc4bc3ec5a722adfb52ba9ed5f136f12b04f411 Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Fri, 10 Jan 2025 11:17:59 +0200 Subject: [PATCH 5/5] 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 `@` --- .github/release-drafter/entraid-config.yml | 50 ++++ .github/workflows/release-drafter-entraid.yml | 24 ++ packages/authx/package.json | 39 ---- packages/authx/tsconfig.json | 21 -- .../lib/authx}/credentials-provider.ts | 2 +- packages/client/lib/authx/disposable.ts | 6 + .../lib/authx}/identity-provider.ts | 0 packages/{ => client/lib}/authx/index.ts | 10 +- .../lib/authx}/token-manager.spec.ts | 24 +- .../lib => client/lib/authx}/token-manager.ts | 93 ++++++-- .../{authx/lib => client/lib/authx}/token.ts | 0 packages/client/lib/client/index.ts | 32 ++- packages/client/lib/test-utils.ts | 4 +- packages/client/package.json | 3 - packages/entraid/.nycrc.json | 10 + packages/entraid/.release-it.json | 11 + packages/entraid/README.md | 137 +++++++++++ .../entraid-integration.spec.ts | 217 ++++++++++++++++++ .../entra-id-credentials-provider-factory.ts | 52 +++-- .../lib/entraid-credentials-provider.spec.ts | 16 +- .../lib/entraid-credentials-provider.ts | 105 +++++---- packages/entraid/lib/index.ts | 3 + .../entraid/lib/msal-identity-provider.ts | 3 +- packages/entraid/lib/test-utils.ts | 2 +- packages/entraid/package.json | 7 +- .../entraid/samples/auth-code-pkce/index.ts | 4 - .../entraid/tsconfig.integration-tests.json | 10 + packages/entraid/tsconfig.json | 5 +- packages/entraid/tsconfig.samples.json | 10 + packages/test-utils/lib/cae-client-testing.ts | 30 +++ tsconfig.json | 8 +- 31 files changed, 724 insertions(+), 214 deletions(-) create mode 100644 .github/release-drafter/entraid-config.yml create mode 100644 .github/workflows/release-drafter-entraid.yml delete mode 100644 packages/authx/package.json delete mode 100644 packages/authx/tsconfig.json rename packages/{authx/lib => client/lib/authx}/credentials-provider.ts (98%) create mode 100644 packages/client/lib/authx/disposable.ts rename packages/{authx/lib => client/lib/authx}/identity-provider.ts (100%) rename packages/{ => client/lib}/authx/index.ts (55%) rename packages/{authx/lib => client/lib/authx}/token-manager.spec.ts (96%) rename packages/{authx/lib => client/lib/authx}/token-manager.ts (73%) rename packages/{authx/lib => client/lib/authx}/token.ts (100%) create mode 100644 packages/entraid/.nycrc.json create mode 100644 packages/entraid/.release-it.json create mode 100644 packages/entraid/integration-tests/entraid-integration.spec.ts create mode 100644 packages/entraid/lib/index.ts create mode 100644 packages/entraid/tsconfig.integration-tests.json create mode 100644 packages/entraid/tsconfig.samples.json create mode 100644 packages/test-utils/lib/cae-client-testing.ts diff --git a/.github/release-drafter/entraid-config.yml b/.github/release-drafter/entraid-config.yml new file mode 100644 index 00000000000..d0ddd00773a --- /dev/null +++ b/.github/release-drafter/entraid-config.yml @@ -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 diff --git a/.github/workflows/release-drafter-entraid.yml b/.github/workflows/release-drafter-entraid.yml new file mode 100644 index 00000000000..d522c6cef6f --- /dev/null +++ b/.github/workflows/release-drafter-entraid.yml @@ -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 }} diff --git a/packages/authx/package.json b/packages/authx/package.json deleted file mode 100644 index 1d8d182f8e8..00000000000 --- a/packages/authx/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@redis/authx", - "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", - "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": { - - }, - "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/authx", - "keywords": [ - "redis" - ] -} diff --git a/packages/authx/tsconfig.json b/packages/authx/tsconfig.json deleted file mode 100644 index 1a916a92d9d..00000000000 --- a/packages/authx/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": [ - "./index.ts", - "./lib/**/*.ts" - ], - "exclude": [ - "./lib/**/*.spec.ts", - ], - "typedocOptions": { - "entryPoints": [ - "./index.ts", - "./lib" - ], - "entryPointStrategy": "expand", - "out": "../../documentation/authx" - } -} diff --git a/packages/authx/lib/credentials-provider.ts b/packages/client/lib/authx/credentials-provider.ts similarity index 98% rename from packages/authx/lib/credentials-provider.ts rename to packages/client/lib/authx/credentials-provider.ts index 06e2422976c..667795be9b3 100644 --- a/packages/authx/lib/credentials-provider.ts +++ b/packages/client/lib/authx/credentials-provider.ts @@ -1,4 +1,4 @@ - +import { Disposable } from './disposable'; /** * Provides credentials asynchronously. */ diff --git a/packages/client/lib/authx/disposable.ts b/packages/client/lib/authx/disposable.ts new file mode 100644 index 00000000000..ee4526a37bd --- /dev/null +++ b/packages/client/lib/authx/disposable.ts @@ -0,0 +1,6 @@ +/** + * Represents a resource that can be disposed. + */ +export interface Disposable { + dispose(): void; +} \ No newline at end of file diff --git a/packages/authx/lib/identity-provider.ts b/packages/client/lib/authx/identity-provider.ts similarity index 100% rename from packages/authx/lib/identity-provider.ts rename to packages/client/lib/authx/identity-provider.ts diff --git a/packages/authx/index.ts b/packages/client/lib/authx/index.ts similarity index 55% rename from packages/authx/index.ts rename to packages/client/lib/authx/index.ts index a9ad4e50e1e..ce611e1497f 100644 --- a/packages/authx/index.ts +++ b/packages/client/lib/authx/index.ts @@ -1,4 +1,4 @@ -export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './lib/token-manager'; +export { TokenManager, TokenManagerConfig, TokenStreamListener, RetryPolicy, IDPError } from './token-manager'; export { CredentialsProvider, StreamingCredentialsProvider, @@ -8,6 +8,8 @@ export { AsyncCredentialsProvider, ReAuthenticationError, BasicAuth -} from './lib/credentials-provider'; -export { Token } from './lib/token'; -export { IdentityProvider, TokenResponse } from './lib/identity-provider'; +} from './credentials-provider'; +export { Token } from './token'; +export { IdentityProvider, TokenResponse } from './identity-provider'; + +export { Disposable } from './disposable' \ No newline at end of file diff --git a/packages/authx/lib/token-manager.spec.ts b/packages/client/lib/authx/token-manager.spec.ts similarity index 96% rename from packages/authx/lib/token-manager.spec.ts rename to packages/client/lib/authx/token-manager.spec.ts index 832d10f9f3b..1cc2a207edc 100644 --- a/packages/authx/lib/token-manager.spec.ts +++ b/packages/client/lib/authx/token-manager.spec.ts @@ -278,7 +278,7 @@ describe('TokenManager', () => { assert.equal(listener.errors.length, 0, 'Should not have any errors'); assert.equal(manager.getCurrentToken().value, 'token3', 'Should have current token'); - disposable?.[Symbol.dispose](); + disposable?.dispose(); }); }); }); @@ -328,7 +328,7 @@ describe('TokenManager', () => { 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].isFatal, true, 'Should be a fatal error'); + 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); @@ -338,7 +338,7 @@ describe('TokenManager', () => { 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?.[Symbol.dispose](); + disposable?.dispose(); }); it('should handle retries with exponential backoff', async () => { @@ -352,7 +352,7 @@ describe('TokenManager', () => { initialDelayMs: 100, maxDelayMs: 1000, backoffMultiplier: 2, - shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' + isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; @@ -389,7 +389,7 @@ describe('TokenManager', () => { // 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].isFatal, false, 'Should not be a fatal error'); + 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) @@ -401,7 +401,7 @@ describe('TokenManager', () => { 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].isFatal, false, 'Should not be a fatal error'); + 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) @@ -420,7 +420,7 @@ describe('TokenManager', () => { assert.equal(manager.isRunning(), true, 'Should continue running after recovery'); assert.equal(identityProvider.getRequestCount(), 4, 'Should have made exactly 4 requests'); - disposable?.[Symbol.dispose](); + disposable?.dispose(); }); it('should stop after max retries exceeded', async () => { @@ -435,7 +435,7 @@ describe('TokenManager', () => { maxDelayMs: 1000, backoffMultiplier: 2, jitterPercentage: 0, - shouldRetry: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' + isRetryable: (error: unknown) => error instanceof Error && error.message === 'Temporary failure' } }; @@ -470,7 +470,7 @@ describe('TokenManager', () => { // 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].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[0].isRetryable, true, 'Should not be a fatal error'); // Advance past first retry await delay(100); @@ -483,7 +483,7 @@ describe('TokenManager', () => { // 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].isFatal, false, 'Should not be a fatal error'); + assert.equal(listener.errors[1].isRetryable, true, 'Should not be a fatal error'); // Advance past second retry await delay(200); @@ -495,11 +495,11 @@ describe('TokenManager', () => { // Should stop after max retries assert.equal(listener.errors.length, 3, 'Should have final error'); - assert.equal(listener.errors[2].isFatal, true, 'Should not be a fatal 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?.[Symbol.dispose](); + disposable?.dispose(); }); }); diff --git a/packages/authx/lib/token-manager.ts b/packages/client/lib/authx/token-manager.ts similarity index 73% rename from packages/authx/lib/token-manager.ts rename to packages/client/lib/authx/token-manager.ts index fc780f1c4e1..6532d88317b 100644 --- a/packages/authx/lib/token-manager.ts +++ b/packages/client/lib/authx/token-manager.ts @@ -1,22 +1,75 @@ 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. + /** + * The maximum number of attempts to retry token refreshes. + */ maxAttempts: number; - // The initial delay in milliseconds before the first retry. + + /** + * 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). + + /** + * 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. e.g. 2 will double the delay each time. + + /** + * 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. e.g. 0.1 will add or subtract up to 10% of the delay. + + /** + * 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; - // A custom function to determine if a retry should be attempted based on the error and attempt number. - shouldRetry?: (error: unknown, attempt: number) => boolean; + + /** + * 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; } /** @@ -36,14 +89,13 @@ export interface TokenManagerConfig { } /** - * IDPError is an error that occurs while calling the underlying IdentityProvider. + * IDPError indicates a failure from the identity provider. * - * It can be transient and if retry policy is configured, the token manager will attempt to obtain a token again. - * This means that receiving non-fatal error is not a stream termination event. - * The stream will be terminated only if the error is fatal. + * 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 isFatal: boolean) { + constructor(public readonly message: string, public readonly isRetryable: boolean) { super(message); this.name = 'IDPError'; } @@ -105,7 +157,6 @@ export class TokenManager { */ public start(listener: TokenStreamListener, initialDelayMs: number = 0): Disposable { if (this.listener) { - console.log('TokenManager is already running, stopping the previous instance'); this.stop(); } @@ -115,7 +166,7 @@ export class TokenManager { this.scheduleNextRefresh(initialDelayMs); return { - [Symbol.dispose]: () => this.stop() + dispose: () => this.stop() }; } @@ -142,14 +193,14 @@ export class TokenManager { private shouldRetry(error: unknown): boolean { if (!this.config.retry) return false; - const { maxAttempts, shouldRetry } = this.config.retry; + const { maxAttempts, isRetryable } = this.config.retry; if (this.retryAttempt >= maxAttempts) { return false; } - if (shouldRetry) { - return shouldRetry(error, this.retryAttempt); + if (isRetryable) { + return isRetryable(error, this.retryAttempt); } return false; @@ -172,10 +223,10 @@ export class TokenManager { if (this.shouldRetry(error)) { this.retryAttempt++; const retryDelay = this.calculateRetryDelay(); - this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, false) + this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true) this.scheduleNextRefresh(retryDelay); } else { - this.notifyError(error, true); + this.notifyError(error, false); this.stop(); } } @@ -255,13 +306,13 @@ export class TokenManager { return this.currentToken; } - private notifyError = (error: unknown, isFatal: boolean): void => { + 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, isFatal)); + this.listener.onError(new IDPError(errorMessage, isRetryable)); } } \ No newline at end of file diff --git a/packages/authx/lib/token.ts b/packages/client/lib/authx/token.ts similarity index 100% rename from packages/authx/lib/token.ts rename to packages/client/lib/authx/token.ts diff --git a/packages/client/lib/client/index.ts b/packages/client/lib/client/index.ts index 76009946e22..5dae1271ecb 100644 --- a/packages/client/lib/client/index.ts +++ b/packages/client/lib/client/index.ts @@ -1,6 +1,6 @@ import COMMANDS from '../commands'; -import { BasicAuth, CredentialsError, CredentialsProvider, StreamingCredentialsProvider, UnableToObtainNewCredentialsError } from '@redis/authx'; 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'; @@ -303,7 +303,7 @@ export default class RedisClient< #epoch: number; #watchEpoch?: number; - private credentialsSubscription: Disposable | null = null; + #credentialsSubscription: Disposable | null = null; get options(): RedisClientOptions | undefined { return this._self.#options; @@ -394,19 +394,17 @@ export default class RedisClient< } } - private subscribeForStreamingCredentials(cp: StreamingCredentialsProvider): Promise<[BasicAuth, Disposable]> { + #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); - console.error('Error during re-authentication', errorMessage); cp.onReAuthenticationError(new CredentialsError(errorMessage)); }); }, onError: (e: Error) => { const errorMessage = `Error from streaming credentials provider: ${e.message}`; - console.error(errorMessage); cp.onReAuthenticationError(new UnableToObtainNewCredentialsError(errorMessage)); } }); @@ -431,8 +429,8 @@ export default class RedisClient< if (cp && cp.type === 'streaming-credentials-provider') { - const [credentials, disposable] = await this.subscribeForStreamingCredentials(cp) - this.credentialsSubscription = disposable; + const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + this.#credentialsSubscription = disposable; if (credentials.password) { hello.AUTH = { @@ -467,8 +465,8 @@ export default class RedisClient< if (cp && cp.type === 'streaming-credentials-provider') { - const [credentials, disposable] = await this.subscribeForStreamingCredentials(cp) - this.credentialsSubscription = disposable; + const [credentials, disposable] = await this.#subscribeForStreamingCredentials(cp) + this.#credentialsSubscription = disposable; if (credentials.username || credentials.password) { commands.push( @@ -1105,8 +1103,8 @@ export default class RedisClient< const chainId = Symbol('Reset Chain'), promises = [this._self.#queue.reset(chainId)], selectedDB = this._self.#options?.database ?? 0; - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.dispose(); + this._self.#credentialsSubscription = null; for (const command of (await this._self.#handshake(selectedDB))) { promises.push( this._self.#queue.addCommand(command, { @@ -1158,8 +1156,8 @@ export default class RedisClient< * @deprecated use .close instead */ QUIT(): Promise { - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.dispose(); + this._self.#credentialsSubscription = null; return this._self.#socket.quit(async () => { clearTimeout(this._self.#pingTimer); const quitPromise = this._self.#queue.addCommand(['QUIT']); @@ -1198,8 +1196,8 @@ export default class RedisClient< resolve(); }; this._self.#socket.on('data', maybeClose); - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.dispose(); + this._self.#credentialsSubscription = null; }); } @@ -1210,8 +1208,8 @@ export default class RedisClient< clearTimeout(this._self.#pingTimer); this._self.#queue.flushAll(new DisconnectsClientError()); this._self.#socket.destroy(); - this.credentialsSubscription?.[Symbol.dispose](); - this.credentialsSubscription = null; + this._self.#credentialsSubscription?.dispose(); + this._self.#credentialsSubscription = null; } ref() { diff --git a/packages/client/lib/test-utils.ts b/packages/client/lib/test-utils.ts index 2074745e7c4..2d561dd2e20 100644 --- a/packages/client/lib/test-utils.ts +++ b/packages/client/lib/test-utils.ts @@ -1,7 +1,7 @@ import TestUtils from '@redis/test-utils'; import { SinonSpy } from 'sinon'; import { setTimeout } from 'node:timers/promises'; -import { CredentialsProvider } from '@redis/authx'; +import { CredentialsProvider } from './authx'; import { Command } from './RESP/types'; import { BasicCommandParser } from './client/parser'; @@ -30,7 +30,7 @@ const streamingCredentialsProvider: CredentialsProvider = subscribe : (observable) => ( Promise.resolve([ { password: 'password' }, { - [Symbol.dispose]: () => { + dispose: () => { console.log('disposing credentials provider subscription'); } } diff --git a/packages/client/package.json b/packages/client/package.json index 74aa34b4a08..9d028aa2bb2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -14,9 +14,6 @@ "dependencies": { "cluster-key-slot": "1.1.2" }, - "peerDependencies": { - "@redis/authx": "^5.0.0-next.5" - }, "devDependencies": { "@redis/test-utils": "*", "@types/sinon": "^17.0.3", diff --git a/packages/entraid/.nycrc.json b/packages/entraid/.nycrc.json new file mode 100644 index 00000000000..848af2b5a27 --- /dev/null +++ b/packages/entraid/.nycrc.json @@ -0,0 +1,10 @@ +{ + "extends": "@istanbuljs/nyc-config-typescript", + "exclude": [ + "integration-tests", + "samples", + "dist", + "**/*.spec.ts", + "lib/test-utils.ts" + ] +} diff --git a/packages/entraid/.release-it.json b/packages/entraid/.release-it.json new file mode 100644 index 00000000000..a5f3a31062e --- /dev/null +++ b/packages/entraid/.release-it.json @@ -0,0 +1,11 @@ +{ + "git": { + "tagName": "entraid@${version}", + "commitMessage": "Release ${tagName}", + "tagAnnotation": "Release ${tagName}" + }, + "npm": { + "versionArgs": ["--workspaces-update=false"], + "publishArgs": ["--access", "public"] + } +} diff --git a/packages/entraid/README.md b/packages/entraid/README.md index e69de29bb2d..e9c7956022e 100644 --- a/packages/entraid/README.md +++ b/packages/entraid/README.md @@ -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 + } + } +}); +``` diff --git a/packages/entraid/integration-tests/entraid-integration.spec.ts b/packages/entraid/integration-tests/entraid-integration.spec.ts new file mode 100644 index 00000000000..deb1d47dec1 --- /dev/null +++ b/packages/entraid/integration-tests/entraid-integration.spec.ts @@ -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 => { + 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 + } + +} + + diff --git a/packages/entraid/lib/entra-id-credentials-provider-factory.ts b/packages/entraid/lib/entra-id-credentials-provider-factory.ts index 1eb32c21956..0f89be8039b 100644 --- a/packages/entraid/lib/entra-id-credentials-provider-factory.ts +++ b/packages/entraid/lib/entra-id-credentials-provider-factory.ts @@ -7,17 +7,16 @@ import { PublicClientApplication, ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo } from '@azure/msal-node'; -import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } from '@redis/authx'; +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 @@ -44,7 +43,7 @@ export class EntraIdCredentialsProviderFactory { const idp = new MSALIdentityProvider( () => client.acquireToken({ - resource: params.scopes?.[0] ?? FALLBACK_SCOPE, + resource: params.scopes?.[0] ?? REDIS_SCOPE, forceRefresh: true }).then(x => x === null ? Promise.reject('Token is null') : x) ); @@ -52,7 +51,7 @@ export class EntraIdCredentialsProviderFactory { return new EntraidCredentialsProvider( new TokenManager(idp, params.tokenManagerConfig), idp, - { onReAuthenticationError: params.onReAuthenticationError } + { onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: OID_CREDENTIALS_MAPPER } ); } @@ -72,12 +71,12 @@ export class EntraIdCredentialsProviderFactory { * @param params */ static createForUserAssignedManagedIdentity( - params: CredentialParams + params: CredentialParams & { userAssignedClientId: string } ): EntraidCredentialsProvider { - return this.createManagedIdentityProvider(params, params.clientId); + return this.createManagedIdentityProvider(params, params.userAssignedClientId); } - private static _createForClientCredentials( + static #createForClientCredentials( authConfig: NodeAuthOptions, params: CredentialParams ): EntraidCredentialsProvider { @@ -96,12 +95,15 @@ export class EntraIdCredentialsProviderFactory { const idp = new MSALIdentityProvider( () => client.acquireTokenByClientCredential({ skipCache: true, - scopes: params.scopes ?? [FALLBACK_SCOPE] + 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 }); + { + onReAuthenticationError: params.onReAuthenticationError, + credentialsMapper: OID_CREDENTIALS_MAPPER + }); } /** @@ -111,7 +113,7 @@ export class EntraIdCredentialsProviderFactory { static createForClientCredentialsWithCertificate( params: ClientCredentialsWithCertificateParams ): EntraidCredentialsProvider { - return this._createForClientCredentials( + return this.#createForClientCredentials( { clientId: params.clientId, clientCertificate: params.certificate @@ -127,7 +129,7 @@ export class EntraIdCredentialsProviderFactory { static createForClientCredentials( params: ClientSecretCredentialsParams ): EntraidCredentialsProvider { - return this._createForClientCredentials( + return this.#createForClientCredentials( { clientId: params.clientId, clientSecret: params.clientSecret @@ -210,11 +212,10 @@ export class EntraIdCredentialsProviderFactory { } } - } - -const FALLBACK_SCOPE = 'https://redis.azure.com/.default'; +const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default'; +const REDIS_SCOPE = 'https://redis.azure.com' export type AuthorityConfig = | { type: 'multi-tenant'; tenantId: string } @@ -257,16 +258,16 @@ const loggerOptions = { if (!containsPii) console.log(message); }, piiLoggingEnabled: false, - logLevel: LogLevel.Verbose + logLevel: LogLevel.Error } /** - * The most imporant part of the RetryPolicy is the shouldRetry function. This function is used to determine if a request should be retried based - * on the error returned from the identity provider. The defaultRetryPolicy is used to retry on network errors only. + * 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 - shouldRetry: (error: unknown) => error instanceof NetworkError, + isRetryable: (error: unknown) => error instanceof NetworkError, maxAttempts: 10, initialDelayMs: 100, maxDelayMs: 100000, @@ -355,3 +356,16 @@ export class AuthCodeFlowHelper { } } +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 + }) + +} diff --git a/packages/entraid/lib/entraid-credentials-provider.spec.ts b/packages/entraid/lib/entraid-credentials-provider.spec.ts index 8967bd18831..1bdf4e9b65f 100644 --- a/packages/entraid/lib/entraid-credentials-provider.spec.ts +++ b/packages/entraid/lib/entraid-credentials-provider.spec.ts @@ -1,5 +1,5 @@ import { AuthenticationResult } from '@azure/msal-node'; -import { IdentityProvider, TokenManager, TokenResponse, BasicAuth } from '@redis/authx'; +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'; @@ -96,12 +96,12 @@ describe('EntraID CredentialsProvider Subscription Behavior', () => { // Cleanup assert.equal(tokenManager.isRunning(), true); - sub1Initial[1][Symbol.dispose](); - sub2Initial[1][Symbol.dispose](); - sub3Initial[1][Symbol.dispose](); + 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][Symbol.dispose](); + 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) @@ -134,15 +134,15 @@ describe('EntraID CredentialsProvider Subscription Behavior', () => { private readonly tokenSequence: AuthenticationResult[] = [ { accessToken: 'initial-token', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult, { accessToken: 'refresh-token-1', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult, { accessToken: 'refresh-token-2', - account: { username: 'test-user' } + uniqueId: 'test-user' } as AuthenticationResult ] ) {} diff --git a/packages/entraid/lib/entraid-credentials-provider.ts b/packages/entraid/lib/entraid-credentials-provider.ts index 662954e25f1..115d6dbff3a 100644 --- a/packages/entraid/lib/entraid-credentials-provider.ts +++ b/packages/entraid/lib/entraid-credentials-provider.ts @@ -1,8 +1,8 @@ import { AuthenticationResult } from '@azure/msal-common/node'; import { BasicAuth, StreamingCredentialsProvider, IdentityProvider, TokenManager, - ReAuthenticationError, StreamingCredentialsListener, IDPError, Token -} from '@redis/authx'; + ReAuthenticationError, StreamingCredentialsListener, IDPError, Token, Disposable +} from '@redis/client/dist/lib/authx'; /** * A streaming credentials provider that uses the Entraid identity provider to provide credentials. @@ -12,32 +12,28 @@ import { export class EntraidCredentialsProvider implements StreamingCredentialsProvider { readonly type = 'streaming-credentials-provider'; - private readonly listeners: Set> = new Set(); + readonly #listeners: Set> = new Set(); - private tokenManagerDisposable: Disposable | null = null; - private isStarting: boolean = false; + #tokenManagerDisposable: Disposable | null = null; + #isStarting: boolean = false; - private pendingSubscribers: Array<{ + #pendingSubscribers: Array<{ resolve: (value: [BasicAuth, Disposable]) => void; reject: (error: Error) => void; pendingListener: StreamingCredentialsListener; }> = []; constructor( - private readonly tokenManager: TokenManager, - private readonly idp: IdentityProvider, - options: { - onReAuthenticationError?: (error: ReAuthenticationError) => void - credentialsMapper?: (token: AuthenticationResult) => BasicAuth + public readonly tokenManager: TokenManager, + public readonly idp: IdentityProvider, + private readonly options: { + onReAuthenticationError?: (error: ReAuthenticationError) => void; + credentialsMapper?: (token: AuthenticationResult) => BasicAuth; + onRetryableError?: (error: string) => void; } = {} ) { - this.onReAuthenticationError = options.onReAuthenticationError ?? - ((error) => console.error('ReAuthenticationError', error)); - this.credentialsMapper = options.credentialsMapper ?? ((token) => ({ - username: token.account?.username ?? undefined, - password: token.accessToken - })); - + this.onReAuthenticationError = options.onReAuthenticationError ?? DEFAULT_ERROR_HANDLER; + this.#credentialsMapper = options.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER; } async subscribe( @@ -47,81 +43,98 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider const currentToken = this.tokenManager.getCurrentToken(); if (currentToken) { - return [this.credentialsMapper(currentToken.value), this.createDisposable(listener)]; + return [this.#credentialsMapper(currentToken.value), this.#createDisposable(listener)]; } - if (this.isStarting) { + if (this.#isStarting) { return new Promise((resolve, reject) => { - this.pendingSubscribers.push({ resolve, reject, pendingListener: listener }); + this.#pendingSubscribers.push({ resolve, reject, pendingListener: listener }); }); } - this.isStarting = true; + this.#isStarting = true; try { - const initialToken = await this.startTokenManagerAndObtainInitialToken(); + const initialToken = await this.#startTokenManagerAndObtainInitialToken(); - this.pendingSubscribers.forEach(({ resolve, pendingListener }) => { - resolve([this.credentialsMapper(initialToken.value), this.createDisposable(pendingListener)]); + this.#pendingSubscribers.forEach(({ resolve, pendingListener }) => { + resolve([this.#credentialsMapper(initialToken.value), this.#createDisposable(pendingListener)]); }); - this.pendingSubscribers = []; + this.#pendingSubscribers = []; - return [this.credentialsMapper(initialToken.value), this.createDisposable(listener)]; + return [this.#credentialsMapper(initialToken.value), this.#createDisposable(listener)]; } finally { - this.isStarting = false; + this.#isStarting = false; } } onReAuthenticationError: (error: ReAuthenticationError) => void; - private credentialsMapper: (token: AuthenticationResult) => BasicAuth ; + #credentialsMapper: (token: AuthenticationResult) => BasicAuth; - private createTokenManagerListener(subscribers: Set>) { + #createTokenManagerListener(subscribers: Set>) { return { onError: (error: IDPError): void => { - if (error.isFatal) { + if (!error.isRetryable) { subscribers.forEach(listener => listener.onError(error)); } else { - console.log('Transient identity provider error', error); + this.options.onRetryableError?.(error.message); } }, onNext: (token: { value: AuthenticationResult }): void => { - const credentials = this.credentialsMapper(token.value); + const credentials = this.#credentialsMapper(token.value); subscribers.forEach(listener => listener.onNext(credentials)); } }; } - private createDisposable(listener: StreamingCredentialsListener): Disposable { - this.listeners.add(listener); + #createDisposable(listener: StreamingCredentialsListener): Disposable { + this.#listeners.add(listener); return { - [Symbol.dispose]: () => { - this.listeners.delete(listener); - if (this.listeners.size === 0 && this.tokenManagerDisposable) { - this.tokenManagerDisposable[Symbol.dispose](); - this.tokenManagerDisposable = null; + dispose: () => { + this.#listeners.delete(listener); + if (this.#listeners.size === 0 && this.#tokenManagerDisposable) { + this.#tokenManagerDisposable.dispose(); + this.#tokenManagerDisposable = null; } } }; } - private async startTokenManagerAndObtainInitialToken(): Promise> { + async #startTokenManagerAndObtainInitialToken(): Promise> { 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.#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; + return this.#tokenManagerDisposable !== null && this.#listeners.size > 0; } public getSubscriptionsCount(): number { - return this.listeners.size; + 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; } -} \ No newline at end of file +} + +const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResult): BasicAuth => ({ + username: token.uniqueId, + password: token.accessToken +}); + +const DEFAULT_ERROR_HANDLER = (error: ReAuthenticationError) => + console.error('ReAuthenticationError', error); \ No newline at end of file diff --git a/packages/entraid/lib/index.ts b/packages/entraid/lib/index.ts new file mode 100644 index 00000000000..4873c9935c5 --- /dev/null +++ b/packages/entraid/lib/index.ts @@ -0,0 +1,3 @@ +export * from './entra-id-credentials-provider-factory'; +export * from './entraid-credentials-provider'; +export * from './msal-identity-provider'; \ No newline at end of file diff --git a/packages/entraid/lib/msal-identity-provider.ts b/packages/entraid/lib/msal-identity-provider.ts index 1b44549fe3a..59b38d18ec6 100644 --- a/packages/entraid/lib/msal-identity-provider.ts +++ b/packages/entraid/lib/msal-identity-provider.ts @@ -1,7 +1,7 @@ import { AuthenticationResult } from '@azure/msal-node'; -import { IdentityProvider, TokenResponse } from '@redis/authx'; +import { IdentityProvider, TokenResponse } from '@redis/client/dist/lib/authx'; export class MSALIdentityProvider implements IdentityProvider { private readonly getToken: () => Promise; @@ -22,7 +22,6 @@ export class MSALIdentityProvider implements IdentityProvider { app.get('/redirect', async (req: AuthRequest, res: Response) => { try { - // Debug log to see exactly what we're receiving - console.log('Full request query:', req.query); - console.log('Code from request:', req.query.code); - console.log('Session state:', req.session); // The authorization code is in req.query.code const { code, client_info } = req.query; diff --git a/packages/entraid/tsconfig.integration-tests.json b/packages/entraid/tsconfig.integration-tests.json new file mode 100644 index 00000000000..5d15f4f2753 --- /dev/null +++ b/packages/entraid/tsconfig.integration-tests.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./integration-tests/**/*.ts", + "./lib/**/*.ts" + ], + "compilerOptions": { + "noEmit": true + }, +} \ No newline at end of file diff --git a/packages/entraid/tsconfig.json b/packages/entraid/tsconfig.json index 3efd3ae0614..414dc1fe755 100644 --- a/packages/entraid/tsconfig.json +++ b/packages/entraid/tsconfig.json @@ -4,17 +4,14 @@ "outDir": "./dist" }, "include": [ - "./samples/**/*.ts", "./lib/**/*.ts" ], "exclude": [ - "./lib/test-utils.ts", "./lib/**/*.spec.ts", - "./lib/sentinel/test-util.ts" + "./lib/test-util.ts", ], "typedocOptions": { "entryPoints": [ - "./index.ts", "./lib" ], "entryPointStrategy": "expand", diff --git a/packages/entraid/tsconfig.samples.json b/packages/entraid/tsconfig.samples.json new file mode 100644 index 00000000000..0eb936369ff --- /dev/null +++ b/packages/entraid/tsconfig.samples.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./samples/**/*.ts", + "./lib/**/*.ts" + ], + "compilerOptions": { + "noEmit": true + } +} \ No newline at end of file diff --git a/packages/test-utils/lib/cae-client-testing.ts b/packages/test-utils/lib/cae-client-testing.ts new file mode 100644 index 00000000000..92b846dd37e --- /dev/null +++ b/packages/test-utils/lib/cae-client-testing.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; + +interface RawRedisEndpoint { + username?: string; + password?: string; + tls: boolean; + endpoints: string[]; +} + +export type RedisEndpointsConfig = Record; + +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 { + 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; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index ec2b133ac2f..8f43ab41d22 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,14 +23,10 @@ "path": "./packages/time-series" }, { - "path": "./packages/redis" - }, - { - "path": "./packages/authx" + "path": "./packages/entraid" }, { - "path": "./packages/entraid" + "path": "./packages/redis" } ] - }