From a971957d5023ad8f1bd5f033c0c955f6b73d74ad Mon Sep 17 00:00:00 2001 From: Pepijn Schildkamp Date: Fri, 16 Jul 2021 20:53:11 +0200 Subject: [PATCH] Add authHandler and login resolver for OAuth2 provider Signed-off-by: Pepijn Schildkamp --- plugins/auth-backend/src/providers/index.ts | 1 + .../src/providers/oauth2/index.ts | 2 +- .../src/providers/oauth2/provider.test.ts | 93 ++++++++ .../src/providers/oauth2/provider.ts | 202 ++++++++++++++---- 4 files changed, 255 insertions(+), 43 deletions(-) create mode 100644 plugins/auth-backend/src/providers/oauth2/provider.test.ts diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 6b262dcf68..576c5011af 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -16,6 +16,7 @@ export * from './google'; export * from './microsoft'; +export * from './oauth2'; export { factories as defaultAuthProviderFactories } from './factories'; // Export the minimal interface required for implementing a diff --git a/plugins/auth-backend/src/providers/oauth2/index.ts b/plugins/auth-backend/src/providers/oauth2/index.ts index d68208a148..4e2509735d 100644 --- a/plugins/auth-backend/src/providers/oauth2/index.ts +++ b/plugins/auth-backend/src/providers/oauth2/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { createOAuth2Provider } from './provider'; +export { createOAuth2Provider, oAuth2EmailSignInResolver } from './provider'; export type { OAuth2ProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.test.ts b/plugins/auth-backend/src/providers/oauth2/provider.test.ts new file mode 100644 index 0000000000..773471c42f --- /dev/null +++ b/plugins/auth-backend/src/providers/oauth2/provider.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { OAuth2AuthProvider } 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('createOAuth2Provider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new OAuth2AuthProvider({ + 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://backstage.io/lols', + }, + }), + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + authorizationUrl: 'mock', + tokenUrl: 'mock', + }); + + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + emails: [{ value: 'conrad@example.com' }], + displayName: 'Conrad', + id: 'conrad', + provider: 'oAuth2', + }, + 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://backstage.io/lols', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index f174543a63..7e09487c32 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -18,15 +18,15 @@ import express from 'express'; import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, OAuthRefreshRequest, + OAuthResponse, OAuthResult, + OAuthStartRequest, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -36,22 +36,46 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + AuthHandler, + AuthProviderFactory, + RedirectInfo, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; }; export type OAuth2AuthProviderOptions = OAuthProviderOptions & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; authorizationUrl: string; tokenUrl: string; scope?: string; + logger: Logger; }; export class OAuth2AuthProvider implements OAuthHandlers { private readonly _strategy: OAuth2Strategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: OAuth2AuthProviderOptions) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; + this._strategy = new OAuth2Strategy( { clientID: options.clientId, @@ -102,18 +126,8 @@ export class OAuth2AuthProvider implements OAuthHandlers { PrivateInfo >(req, this._strategy); - const profile = makeProfileInfo(result.fullProfile, 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, - }, - }), + response: await this.handleResult(result), refreshToken: privateInfo.refreshToken, }; } @@ -130,46 +144,124 @@ export class OAuth2AuthProvider implements OAuthHandlers { refreshToken: updatedRefreshToken, } = refreshTokenResponse; - const rawProfile = await executeFetchUserProfileStrategy( + const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, ); - const profile = makeProfileInfo(rawProfile, params.id_token); - return this.populateIdentity({ - providerInfo: { - accessToken, - refreshToken: updatedRefreshToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: updatedRefreshToken, }); } - // Use this function to grab the user profile info from the token - // Then populate the profile with it - private async populateIdentity( - response: OAuthResponse, - ): Promise { - const { profile } = response; + private async handleResult(result: OAuthResult) { + const { profile } = await this.authHandler(result); - if (!profile.email) { - throw new Error('Profile does not contain an email'); + const response: OAuthResponse = { + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + }; + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); } - const id = profile.email.split('@')[0]; - return { ...response, backstageIdentity: { id } }; + return response; } } -export type OAuth2ProviderOptions = {}; +export const oAuth2EmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('OAuth2 profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'oauth2/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; + +export const oAuth2DefaultSignInResolver: 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: { + 'oauth2/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 OAuth2ProviderOptions = { + authHandler?: AuthHandler; + + signIn?: { + resolver?: SignInResolver; + }; +}; export const createOAuth2Provider = ( - _options?: OAuth2ProviderOptions, + options?: OAuth2ProviderOptions, ): 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'); @@ -178,13 +270,39 @@ export const createOAuth2Provider = ( const tokenUrl = envConfig.getString('tokenUrl'); const scope = envConfig.getOptionalString('scope'); + 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 ?? oAuth2DefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new OAuth2AuthProvider({ clientId, clientSecret, + tokenIssuer, + catalogIdentityClient, callbackUrl, + signInResolver, + authHandler, authorizationUrl, tokenUrl, scope, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, {