diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts index c1270a71dc..57c3770b23 100644 --- a/plugins/auth-backend/src/providers/github/provider.test.ts +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -15,9 +15,13 @@ */ import { Profile as PassportProfile } from 'passport'; -import { GithubAuthProvider } from './provider'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { GithubAuthProvider, githubDefaultSignInResolver } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { OAuthResult } from '../../lib/oauth'; +import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; const mockFrameHandler = jest.spyOn( helpers, @@ -25,11 +29,28 @@ const mockFrameHandler = jest.spyOn( ) as unknown as jest.MockedFunction< () => Promise<{ result: Omit & { params: { scope: string } }; + privateInfo: { refreshToken?: string }; }> >; describe('GithubAuthProvider', () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + const provider = new GithubAuthProvider({ + logger: getVoidLogger(), + catalogIdentityClient: + catalogIdentityClient as unknown as CatalogIdentityClient, + tokenIssuer: tokenIssuer as unknown as TokenIssuer, + signInResolver: githubDefaultSignInResolver, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), callbackUrl: 'mock', clientId: 'mock', clientSecret: 'mock', @@ -80,6 +101,7 @@ describe('GithubAuthProvider', () => { mockFrameHandler.mockResolvedValueOnce({ result: { fullProfile, accessToken, params }, + privateInfo: {}, }); const { response } = await provider.handler({} as any); expect(response).toEqual(expected); @@ -124,6 +146,7 @@ describe('GithubAuthProvider', () => { mockFrameHandler.mockResolvedValueOnce({ result: { fullProfile, accessToken, params }, + privateInfo: {}, }); const { response } = await provider.handler({} as any); expect(response).toEqual(expected); @@ -167,6 +190,7 @@ describe('GithubAuthProvider', () => { mockFrameHandler.mockResolvedValueOnce({ result: { fullProfile, accessToken, params }, + privateInfo: {}, }); const { response } = await provider.handler({} as any); expect(response).toEqual(expected); @@ -210,6 +234,7 @@ describe('GithubAuthProvider', () => { mockFrameHandler.mockResolvedValueOnce({ result: { fullProfile, accessToken, params }, + privateInfo: {}, }); const { response } = await provider.handler({} as any); expect(response).toEqual(expected); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 62f2534601..f69233c03e 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -15,14 +15,22 @@ */ import express from 'express'; +import { Logger } from 'winston'; import { Strategy as GithubStrategy } from 'passport-github2'; import { + executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, + executeRefreshTokenStrategy, makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + RedirectInfo, + AuthProviderFactory, + AuthHandler, + SignInResolver, +} from '../types'; import { OAuthAdapter, OAuthProviderOptions, @@ -31,18 +39,41 @@ import { OAuthStartRequest, encodeState, OAuthResult, + OAuthRefreshRequest, + OAuthResponse, } from '../../lib/oauth'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; + +type PrivateInfo = { + refreshToken?: string; +}; export type GithubAuthProviderOptions = OAuthProviderOptions & { tokenUrl?: string; userProfileUrl?: string; authorizationUrl?: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class GithubAuthProvider implements OAuthHandlers { private readonly _strategy: GithubStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: GithubAuthProviderOptions) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new GithubStrategy( { clientID: options.clientId, @@ -54,12 +85,12 @@ export class GithubAuthProvider implements OAuthHandlers { }, ( accessToken: any, - _refreshToken: any, + refreshToken: any, params: any, fullProfile: any, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { - done(undefined, { fullProfile, params, accessToken }); + done(undefined, { fullProfile, params, accessToken }, { refreshToken }); }, ); } @@ -72,42 +103,112 @@ export class GithubAuthProvider implements OAuthHandlers { } async handler(req: express.Request) { - const { - result: { fullProfile, accessToken, params }, - } = await executeFrameHandlerStrategy(req, this._strategy); - - const profile = makeProfileInfo( - { - ...fullProfile, - id: fullProfile.username || fullProfile.id, - displayName: - fullProfile.displayName || fullProfile.username || fullProfile.id, - }, - params.id_token, - ); + const { result, privateInfo } = await executeFrameHandlerStrategy< + OAuthResult, + PrivateInfo + >(req, this._strategy); return { - response: { - profile, - providerInfo: { - accessToken, - scope: params.scope, - expiresInSeconds: params.expires_in, - }, - backstageIdentity: { - id: fullProfile.username || fullProfile.id, - }, - }, + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, }; } + + async refresh(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.scope, + ); + const fullProfile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + ); + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, + }); + } + + private async handleResult(result: OAuthResult) { + const { profile } = await this.authHandler(result); + + const response: OAuthResponse = { + providerInfo: { + 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, + }, + ); + } + + return response; + } } -export type GithubProviderOptions = {}; +export const githubDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { fullProfile } = info.result; + + const userId = fullProfile.username || fullProfile.id; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, token }; +}; + +export type GithubProviderOptions = { + /** + * 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 `google.com/email` annotation. + */ + signIn?: { + resolver?: SignInResolver; + }; +}; export const createGithubProvider = ( - _options?: GithubProviderOptions, + options?: GithubProviderOptions, ): 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'); @@ -125,6 +226,27 @@ export const createGithubProvider = ( : undefined; const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + + const signInResolverFn = + options?.signIn?.resolver ?? githubDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + const provider = new GithubAuthProvider({ clientId, clientSecret, @@ -132,10 +254,14 @@ export const createGithubProvider = ( tokenUrl, userProfileUrl, authorizationUrl, + signInResolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: true, persistScopes: true, providerId, tokenIssuer,