1
0
mirror of https://github.com/redis/node-redis.git synced 2025-08-07 13:22:56 +03:00

feat(entraid): add support for azure identity (#2901)

This PR adds support for using Azure Identity's credential classes with Redis Enterprise Entra ID authentication.
The main changes include:

- Add a new factory method createForDefaultAzureCredential to enable using Azure Identity credentials
- Add @azure/identity as a dependency to support the new authentication flow
- Add support for DefaultAzureCredential, EnvironmentCredential, and any other TokenCredential implementation
- Create a new AzureIdentityProvider to support DefaultAzureCredential
- Update documentation and README with usage examples for DefaultAzureCredential
- Add integration tests for the new authentication methods
- Include a sample application demonstrating interactive browser authentication
- Export constants for Redis scopes / credential mappers to simplify authentication configuration
This commit is contained in:
Bobby I.
2025-03-05 14:47:18 +02:00
committed by GitHub
parent 69d507a572
commit 8b4ed0059a
9 changed files with 655 additions and 123 deletions

View File

@@ -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
@@ -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
@@ -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

View File

@@ -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';
@@ -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;
@@ -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
};
};
@@ -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()
);
@@ -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();
}

View File

@@ -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()
};
}
}

View File

@@ -1,3 +1,4 @@
import type { GetTokenOptions, TokenCredential } from '@azure/core-auth';
import { NetworkError } from '@azure/msal-common';
import {
LogLevel,
@@ -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';
/**
@@ -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
}
);
}
@@ -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
});
}
@@ -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
@@ -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
});
}
};
}
@@ -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 }
@@ -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 & {
@@ -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
})
}

View File

@@ -1,4 +1,5 @@
import { AuthenticationResult } from '@azure/msal-common/node';
import { AccessToken } from '@azure/core-auth';
import {
BasicAuth, StreamingCredentialsProvider, IdentityProvider, TokenManager,
ReAuthenticationError, StreamingCredentialsListener, IDPError, Token, Disposable
@@ -9,6 +10,9 @@ import {
* 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 type AuthenticationResponse = AuthenticationResult | AccessToken
export class EntraidCredentialsProvider implements StreamingCredentialsProvider {
readonly type = 'streaming-credentials-provider';
@@ -24,11 +28,11 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
}> = [];
constructor(
public readonly tokenManager: TokenManager<AuthenticationResult>,
public readonly idp: IdentityProvider<AuthenticationResult>,
public readonly tokenManager: TokenManager<AuthenticationResponse>,
public readonly idp: IdentityProvider<AuthenticationResponse>,
private readonly options: {
onReAuthenticationError?: (error: ReAuthenticationError) => void;
credentialsMapper?: (token: AuthenticationResult) => BasicAuth;
credentialsMapper?: (token: AuthenticationResponse) => BasicAuth;
onRetryableError?: (error: string) => void;
} = {}
) {
@@ -69,7 +73,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
onReAuthenticationError: (error: ReAuthenticationError) => void;
#credentialsMapper: (token: AuthenticationResult) => BasicAuth;
#credentialsMapper: (token: AuthenticationResponse) => BasicAuth;
#createTokenManagerListener(subscribers: Set<StreamingCredentialsListener<BasicAuth>>) {
return {
@@ -80,7 +84,7 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
this.options.onRetryableError?.(error.message);
}
},
onNext: (token: { value: AuthenticationResult }): void => {
onNext: (token: { value: AuthenticationResult | AccessToken }): void => {
const credentials = this.#credentialsMapper(token.value);
subscribers.forEach(listener => listener.onNext(credentials));
}
@@ -101,10 +105,10 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
};
}
async #startTokenManagerAndObtainInitialToken(): Promise<Token<AuthenticationResult>> {
const initialResponse = await this.idp.requestToken();
const token = this.tokenManager.wrapAndSetCurrentToken(initialResponse.token, initialResponse.ttlMs);
async #startTokenManagerAndObtainInitialToken(): Promise<Token<AuthenticationResponse>> {
const { ttlMs, token: initialToken } = await this.idp.requestToken();
const token = this.tokenManager.wrapAndSetCurrentToken(initialToken, ttlMs);
this.#tokenManagerDisposable = this.tokenManager.start(
this.#createTokenManagerListener(this.#listeners),
this.tokenManager.calculateRefreshTime(token)
@@ -131,10 +135,61 @@ export class EntraidCredentialsProvider implements StreamingCredentialsProvider
}
const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResult): BasicAuth => ({
username: token.uniqueId,
password: token.accessToken
});
export const DEFAULT_CREDENTIALS_MAPPER = (token: AuthenticationResponse): BasicAuth => {
if (isAuthenticationResult(token)) {
return {
username: token.uniqueId,
password: token.accessToken
}
} else {
return OID_CREDENTIALS_MAPPER(token)
}
};
const DEFAULT_ERROR_HANDLER = (error: ReAuthenticationError) =>
console.error('ReAuthenticationError', error);
console.error('ReAuthenticationError', error);
export const OID_CREDENTIALS_MAPPER = (token: (AuthenticationResult | AccessToken)) => {
if (isAuthenticationResult(token)) {
// 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
})
} else {
const accessToken = JSON.parse(Buffer.from(token.token.split('.')[1], 'base64').toString());
return ({
username: accessToken.oid,
password: token.token
})
}
}
/**
* Type guard to check if a token is an MSAL AuthenticationResult
*
* @param auth - The token to check
* @returns true if the token is an AuthenticationResult
*/
export function isAuthenticationResult(auth: AuthenticationResult | AccessToken): auth is AuthenticationResult {
return typeof (auth as AuthenticationResult).accessToken === 'string' &&
!('token' in auth)
}
/**
* Type guard to check if a token is an Azure Identity AccessToken
*
* @param auth - The token to check
* @returns true if the token is an AccessToken
*/
export function isAccessToken(auth: AuthenticationResult | AccessToken): auth is AccessToken {
return typeof (auth as AccessToken).token === 'string' &&
!('accessToken' in auth);
}

View File

@@ -11,21 +11,15 @@ export class MSALIdentityProvider implements IdentityProvider<AuthenticationResu
}
async requestToken(): Promise<TokenResponse<AuthenticationResult>> {
try {
const result = await this.getToken();
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) {
throw error;
if (!result?.accessToken || !result?.expiresOn) {
throw new Error('Invalid token response');
}
return {
token: result,
ttlMs: result.expiresOn.getTime() - Date.now()
};
}
}

View File

@@ -12,10 +12,12 @@
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"start:auth-pkce": "tsx --tsconfig tsconfig.samples.json ./samples/auth-code-pkce/index.ts",
"start:interactive-browser": "tsx --tsconfig tsconfig.samples.json ./samples/interactive-browser/index.ts",
"test-integration": "mocha -r tsx --tsconfig tsconfig.integration-tests.json './integration-tests/**/*.spec.ts'",
"test": "nyc -r text-summary -r lcov mocha -r tsx './lib/**/*.spec.ts'"
},
"dependencies": {
"@azure/identity": "4.7.0",
"@azure/msal-node": "^2.16.1"
},
"peerDependencies": {

View File

@@ -0,0 +1,111 @@
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';
import { InteractiveBrowserCredential } from '@azure/identity';
dotenv.config();
if (!process.env.SESSION_SECRET) {
throw new Error('SESSION_SECRET environment variable must be set');
}
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');
}
app.get('/login', async (req: Request, res: Response) => {
try {
// Create an instance of InteractiveBrowserCredential
const credential = new InteractiveBrowserCredential({
clientId: process.env.MSAL_CLIENT_ID!,
tenantId: process.env.MSAL_TENANT_ID!,
loginStyle: 'popup',
redirectUri: 'http://localhost:3000/redirect'
});
// Create Redis client using the EntraID credentials provider
const entraidCredentialsProvider = EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
credential,
scopes: ['user.read'],
tokenManagerConfig: DEFAULT_TOKEN_MANAGER_CONFIG
});
// Subscribe to credentials updates
const initialCredentials = entraidCredentialsProvider.subscribe({
onNext: (token) => {
// Never log the full token in production
console.log('Token acquired successfully');
console.log('Username:', token.username);
},
onError: (error) => {
console.error('Token acquisition failed:', error);
}
});
// Wait for the initial credentials
const [credentials] = await initialCredentials;
// Return success response
res.json({
status: 'success',
message: 'Authentication successful',
credentials: {
username: credentials.username,
password: credentials.password
}
});
} catch (error) {
console.error('Authentication failed:', error);
res.status(500).json({
status: 'error',
message: 'Authentication failed',
error: error instanceof Error ? error.message : String(error)
});
}
});
// Create a simple status page
app.get('/', (req: Request, res: Response) => {
res.send(`
<html>
<head>
<title>Interactive Browser Credential Demo</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
.button { display: inline-block; padding: 10px 20px; background: #0078d4; color: white; text-decoration: none; border-radius: 4px; }
</style>
</head>
<body>
<h1>Interactive Browser Credential Demo</h1>
<p>This example demonstrates using the InteractiveBrowserCredential from @azure/identity to authenticate with Microsoft Entra ID.</p>
<p>When you click the button below, you'll be redirected to the Microsoft login page.</p>
<a href="/login" class="button">Login with Microsoft</a>
</body>
</html>
`);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Open http://localhost:${PORT} in your browser to start`);
});