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({