diff --git a/.changeset/dull-fireants-repeat.md b/.changeset/dull-fireants-repeat.md new file mode 100644 index 0000000000..4e849bfbaf --- /dev/null +++ b/.changeset/dull-fireants-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Use the externalized `auth-backend-module-microsoft-provider` again. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index d80648a10f..f6d0716c13 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -531,9 +531,9 @@ export const providers: Readonly<{ | undefined, ) => AuthProviderFactory_2; resolvers: Readonly<{ - emailLocalPartMatchingUserEntityName: () => SignInResolver; - emailMatchingUserEntityProfileEmail: () => SignInResolver; - emailMatchingUserEntityAnnotation(): SignInResolver; + emailMatchingUserEntityProfileEmail: () => SignInResolver_2; + emailLocalPartMatchingUserEntityName: () => SignInResolver_2; + emailMatchingUserEntityAnnotation: () => SignInResolver_2; }>; }>; oauth2: Readonly<{ diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 34139593d3..44848bf653 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -180,18 +180,6 @@ export interface Config { }; }; /** @visibility frontend */ - microsoft?: { - [authEnv: string]: { - clientId: string; - /** - * @visibility secret - */ - clientSecret: string; - tenantId: string; - callbackUrl?: string; - }; - }; - /** @visibility frontend */ onelogin?: { [authEnv: string]: { clientId: string; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index f05551736a..0cc72db917 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -43,6 +43,7 @@ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^", "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^", "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^", "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^", diff --git a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts b/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts deleted file mode 100644 index dee37f0f6b..0000000000 --- a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9b3ca09c57..0000000000 --- a/plugins/auth-backend/src/providers/microsoft/__testUtils__/fake.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7bbfd085d6..0000000000 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ /dev/null @@ -1,450 +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. - */ - -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 { 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'); - const mockBackstageToken = `header.${Buffer.from( - JSON.stringify({ sub: 'user:default/mock' }), - 'utf8', - ).toString('base64')}.backstage`; - - const server = setupServer(); - const microsoftApi = new FakeMicrosoftAPI(); - let provider: AuthProviderRouteHandlers; - let response: jest.Mocked; - - setupRequestMockHandlers(server); - - beforeEach(() => { - provider = microsoft.create({ - signIn: { - resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(), - }, - })({ - providerId: 'microsoft', - baseUrl: 'http://backstage.test/api/auth', - appUrl: 'http://backstage.test', - isOriginAllowed: _ => true, - 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: { - issueToken: jest.fn(), - findCatalogUser: jest.fn(), - signInWithCatalogUser: async _ => ({ - token: mockBackstageToken, - }), - } as AuthResolverContext, - }) as AuthProviderRouteHandlers; - - 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), - ); - }, - ), - ); - 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); - }); - - describe('#start', () => { - const randomBytes = jest.spyOn( - crypto, - 'randomBytes', - ) as unknown as jest.MockedFunction<(size: number) => Buffer>; - - afterEach(() => { - randomBytes.mockRestore(); - }); - - it('redirects to authorize URL', async () => { - randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); - - 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=${encodeURIComponent( - 'http://backstage.test/api/auth/microsoft/handler/frame', - )}` + - `&scope=${encodeURIComponent('email openid profile User.Read')}` + - `&state=${state}` + - '&client_id=clientId', - ); - }); - }); - - 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: { - 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: mockBackstageToken, - identity: { - type: 'user', - userEntityRef: 'user:default/mock', - 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: {}, - }, - }), - ), - ), - ); - }); - - 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, - ); - - 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: { - 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: mockBackstageToken, - identity: { - type: 'user', - userEntityRef: 'user:default/mock', - ownershipEntityRefs: [], - }, - }, - }, - }), - ), - ), - ); - }); - }); - - 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', - ), - 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', - }, - }), - ); - }); - - it('returns access token for non-microsoft graph scope', async () => { - await provider.refresh!( - { - query: { - env: 'development', - scope: 'aks-audience/user.read', - }, - 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: { - 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: mockBackstageToken, - }), - }), - ); - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 8fc8459f10..8947c7432b 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -14,218 +14,25 @@ * limitations under the License. */ -import express from 'express'; -import passport from 'passport'; -import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; -import { - encodeState, - OAuthAdapter, - OAuthEnvironmentHandler, - OAuthHandlers, - OAuthProviderOptions, - OAuthRefreshRequest, - OAuthResponse, - OAuthResult, - OAuthStartRequest, -} from '../../lib/oauth'; -import { - executeFetchUserProfileStrategy, - executeFrameHandlerStrategy, - executeRedirectStrategy, - executeRefreshTokenStrategy, - makeProfileInfo, - PassportDoneCallback, -} from '../../lib/passport'; -import { - AuthHandler, - OAuthStartResponse, - SignInResolver, - AuthResolverContext, -} from '../types'; +import { SignInResolver, AuthHandler } from '../types'; +import { OAuthResult } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { - commonByEmailLocalPartResolver, - commonByEmailResolver, -} from '../resolvers'; -import { LoggerService } from '@backstage/backend-plugin-api'; -import fetch from 'node-fetch'; -import { decodeJwt } from 'jose'; -import { Profile as PassportProfile } from 'passport'; -import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; - -type PrivateInfo = { - refreshToken: string; -}; - -type Options = OAuthProviderOptions & { - signInResolver?: SignInResolver; - authHandler: AuthHandler; - logger: LoggerService; - resolverContext: AuthResolverContext; - authorizationUrl?: string; - tokenUrl?: string; -}; - -export class MicrosoftAuthProvider implements OAuthHandlers { - private readonly _strategy: MicrosoftStrategy; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly logger: LoggerService; - private readonly resolverContext: AuthResolverContext; - - constructor(options: Options) { - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.logger = options.logger; - this.resolverContext = options.resolverContext; - - this._strategy = new MicrosoftStrategy( - { - clientID: options.clientId, - clientSecret: options.clientSecret, - callbackURL: options.callbackUrl, - authorizationURL: options.authorizationUrl, - tokenURL: options.tokenUrl, - passReqToCallback: false, - skipUserProfile: ( - accessToken: string, - done: (err: unknown, skip: boolean) => void, - ) => { - done(null, this.skipUserProfile(accessToken)); - }, - }, - ( - accessToken: any, - refreshToken: any, - params: any, - fullProfile: passport.Profile, - done: PassportDoneCallback, - ) => { - done(undefined, { fullProfile, accessToken, params }, { refreshToken }); - }, - ); - } - - 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, - state: encodeState(req.state), - }); - } - - async handler(req: express.Request) { - const { result, privateInfo } = await executeFrameHandlerStrategy< - OAuthResult, - PrivateInfo - >(req, this._strategy); - - return { - response: await this.handleResult(result), - refreshToken: privateInfo.refreshToken, - }; - } - - async refresh(req: OAuthRefreshRequest) { - const { accessToken, refreshToken, params } = - await executeRefreshTokenStrategy( - this._strategy, - req.refreshToken, - req.scope, - ); - - return { - response: await this.handleResult({ - params, - accessToken, - ...(!this.skipUserProfile(accessToken) && { - fullProfile: await executeFetchUserProfileStrategy( - this._strategy, - accessToken, - ), - }), - }), - refreshToken, - }; - } - - 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); - - return { - providerInfo: { - 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, - ), - }), - }; - } - - private async getUserPhoto(accessToken: string): Promise { - try { - const res = await fetch( - 'https://graph.microsoft.com/v1.0/me/photos/48x48/$value', - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - const data = await res.buffer(); - - return `data:image/jpeg;base64,${data.toString('base64')}`; - } catch (error) { - this.logger.warn( - `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, - ); - return undefined; - } - } -} + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { + adaptLegacyOAuthHandler, + adaptLegacyOAuthSignInResolver, + adaptOAuthSignInResolverToLegacy, +} from '../../lib/legacy'; +import { + microsoftAuthenticator, + microsoftSignInResolvers, +} from '@backstage/plugin-auth-backend-module-microsoft-provider'; /** - * Auth provider integration for Microsoft auth + * Auth provider integration for GitLab auth * * @public */ @@ -241,75 +48,21 @@ export const microsoft = createAuthProviderIntegration({ * Configure sign-in for this provider, without it the provider can not be used to sign users in. */ signIn?: { - /** - * Maps an auth result to a Backstage identity for the user. - */ resolver: SignInResolver; }; }) { - return ({ providerId, globalConfig, config, logger, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const tenantId = envConfig.getString('tenantId'); - - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; - const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; - - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile ?? {}, params.id_token), - }); - - const provider = new MicrosoftAuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, - authHandler, - signInResolver: options?.signIn?.resolver, - logger, - resolverContext, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); - }, - resolvers: { - /** - * Looks up the user by matching their email local part to the entity name. - */ - emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, - /** - * Looks up the user by matching their email to the entity email. - */ - emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, - /** - * Looks up the user by matching their email to the `microsoft.com/email` annotation. - */ - emailMatchingUserEntityAnnotation(): SignInResolver { - return async (info, ctx) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Microsoft profile contained no email'); - } - - return ctx.signInWithCatalogUser({ - annotations: { - 'microsoft.com/email': profile.email, - }, - }); - }; - }, + return createOAuthProviderFactory({ + authenticator: microsoftAuthenticator, + profileTransform: adaptLegacyOAuthHandler(options?.authHandler), + signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver), + }); }, + resolvers: adaptOAuthSignInResolverToLegacy({ + emailLocalPartMatchingUserEntityName: + commonSignInResolvers.emailLocalPartMatchingUserEntityName(), + emailMatchingUserEntityProfileEmail: + commonSignInResolvers.emailMatchingUserEntityProfileEmail(), + emailMatchingUserEntityAnnotation: + microsoftSignInResolvers.emailMatchingUserEntityAnnotation(), + }), }); diff --git a/yarn.lock b/yarn.lock index ce3c3512b8..fa69052d87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4724,7 +4724,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider": +"@backstage/plugin-auth-backend-module-microsoft-provider@workspace:^, @backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-microsoft-provider@workspace:plugins/auth-backend-module-microsoft-provider" dependencies: @@ -4854,6 +4854,7 @@ __metadata: "@backstage/plugin-auth-backend-module-github-provider": "workspace:^" "@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^" "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^" "@backstage/plugin-auth-backend-module-okta-provider": "workspace:^"