From 3b0ac0d7e28038e800f7d57e9e2deed4b86d18de Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Fri, 3 Feb 2023 22:47:11 -0500 Subject: [PATCH 1/8] convert module mock to msw This should allow for easier refactoring and adding new microsoft-specific features. Signed-off-by: Jamie Klassen --- .../src/providers/microsoft/provider.test.ts | 146 +++++++++--------- 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts index e4e919004c..f455b0e3f6 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -15,84 +15,60 @@ */ import { MicrosoftAuthProvider } from './provider'; -import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { OAuthResult } from '../../lib/oauth'; import { getVoidLogger } from '@backstage/backend-common'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { AuthResolverContext } from '../types'; +import express from 'express'; -jest.mock('../../lib/passport/PassportStrategyHelper', () => { - return { - executeFrameHandlerStrategy: jest.fn(), - }; -}); +describe('MicrosoftAuthProvider#handle', () => { + const server = setupServer(); + setupRequestMockHandlers(server); -const mockFrameHandler = jest.spyOn( - helpers, - 'executeFrameHandlerStrategy', -) as unknown as jest.MockedFunction< - () => Promise<{ result: OAuthResult; privateInfo: any }> ->; - -const mockResult = { - result: { - fullProfile: { - emails: [ - { - type: 'work', - value: 'conrad@example.com', + beforeEach(() => { + server.use( + rest.post( + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + (_, res, ctx) => + res( + ctx.json({ + token_type: 'Bearer', + scope: 'email openid profile User.Read', + expires_in: 123, + ext_expires_in: 123, + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: 'idToken', + }), + ), + ), + rest.get('https://graph.microsoft.com/v1.0/me/', (_, res, ctx) => + res( + ctx.json({ + id: 'conrad', + displayName: 'Conrad', + surname: 'Ribas', + givenName: 'Francisco', + mail: 'conrad@example.com', + }), + ), + ), + rest.get( + 'https://graph.microsoft.com/v1.0/me/photos/*', + async (_, res, ctx) => { + const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer; + return res( + ctx.set('Content-Length', imageBuffer.byteLength.toString()), + ctx.set('Content-Type', 'image/jpeg'), + ctx.body(imageBuffer), + ); }, - ], - displayName: 'Conrad', - name: { - familyName: 'Ribas', - givenName: 'Francisco', - }, - id: 'conrad', - provider: 'microsoft', - photos: [ - { - value: 'some-data', - }, - ], - }, - params: { - id_token: 'idToken', - scope: 'scope', - expires_in: 123, - }, - accessToken: 'accessToken', - }, - privateInfo: { - refreshToken: 'wacka', - }, -}; - -const server = setupServer(); -setupRequestMockHandlers(server); - -const setupHandlers = () => { - server.use( - rest.get( - 'https://graph.microsoft.com/v1.0/me/photos/*', - async (_, res, ctx) => { - const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer; - return res( - ctx.set('Content-Length', imageBuffer.byteLength.toString()), - ctx.set('Content-Type', 'image/jpeg'), - ctx.body(imageBuffer), - ); - }, - ), - ); -}; - -describe('createMicrosoftProvider', () => { - it('should auth', async () => { - setupHandlers(); + ), + ); + }); + it('returns providerInfo and profile', async () => { const provider = new MicrosoftAuthProvider({ logger: getVoidLogger(), resolverContext: {} as AuthResolverContext, @@ -105,17 +81,25 @@ describe('createMicrosoftProvider', () => { }), clientId: 'mock', clientSecret: 'mock', - callbackUrl: 'mock', + callbackUrl: 'http://backstage.test/api/auth/microsoft/handler/frame', }); - mockFrameHandler.mockResolvedValueOnce(mockResult); - const { response } = await provider.handler({} as any); + const { response } = await provider.handler({ + method: 'GET', + url: 'http://backstage.test/api/auth/microsoft/handler/frame', + query: { + code: 'authorizationcode', + }, + headers: { host: 'backstage.test' }, + connection: {}, + } as unknown as express.Request); + expect(response).toEqual({ providerInfo: { accessToken: 'accessToken', expiresInSeconds: 123, idToken: 'idToken', - scope: 'scope', + scope: 'email openid profile User.Read', }, profile: { email: 'conrad@example.com', @@ -125,8 +109,7 @@ describe('createMicrosoftProvider', () => { }); }); - it('should return the base64 encoded photo data of the profile', async () => { - setupHandlers(); + it('returns base64 encoded photo data', async () => { const provider = new MicrosoftAuthProvider({ logger: getVoidLogger(), resolverContext: {} as AuthResolverContext, @@ -149,8 +132,17 @@ describe('createMicrosoftProvider', () => { }; }, }); - mockFrameHandler.mockResolvedValueOnce(mockResult); - const { response } = await provider.handler({} as any); + + const { response } = await provider.handler({ + method: 'GET', + url: 'http://backstage.test/api/auth/microsoft/handler/frame', + query: { + code: 'authorizationcode', + }, + headers: { host: 'backstage.test' }, + connection: {}, + } as unknown as express.Request); + const overloadedIdentity = response.backstageIdentity as any; const photo = overloadedIdentity.info.result.fullProfile.photos[0]; expect(photo.value).toEqual('data:image/jpeg;base64,aG93ZHk='); From 646025786e2bc985c61d5368510599364e467c15 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Fri, 3 Feb 2023 22:47:56 -0500 Subject: [PATCH 2/8] construct test subject via factory function Rather than using the MicrosoftAuthProvider constructor directly -- this is more faithful to how this object is instantiated in practice. Also, introduce FakeMicrosoftAPI to simulate negotiating scopes, authorization codes, access tokens, and refresh tokens with Azure. Signed-off-by: Jamie Klassen --- .../microsoft/__testUtils__/fake.test.ts | 90 ++++ .../providers/microsoft/__testUtils__/fake.ts | 126 ++++++ .../src/providers/microsoft/provider.test.ts | 394 ++++++++++++++---- 3 files changed, 525 insertions(+), 85 deletions(-) create mode 100644 plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts create mode 100644 plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts diff --git a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts new file mode 100644 index 0000000000..dee37f0f6b --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { FakeMicrosoftAPI } from './fake'; + +describe('FakeMicrosoftAPI', () => { + const api = new FakeMicrosoftAPI(); + + describe('#token', () => { + it('exchanges auth codes', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true); + }); + + it('supports scopes for the first requested audience only', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('someaudience/somescope User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false); + }); + + it('special openid scopes do not count towards the 1-audience limit', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('openid offline_access User.Read'), + }), + ); + + expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true); + }); + + it('refreshes tokens', () => { + const { access_token } = api.token( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: api.generateRefreshToken( + 'email openid profile User.Read', + ), + }), + ); + + expect( + api.tokenHasScope(access_token, 'email openid profile User.Read'), + ).toBe(true); + }); + it('requires `openid` scope for ID token', () => { + const { id_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(id_token).toBeUndefined(); + }); + it('requires `offline_access` scope for refresh token', () => { + const { refresh_token } = api.token( + new URLSearchParams({ + grant_type: 'authorization_code', + code: api.generateAuthCode('User.Read'), + }), + ); + + expect(refresh_token).toBeUndefined(); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts new file mode 100644 index 0000000000..9b3ca09c57 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts @@ -0,0 +1,126 @@ +/* + * 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 { decodeJwt } from 'jose'; + +type Claims = { aud: string; scp: string }; + +export class FakeMicrosoftAPI { + generateAccessToken(scope: string): string { + return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token; + } + generateAuthCode(scope: string): string { + return this.encodeClaims(this.allClaimsForScope(scope)); + } + generateRefreshToken(scope: string): string { + return this.encodeClaims(this.allClaimsForScope(scope)); + } + token(formData: URLSearchParams): { + access_token: string; + scope: string; + refresh_token?: string; + id_token?: string; + } { + const scopeParameter = formData.get('scope'); + const claims = + (scopeParameter && this.allClaimsForScope(scopeParameter)) ?? + formData.get('grant_type') === 'refresh_token' + ? this.decodeClaims(formData.get('refresh_token')!) + : this.decodeClaims(formData.get('code')!); + return { + ...this.tokenWithClaims(claims), + ...(this.hasScope(claims, 'offline_access') && { + refresh_token: this.encodeClaims(claims), + }), + ...(this.hasScope(claims, 'openid') && { + id_token: 'header.e30K.microsoft', + }), + }; + } + tokenHasScope(token: string, scope: string): boolean { + const { aud, scp } = decodeJwt(token); + return this.hasScope({ aud: aud as string, scp: scp as string }, scope); + } + private tokenWithClaims(claims: Claims): { + access_token: string; + scope: string; + } { + const filteredClaims = { + ...claims, + scp: claims.scp + .split(' ') + .filter(s => s !== 'offline_access') + .join(' '), + }; + return { + access_token: `header.${Buffer.from( + JSON.stringify(filteredClaims), + ).toString('base64')}.signature`, + scope: this.scopeFromClaims(filteredClaims), + }; + } + private allClaimsForScope(scope: string): Claims { + const scopes = scope.split(' ').map(this.parseScope); + const firstAudience = scopes + .map(({ aud }) => aud) + .find(aud => aud !== 'openid'); + return { + aud: firstAudience ?? '00000003-0000-0000-c000-000000000000', + scp: scopes + .filter(({ aud }) => aud === 'openid' || aud === firstAudience) + .map(({ scp }) => scp) + .join(' '), + }; + } + // auth codes and refresh tokens in this fake system are base64-encoded JSON + // strings of claims + private encodeClaims(claims: Claims): string { + return Buffer.from(JSON.stringify(claims)).toString('base64'); + } + private decodeClaims(encoded: string): Claims { + return JSON.parse(Buffer.from(encoded, 'base64').toString()); + } + private hasScope(claims: Claims, scope: string): boolean { + return this.scopeFromClaims(claims).includes(scope); + } + private parseScope(s: string): Claims { + if (s.includes('/')) { + const [aud, scp] = s.split('/'); + return { aud, scp }; + } + switch (s) { + case 'email': + case 'openid': + case 'offline_access': + case 'profile': { + return { aud: 'openid', scp: s }; + } + default: + return { aud: '00000003-0000-0000-c000-000000000000', scp: s }; + } + } + private scopeFromClaims(claims: Claims): string { + return claims.scp + .split(' ') + .map(this.parseScope) + .map(({ aud, scp }) => + aud === 'openid' || + claims.aud === '00000003-0000-0000-c000-000000000000' + ? scp + : `${claims.aud}/${scp}`, + ) + .join(' '); + } +} diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts index f455b0e3f6..00ee34bc79 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -14,37 +14,73 @@ * limitations under the License. */ -import { MicrosoftAuthProvider } from './provider'; +import { microsoft } from './provider'; import { getVoidLogger } from '@backstage/backend-common'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { AuthResolverContext } from '../types'; +import { AuthProviderRouteHandlers, AuthResolverContext } from '../types'; import express from 'express'; +import crypto from 'crypto'; +import { FakeMicrosoftAPI } from './__testUtils__/fake'; + +describe('MicrosoftAuthProvider', () => { + const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 + const state = Buffer.from( + `nonce=${encodeURIComponent(nonce)}&env=development`, + ).toString('hex'); -describe('MicrosoftAuthProvider#handle', () => { const server = setupServer(); + const microsoftApi = new FakeMicrosoftAPI(); + let provider: AuthProviderRouteHandlers; + let response: jest.Mocked; + setupRequestMockHandlers(server); beforeEach(() => { + provider = microsoft.create()({ + providerId: 'microsoft', + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: {} as AuthResolverContext, + }); + server.use( rest.post( - 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - (_, res, ctx) => - res( + 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token', + async (req, res, ctx) => { + return res( ctx.json({ + ...microsoftApi.token(new URLSearchParams(await req.text())), token_type: 'Bearer', - scope: 'email openid profile User.Read', expires_in: 123, ext_expires_in: 123, - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: 'idToken', }), - ), + ); + }, ), - rest.get('https://graph.microsoft.com/v1.0/me/', (_, res, ctx) => - res( + rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => { + if ( + !microsoftApi.tokenHasScope( + req.headers.get('authorization')!.replace(/^Bearer /, ''), + 'User.Read', + ) + ) { + return res(ctx.status(403)); + } + return res( ctx.json({ id: 'conrad', displayName: 'Conrad', @@ -52,11 +88,19 @@ describe('MicrosoftAuthProvider#handle', () => { givenName: 'Francisco', mail: 'conrad@example.com', }), - ), - ), + ); + }), rest.get( 'https://graph.microsoft.com/v1.0/me/photos/*', - async (_, res, ctx) => { + async (req, res, ctx) => { + if ( + !microsoftApi.tokenHasScope( + req.headers.get('authorization')!.replace(/^Bearer /, ''), + 'User.Read', + ) + ) { + return res(ctx.status(403)); + } const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer; return res( ctx.set('Content-Length', imageBuffer.byteLength.toString()), @@ -66,85 +110,265 @@ describe('MicrosoftAuthProvider#handle', () => { }, ), ); + response = { + cookie: jest.fn(), + end: jest.fn(), + json: jest.fn(), + setHeader: jest.fn(), + status: jest.fn(), + } as unknown as jest.Mocked; + response.status.mockReturnValue(response); }); - it('returns providerInfo and profile', async () => { - const provider = new MicrosoftAuthProvider({ - logger: getVoidLogger(), - resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: { - email: fullProfile.emails![0]!.value, - displayName: fullProfile.displayName, - picture: 'http://microsoft.com/lols', - }, - }), - clientId: 'mock', - clientSecret: 'mock', - callbackUrl: 'http://backstage.test/api/auth/microsoft/handler/frame', + describe('#start', () => { + const randomBytes = jest.spyOn( + crypto, + 'randomBytes', + ) as unknown as jest.MockedFunction<(size: number) => Buffer>; + + afterEach(() => { + randomBytes.mockRestore(); }); - const { response } = await provider.handler({ - method: 'GET', - url: 'http://backstage.test/api/auth/microsoft/handler/frame', - query: { - code: 'authorizationcode', - }, - headers: { host: 'backstage.test' }, - connection: {}, - } as unknown as express.Request); + it('redirects to authorize URL', async () => { + randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); - expect(response).toEqual({ - providerInfo: { - accessToken: 'accessToken', - expiresInSeconds: 123, - idToken: 'idToken', - scope: 'email openid profile User.Read', - }, - profile: { - email: 'conrad@example.com', - displayName: 'Conrad', - picture: 'http://microsoft.com/lols', - }, + await provider.start( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + } as unknown as express.Request, + response, + ); + + expect(response.setHeader).toHaveBeenCalledWith( + 'Location', + 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' + + '?response_type=code' + + '&redirect_uri=http%3A%2F%2Fbackstage.test%2Fapi%2Fauth%2Fmicrosoft%2Fhandler%2Fframe' + + '&scope=email%20openid%20profile%20User.Read' + + `&state=${state}` + + '&client_id=clientId', + ); }); }); - it('returns base64 encoded photo data', async () => { - const provider = new MicrosoftAuthProvider({ - logger: getVoidLogger(), - resolverContext: {} as AuthResolverContext, - authHandler: async ({ fullProfile }) => ({ - profile: { - email: fullProfile.emails![0]!.value, - displayName: fullProfile.displayName, - picture: 'http://microsoft.com/lols', - }, - }), - clientId: 'mock', - clientSecret: 'mock', - callbackUrl: 'mock', - // define resolver to return user `info` for photo validation - signInResolver: async (info, _) => { - return { - id: 'user.name', - token: 'token', - info: info, - }; - }, + describe('#handle', () => { + it('returns provider info and profile with photo data', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + idToken: 'header.e30K.microsoft', + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + scope: 'email openid profile User.Read', + expiresInSeconds: 123, + }, + profile: { + email: 'conrad@example.com', + picture: 'data:image/jpeg;base64,aG93ZHk=', + displayName: 'Conrad', + }, + }, + }), + ), + ), + ); }); - const { response } = await provider.handler({ - method: 'GET', - url: 'http://backstage.test/api/auth/microsoft/handler/frame', - query: { - code: 'authorizationcode', - }, - headers: { host: 'backstage.test' }, - connection: {}, - } as unknown as express.Request); + it('sets refresh token', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email offline_access openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); - const overloadedIdentity = response.backstageIdentity as any; - const photo = overloadedIdentity.info.result.fullProfile.photos[0]; - expect(photo.value).toEqual('data:image/jpeg;base64,aG93ZHk='); + expect(response.cookie).toHaveBeenCalledWith( + 'microsoft-refresh-token', + microsoftApi.generateRefreshToken( + 'email offline_access openid profile User.Read', + ), + { + domain: 'backstage.test', + httpOnly: true, + maxAge: 86400000000, + path: '/api/auth/microsoft', + sameSite: 'lax', + secure: false, + }, + ); + }); + + it('omits photo data when fetching it fails', async () => { + server.use( + rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) => + res.networkError('remote hung up'), + ), + ); + + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode( + 'email openid profile User.Read', + ), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + idToken: 'header.e30K.microsoft', + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + scope: 'email openid profile User.Read', + expiresInSeconds: 123, + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + }, + }, + }), + ), + ), + ); + }); + }); + + describe('#refresh', () => { + it('returns provider info and profile with photo data', async () => { + await provider.refresh!( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'email openid profile User.Read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'email openid profile User.Read', + ), + expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', + scope: 'email openid profile User.Read', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + picture: 'data:image/jpeg;base64,aG93ZHk=', + }, + }), + ); + }); + + it('returns backstage identity when sign-in resolver is configured', async () => { + provider = microsoft.create({ + signIn: { + resolver: _ => + Promise.resolve({ + token: 'protectedheader.e30K.signature', + }), + }, + })({ + providerId: 'microsoft', + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: {} as AuthResolverContext, + }) as AuthProviderRouteHandlers; + + await provider.refresh!( + { + query: { + env: 'development', + scope: 'email openid profile User.Read', + }, + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'email openid profile User.Read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + backstageIdentity: expect.objectContaining({ + token: 'protectedheader.e30K.signature', + }), + }), + ); + }); }); }); From 475abd1dc3fa3cfe41b77d7f5337b23e04147cc0 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Tue, 14 Feb 2023 09:27:08 -0500 Subject: [PATCH 3/8] support non-microsoft graph scopes Signed-off-by: Jamie Klassen --- .changeset/mighty-tips-flow.md | 11 ++ .../src/providers/microsoft/provider.test.ts | 130 +++++++++++++----- .../src/providers/microsoft/provider.ts | 80 +++++++---- 3 files changed, 161 insertions(+), 60 deletions(-) create mode 100644 .changeset/mighty-tips-flow.md diff --git a/.changeset/mighty-tips-flow.md b/.changeset/mighty-tips-flow.md new file mode 100644 index 0000000000..d876942fde --- /dev/null +++ b/.changeset/mighty-tips-flow.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +The `microsoft` (i.e. Azure) auth provider now supports negotiating tokens for +Azure resources besides Microsoft Graph (e.g. AKS, Virtual Machines, Machine +Learning Services, etc.). When the `/frame/handler` endpoint is called with an +authorization code for a non-Microsoft Graph scope, the user profile will not be +fetched. Similarly no user profile or photo data will be fetched by the backend +if the `/refresh` endpoint is called with the `scope` query parameter strictly +containing scopes for resources besides Microsoft Graph. diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts index 00ee34bc79..6f8df2d4df 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -39,7 +39,11 @@ describe('MicrosoftAuthProvider', () => { setupRequestMockHandlers(server); beforeEach(() => { - provider = microsoft.create()({ + provider = microsoft.create({ + signIn: { + resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(), + }, + })({ providerId: 'microsoft', globalConfig: { baseUrl: 'http://backstage.test/api/auth', @@ -54,8 +58,15 @@ describe('MicrosoftAuthProvider', () => { }, }), logger: getVoidLogger(), - resolverContext: {} as AuthResolverContext, - }); + resolverContext: { + issueToken: jest.fn(), + findCatalogUser: jest.fn(), + signInWithCatalogUser: _ => + Promise.resolve({ + token: 'header.e30K.backstage', + }), + } as AuthResolverContext, + }) as AuthProviderRouteHandlers; server.use( rest.post( @@ -147,8 +158,10 @@ describe('MicrosoftAuthProvider', () => { 'Location', 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' + '?response_type=code' + - '&redirect_uri=http%3A%2F%2Fbackstage.test%2Fapi%2Fauth%2Fmicrosoft%2Fhandler%2Fframe' + - '&scope=email%20openid%20profile%20User.Read' + + `&redirect_uri=${encodeURIComponent( + 'http://backstage.test/api/auth/microsoft/handler/frame', + )}` + + `&scope=${encodeURIComponent('email openid profile User.Read')}` + `&state=${state}` + '&client_id=clientId', ); @@ -180,18 +193,58 @@ describe('MicrosoftAuthProvider', () => { type: 'authorization_response', response: { providerInfo: { - idToken: 'header.e30K.microsoft', accessToken: microsoftApi.generateAccessToken( 'email openid profile User.Read', ), scope: 'email openid profile User.Read', expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', }, profile: { email: 'conrad@example.com', picture: 'data:image/jpeg;base64,aG93ZHk=', displayName: 'Conrad', }, + backstageIdentity: { + token: 'header.e30K.backstage', + identity: { type: 'user', ownershipEntityRefs: [] }, + }, + }, + }), + ), + ), + ); + }); + + it('returns access token for non-microsoft graph scope', async () => { + await provider.frameHandler( + { + query: { + env: 'development', + code: microsoftApi.generateAuthCode('aks-audience/user.read'), + state, + }, + cookies: { + 'microsoft-nonce': nonce, + }, + } as unknown as express.Request, + response, + ); + + expect(response.end).toHaveBeenCalledWith( + expect.stringContaining( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'aks-audience/user.read', + ), + scope: 'aks-audience/user.read', + expiresInSeconds: 123, + }, + profile: {}, }, }), ), @@ -262,17 +315,21 @@ describe('MicrosoftAuthProvider', () => { type: 'authorization_response', response: { providerInfo: { - idToken: 'header.e30K.microsoft', accessToken: microsoftApi.generateAccessToken( 'email openid profile User.Read', ), scope: 'email openid profile User.Read', expiresInSeconds: 123, + idToken: 'header.e30K.microsoft', }, profile: { email: 'conrad@example.com', displayName: 'Conrad', }, + backstageIdentity: { + token: 'header.e30K.backstage', + identity: { type: 'user', ownershipEntityRefs: [] }, + }, }, }), ), @@ -306,45 +363,50 @@ describe('MicrosoftAuthProvider', () => { accessToken: microsoftApi.generateAccessToken( 'email openid profile User.Read', ), + scope: 'email openid profile User.Read', expiresInSeconds: 123, idToken: 'header.e30K.microsoft', - scope: 'email openid profile User.Read', }, profile: { email: 'conrad@example.com', - displayName: 'Conrad', picture: 'data:image/jpeg;base64,aG93ZHk=', + displayName: 'Conrad', }, }), ); }); - it('returns backstage identity when sign-in resolver is configured', async () => { - provider = microsoft.create({ - signIn: { - resolver: _ => - Promise.resolve({ - token: 'protectedheader.e30K.signature', - }), - }, - })({ - providerId: 'microsoft', - globalConfig: { - baseUrl: 'http://backstage.test/api/auth', - appUrl: 'http://backstage.test', - isOriginAllowed: _ => true, - }, - config: new ConfigReader({ - development: { - tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', + it('returns access token for non-microsoft graph scope', async () => { + await provider.refresh!( + { + query: { + env: 'development', + scope: 'aks-audience/user.read', }, - }), - logger: getVoidLogger(), - resolverContext: {} as AuthResolverContext, - }) as AuthProviderRouteHandlers; + header: jest.fn(_ => 'XMLHttpRequest'), + cookies: { + 'microsoft-refresh-token': microsoftApi.generateRefreshToken( + 'aks-audience/user.read', + ), + }, + get: jest.fn(), + } as unknown as express.Request, + response, + ); + expect(response.json).toHaveBeenCalledWith({ + providerInfo: { + accessToken: microsoftApi.generateAccessToken( + 'aks-audience/user.read', + ), + expiresInSeconds: 123, + scope: 'aks-audience/user.read', + }, + profile: {}, + }); + }); + + it('returns backstage identity', async () => { await provider.refresh!( { query: { @@ -365,7 +427,7 @@ describe('MicrosoftAuthProvider', () => { expect(response.json).toHaveBeenCalledWith( expect.objectContaining({ backstageIdentity: expect.objectContaining({ - token: 'protectedheader.e30K.signature', + token: 'header.e30K.backstage', }), }), ); diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 9b14a3c91b..5d02b7ef61 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -49,6 +49,8 @@ import { } from '../resolvers'; import { Logger } from 'winston'; import fetch from 'node-fetch'; +import { decodeJwt } from 'jose'; +import { Profile as PassportProfile } from 'passport'; const BACKSTAGE_SESSION_EXPIRATION = 3600; @@ -86,6 +88,12 @@ export class MicrosoftAuthProvider implements OAuthHandlers { authorizationURL: options.authorizationUrl, tokenURL: options.tokenUrl, passReqToCallback: false, + skipUserProfile: ( + accessToken: string, + done: (err: unknown, skip: boolean) => void, + ) => { + done(null, this.skipUserProfile(accessToken)); + }, }, ( accessToken: any, @@ -99,6 +107,17 @@ export class MicrosoftAuthProvider implements OAuthHandlers { ); } + private skipUserProfile = (accessToken: string): boolean => { + const { aud, scp } = decodeJwt(accessToken); + const hasGraphReadScope = + aud === '00000003-0000-0000-c000-000000000000' && + (scp as string) + .split(' ') + .map(s => s.toLowerCase()) + .includes('user.read'); + return !hasGraphReadScope; + }; + async start(req: OAuthStartRequest): Promise { return await executeRedirectStrategy(req, this._strategy, { scope: req.scope, @@ -126,53 +145,62 @@ export class MicrosoftAuthProvider implements OAuthHandlers { req.scope, ); - const fullProfile = await executeFetchUserProfileStrategy( - this._strategy, - accessToken, - ); - return { response: await this.handleResult({ - fullProfile, params, accessToken, + ...(!this.skipUserProfile(accessToken) && { + fullProfile: await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + ), + }), }), refreshToken, }; } - private async handleResult(result: OAuthResult) { - const photo = await this.getUserPhoto(result.accessToken); - result.fullProfile.photos = photo ? [{ value: photo }] : undefined; - - const { profile } = await this.authHandler(result, this.resolverContext); + private async handleResult(result: { + fullProfile?: PassportProfile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; + }): Promise { + let profile = {}; + if (result.fullProfile) { + const photo = await this.getUserPhoto(result.accessToken); + result.fullProfile.photos = photo ? [{ value: photo }] : undefined; + ({ profile } = await this.authHandler( + result as OAuthResult, + this.resolverContext, + )); + } const expiresInSeconds = result.params.expires_in === undefined ? BACKSTAGE_SESSION_EXPIRATION : Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION); - const response: OAuthResponse = { + return { providerInfo: { - idToken: result.params.id_token, accessToken: result.accessToken, scope: result.params.scope, expiresInSeconds, + ...{ idToken: result.params.id_token }, }, profile, + ...(result.fullProfile && + this.signInResolver && { + backstageIdentity: await this.signInResolver( + { result: result as OAuthResult, profile }, + this.resolverContext, + ), + }), }; - - if (this.signInResolver) { - response.backstageIdentity = await this.signInResolver( - { - result, - profile, - }, - this.resolverContext, - ); - } - - return response; } private async getUserPhoto(accessToken: string): Promise { @@ -236,7 +264,7 @@ export const microsoft = createAuthProviderIntegration({ const authHandler: AuthHandler = options?.authHandler ? options.authHandler : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), + profile: makeProfileInfo(fullProfile ?? {}, params.id_token), }); const provider = new MicrosoftAuthProvider({ From c5a12022d88d63e52e78c4a847fabeba02330988 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Wed, 8 Feb 2023 15:26:20 -0500 Subject: [PATCH 4/8] refactor MicrosoftAuth to allow overriding some behaviors in subsequent commits Signed-off-by: Jamie Klassen --- packages/core-app-api/api-report.md | 19 +++++ .../auth/microsoft/MicrosoftAuth.ts | 75 ++++++++++++++++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index d7e4a22873..f2df03d806 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -442,6 +442,25 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi { export class MicrosoftAuth { // (undocumented) static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; + // (undocumented) + getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ): Promise; + // (undocumented) + getBackstageIdentity( + options?: AuthRequestOptions, + ): Promise; + // (undocumented) + getIdToken(options?: AuthRequestOptions): Promise; + // (undocumented) + getProfile(options?: AuthRequestOptions): Promise; + // (undocumented) + sessionState$(): Observable; + // (undocumented) + signIn(): Promise; + // (undocumented) + signOut(): Promise; } // @public 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 30cfde7c6e..3f08ecaac7 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 @@ -14,7 +14,13 @@ * limitations under the License. */ -import { microsoftAuthApiRef } from '@backstage/core-plugin-api'; +import { + microsoftAuthApiRef, + AuthRequestOptions, + AuthProviderInfo, + DiscoveryApi, + OAuthRequestApi, +} from '@backstage/core-plugin-api'; import { OAuth2 } from '../oauth2'; import { OAuthApiCreateOptions } from '../types'; @@ -30,7 +36,18 @@ const DEFAULT_PROVIDER = { * @public */ export default class MicrosoftAuth { + private oauth2: Record; + private environment: string; + private provider: AuthProviderInfo; + private oauthRequestApi: OAuthRequestApi; + private discoveryApi: DiscoveryApi; + + private static MicrosoftGraphID = '00000003-0000-0000-c000-000000000000'; + static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + return new MicrosoftAuth(options); + } + private constructor(options: OAuthApiCreateOptions) { const { configApi, environment = 'development', @@ -46,13 +63,53 @@ export default class MicrosoftAuth { ], } = options; - return OAuth2.create({ - configApi, - discoveryApi, - oauthRequestApi, - provider, - environment, - defaultScopes, - }); + this.configApi = configApi; + this.environment = environment; + this.provider = provider; + this.oauthRequestApi = oauthRequestApi; + this.discoveryApi = discoveryApi; + + this.oauth2 = { + [MicrosoftAuth.MicrosoftGraphID]: OAuth2.create({ + configApi: this.configApi, + discoveryApi: this.discoveryApi, + oauthRequestApi: this.oauthRequestApi, + provider: this.provider, + environment: this.environment, + defaultScopes, + }), + }; + } + + private microsoftGraph(): OAuth2 { + return this.oauth2[MicrosoftAuth.MicrosoftGraphID]; + } + + getAccessToken(scope?: string | string[], options?: AuthRequestOptions) { + return this.microsoftGraph().getAccessToken(scope, options); + } + + getIdToken(options?: AuthRequestOptions) { + return this.microsoftGraph().getIdToken(options); + } + + getProfile(options?: AuthRequestOptions) { + return this.microsoftGraph().getProfile(options); + } + + getBackstageIdentity(options?: AuthRequestOptions) { + return this.microsoftGraph().getBackstageIdentity(options); + } + + signIn() { + return this.microsoftGraph().signIn(); + } + + signOut() { + return this.microsoftGraph().signOut(); + } + + sessionState$() { + return this.microsoftGraph().sessionState$(); } } From 000cbcc99a448c19bba369c196bef8e6ed9c5dda Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Thu, 9 Feb 2023 18:24:24 -0500 Subject: [PATCH 5/8] can get non-microsoft graph tokens via popup These tokens will need to be requested on demand with a popup every time: Azure does allow you to get a refresh token from its `/token` endpoint for a set of Microsoft Graph scopes and use that refresh token to get access tokens for other resources, but our session manager isn't quite subtle enough to handle use cases like that yet. Signed-off-by: Jamie Klassen --- .../auth/microsoft/MicrosoftAuth.test.ts | 178 ++++++++++++++++++ .../auth/microsoft/MicrosoftAuth.ts | 54 +++++- 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.test.ts 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) { From c15e0cedbe1981ccbe29bba83524d30b5357f74f Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Mon, 13 Feb 2023 11:50:04 -0500 Subject: [PATCH 6/8] can get non-microsoft graph tokens via refresh Next up will be to make it possible to _request_ refreshable tokens via `getAccessToken`, accounting for the fact that the `offline_access` scope does not appear in the auth-backend response when it is requested -- instead the response has a set-cookie header. Signed-off-by: Jamie Klassen --- .changeset/sixty-insects-visit.md | 9 +++ .../auth/microsoft/MicrosoftAuth.test.ts | 66 +++++-------------- .../DefaultAuthConnector.test.ts | 34 +++++----- .../lib/AuthConnector/DefaultAuthConnector.ts | 7 +- .../src/lib/AuthConnector/types.ts | 2 +- .../RefreshingAuthSessionManager.test.ts | 6 +- .../RefreshingAuthSessionManager.ts | 10 +-- 7 files changed, 57 insertions(+), 77 deletions(-) create mode 100644 .changeset/sixty-insects-visit.md diff --git a/.changeset/sixty-insects-visit.md b/.changeset/sixty-insects-visit.md new file mode 100644 index 0000000000..adbe27e0ac --- /dev/null +++ b/.changeset/sixty-insects-visit.md @@ -0,0 +1,9 @@ +--- +'@backstage/core-app-api': minor +--- + +The `AuthConnector` interface now supports specifying a set of scopes when +refreshing a session. The `DefaultAuthConnector` implementation passes the +`scope` query parameter to the auth-backend plugin appropriately. The +`RefreshingAuthSessionManager` passes any scopes in its `GetSessionRequest` +appropriately. 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 index e2aedbc9b4..61a0a5b64a 100644 --- 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 @@ -34,59 +34,20 @@ describe('MicrosoftAuth', () => { ), }); - 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'; + async (req, res, ctx) => { + const scopeParam = req.url.searchParams.get('scope'); 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, + accessToken: scopeParam + ? 'tokenForOtherResource' + : 'tokenForGrantScopes', + scope: scopeParam || 'grant-resource/scope', }, }), ); @@ -95,13 +56,18 @@ describe('MicrosoftAuth', () => { ); }); - it('gets access token for Microsoft Graph', async () => { + it('gets access token with requested scopes for grant', async () => { const accessToken = await microsoftAuth.getAccessToken(); - expect(accessToken).toHaveJWTClaims({ - aud: '00000003-0000-0000-c000-000000000000', - scp: 'openid profile email User.Read', - }); + expect(accessToken).toEqual('tokenForGrantScopes'); + }); + + it('gets access token for other consented scopes besides those directly granted', async () => { + const accessToken = await microsoftAuth.getAccessToken( + 'azure-resource/scope', + ); + + expect(accessToken).toEqual('tokenForOtherResource'); }); }); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 51da9cbfb5..c46e87581f 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -59,22 +59,22 @@ describe('DefaultAuthConnector', () => { jest.resetAllMocks(); }); - it('should refresh a session', async () => { + it('should refresh a session with scope', async () => { server.use( - rest.get('*', (_req, res, ctx) => + rest.get('*', (req, res, ctx) => res( ctx.json({ idToken: 'mock-id-token', accessToken: 'mock-access-token', - scopes: 'a b c', + scopes: req.url.searchParams.get('scope') || 'default-scope', expiresInSeconds: '60', }), ), ), ); - const helper = new DefaultAuthConnector(defaultOptions); - const session = await helper.refreshSession(); + const connector = new DefaultAuthConnector(defaultOptions); + const session = await connector.refreshSession(new Set(['a', 'b', 'c'])); expect(session.idToken).toBe('mock-id-token'); expect(session.accessToken).toBe('mock-access-token'); expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); @@ -89,8 +89,8 @@ describe('DefaultAuthConnector', () => { ), ); - const helper = new DefaultAuthConnector(defaultOptions); - await expect(helper.refreshSession()).rejects.toThrow( + const connector = new DefaultAuthConnector(defaultOptions); + await expect(connector.refreshSession()).rejects.toThrow( 'Auth refresh request failed, Error: Network NOPE', ); }); @@ -98,19 +98,19 @@ describe('DefaultAuthConnector', () => { it('should handle failure response when refreshing session', async () => { server.use(rest.get('*', (_req, res, ctx) => res(ctx.status(401, 'NOPE')))); - const helper = new DefaultAuthConnector(defaultOptions); - await expect(helper.refreshSession()).rejects.toThrow( + const connector = new DefaultAuthConnector(defaultOptions); + await expect(connector.refreshSession()).rejects.toThrow( 'Auth refresh request failed, NOPE', ); }); it('should fail if popup was rejected', async () => { const mockOauth = new MockOAuthApi(); - const helper = new DefaultAuthConnector({ + const connector = new DefaultAuthConnector({ ...defaultOptions, oauthRequestApi: mockOauth, }); - const promise = helper.createSession({ scopes: new Set(['a', 'b']) }); + const promise = connector.createSession({ scopes: new Set(['a', 'b']) }); await mockOauth.rejectAll(); await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); }); @@ -125,12 +125,12 @@ describe('DefaultAuthConnector', () => { scopes: 'a b', expiresInSeconds: 3600, }); - const helper = new DefaultAuthConnector({ + const connector = new DefaultAuthConnector({ ...defaultOptions, oauthRequestApi: mockOauth, }); - const sessionPromise = helper.createSession({ + const sessionPromise = connector.createSession({ scopes: new Set(['a', 'b']), }); @@ -153,13 +153,13 @@ describe('DefaultAuthConnector', () => { const popupSpy = jest .spyOn(loginPopup, 'showLoginPopup') .mockResolvedValue('my-session'); - const helper = new DefaultAuthConnector({ + const connector = new DefaultAuthConnector({ ...defaultOptions, oauthRequestApi: new MockOAuthApi(), sessionTransform: str => str, }); - const sessionPromise = helper.createSession({ + const sessionPromise = connector.createSession({ scopes: new Set(), instantPopup: true, }); @@ -174,13 +174,13 @@ describe('DefaultAuthConnector', () => { const popupSpy = jest .spyOn(loginPopup, 'showLoginPopup') .mockResolvedValue({ scopes: '' }); - const helper = new DefaultAuthConnector({ + const connector = new DefaultAuthConnector({ ...defaultOptions, joinScopes: scopes => `-${[...scopes].join('')}-`, oauthRequestApi: mockOauth, }); - helper.createSession({ scopes: new Set(['a', 'b']) }); + connector.createSession({ scopes: new Set(['a', 'b']) }); await mockOauth.triggerAll(); diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index f2c35b0ba3..728ed3878c 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -126,9 +126,12 @@ export class DefaultAuthConnector return this.authRequester(options.scopes); } - async refreshSession(): Promise { + async refreshSession(scopes?: Set): Promise { const res = await fetch( - await this.buildUrl('/refresh', { optional: true }), + await this.buildUrl('/refresh', { + optional: true, + ...(scopes && { scope: this.joinScopesFunc(scopes) }), + }), { headers: { 'x-requested-with': 'XMLHttpRequest', diff --git a/packages/core-app-api/src/lib/AuthConnector/types.ts b/packages/core-app-api/src/lib/AuthConnector/types.ts index 464a2ed627..c8e83e1ccc 100644 --- a/packages/core-app-api/src/lib/AuthConnector/types.ts +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -25,6 +25,6 @@ export type CreateSessionOptions = { */ export type AuthConnector = { createSession(options: CreateSessionOptions): Promise; - refreshSession(): Promise; + refreshSession(scopes?: Set): Promise; removeSession(): Promise; }; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index a94aca85f8..23432856cb 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -47,7 +47,7 @@ describe('RefreshingAuthSessionManager', () => { await manager.getSession({}); expect(createSession).toHaveBeenCalledTimes(1); - expect(refreshSession).toHaveBeenCalledTimes(1); + expect(refreshSession).toHaveBeenCalledWith(undefined); expect(stateSubscriber.mock.calls).toEqual([ [SessionState.SignedOut], [SessionState.SignedIn], @@ -103,7 +103,7 @@ describe('RefreshingAuthSessionManager', () => { await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toHaveBeenCalledTimes(1); - expect(refreshSession).toHaveBeenCalledTimes(1); + expect(refreshSession).toHaveBeenCalledWith(new Set(['a'])); await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toHaveBeenCalledTimes(1); @@ -134,7 +134,7 @@ describe('RefreshingAuthSessionManager', () => { expect(await manager.getSession({ optional: true })).toBe(undefined); expect(createSession).toHaveBeenCalledTimes(0); - expect(refreshSession).toHaveBeenCalledTimes(1); + expect(refreshSession).toHaveBeenCalledWith(undefined); }); it('should forward option to instantly show auth popup and not attempt refresh', async () => { diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 67d83bb36a..c04eb637de 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -73,7 +73,9 @@ export class RefreshingAuthSessionManager implements SessionManager { } try { - const refreshedSession = await this.collapsedSessionRefresh(); + const refreshedSession = await this.collapsedSessionRefresh( + options.scopes, + ); const currentScopes = this.sessionScopesFunc(this.currentSession!); const refreshedScopes = this.sessionScopesFunc(refreshedSession); if (hasScopes(refreshedScopes, currentScopes)) { @@ -97,7 +99,7 @@ export class RefreshingAuthSessionManager implements SessionManager { // already had an existing session. if (!this.currentSession && !options.instantPopup) { try { - const newSession = await this.collapsedSessionRefresh(); + const newSession = await this.collapsedSessionRefresh(options.scopes); this.currentSession = newSession; // The session might not have the scopes requested so go back and check again return this.getSession(options); @@ -130,12 +132,12 @@ export class RefreshingAuthSessionManager implements SessionManager { return this.stateTracker.sessionState$(); } - private async collapsedSessionRefresh(): Promise { + private async collapsedSessionRefresh(scopes?: Set): Promise { if (this.refreshPromise) { return this.refreshPromise; } - this.refreshPromise = this.connector.refreshSession(); + this.refreshPromise = this.connector.refreshSession(scopes); try { const session = await this.refreshPromise; From 01e6d459e2906c9b92b83d359e21223e1a34a279 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Mon, 13 Feb 2023 22:13:40 -0500 Subject: [PATCH 7/8] MicrosoftAuth automatically gets refresh tokens Signed-off-by: Jamie Klassen --- .changeset/mighty-tips-flow.md | 5 +++++ packages/core-app-api/api-report.md | 2 +- .../auth/microsoft/MicrosoftAuth.test.ts | 6 +++--- .../implementations/auth/microsoft/MicrosoftAuth.ts | 12 ++++++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.changeset/mighty-tips-flow.md b/.changeset/mighty-tips-flow.md index d876942fde..514d514588 100644 --- a/.changeset/mighty-tips-flow.md +++ b/.changeset/mighty-tips-flow.md @@ -9,3 +9,8 @@ authorization code for a non-Microsoft Graph scope, the user profile will not be fetched. Similarly no user profile or photo data will be fetched by the backend if the `/refresh` endpoint is called with the `scope` query parameter strictly containing scopes for resources besides Microsoft Graph. + +Furthermore, the `offline_access` scope will be requested by default, even when +it is not mentioned in the argument to `getAccessToken`. This means that any +Azure access token can be automatically refreshed, even if the user has not +signed in via Azure. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index f2df03d806..4ce4533fe1 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -441,7 +441,7 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi { // @public export class MicrosoftAuth { // (undocumented) - static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; + static create(options: OAuth2CreateOptions): typeof microsoftAuthApiRef.T; // (undocumented) getAccessToken( scope?: string | string[], 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 index 61a0a5b64a..0d2e016b41 100644 --- 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 @@ -125,11 +125,11 @@ describe('MicrosoftAuth', () => { ); }); - it('gets access token for other azure resources via popup', async () => { + it('gets access + refresh token for other azure resources via popup', async () => { await sut.getAccessToken('azure-resource/scope'); expect(mockRequester).toHaveBeenCalledWith( - new Set(['azure-resource/scope']), + new Set(['azure-resource/scope', 'offline_access']), ); }); @@ -137,7 +137,7 @@ describe('MicrosoftAuth', () => { await sut.getAccessToken('api://customApiClientId/some.scope'); expect(mockRequester).toHaveBeenCalledWith( - new Set(['api://customApiClientId/some.scope']), + new Set(['api://customApiClientId/some.scope', 'offline_access']), ); }); }); 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 1b2fc20f37..fa74979c6b 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 @@ -22,8 +22,7 @@ import { DiscoveryApi, OAuthRequestApi, } from '@backstage/core-plugin-api'; -import { OAuth2 } from '../oauth2'; -import { OAuthApiCreateOptions } from '../types'; +import { OAuth2, OAuth2CreateOptions } from '../oauth2'; const DEFAULT_PROVIDER = { id: 'microsoft', @@ -43,13 +42,14 @@ export default class MicrosoftAuth { private provider: AuthProviderInfo; private oauthRequestApi: OAuthRequestApi; private discoveryApi: DiscoveryApi; + private scopeTransform: (scopes: string[]) => string[]; private static MicrosoftGraphID = '00000003-0000-0000-c000-000000000000'; - static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T { + static create(options: OAuth2CreateOptions): typeof microsoftAuthApiRef.T { return new MicrosoftAuth(options); } - private constructor(options: OAuthApiCreateOptions) { + private constructor(options: OAuth2CreateOptions) { const { configApi, environment = 'development', @@ -63,6 +63,7 @@ export default class MicrosoftAuth { 'email', 'User.Read', ], + scopeTransform = scopes => scopes.concat('offline_access'), } = options; this.configApi = configApi; @@ -70,6 +71,7 @@ export default class MicrosoftAuth { this.provider = provider; this.oauthRequestApi = oauthRequestApi; this.discoveryApi = discoveryApi; + this.scopeTransform = scopeTransform; this.oauth2 = { [MicrosoftAuth.MicrosoftGraphID]: OAuth2.create({ @@ -78,6 +80,7 @@ export default class MicrosoftAuth { oauthRequestApi: this.oauthRequestApi, provider: this.provider, environment: this.environment, + scopeTransform: this.scopeTransform, defaultScopes, }), }; @@ -132,6 +135,7 @@ export default class MicrosoftAuth { oauthRequestApi: this.oauthRequestApi, provider: this.provider, environment: this.environment, + scopeTransform: this.scopeTransform, }); } return this.oauth2[aud].getAccessToken(scope, options); From 85adf6943b8c756fe44a34fa2577d8109ad81077 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Fri, 17 Mar 2023 17:33:35 -0400 Subject: [PATCH 8/8] fail multi-resource access token requests The emphasis on the word 'erroneously' in [this doc](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/ef0a1ae38249ba2cf6ec5d75731e711a6ae62cba/lib/msal-browser/docs/resources-and-scopes.md?plain=1#L36) makes this behaviour pretty compelling. Signed-off-by: Jamie Klassen --- .../auth/microsoft/MicrosoftAuth.test.ts | 10 ++++++++++ .../auth/microsoft/MicrosoftAuth.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) 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 index 0d2e016b41..c71799ed98 100644 --- 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 @@ -69,6 +69,16 @@ describe('MicrosoftAuth', () => { expect(accessToken).toEqual('tokenForOtherResource'); }); + + it('fails when requesting scopes for multiple resources at once', async () => { + const accessTokenPromise = microsoftAuth.getAccessToken( + 'one-resource/scope other-resource/scope', + ); + + await expect(accessTokenPromise).rejects.toThrow( + 'Requested access token with scopes from multiple Azure resources: one-resource, other-resource. Access tokens can only have a single audience.', + ); + }); }); describe('without a refresh token', () => { 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 fa74979c6b..fdffb3fc45 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 @@ -91,11 +91,20 @@ export default class MicrosoftAuth { } private static resourceForScopes(scope: string): Promise { - const audience = - scope - .split(' ') - .map(MicrosoftAuth.resourceForScope) - .find(aud => aud !== 'openid') ?? MicrosoftAuth.MicrosoftGraphID; + const audiences = scope + .split(' ') + .map(MicrosoftAuth.resourceForScope) + .filter(aud => aud !== 'openid'); + if (audiences.length > 1) { + return Promise.reject( + new Error( + `Requested access token with scopes from multiple Azure resources: ${audiences.join( + ', ', + )}. Access tokens can only have a single audience.`, + ), + ); + } + const audience = audiences[0] ?? MicrosoftAuth.MicrosoftGraphID; return Promise.resolve(audience); }