Skip to content

Commit a0c324b

Browse files
committed
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
1 parent ffa7d25 commit a0c324b

File tree

4 files changed

+361
-43
lines changed

4 files changed

+361
-43
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Provides credentials asynchronously.
3+
*/
4+
export interface AsyncCredentialsProvider {
5+
readonly type: 'async-credentials-provider';
6+
credentials: () => Promise<BasicAuth>
7+
}
8+
9+
/**
10+
* Provides credentials asynchronously with support for continuous updates via a subscription model.
11+
* This is useful for environments where credentials are frequently rotated or updated or can be revoked.
12+
*/
13+
export interface StreamingCredentialsProvider {
14+
readonly type: 'streaming-credentials-provider';
15+
16+
/**
17+
* Provides initial credentials and subscribes to subsequent updates. This is used internally by the node-redis client
18+
* to handle credential rotation and re-authentication.
19+
*
20+
* Note: The node-redis client manages the subscription lifecycle automatically. Users only need to implement
21+
* onReAuthenticationError if they want to be notified about authentication failures.
22+
*
23+
* Error handling:
24+
* - Errors received via onError indicate a fatal issue with the credentials stream
25+
* - The stream is automatically closed(disposed) when onError occurs
26+
* - onError typically mean the provider failed to fetch new credentials after retrying
27+
*
28+
* @example
29+
* ```ts
30+
* const provider = getStreamingProvider();
31+
* const [initialCredentials, disposable] = await provider.subscribe({
32+
* onNext: (newCredentials) => {
33+
* // Handle credential update
34+
* },
35+
* onError: (error) => {
36+
* // Handle fatal stream error
37+
* }
38+
* });
39+
*
40+
* @param listener - Callbacks to handle credential updates and errors
41+
* @returns A Promise resolving to [initial credentials, cleanup function]
42+
*/
43+
subscribe: (listener: StreamingCredentialsListener<BasicAuth>) => Promise<[BasicAuth, Disposable]>
44+
45+
/**
46+
* Called when authentication fails or credentials cannot be renewed in time.
47+
* Implement this to handle authentication errors in your application.
48+
*
49+
* @param error - Either a CredentialsError (invalid/expired credentials) or
50+
* UnableToObtainNewCredentialsError (failed to fetch new credentials on time)
51+
*/
52+
onReAuthenticationError: (error: ReAuthenticationError) => void;
53+
54+
}
55+
56+
/**
57+
* Type representing basic authentication credentials.
58+
*/
59+
export type BasicAuth = { username?: string, password?: string }
60+
61+
/**
62+
* Callback to handle credential updates and errors.
63+
*/
64+
export type StreamingCredentialsListener<T> = {
65+
onNext: (credentials: T) => void;
66+
onError: (e: Error) => void;
67+
}
68+
69+
/**
70+
* Disposable is an interface for objects that hold resources that should be released when they are no longer needed.
71+
*/
72+
export type Disposable = {
73+
dispose: () => void;
74+
}
75+
76+
/**
77+
* Providers that can supply authentication credentials
78+
*/
79+
export type CredentialsProvider = AsyncCredentialsProvider | StreamingCredentialsProvider
80+
81+
/**
82+
* Errors that can occur during re-authentication.
83+
*/
84+
export type ReAuthenticationError = CredentialsError | UnableToObtainNewCredentialsError
85+
86+
/**
87+
* Thrown when re-authentication fails with provided credentials .
88+
* e.g. when the credentials are invalid, expired or revoked.
89+
*
90+
*/
91+
export class CredentialsError extends Error {
92+
constructor(message: string) {
93+
super(`Re-authentication with latest credentials failed: ${message}`);
94+
this.name = 'CredentialsError';
95+
}
96+
97+
}
98+
99+
/**
100+
* Thrown when new credentials cannot be obtained before current ones expire
101+
*/
102+
export class UnableToObtainNewCredentialsError extends Error {
103+
constructor(message: string) {
104+
super(`Unable to obtain new credentials : ${message}`);
105+
this.name = 'UnableToObtainNewCredentialsError';
106+
}
107+
}

packages/client/lib/client/index.spec.ts

Lines changed: 94 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { strict as assert } from 'node:assert';
22
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
3-
import RedisClient, { RedisClientType } from '.';
3+
import RedisClient, { RedisClientOptions, RedisClientType } from '.';
44
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors';
55
import { defineScript } from '../lua-script';
66
import { spy } from 'sinon';
@@ -25,36 +25,87 @@ export const SQUARE_SCRIPT = defineScript({
2525

2626
describe('Client', () => {
2727
describe('parseURL', () => {
28-
it('redis://user:secret@localhost:6379/0', () => {
29-
assert.deepEqual(
30-
RedisClient.parseURL('redis://user:secret@localhost:6379/0'),
31-
{
32-
socket: {
33-
host: 'localhost',
34-
port: 6379
35-
},
36-
username: 'user',
37-
password: 'secret',
38-
database: 0
28+
it('redis://user:secret@localhost:6379/0', async () => {
29+
const result = RedisClient.parseURL('redis://user:secret@localhost:6379/0');
30+
const expected : RedisClientOptions = {
31+
socket: {
32+
host: 'localhost',
33+
port: 6379
34+
},
35+
username: 'user',
36+
password: 'secret',
37+
database: 0,
38+
credentialsProvider: {
39+
type: 'async-credentials-provider',
40+
credentials: async () => ({
41+
password: 'secret',
42+
username: 'user'
43+
})
3944
}
40-
);
45+
};
46+
47+
// Compare everything except the credentials function
48+
const { credentialsProvider: resultCredProvider, ...resultRest } = result;
49+
const { credentialsProvider: expectedCredProvider, ...expectedRest } = expected;
50+
51+
// Compare non-function properties
52+
assert.deepEqual(resultRest, expectedRest);
53+
54+
if(result.credentialsProvider.type === 'async-credentials-provider'
55+
&& expected.credentialsProvider.type === 'async-credentials-provider') {
56+
57+
// Compare the actual output of the credentials functions
58+
const resultCreds = await result.credentialsProvider.credentials();
59+
const expectedCreds = await expected.credentialsProvider.credentials();
60+
assert.deepEqual(resultCreds, expectedCreds);
61+
} else {
62+
assert.fail('Credentials provider type mismatch');
63+
}
64+
65+
4166
});
4267

43-
it('rediss://user:secret@localhost:6379/0', () => {
44-
assert.deepEqual(
45-
RedisClient.parseURL('rediss://user:secret@localhost:6379/0'),
46-
{
47-
socket: {
48-
host: 'localhost',
49-
port: 6379,
50-
tls: true
51-
},
52-
username: 'user',
53-
password: 'secret',
54-
database: 0
68+
it('rediss://user:secret@localhost:6379/0', async () => {
69+
const result = RedisClient.parseURL('rediss://user:secret@localhost:6379/0');
70+
const expected: RedisClientOptions = {
71+
socket: {
72+
host: 'localhost',
73+
port: 6379,
74+
tls: true
75+
},
76+
username: 'user',
77+
password: 'secret',
78+
database: 0,
79+
credentialsProvider: {
80+
credentials: async () => ({
81+
password: 'secret',
82+
username: 'user'
83+
}),
84+
type: 'async-credentials-provider'
5585
}
56-
);
57-
});
86+
};
87+
88+
// Compare everything except the credentials function
89+
const { credentialsProvider: resultCredProvider, ...resultRest } = result;
90+
const { credentialsProvider: expectedCredProvider, ...expectedRest } = expected;
91+
92+
// Compare non-function properties
93+
assert.deepEqual(resultRest, expectedRest);
94+
assert.equal(resultCredProvider.type, expectedCredProvider.type);
95+
96+
if (result.credentialsProvider.type === 'async-credentials-provider' &&
97+
expected.credentialsProvider.type === 'async-credentials-provider') {
98+
99+
// Compare the actual output of the credentials functions
100+
const resultCreds = await result.credentialsProvider.credentials();
101+
const expectedCreds = await expected.credentialsProvider.credentials();
102+
assert.deepEqual(resultCreds, expectedCreds);
103+
104+
} else {
105+
assert.fail('Credentials provider type mismatch');
106+
}
107+
108+
})
58109

59110
it('Invalid protocol', () => {
60111
assert.throws(
@@ -90,6 +141,21 @@ describe('Client', () => {
90141
);
91142
}, GLOBAL.SERVERS.PASSWORD);
92143

144+
testUtils.testWithClient('Client can authenticate asynchronously ', async client => {
145+
assert.equal(
146+
await client.ping(),
147+
'PONG'
148+
);
149+
}, GLOBAL.SERVERS.ASYNC_BASIC_AUTH);
150+
151+
testUtils.testWithClient('Client can authenticate using the streaming credentials provider for initial token acquisition',
152+
async client => {
153+
assert.equal(
154+
await client.ping(),
155+
'PONG'
156+
);
157+
}, GLOBAL.SERVERS.STREAMING_AUTH);
158+
93159
testUtils.testWithClient('should execute AUTH before SELECT', async client => {
94160
assert.equal(
95161
(await client.clientInfo()).db,
@@ -294,6 +360,7 @@ describe('Client', () => {
294360
assert.equal(err.replies.length, 2);
295361
assert.deepEqual(err.errorIndexes, [1]);
296362
assert.ok(err.replies[1] instanceof ErrorReply);
363+
// @ts-ignore TS2802
297364
assert.deepEqual([...err.errors()], [err.replies[1]]);
298365
return true;
299366
}

0 commit comments

Comments
 (0)