From e00efe55ab6721f0b889077296f6f2c519784f46 Mon Sep 17 00:00:00 2001 From: borislav ivanov Date: Fri, 21 Feb 2025 12:37:39 +0200 Subject: [PATCH] entraid: add support for azure identity 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 --- package-lock.json | 291 +++++++++++++++--- packages/entraid/README.md | 50 +++ .../entraid-integration.spec.ts | 113 +++++-- .../entraid/lib/azure-identity-provider.ts | 22 ++ .../entra-id-credentials-provider-factory.ts | 88 ++++-- .../lib/entraid-credentials-provider.ts | 81 ++++- .../entraid/lib/msal-identity-provider.ts | 20 +- packages/entraid/package.json | 2 + .../samples/interactive-browser/index.ts | 111 +++++++ 9 files changed, 655 insertions(+), 123 deletions(-) create mode 100644 packages/entraid/lib/azure-identity-provider.ts create mode 100644 packages/entraid/samples/interactive-browser/index.ts diff --git a/package-lock.json b/package-lock.json index 8fdd049a5b2..25a1dc9d51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,214 @@ "node": ">=6.0.0" } }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz", + "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.9.2.tgz", + "integrity": "sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.4.0", + "@azure/core-rest-pipeline": "^1.9.1", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.6.1", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.19.0.tgz", + "integrity": "sha512-bM3308LRyg5g7r3Twprtqww0R/r7+GyVxj4BafcmVPo4WQoGt5JXuaqxHEFjw2o3rvFZcUPiqJMg6WuvEEeVUA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.8.0", + "@azure/core-tracing": "^1.0.1", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.2.0.tgz", + "integrity": "sha512-UKTiEJPkWcESPYJz3X5uKRYyOcJD+4nYph+KpfdPRnQJVrZfk0KJgdnaAWKfhsBBtAf/D58Az4AvCJEmWgIBAg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz", + "integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.7.0.tgz", + "integrity": "sha512-6z/S2KorkbKaZ0DgZFVRdu7RCuATmMSTjKpuhj7YpjxkJ0vnJ7kTM3cpNgzFgk9OPYfZ31wrBEtC/iwAS4jQDA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.2.1", + "events": "^3.0.0", + "jws": "^4.0.0", + "open": "^10.1.0", + "stoppable": "^1.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/identity/node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/identity/node_modules/@azure/msal-node": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.2.3.tgz", + "integrity": "sha512-0eaPqBIWEAizeYiXdeHb09Iq0tvHJ17ztvNEaLdr/KcJJhJxbpkkEQf09DB+vKlFE0tzYi7j4rYLTXtES/InEQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/identity/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@azure/identity/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@azure/identity/node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@azure/logger": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.1.4.tgz", + "integrity": "sha512-4IXXzcCdLdlXuCG+8UKEwLA1T1NHqUfanhXYHiQTn+6sfWCZXduqbtXDGceg3Ce5QxTGo7EqmbV6Bi+aqKuClQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.4.0.tgz", + "integrity": "sha512-rU6juYXk67CKQmpgi6fDgZoPQ9InZ1760z1BSAH7RbeIc4lHZM/Tu+H0CyRk7cnrfvTkexyYE4pjYhMghpzheA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-browser/node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@azure/msal-common": { "version": "14.16.0", "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", @@ -847,10 +1055,6 @@ "node": ">=12" } }, - "node_modules/@redis/authx": { - "resolved": "packages/authx", - "link": true - }, "node_modules/@redis/bloom": { "resolved": "packages/bloom", "link": true @@ -1128,7 +1332,6 @@ }, "node_modules/agent-base": { "version": "7.1.0", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -1676,7 +1879,6 @@ }, "node_modules/bundle-name": { "version": "4.1.0", - "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -2161,7 +2363,6 @@ }, "node_modules/debug": { "version": "4.3.4", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -2177,7 +2378,6 @@ }, "node_modules/debug/node_modules/ms": { "version": "2.1.2", - "dev": true, "license": "MIT" }, "node_modules/decamelize": { @@ -2223,7 +2423,6 @@ }, "node_modules/default-browser": { "version": "5.2.1", - "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -2238,7 +2437,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2300,7 +2498,6 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2736,6 +2933,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "8.0.1", "dev": true, @@ -3689,7 +3895,6 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -3713,7 +3918,6 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.2", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.0.2", @@ -4087,7 +4291,6 @@ }, "node_modules/is-docker": { "version": "3.0.0", - "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -4142,7 +4345,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -4391,7 +4593,6 @@ }, "node_modules/is-wsl": { "version": "3.1.0", - "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -6654,7 +6855,6 @@ }, "node_modules/run-applescript": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7146,6 +7346,16 @@ "node": ">= 0.4" } }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "dev": true, @@ -7368,7 +7578,6 @@ }, "node_modules/tslib": { "version": "2.6.2", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -8129,6 +8338,7 @@ "packages/authx": { "name": "@redis/authx", "version": "5.0.0-next.5", + "extraneous": true, "license": "MIT", "dependencies": { "@azure/msal-node": "^2.16.1" @@ -8143,7 +8353,7 @@ }, "packages/bloom": { "name": "@redis/bloom", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -8152,12 +8362,12 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } }, "packages/client": { "name": "@redis/client", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "dependencies": { "cluster-key-slot": "1.1.2" @@ -8169,16 +8379,14 @@ }, "engines": { "node": ">= 18" - }, - "peerDependencies": { - "@redis/authx": "^5.0.0-next.5" } }, "packages/entraid": { "name": "@redis/entraid", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "dependencies": { + "@azure/identity": "4.7.0", "@azure/msal-node": "^2.16.1" }, "devDependencies": { @@ -8194,8 +8402,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/authx": "^5.0.0-next.5", - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } }, "packages/entraid/node_modules/@types/node": { @@ -8217,7 +8424,7 @@ }, "packages/graph": { "name": "@redis/graph", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -8226,12 +8433,12 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } }, "packages/json": { "name": "@redis/json", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -8240,19 +8447,19 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } }, "packages/redis": { - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "dependencies": { - "@redis/bloom": "5.0.0-next.5", - "@redis/client": "5.0.0-next.5", - "@redis/graph": "5.0.0-next.5", - "@redis/json": "5.0.0-next.5", - "@redis/search": "5.0.0-next.5", - "@redis/time-series": "5.0.0-next.5" + "@redis/bloom": "5.0.0-next.6", + "@redis/client": "5.0.0-next.6", + "@redis/graph": "5.0.0-next.6", + "@redis/json": "5.0.0-next.6", + "@redis/search": "5.0.0-next.6", + "@redis/time-series": "5.0.0-next.6" }, "engines": { "node": ">= 18" @@ -8260,7 +8467,7 @@ }, "packages/search": { "name": "@redis/search", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -8269,7 +8476,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } }, "packages/test-utils": { @@ -8338,7 +8545,7 @@ }, "packages/time-series": { "name": "@redis/time-series", - "version": "5.0.0-next.5", + "version": "5.0.0-next.6", "license": "MIT", "devDependencies": { "@redis/test-utils": "*" @@ -8347,7 +8554,7 @@ "node": ">= 18" }, "peerDependencies": { - "@redis/client": "^5.0.0-next.5" + "@redis/client": "^5.0.0-next.6" } } } diff --git a/packages/entraid/README.md b/packages/entraid/README.md index eec88d71360..f2212848455 100644 --- a/packages/entraid/README.md +++ b/packages/entraid/README.md @@ -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 diff --git a/packages/entraid/integration-tests/entraid-integration.spec.ts b/packages/entraid/integration-tests/entraid-integration.spec.ts index deb1d47dec1..4d078a01ede 100644 --- a/packages/entraid/integration-tests/entraid-integration.spec.ts +++ b/packages/entraid/integration-tests/entraid-integration.spec.ts @@ -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(); } diff --git a/packages/entraid/lib/azure-identity-provider.ts b/packages/entraid/lib/azure-identity-provider.ts new file mode 100644 index 00000000000..d522c9d4b89 --- /dev/null +++ b/packages/entraid/lib/azure-identity-provider.ts @@ -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 { + private readonly getToken: () => Promise; + + constructor(getToken: () => Promise) { + this.getToken = getToken; + } + + async requestToken(): Promise> { + const result = await this.getToken(); + return { + token: result, + ttlMs: result.expiresOnTimestamp - Date.now() + }; + } + +} + + diff --git a/packages/entraid/lib/entra-id-credentials-provider-factory.ts b/packages/entraid/lib/entra-id-credentials-provider-factory.ts index 0f89be8039b..98a3a11078a 100644 --- a/packages/entraid/lib/entra-id-credentials-provider-factory.ts +++ b/packages/entraid/lib/entra-id-credentials-provider-factory.ts @@ -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 - }) - -} diff --git a/packages/entraid/lib/entraid-credentials-provider.ts b/packages/entraid/lib/entraid-credentials-provider.ts index 115d6dbff3a..465c9e8a975 100644 --- a/packages/entraid/lib/entraid-credentials-provider.ts +++ b/packages/entraid/lib/entraid-credentials-provider.ts @@ -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, - public readonly idp: IdentityProvider, + public readonly tokenManager: TokenManager, + public readonly idp: IdentityProvider, 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>) { 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> { - const initialResponse = await this.idp.requestToken(); - const token = this.tokenManager.wrapAndSetCurrentToken(initialResponse.token, initialResponse.ttlMs); + async #startTokenManagerAndObtainInitialToken(): Promise> { + 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); \ No newline at end of file + 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); +} \ 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 59b38d18ec6..0f15e01fcdc 100644 --- a/packages/entraid/lib/msal-identity-provider.ts +++ b/packages/entraid/lib/msal-identity-provider.ts @@ -11,21 +11,15 @@ export class MSALIdentityProvider implements IdentityProvider> { - 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() + }; } } - - diff --git a/packages/entraid/package.json b/packages/entraid/package.json index da0d7df9935..fd504cd44dc 100644 --- a/packages/entraid/package.json +++ b/packages/entraid/package.json @@ -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": { diff --git a/packages/entraid/samples/interactive-browser/index.ts b/packages/entraid/samples/interactive-browser/index.ts new file mode 100644 index 00000000000..f458ad9e190 --- /dev/null +++ b/packages/entraid/samples/interactive-browser/index.ts @@ -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(` + + + Interactive Browser Credential Demo + + + +

Interactive Browser Credential Demo

+

This example demonstrates using the InteractiveBrowserCredential from @azure/identity to authenticate with Microsoft Entra ID.

+

When you click the button below, you'll be redirected to the Microsoft login page.

+ Login with Microsoft + + + `); +}); + +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`); +});