diff --git a/.changeset/gentle-pumpkins-carry.md b/.changeset/gentle-pumpkins-carry.md new file mode 100644 index 0000000000..721fbe8195 --- /dev/null +++ b/.changeset/gentle-pumpkins-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +--- + +Added support for specifying a domain hint on the Microsoft authentication provider configuration. 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/api-report.md b/plugins/auth-backend-module-microsoft-provider/api-report.md index 60192c5cbc..21bf77d545 100644 --- a/plugins/auth-backend-module-microsoft-provider/api-report.md +++ b/plugins/auth-backend-module-microsoft-provider/api-report.md @@ -15,7 +15,10 @@ export const authModuleMicrosoftProvider: () => BackendFeature; // @public (undocumented) export const microsoftAuthenticator: OAuthAuthenticator< - PassportOAuthAuthenticatorHelper, + { + helper: PassportOAuthAuthenticatorHelper; + domainHint: string | undefined; + }, PassportProfile >; diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index c58178d6eb..aa78f72271 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 { * @visibility secret */ 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..9687c7ce1a 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts @@ -30,8 +30,9 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ const clientId = config.getString('clientId'); const clientSecret = config.getString('clientSecret'); const tenantId = config.getString('tenantId'); + const domainHint = config.getOptionalString('domainHint'); - return PassportOAuthAuthenticatorHelper.from( + const helper = PassportOAuthAuthenticatorHelper.from( new MicrosoftStrategy( { clientID: clientId, @@ -55,19 +56,30 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ }, ), ); + + return { + helper, + domainHint, + }; }, - async start(input, helper) { - return helper.start(input, { + async start(input, ctx) { + const options: Record = { accessType: 'offline', - }); + }; + + if (ctx.domainHint !== undefined) { + options.domain_hint = ctx.domainHint; + } + + return ctx.helper.start(input, options); }, - async authenticate(input, helper) { - return helper.authenticate(input); + async authenticate(input, ctx) { + return ctx.helper.authenticate(input); }, - async refresh(input, helper) { - return helper.refresh(input); + async refresh(input, ctx) { + return ctx.helper.refresh(input); }, }); 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), + }); + }); });