diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.test.ts new file mode 100644 index 0000000000..e2aedbc9b4 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MicrosoftAuth from './MicrosoftAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import { ConfigReader } from '@backstage/config'; +import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +describe('MicrosoftAuth', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + const microsoftAuth = MicrosoftAuth.create({ + configApi: new ConfigReader(undefined), + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile( + 'http://backstage.test/api/{{ pluginId }}', + ), + }); + + const toHaveJWTClaims = function toHaveJWTClaims( + this: jest.MatcherContext, + received: string, + expected: Record, + ): jest.CustomMatcherResult { + let parsedClaims: Record; + try { + parsedClaims = JSON.parse( + Buffer.from(received.split('.')[1], 'base64').toString(), + ); + } catch (e) { + return { + pass: false, + message: () => + `Expected JWT with claims: ${this.utils.printExpected( + expected, + )}\nReceived invalid JWT ${this.utils.printReceived( + received, + )}\nError: ${e}`, + }; + } + const expectedResult = expect.objectContaining(expected); + return { + pass: this.equals(parsedClaims, expectedResult), + message: () => + `Expected JWT with claims: ${this.utils.printExpected( + expected, + )}\nReceived JWT with claims: ${this.utils.printReceived( + parsedClaims, + )}\n\n${this.utils.diff(expectedResult, parsedClaims)}`, + }; + }; + expect.extend({ toHaveJWTClaims }); + + describe('with a refresh token', () => { + beforeEach(() => { + server.use( + rest.get( + 'http://backstage.test/api/auth/microsoft/refresh', + (req, res, ctx) => { + const scope = + req.url.searchParams.get('scope') || + 'openid profile email User.Read'; + return res( + ctx.json({ + providerInfo: { + accessToken: `header.${Buffer.from( + JSON.stringify({ + aud: '00000003-0000-0000-c000-000000000000', + scp: 'openid profile email User.Read', + }), + ).toString('base64')}.signature`, + scope, + }, + }), + ); + }, + ), + ); + }); + + it('gets access token for Microsoft Graph', async () => { + const accessToken = await microsoftAuth.getAccessToken(); + + expect(accessToken).toHaveJWTClaims({ + aud: '00000003-0000-0000-c000-000000000000', + scp: 'openid profile email User.Read', + }); + }); + }); + + describe('without a refresh token', () => { + let mockRequester: jest.Mock; + let sut: typeof microsoftAuthApiRef.T; + + beforeEach(() => { + mockRequester = jest.fn(); + sut = MicrosoftAuth.create({ + configApi: new ConfigReader(undefined), + oauthRequestApi: { + createAuthRequester: jest.fn().mockReturnValue(mockRequester), + authRequest$: jest.fn(), + }, + discoveryApi: UrlPatternDiscovery.compile( + 'http://backstage.test/api/{{ pluginId }}', + ), + }); + server.use( + rest.get( + 'http://backstage.test/api/auth/microsoft/refresh', + (_, res, ctx) => { + return res( + ctx.status(401), + ctx.json({ + error: { + name: 'AuthenticationError', + message: + 'Refresh failed; caused by InputError: Missing session cookie', + cause: { + name: 'InputError', + message: 'Missing session cookie', + }, + }, + request: { + method: 'GET', + url: '/api/auth/microsoft/refresh?env=development', + }, + response: { + statusCode: 401, + }, + }), + ); + }, + ), + ); + }); + + it('gets access token for Microsoft Graph via popup', async () => { + await sut.getAccessToken(); + + expect(mockRequester).toHaveBeenCalledWith( + new Set(['User.Read', 'email', 'offline_access', 'openid', 'profile']), + ); + }); + + it('gets access token for other azure resources via popup', async () => { + await sut.getAccessToken('azure-resource/scope'); + + expect(mockRequester).toHaveBeenCalledWith( + new Set(['azure-resource/scope']), + ); + }); + + it('requests scopes for resource ID when scope contains a resource URI', async () => { + await sut.getAccessToken('api://customApiClientId/some.scope'); + + expect(mockRequester).toHaveBeenCalledWith( + new Set(['api://customApiClientId/some.scope']), + ); + }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 3f08ecaac7..1b2fc20f37 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -18,6 +18,7 @@ import { microsoftAuthApiRef, AuthRequestOptions, AuthProviderInfo, + ConfigApi, DiscoveryApi, OAuthRequestApi, } from '@backstage/core-plugin-api'; @@ -36,7 +37,8 @@ const DEFAULT_PROVIDER = { * @public */ export default class MicrosoftAuth { - private oauth2: Record; + private oauth2: { [aud: string]: OAuth2 }; + private configApi: ConfigApi | undefined; private environment: string; private provider: AuthProviderInfo; private oauthRequestApi: OAuthRequestApi; @@ -85,8 +87,54 @@ export default class MicrosoftAuth { return this.oauth2[MicrosoftAuth.MicrosoftGraphID]; } - getAccessToken(scope?: string | string[], options?: AuthRequestOptions) { - return this.microsoftGraph().getAccessToken(scope, options); + private static resourceForScopes(scope: string): Promise { + const audience = + scope + .split(' ') + .map(MicrosoftAuth.resourceForScope) + .find(aud => aud !== 'openid') ?? MicrosoftAuth.MicrosoftGraphID; + return Promise.resolve(audience); + } + + private static resourceForScope(scope: string): string { + const groups = scope.match(/^(?.*)\/(?[^\/]*)$/)?.groups; + if (groups) { + const { resourceURI } = groups; + const aud = resourceURI.replace(/^api:\/\//, ''); + return aud; + } + switch (scope) { + case 'email': + case 'openid': + case 'offline_access': + case 'profile': { + return 'openid'; + } + default: + return MicrosoftAuth.MicrosoftGraphID; + } + } + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ): Promise { + const aud = + scope === undefined + ? MicrosoftAuth.MicrosoftGraphID + : await MicrosoftAuth.resourceForScopes( + Array.isArray(scope) ? scope.join(' ') : scope, + ); + if (!(aud in this.oauth2)) { + this.oauth2[aud] = OAuth2.create({ + configApi: this.configApi, + discoveryApi: this.discoveryApi, + oauthRequestApi: this.oauthRequestApi, + provider: this.provider, + environment: this.environment, + }); + } + return this.oauth2[aud].getAccessToken(scope, options); } getIdToken(options?: AuthRequestOptions) {