Skip to content

entraid: add support for azure identity #2901

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 249 additions & 42 deletions package-lock.json

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions packages/entraid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Secure token-based authentication for Redis clients using Microsoft Entra ID (fo
- Managed identities (system-assigned and user-assigned)
- Service principals (with or without certificates)
- Authorization Code with PKCE flow
- DefaultAzureCredential from @azure/identity
- Built-in retry mechanisms for transient failures

## Installation
Expand All @@ -30,6 +31,7 @@ The first step to using @redis/entraid is choosing the right credentials provide
- `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
- `createForDefaultAzureCredential`: Use when you want to leverage Azure Identity's DefaultAzureCredential

## Usage Examples

Expand Down Expand Up @@ -82,6 +84,54 @@ const provider = EntraIdCredentialsProviderFactory.createForUserAssignedManagedI
});
```

### DefaultAzureCredential Authentication

tip: see a real sample here: [samples/interactive-browser/index.ts](./samples/interactive-browser/index.ts)

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).

```typescript
import { createClient } from '@redis/client';
import { DefaultAzureCredential } from '@azure/identity';
import { EntraIdCredentialsProviderFactory, REDIS_SCOPE_DEFAULT } from '@redis/entraid/dist/lib/entra-id-credentials-provider-factory';

// Create a DefaultAzureCredential instance
const credential = new DefaultAzureCredential();

// Create a provider using DefaultAzureCredential
const provider = EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
// Use the same parameters you would pass to credential.getToken()
credential,
scopes: REDIS_SCOPE_DEFAULT, // The Redis scope
// Optional additional parameters for getToken
options: {
// Any options you would normally pass to credential.getToken()
},
tokenManagerConfig: {
expirationRefreshRatio: 0.8
}
});

const client = createClient({
url: 'redis://your-host',
credentialsProvider: provider
});

await client.connect();
```

#### Important Notes on Using DefaultAzureCredential

When using the `createForDefaultAzureCredential` method, you need to:

1. Create your own instance of `DefaultAzureCredential`
2. Pass the same parameters to the factory method that you would use with the `getToken()` method:
- `scopes`: The Redis scope (use the exported `REDIS_SCOPE_DEFAULT` constant)
- `options`: Any additional options for the getToken method

This factory method creates a wrapper around DefaultAzureCredential that adapts it to the Redis client's
authentication system, while maintaining all the flexibility of the original Azure Identity authentication.

## Important Limitations

### RESP2 PUB/SUB Limitations
Expand Down
113 changes: 79 additions & 34 deletions packages/entraid/integration-tests/entraid-integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DefaultAzureCredential, EnvironmentCredential } from '@azure/identity';
import { BasicAuth } from '@redis/client/dist/lib/authx';
import { createClient } from '@redis/client';
import { EntraIdCredentialsProviderFactory } from '../lib/entra-id-credentials-provider-factory';
import { EntraIdCredentialsProviderFactory, REDIS_SCOPE_DEFAULT } from '../lib/entra-id-credentials-provider-factory';
import { strict as assert } from 'node:assert';
import { spy, SinonSpy } from 'sinon';
import { randomUUID } from 'crypto';
Expand Down Expand Up @@ -51,6 +52,35 @@ describe('EntraID Integration Tests', () => {
);
});

it('client with DefaultAzureCredential should be able to authenticate/re-authenticate', async () => {

const azureCredential = new DefaultAzureCredential();

await runAuthenticationTest(() =>
EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
credential: azureCredential,
scopes: REDIS_SCOPE_DEFAULT,
tokenManagerConfig: {
expirationRefreshRatio: 0.00001
}
})
, { testingDefaultAzureCredential: true });
});

it('client with EnvironmentCredential should be able to authenticate/re-authenticate', async () => {
const envCredential = new EnvironmentCredential();

await runAuthenticationTest(() =>
EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
credential: envCredential,
scopes: REDIS_SCOPE_DEFAULT,
tokenManagerConfig: {
expirationRefreshRatio: 0.00001
}
})
, { testingDefaultAzureCredential: true });
});

interface TestConfig {
clientId: string;
clientSecret: string;
Expand Down Expand Up @@ -83,15 +113,15 @@ describe('EntraID Integration Tests', () => {
});

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
endpoints: await loadFromFile(requiredEnvVars.REDIS_ENDPOINTS_CONFIG_PATH as string),
clientId: requiredEnvVars.AZURE_CLIENT_ID as string,
clientSecret: requiredEnvVars.AZURE_CLIENT_SECRET as string,
authority: requiredEnvVars.AZURE_AUTHORITY as string,
tenantId: requiredEnvVars.AZURE_TENANT_ID as string,
redisScopes: requiredEnvVars.AZURE_REDIS_SCOPES as string,
cert: requiredEnvVars.AZURE_CERT as string,
privateKey: requiredEnvVars.AZURE_PRIVATE_KEY as string,
userAssignedManagedId: requiredEnvVars.AZURE_USER_ASSIGNED_MANAGED_ID as string
};
};

Expand Down Expand Up @@ -127,12 +157,22 @@ describe('EntraID Integration Tests', () => {
}
};

const validateTokens = (reAuthSpy: SinonSpy) => {
/**
* Validates authentication tokens generated during re-authentication
*
* @param reAuthSpy - The Sinon spy on the reAuthenticate method
* @param skipUniqueCheckForDefaultAzureCredential - Skip the unique check for DefaultAzureCredential as there are no guarantees that the tokens will be unique
* if the test is using default azure credential
*/
const validateTokens = (reAuthSpy: SinonSpy, skipUniqueCheckForDefaultAzureCredential: boolean) => {
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;
if (!creds.password) {
throw new Error('Expected password to be set in BasicAuth credentials');
}
const tokenPayload = JSON.parse(
Buffer.from(creds.password.split('.')[1], 'base64').toString()
);
Expand All @@ -146,38 +186,43 @@ describe('EntraID Integration Tests', () => {
};
});

// 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`
);
// we can't guarantee that the tokens will be unique when using DefaultAzureCredential
if (!skipUniqueCheckForDefaultAzureCredential) {
// 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 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: ${(Array.from(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(', ')}`
);
// 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: ${(Array.from(uniqueUti).join(','))}`
);
}
};

const runAuthenticationTest = async (setupCredentialsProvider: () => any) => {
const runAuthenticationTest = async (setupCredentialsProvider: () => any, options: {
testingDefaultAzureCredential: boolean
} = { testingDefaultAzureCredential: false }) => {
const { client, reAuthSpy } = await setupTestClient(setupCredentialsProvider());

try {
await client.connect();
await runClientOperations(client);
validateTokens(reAuthSpy);
validateTokens(reAuthSpy, options.testingDefaultAzureCredential);
} finally {
await client.destroy();
}
Expand Down
22 changes: 22 additions & 0 deletions packages/entraid/lib/azure-identity-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AccessToken } from '@azure/core-auth';

import { IdentityProvider, TokenResponse } from '@redis/client/dist/lib/authx';

export class AzureIdentityProvider implements IdentityProvider<AccessToken> {
private readonly getToken: () => Promise<AccessToken>;

constructor(getToken: () => Promise<AccessToken>) {
this.getToken = getToken;
}

async requestToken(): Promise<TokenResponse<AccessToken>> {
const result = await this.getToken();
return {
token: result,
ttlMs: result.expiresOnTimestamp - Date.now()
};
}

}


88 changes: 67 additions & 21 deletions packages/entraid/lib/entra-id-credentials-provider-factory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { GetTokenOptions, TokenCredential } from '@azure/core-auth';
import { NetworkError } from '@azure/msal-common';
import {
LogLevel,
Expand All @@ -7,8 +8,9 @@ import {
PublicClientApplication,
ConfidentialClientApplication, AuthorizationUrlRequest, AuthorizationCodeRequest, CryptoProvider, Configuration, NodeAuthOptions, AccountInfo
} from '@azure/msal-node';
import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError } from '@redis/client/dist/lib/authx';
import { EntraidCredentialsProvider } from './entraid-credentials-provider';
import { RetryPolicy, TokenManager, TokenManagerConfig, ReAuthenticationError, BasicAuth } from '@redis/client/dist/lib/authx';
import { AzureIdentityProvider } from './azure-identity-provider';
import { AuthenticationResponse, DEFAULT_CREDENTIALS_MAPPER, EntraidCredentialsProvider, OID_CREDENTIALS_MAPPER } from './entraid-credentials-provider';
import { MSALIdentityProvider } from './msal-identity-provider';

/**
Expand Down Expand Up @@ -51,7 +53,11 @@ export class EntraIdCredentialsProviderFactory {
return new EntraidCredentialsProvider(
new TokenManager(idp, params.tokenManagerConfig),
idp,
{ onReAuthenticationError: params.onReAuthenticationError, credentialsMapper: OID_CREDENTIALS_MAPPER }
{
onReAuthenticationError: params.onReAuthenticationError,
credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER,
onRetryableError: params.onRetryableError
}
);
}

Expand Down Expand Up @@ -102,7 +108,8 @@ export class EntraIdCredentialsProviderFactory {
return new EntraidCredentialsProvider(new TokenManager(idp, params.tokenManagerConfig), idp,
{
onReAuthenticationError: params.onReAuthenticationError,
credentialsMapper: OID_CREDENTIALS_MAPPER
credentialsMapper: params.credentialsMapper ?? OID_CREDENTIALS_MAPPER,
onRetryableError: params.onRetryableError
});
}

Expand Down Expand Up @@ -138,6 +145,42 @@ export class EntraIdCredentialsProviderFactory {
);
}

/**
* This method is used to create a credentials provider using DefaultAzureCredential.
*
* The user needs to create a configured instance of DefaultAzureCredential ( or any other class that implements TokenCredential )and pass it to this method.
*
* The default credentials mapper for this method is OID_CREDENTIALS_MAPPER which extracts the object ID from JWT
* encoded token.
*
* Depending on the actual flow that DefaultAzureCredential uses, the user may need to provide different
* credential mapper via the credentialsMapper parameter.
*
*/
static createForDefaultAzureCredential(
{
credential,
scopes,
options,
tokenManagerConfig,
onReAuthenticationError,
credentialsMapper,
onRetryableError
}: DefaultAzureCredentialsParams
): EntraidCredentialsProvider {

const idp = new AzureIdentityProvider(
() => credential.getToken(scopes, options).then(x => x === null ? Promise.reject('Token is null') : x)
);

return new EntraidCredentialsProvider(new TokenManager(idp, tokenManagerConfig), idp,
{
onReAuthenticationError: onReAuthenticationError,
credentialsMapper: credentialsMapper ?? OID_CREDENTIALS_MAPPER,
onRetryableError: onRetryableError
});
}

/**
* This method is used to create a credentials provider for the Authorization Code Flow with PKCE.
* @param params
Expand Down Expand Up @@ -194,7 +237,11 @@ export class EntraIdCredentialsProviderFactory {
}
);
const tm = new TokenManager(idp, params.tokenManagerConfig);
return new EntraidCredentialsProvider(tm, idp, { onReAuthenticationError: params.onReAuthenticationError });
return new EntraidCredentialsProvider(tm, idp, {
onReAuthenticationError: params.onReAuthenticationError,
credentialsMapper: params.credentialsMapper ?? DEFAULT_CREDENTIALS_MAPPER,
onRetryableError: params.onRetryableError
});
}
};
}
Expand All @@ -214,8 +261,8 @@ export class EntraIdCredentialsProviderFactory {

}

const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default';
const REDIS_SCOPE = 'https://redis.azure.com'
export const REDIS_SCOPE_DEFAULT = 'https://redis.azure.com/.default';
export const REDIS_SCOPE = 'https://redis.azure.com'

export type AuthorityConfig =
| { type: 'multi-tenant'; tenantId: string }
Expand All @@ -234,7 +281,19 @@ export type CredentialParams = {
authorityConfig?: AuthorityConfig;

tokenManagerConfig: TokenManagerConfig
onReAuthenticationError?: (error: ReAuthenticationError) => void;
onReAuthenticationError?: (error: ReAuthenticationError) => void
credentialsMapper?: (token: AuthenticationResponse) => BasicAuth
onRetryableError?: (error: string) => void
}

export type DefaultAzureCredentialsParams = {
scopes: string | string[],
options?: GetTokenOptions,
credential: TokenCredential
tokenManagerConfig: TokenManagerConfig
onReAuthenticationError?: (error: ReAuthenticationError) => void
credentialsMapper?: (token: AuthenticationResponse) => BasicAuth
onRetryableError?: (error: string) => void
}

export type AuthCodePKCEParams = CredentialParams & {
Expand Down Expand Up @@ -356,16 +415,3 @@ 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
})

}
Loading
Loading