From 3979524c74185c3d03254c7bb2af0b5bfd70d0bc Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Thu, 19 Oct 2023 22:54:50 +0100 Subject: [PATCH] Add `domain_hint` support to Entra ID login When a user is logged in to multiple microsoft accounts, there can be be a little bit of friction in the Entra login process as users will be asked to select the account to login with. Scenarios in which a user may have multiple microsoft accounts 1. Someone logged in to your work Entra ID account, and a personal microsoft account 2. A consultant who has an Entra ID account at both their employer, as well as the company they're contracted out to. 3. A user has a regular account, as well as one or more high priviliged accounts. When a domain hint is provided, Entra will filter out all the accounts which don't belong to the tenant specified on the `domain_hint`. In many cases, this will filter to a single account, avoiding the need to select an account at all (e.g. scenario 1 & 2). This won't always happen (e.g. scenario 3). Additionally in the case a tenant has been configured to federate authentication elsewhere (e.g. to an on premise AD FS), setting the domain hint means entra can send the user straight to the federated authentication soruce, removing further steps If backstage is allowign authentication from multiple tenants, this field should be left blank. For more details, see https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/home-realm-discovery-policy 99% of the time, this value should be the same as the tenantId, so we could get rid of hte domain hint, and set it to the same value as the tenant id automatically. We'd need to provide a config option (e.g. `isMultiTenant: true`) to opt out of this. For those edge cases, this would be a breaking change. I decided to go with specifying the `domain_hint` seperatly for now just in case my assumptions are wrong and there are more cases wher ehte `domain_hint` will get in the way. We can always make this the default behaviour later on. Signed-off-by: Alex Crome --- .changeset/gentle-pumpkins-carry.md | 16 +++++ docs/auth/microsoft/provider.md | 7 ++- .../config.d.ts | 1 + .../src/authenticator.ts | 13 +++- .../src/module.test.ts | 61 ++++++++++++++++++- 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 .changeset/gentle-pumpkins-carry.md diff --git a/.changeset/gentle-pumpkins-carry.md b/.changeset/gentle-pumpkins-carry.md new file mode 100644 index 0000000000..22abd16798 --- /dev/null +++ b/.changeset/gentle-pumpkins-carry.md @@ -0,0 +1,16 @@ +--- +'@backstage/plugin-auth-backend-module-microsoft-provider': minor +--- + +Added support for specifying a `domain_hint` on Microsoft authentication provider configuration. +This should typically be set to the same value as your `tenantId`. +If you allow users from multiple tenants to authenticate, then leave this blank. + +```yaml +auth: + providers: + microsoft: + development: + #... + domainHint: ${AZURE_TENANT_ID} +``` diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index 583139cff1..2051a7aa0b 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -38,13 +38,18 @@ auth: clientId: ${AUTH_MICROSOFT_CLIENT_ID} clientSecret: ${AUTH_MICROSOFT_CLIENT_SECRET} tenantId: ${AUTH_MICROSOFT_TENANT_ID} + domainHint: ${AZURE_TENANT_ID} ``` -The Microsoft provider is a structure with three configuration keys: +The Microsoft provider is a structure with three mandatory configuration keys: - `clientId`: Application (client) ID, found on App Registration > Overview - `clientSecret`: Secret, found on App Registration > Certificates & secrets - `tenantId`: Directory (tenant) ID, found on App Registration > Overview +- `domainHint` (optional): Typically the same as `tenantId`. + Leave blank if your app registration is multi tenant. + When specified, this reduces login friction for users with accounts in multiple tenants by automatically filtering away accounts from other tenants. + For more details, see [Home Realm Discovery](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/home-realm-discovery-policy) ## Outbound Network Access diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index 5f1fa958e5..ee1e45b720 100644 --- a/plugins/auth-backend-module-microsoft-provider/config.d.ts +++ b/plugins/auth-backend-module-microsoft-provider/config.d.ts @@ -26,6 +26,7 @@ export interface Config { */ tenantId: string; clientSecret: string; + domainHint?: string; callbackUrl?: string; }; }; diff --git a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts index ba1d75adf8..edf985b0f2 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts @@ -22,6 +22,8 @@ import { PassportProfile, } from '@backstage/plugin-auth-node'; +let domainHint: string | undefined = undefined; + /** @public */ export const microsoftAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: @@ -30,6 +32,7 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); const tenantId = config.getString('tenantId'); + domainHint = config.getOptionalString('domainHint'); return PassportOAuthAuthenticatorHelper.from( new MicrosoftStrategy( @@ -58,9 +61,15 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ }, async start(input, helper) { - return helper.start(input, { + const options: Record = { accessType: 'offline', - }); + }; + + if (domainHint !== undefined) { + options.domain_hint = domainHint; + } + + return helper.start(input, options); }, async authenticate(input, helper) { diff --git a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts index 2cfedc5d5e..21f9a7e845 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/module.test.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/module.test.ts @@ -21,7 +21,7 @@ import request from 'supertest'; import { authModuleMicrosoftProvider } from './module'; describe('authModuleMicrosoftProvider', () => { - it('should start', async () => { + it('should start without domain hint', async () => { const { server } = await startTestBackend({ features: [ authPlugin, @@ -77,4 +77,63 @@ describe('authModuleMicrosoftProvider', () => { nonce: decodeURIComponent(nonceCookie.value), }); }); + + it('should start with domain hint', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleMicrosoftProvider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + microsoft: { + development: { + clientId: 'another-client-id', + clientSecret: 'another-client-secret', + tenantId: 'another-tenant-id', + domainHint: 'somedomain', + }, + }, + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get('/api/auth/microsoft/start?env=development'); + + expect(res.status).toEqual(302); + + const nonceCookie = agent.jar.getCookie('microsoft-nonce', { + domain: 'localhost', + path: '/api/auth/microsoft/handler', + script: false, + secure: false, + }); + expect(nonceCookie).toBeDefined(); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://login.microsoftonline.com'); + expect(startUrl.pathname).toBe('/another-tenant-id/oauth2/v2.0/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual({ + response_type: 'code', + scope: 'user.read', + client_id: 'another-client-id', + redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`, + state: expect.any(String), + domain_hint: 'somedomain', + }); + + expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({ + env: 'development', + nonce: decodeURIComponent(nonceCookie.value), + }); + }); });