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', + }), + }), + ); + }); }); });