From 30a8a345e980d2254524d2ebf702a5a2886bda1e Mon Sep 17 00:00:00 2001 From: chicoribas Date: Sat, 10 Jul 2021 01:48:26 -0300 Subject: [PATCH] SignIn and Auth handlers for MS provider Signed-off-by: chicoribas --- plugins/auth-backend/src/providers/index.ts | 1 + .../src/providers/microsoft/index.ts | 6 +- .../src/providers/microsoft/provider.test.ts | 105 +++++++ .../src/providers/microsoft/provider.ts | 266 +++++++++++++----- 4 files changed, 303 insertions(+), 75 deletions(-) create mode 100644 plugins/auth-backend/src/providers/microsoft/provider.test.ts diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 940ceadffe..6b262dcf68 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -15,6 +15,7 @@ */ export * from './google'; +export * from './microsoft'; export { factories as defaultAuthProviderFactories } from './factories'; // Export the minimal interface required for implementing a diff --git a/plugins/auth-backend/src/providers/microsoft/index.ts b/plugins/auth-backend/src/providers/microsoft/index.ts index 374e451ae9..5b6637bef2 100644 --- a/plugins/auth-backend/src/providers/microsoft/index.ts +++ b/plugins/auth-backend/src/providers/microsoft/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { createMicrosoftProvider } from './provider'; +export { + createMicrosoftProvider, + microsoftEmailSignInResolver, + microsoftDefaultSignInResolver, +} from './provider'; export type { MicrosoftProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts new file mode 100644 index 0000000000..c979bed582 --- /dev/null +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { MicrosoftAuthProvider } from './provider'; +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +import { OAuthResult } from '../../lib/oauth'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; + +const mockFrameHandler = (jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown) as jest.MockedFunction< + () => Promise<{ result: OAuthResult; privateInfo: any }> +>; + +describe('createMicrosoftProvider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new MicrosoftAuthProvider({ + logger: getVoidLogger(), + catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient, + tokenIssuer: (tokenIssuer as unknown) as TokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + picture: 'http://microsoft.com/lols', + }, + }), + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + }); + + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + emails: [ + { + type: 'work', + value: 'conrad@example.com', + }, + ], + 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 { response } = await provider.handler({} as any); + expect(response).toEqual({ + providerInfo: { + accessToken: 'accessToken', + expiresInSeconds: 123, + idToken: 'idToken', + scope: 'scope', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + picture: 'http://microsoft.com/lols', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 86c7d3a3a4..f97512cda5 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -17,45 +17,65 @@ import express from 'express'; import passport from 'passport'; import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; - +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthResult, + OAuthStartRequest, +} from '../../lib/oauth'; +import { + executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, executeRefreshTokenStrategy, makeProfileInfo, - executeFetchUserProfileStrategy, PassportDoneCallback, } from '../../lib/passport'; - -import { RedirectInfo, AuthProviderFactory } from '../types'; - import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, - encodeState, - OAuthRefreshRequest, - OAuthResult, -} from '../../lib/oauth'; - + AuthProviderFactory, + AuthHandler, + RedirectInfo, + SignInResolver, +} from '../types'; +import { Logger } from 'winston'; import got from 'got'; type PrivateInfo = { refreshToken: string; }; -export type MicrosoftAuthProviderOptions = OAuthProviderOptions & { +type Options = OAuthProviderOptions & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; authorizationUrl?: string; tokenUrl?: string; }; export class MicrosoftAuthProvider implements OAuthHandlers { private readonly _strategy: MicrosoftStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + + constructor(options: Options) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.logger = options.logger; + this.catalogIdentityClient = options.catalogIdentityClient; - constructor(options: MicrosoftAuthProviderOptions) { this._strategy = new MicrosoftStrategy( { clientID: options.clientId, @@ -92,32 +112,10 @@ export class MicrosoftAuthProvider implements OAuthHandlers { PrivateInfo >(req, this._strategy); - try { - const photoUrl = await this.getUserPhoto(result.accessToken); - - const profile = makeProfileInfo( - { - ...result.fullProfile, - photos: photoUrl ? [{ value: photoUrl }] : undefined, - }, - result.params.id_token, - ); - - return { - response: await this.populateIdentity({ - profile, - providerInfo: { - idToken: result.params.id_token, - accessToken: result.accessToken, - scope: result.params.scope, - expiresInSeconds: result.params.expires_in, - }, - }), - refreshToken: privateInfo.refreshToken, - }; - } catch (error) { - throw new Error(`Error processing auth response: ${error}`); - } + return { + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, + }; } async refresh(req: OAuthRefreshRequest): Promise { @@ -131,21 +129,50 @@ export class MicrosoftAuthProvider implements OAuthHandlers { this._strategy, accessToken, ); - const profile = makeProfileInfo(fullProfile, params.id_token); - const photo = await this.getUserPhoto(accessToken); - if (photo) { - profile.picture = photo; - } - return this.populateIdentity({ + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.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); + + const response: OAuthResponse = { providerInfo: { - accessToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, }, profile, - }); + }; + + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + } + + return response; } private getUserPhoto(accessToken: string): Promise { @@ -165,7 +192,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { resolve(photoURL); }) .catch(error => { - console.log( + this.logger.warn( `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, ); // User profile photo is optional, ignore errors and resolve undefined @@ -173,29 +200,94 @@ export class MicrosoftAuthProvider implements OAuthHandlers { }); }); } - - private async populateIdentity( - response: OAuthResponse, - ): Promise { - const { profile } = response; - - if (!profile.email) { - throw new Error('Microsoft profile contained no email'); - } - - // Like Google implementation, setting this to local part of email for now - const id = profile.email.split('@')[0]; - - return { ...response, backstageIdentity: { id } }; - } } -export type MicrosoftProviderOptions = {}; +export const microsoftEmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'microsoft.com/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; + +export const microsoftDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Profile contained no email'); + } + + let userId: string; + try { + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'microsoft.com/email': profile.email, + }, + }); + userId = entity.metadata.name; + } catch (error) { + ctx.logger.warn( + `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`, + ); + userId = profile.email.split('@')[0]; + } + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, token }; +}; + +export type MicrosoftProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + /** + * Maps an auth result to a Backstage identity for the user. + * + * Set to `'email'` to use the default email-based sign in resolver, which will search + * the catalog for a single user entity that has a matching `microsoft.com/email` annotation. + */ + signIn?: { + resolver?: SignInResolver; + }; +}; export const createMicrosoftProvider = ( - _options?: MicrosoftProviderOptions, + options?: MicrosoftProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -205,12 +297,38 @@ export const createMicrosoftProvider = ( const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolverFn = + options?.signIn?.resolver ?? microsoftDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new MicrosoftAuthProvider({ clientId, clientSecret, callbackUrl, authorizationUrl, tokenUrl, + authHandler, + signInResolver, + catalogIdentityClient, + logger, + tokenIssuer, }); return OAuthAdapter.fromConfig(globalConfig, provider, {