Skip to content

Commit 3ac99d5

Browse files
committed
entraid: add support for azure identity
1 parent 69d507a commit 3ac99d5

9 files changed

+656
-123
lines changed

package-lock.json

+249-42
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/entraid/README.md

+50
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Secure token-based authentication for Redis clients using Microsoft Entra ID (fo
1111
- Managed identities (system-assigned and user-assigned)
1212
- Service principals (with or without certificates)
1313
- Authorization Code with PKCE flow
14+
- DefaultAzureCredential from @azure/identity
1415
- Built-in retry mechanisms for transient failures
1516

1617
## Installation
@@ -30,6 +31,7 @@ The first step to using @redis/entraid is choosing the right credentials provide
3031
- `createForClientCredentials`: Use when authenticating with a service principal using client secret
3132
- `createForClientCredentialsWithCertificate`: Use when authenticating with a service principal using a certificate
3233
- `createForAuthorizationCodeWithPKCE`: Use for interactive authentication flows in user applications
34+
- `createForDefaultAzureCredential`: Use when you want to leverage Azure Identity's DefaultAzureCredential
3335

3436
## Usage Examples
3537

@@ -82,6 +84,54 @@ const provider = EntraIdCredentialsProviderFactory.createForUserAssignedManagedI
8284
});
8385
```
8486

87+
### DefaultAzureCredential Authentication
88+
89+
tip: see a real sample here: [samples/interactive-browser/index.ts](./samples/interactive-browser/index.ts)
90+
91+
The DefaultAzureCredential from @azure/identity provides a simplified authentication experience that automatically tries different authentication methods based on the environment. This is especially useful for applications that need to work in different environments (local development, CI/CD, and production).
92+
93+
```typescript
94+
import { createClient } from '@redis/client';
95+
import { DefaultAzureCredential } from '@azure/identity';
96+
import { EntraIdCredentialsProviderFactory, REDIS_SCOPE_DEFAULT } from '@redis/entraid/dist/lib/entra-id-credentials-provider-factory';
97+
98+
// Create a DefaultAzureCredential instance
99+
const credential = new DefaultAzureCredential();
100+
101+
// Create a provider using DefaultAzureCredential
102+
const provider = EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
103+
// Use the same parameters you would pass to credential.getToken()
104+
credential,
105+
scopes: REDIS_SCOPE_DEFAULT, // The Redis scope
106+
// Optional additional parameters for getToken
107+
options: {
108+
// Any options you would normally pass to credential.getToken()
109+
},
110+
tokenManagerConfig: {
111+
expirationRefreshRatio: 0.8
112+
}
113+
});
114+
115+
const client = createClient({
116+
url: 'redis://your-host',
117+
credentialsProvider: provider
118+
});
119+
120+
await client.connect();
121+
```
122+
123+
#### Important Notes on Using DefaultAzureCredential
124+
125+
When using the `createForDefaultAzureCredential` method, you need to:
126+
127+
1. Create your own instance of `DefaultAzureCredential`
128+
2. Pass the same parameters to the factory method that you would use with the `getToken()` method:
129+
- `scopes`: The Redis scope (use the exported `REDIS_SCOPE_DEFAULT` constant)
130+
- `options`: Any additional options for the getToken method
131+
132+
This factory method creates a wrapper around DefaultAzureCredential that adapts it to the Redis client's
133+
authentication system, while maintaining all the flexibility of the original Azure Identity authentication.
134+
85135
## Important Limitations
86136

87137
### RESP2 PUB/SUB Limitations

packages/entraid/integration-tests/entraid-integration.spec.ts

+80-34
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { DefaultAzureCredential, EnvironmentCredential } from '@azure/identity';
12
import { BasicAuth } from '@redis/client/dist/lib/authx';
23
import { createClient } from '@redis/client';
3-
import { EntraIdCredentialsProviderFactory } from '../lib/entra-id-credentials-provider-factory';
4+
import { EntraIdCredentialsProviderFactory, REDIS_SCOPE_DEFAULT } from '../lib/entra-id-credentials-provider-factory';
45
import { strict as assert } from 'node:assert';
56
import { spy, SinonSpy } from 'sinon';
67
import { randomUUID } from 'crypto';
@@ -51,6 +52,36 @@ describe('EntraID Integration Tests', () => {
5152
);
5253
});
5354

55+
it('client with DefaultAzureCredential should be able to authenticate/re-authenticate', async () => {
56+
const config = await readConfigFromEnv();
57+
58+
const azureCredential = new DefaultAzureCredential();
59+
60+
await runAuthenticationTest(() =>
61+
EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
62+
credential: azureCredential,
63+
scopes: REDIS_SCOPE_DEFAULT,
64+
tokenManagerConfig: {
65+
expirationRefreshRatio: 0.00001
66+
}
67+
})
68+
, { testingDefaultAzureCredential: true });
69+
});
70+
71+
it('client with EnvironmentCredential should be able to authenticate/re-authenticate', async () => {
72+
const envCredential = new EnvironmentCredential();
73+
74+
await runAuthenticationTest(() =>
75+
EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
76+
credential: envCredential,
77+
scopes: REDIS_SCOPE_DEFAULT,
78+
tokenManagerConfig: {
79+
expirationRefreshRatio: 0.00001
80+
}
81+
})
82+
, { testingDefaultAzureCredential: true });
83+
});
84+
5485
interface TestConfig {
5586
clientId: string;
5687
clientSecret: string;
@@ -83,15 +114,15 @@ describe('EntraID Integration Tests', () => {
83114
});
84115

85116
return {
86-
endpoints: await loadFromFile(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH),
87-
clientId: requiredEnvVars.AZURE_CLIENT_ID,
88-
clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET,
89-
authority: requiredEnvVars.AZURE_AUTHORITY,
90-
tenantId: requiredEnvVars.AZURE_TENANT_ID,
91-
redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES,
92-
cert: requiredEnvVars.AZURE_CERT,
93-
privateKey: requiredEnvVars.AZURE_PRIVATE_KEY,
94-
userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID
117+
endpoints: await loadFromFile(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH as string),
118+
clientId: requiredEnvVars.AZURE_CLIENT_ID as string,
119+
clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET as string,
120+
authority: requiredEnvVars.AZURE_AUTHORITY as string,
121+
tenantId: requiredEnvVars.AZURE_TENANT_ID as string,
122+
redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES as string,
123+
cert: requiredEnvVars.AZURE_CERT as string,
124+
privateKey: requiredEnvVars.AZURE_PRIVATE_KEY as string,
125+
userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID as string
95126
};
96127
};
97128

@@ -127,12 +158,22 @@ describe('EntraID Integration Tests', () => {
127158
}
128159
};
129160

130-
const validateTokens = (reAuthSpy: SinonSpy) => {
161+
/**
162+
* Validates authentication tokens generated during re-authentication
163+
*
164+
* @param reAuthSpy - The Sinon spy on the reAuthenticate method
165+
* @param skipUniqueCheckForDefaultAzureCredential - Skip the unique check for DefaultAzureCredential as there are no guarantees that the tokens will be unique
166+
* if the test is using default azure credential
167+
*/
168+
const validateTokens = (reAuthSpy: SinonSpy, skipUniqueCheckForDefaultAzureCredential: boolean) => {
131169
assert(reAuthSpy.callCount >= 1,
132170
`reAuthenticate should have been called at least once, but was called ${reAuthSpy.callCount} times`);
133171

134172
const tokenDetails: TokenDetail[] = reAuthSpy.getCalls().map(call => {
135173
const creds = call.args[0] as BasicAuth;
174+
if (!creds.password) {
175+
throw new Error('Expected password to be set in BasicAuth credentials');
176+
}
136177
const tokenPayload = JSON.parse(
137178
Buffer.from(creds.password.split('.')[1], 'base64').toString()
138179
);
@@ -146,38 +187,43 @@ describe('EntraID Integration Tests', () => {
146187
};
147188
});
148189

149-
// Verify unique tokens
150-
const uniqueTokens = new Set(tokenDetails.map(detail => detail.token));
151-
assert.equal(
152-
uniqueTokens.size,
153-
reAuthSpy.callCount,
154-
`Expected ${reAuthSpy.callCount} different tokens, but got ${uniqueTokens.size} unique tokens`
155-
);
190+
// we can't guarantee that the tokens will be unique when using DefaultAzureCredential
191+
if (!skipUniqueCheckForDefaultAzureCredential) {
192+
// Verify unique tokens
193+
const uniqueTokens = new Set(tokenDetails.map(detail => detail.token));
194+
assert.equal(
195+
uniqueTokens.size,
196+
reAuthSpy.callCount,
197+
`Expected ${reAuthSpy.callCount} different tokens, but got ${uniqueTokens.size} unique tokens`
198+
);
156199

157-
// Verify all tokens are not cached (i.e. have the same lifetime)
158-
const uniqueLifetimes = new Set(tokenDetails.map(detail => detail.lifetime));
159-
assert.equal(
160-
uniqueLifetimes.size,
161-
1,
162-
`Expected all tokens to have the same lifetime, but found ${uniqueLifetimes.size} different lifetimes: ${[uniqueLifetimes].join(', ')} seconds`
163-
);
200+
// Verify all tokens are not cached (i.e. have the same lifetime)
201+
const uniqueLifetimes = new Set(tokenDetails.map(detail => detail.lifetime));
202+
assert.equal(
203+
uniqueLifetimes.size,
204+
1,
205+
`Expected all tokens to have the same lifetime, but found ${uniqueLifetimes.size} different lifetimes: ${(Array.from(uniqueLifetimes).join(','))} seconds`
206+
);
164207

165-
// Verify that all tokens have different uti (unique token identifier)
166-
const uniqueUti = new Set(tokenDetails.map(detail => detail.uti));
167-
assert.equal(
168-
uniqueUti.size,
169-
reAuthSpy.callCount,
170-
`Expected all tokens to have different uti, but found ${uniqueUti.size} different uti in: ${[uniqueUti].join(', ')}`
171-
);
208+
// Verify that all tokens have different uti (unique token identifier)
209+
const uniqueUti = new Set(tokenDetails.map(detail => detail.uti));
210+
assert.equal(
211+
uniqueUti.size,
212+
reAuthSpy.callCount,
213+
`Expected all tokens to have different uti, but found ${uniqueUti.size} different uti in: ${(Array.from(uniqueUti).join(','))}`
214+
);
215+
}
172216
};
173217

174-
const runAuthenticationTest = async (setupCredentialsProvider: () => any) => {
218+
const runAuthenticationTest = async (setupCredentialsProvider: () => any, options: {
219+
testingDefaultAzureCredential: boolean
220+
} = { testingDefaultAzureCredential: false }) => {
175221
const { client, reAuthSpy } = await setupTestClient(setupCredentialsProvider());
176222

177223
try {
178224
await client.connect();
179225
await runClientOperations(client);
180-
validateTokens(reAuthSpy);
226+
validateTokens(reAuthSpy, options.testingDefaultAzureCredential);
181227
} finally {
182228
await client.destroy();
183229
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { AccessToken } from '@azure/core-auth';
2+
3+
import { IdentityProvider, TokenResponse } from '@redis/client/dist/lib/authx';
4+
5+
export class AzureIdentityProvider implements IdentityProvider<AccessToken> {
6+
private readonly getToken: () => Promise<AccessToken>;
7+
8+
constructor(getToken: () => Promise<AccessToken>) {
9+
this.getToken = getToken;
10+
}
11+
12+
async requestToken(): Promise<TokenResponse<AccessToken>> {
13+
const result = await this.getToken();
14+
return {
15+
token: result,
16+
ttlMs: result.expiresOnTimestamp - Date.now()
17+
};
18+
}
19+
20+
}
21+
22+

0 commit comments

Comments
 (0)