diff --git a/.changeset/orange-ears-kiss.md b/.changeset/orange-ears-kiss.md new file mode 100644 index 0000000000..1620127c20 --- /dev/null +++ b/.changeset/orange-ears-kiss.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +--- + +Re-add the missing profile photo +as well as access token retrieval for foreign scopes. + +Additionally, we switch from previously 48x48 to 96x96 +which is the size used at the profile card. diff --git a/plugins/auth-backend-module-microsoft-provider/package.json b/plugins/auth-backend-module-microsoft-provider/package.json index 17d5ad3522..0863a7ced2 100644 --- a/plugins/auth-backend-module-microsoft-provider/package.json +++ b/plugins/auth-backend-module-microsoft-provider/package.json @@ -27,6 +27,8 @@ "@backstage/backend-plugin-api": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "express": "^4.18.2", + "jose": "^4.6.0", + "node-fetch": "^2.6.7", "passport": "^0.6.0", "passport-microsoft": "^1.0.0" }, @@ -34,7 +36,10 @@ "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", + "@types/passport-microsoft": "^1.0.0", + "msw": "^1.0.0", "supertest": "^6.3.3" }, "configSchema": "config.d.ts", diff --git a/plugins/auth-backend-module-microsoft-provider/src/__testUtils__/fake.test.ts b/plugins/auth-backend-module-microsoft-provider/src/__testUtils__/fake.test.ts new file mode 100644 index 0000000000..dee37f0f6b --- /dev/null +++ b/plugins/auth-backend-module-microsoft-provider/src/__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-module-microsoft-provider/src/__testUtils__/fake.ts b/plugins/auth-backend-module-microsoft-provider/src/__testUtils__/fake.ts new file mode 100644 index 0000000000..9247ca9e17 --- /dev/null +++ b/plugins/auth-backend-module-microsoft-provider/src/__testUtils__/fake.ts @@ -0,0 +1,137 @@ +/* + * 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-module-microsoft-provider/src/authenticator.test.ts b/plugins/auth-backend-module-microsoft-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..35d35c464c --- /dev/null +++ b/plugins/auth-backend-module-microsoft-provider/src/authenticator.test.ts @@ -0,0 +1,277 @@ +/* + * 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { + encodeOAuthState, + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorStartInput, + OAuthState, + PassportOAuthAuthenticatorHelper, +} from '@backstage/plugin-auth-node'; +import express from 'express'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { FakeMicrosoftAPI } from './__testUtils__/fake'; +import { microsoftAuthenticator } from './authenticator'; + +describe('microsoftAuthenticator', () => { + const oauthState: OAuthState = { + nonce: 'AAAAAAAAAAAAAAAAAAAAAA==', + env: 'development', + }; + const scope = 'email openid profile User.Read'; + const state = encodeOAuthState(oauthState); + + const photo = 'data:image/jpeg;base64,aG93ZHk='; + + const microsoftApi = new FakeMicrosoftAPI(); + + const server = setupServer(); + setupRequestMockHandlers(server); + + let implementation: { + domainHint: string | undefined; + helper: PassportOAuthAuthenticatorHelper; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + server.use( + rest.post( + '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', + expires_in: 123, + ext_expires_in: 123, + }), + ); + }, + ), + 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', + surname: 'Ribas', + givenName: 'Francisco', + mail: 'conrad@example.com', + }), + ); + }), + rest.get( + 'https://graph.microsoft.com/v1.0/me/photos/*', + 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()), + ctx.set('Content-Type', 'image/jpeg'), + ctx.body(imageBuffer), + ); + }, + ), + ); + + implementation = microsoftAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + tenantId: 'tenantId', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + }); + }); + + describe('#start', () => { + it('redirects to authorize URL', async () => { + const startRequest: OAuthAuthenticatorStartInput = { + scope, + state, + req: { + method: 'GET', + url: 'test', + } as unknown as express.Request, + }; + const startResponse = await microsoftAuthenticator.start( + startRequest, + implementation, + ); + + expect(startResponse.url).toBe( + 'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' + + '?response_type=code' + + `&redirect_uri=${encodeURIComponent( + 'https://backstage.test/callback', + )}` + + `&scope=${encodeURIComponent(scope)}` + + `&state=${state}` + + '&client_id=clientId', + ); + }); + }); + + describe('#authenticate', () => { + const createAuthenticateRequest = ( + scopeForRequest: string, + ): OAuthAuthenticatorAuthenticateInput => { + const authCode = microsoftApi.generateAuthCode(scopeForRequest); + return { + req: { + method: 'GET', + url: 'test', + query: { + code: authCode, + state, + }, + session: {}, + cookies: { + 'microsoft-nonce': oauthState.nonce, + }, + } as unknown as express.Request, + }; + }; + + it('returns provider info and profile with photo data', async () => { + const authenticateResponse = await microsoftAuthenticator.authenticate( + createAuthenticateRequest(scope), + implementation, + ); + + const profile = authenticateResponse.fullProfile; + expect(profile.displayName).toBe('Conrad'); + expect(profile.emails).toStrictEqual([ + { + type: 'work', + value: 'conrad@example.com', + }, + ]); + expect(profile.photos).toStrictEqual([{ value: photo }]); + }); + + it('returns access token for non-microsoft graph scope', async () => { + const foreignScope = 'aks-audience/user.read'; + const authenticateResponse = await microsoftAuthenticator.authenticate( + createAuthenticateRequest(foreignScope), + implementation, + ); + + expect(authenticateResponse.fullProfile).toBeUndefined(); + expect(authenticateResponse.session.accessToken).toBe( + microsoftApi.generateAccessToken(foreignScope), + ); + }); + + it('sets refresh token', async () => { + const refreshScope = 'email offline_access openid profile User.Read'; + const authenticateResponse = await microsoftAuthenticator.authenticate( + createAuthenticateRequest(refreshScope), + implementation, + ); + + const session = authenticateResponse.session; + expect(session.refreshToken).toBe( + microsoftApi.generateRefreshToken(refreshScope), + ); + }); + + 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'), + ), + ); + + const authenticateResponse = await microsoftAuthenticator.authenticate( + createAuthenticateRequest(scope), + implementation, + ); + + const profile = authenticateResponse.fullProfile; + expect(profile.displayName).toBe('Conrad'); + expect(profile.emails).toStrictEqual([ + { + type: 'work', + value: 'conrad@example.com', + }, + ]); + expect(profile.photos).toBeUndefined(); + }); + }); + + describe('#refresh', () => { + const createRefreshRequest = ( + scopeForRequest: string, + ): OAuthAuthenticatorRefreshInput => { + return { + scope: scopeForRequest, + refreshToken: microsoftApi.generateRefreshToken(scopeForRequest), + req: {} as unknown as express.Request, + }; + }; + + it('returns provider info and profile with photo data', async () => { + const refreshResponse = await microsoftAuthenticator.refresh( + createRefreshRequest(scope), + implementation, + ); + + const profile = refreshResponse.fullProfile; + expect(profile.displayName).toBe('Conrad'); + expect(profile.emails).toStrictEqual([ + { + type: 'work', + value: 'conrad@example.com', + }, + ]); + expect(profile.photos).toStrictEqual([{ value: photo }]); + }); + + it('returns access token for non-microsoft graph scope', async () => { + const foreignScope = 'aks-audience/user.read'; + const refreshResponse = await microsoftAuthenticator.refresh( + createRefreshRequest(foreignScope), + implementation, + ); + + expect(refreshResponse.fullProfile).toBeUndefined(); + expect(refreshResponse.session.accessToken).toBe( + microsoftApi.generateAccessToken(foreignScope), + ); + }); + }); +}); diff --git a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts index 9687c7ce1a..52ef1ddd90 100644 --- a/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-microsoft-provider/src/authenticator.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; import { createOAuthAuthenticator, PassportOAuthAuthenticatorHelper, PassportOAuthDoneCallback, PassportProfile, } from '@backstage/plugin-auth-node'; +import { ExtendedMicrosoftStrategy } from './strategy'; /** @public */ export const microsoftAuthenticator = createOAuthAuthenticator({ @@ -33,7 +33,7 @@ export const microsoftAuthenticator = createOAuthAuthenticator({ const domainHint = config.getOptionalString('domainHint'); const helper = PassportOAuthAuthenticatorHelper.from( - new MicrosoftStrategy( + new ExtendedMicrosoftStrategy( { clientID: clientId, clientSecret: clientSecret, diff --git a/plugins/auth-backend-module-microsoft-provider/src/strategy.ts b/plugins/auth-backend-module-microsoft-provider/src/strategy.ts new file mode 100644 index 0000000000..cf2ff73965 --- /dev/null +++ b/plugins/auth-backend-module-microsoft-provider/src/strategy.ts @@ -0,0 +1,107 @@ +/* + * 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 { PassportProfile } from '@backstage/plugin-auth-node'; +import { decodeJwt } from 'jose'; +import fetch from 'node-fetch'; +import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; + +export class ExtendedMicrosoftStrategy extends MicrosoftStrategy { + userProfile( + accessToken: string, + done: (err?: Error | null, profile?: PassportProfile) => void, + ): void { + if (this.skipUserProfile(accessToken)) { + done(null, undefined); + return; + } + + super.userProfile( + accessToken, + (err?: Error | null, profile?: PassportProfile) => { + if (!profile || profile.photos) { + done(err, profile); + return; + } + + this.getProfilePhotos(accessToken).then(photos => { + profile.photos = photos; + done(err, profile); + }); + }, + ); + } + + private hasGraphReadScope(accessToken: string): boolean { + const { aud, scp } = decodeJwt(accessToken); + return ( + aud === '00000003-0000-0000-c000-000000000000' && + !!scp && + (scp as string) + .split(' ') + .map(s => s.toLocaleLowerCase('en-US')) + .some(s => + [ + 'https://graph.microsoft.com/user.read', + 'https://graph.microsoft.com/user.read.all', + 'user.read', + 'user.read.all', + ].includes(s), + ) + ); + } + + private skipUserProfile(accessToken: string): boolean { + try { + return !this.hasGraphReadScope(accessToken); + } catch { + // If there is any error with checking the scope + // we fall back to not skipping the user profile + // which may still result in an auth failure + // e.g. due to a foreign scope. + return false; + } + } + + private async getProfilePhotos( + accessToken: string, + ): Promise | undefined> { + return this.getCurrentUserPhoto(accessToken, '96x96').then(photo => + photo ? [{ value: photo }] : undefined, + ); + } + + private async getCurrentUserPhoto( + accessToken: string, + size: string, + ): Promise { + try { + const res = await fetch( + `https://graph.microsoft.com/v1.0/me/photos/${size}/$value`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const data = await res.buffer(); + + return `data:image/jpeg;base64,${data.toString('base64')}`; + } catch (error) { + return undefined; + } + } +} diff --git a/plugins/auth-backend-module-microsoft-provider/src/types.d.ts b/plugins/auth-backend-module-microsoft-provider/src/types.d.ts deleted file mode 100644 index ca6ae9ac40..0000000000 --- a/plugins/auth-backend-module-microsoft-provider/src/types.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 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. - */ - -declare module 'passport-microsoft' { - import { Request } from 'express'; - import { StrategyCreated } from 'passport'; - - export class Strategy { - constructor(options: any, verify: any); - authenticate(this: StrategyCreated, req: Request, options?: any): any; - } -} diff --git a/yarn.lock b/yarn.lock index af3a8940ed..1132d65a30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4988,9 +4988,14 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" + "@types/passport-microsoft": ^1.0.0 express: ^4.18.2 + jose: ^4.6.0 + msw: ^1.0.0 + node-fetch: ^2.6.7 passport: ^0.6.0 passport-microsoft: ^1.0.0 supertest: ^6.3.3