From 36fa32216f08edf2610096ae06a078850b7a6cb3 Mon Sep 17 00:00:00 2001 From: Hasan Ozdemir <21654050+nodify-at@users.noreply.github.com> Date: Wed, 1 Dec 2021 23:54:12 +0100 Subject: [PATCH] Auth handler and sign in resolvers for oidc provider Signed-off-by: Hasan Ozdemir <21654050+nodify-at@users.noreply.github.com> --- .changeset/lazy-foxes-build.md | 5 + plugins/auth-backend/api-report.md | 10 ++ plugins/auth-backend/src/providers/index.ts | 1 + .../auth-backend/src/providers/oidc/index.ts | 1 - .../src/providers/oidc/provider.test.ts | 23 ++- .../src/providers/oidc/provider.ts | 161 +++++++++++++----- 6 files changed, 152 insertions(+), 49 deletions(-) create mode 100644 .changeset/lazy-foxes-build.md diff --git a/.changeset/lazy-foxes-build.md b/.changeset/lazy-foxes-build.md new file mode 100644 index 0000000000..478eb1c4c2 --- /dev/null +++ b/.changeset/lazy-foxes-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added signIn and authHandler resolver for oAuth2 provider diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index eb7c53bd6b..885e31feab 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -14,7 +14,9 @@ import { Logger as Logger_2 } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; +import { TokenSet } from 'openid-client'; import { UserEntity } from '@backstage/catalog-model'; +import { UserinfoResponse } from 'openid-client'; // Warning: (ae-missing-release-tag) "AtlassianAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -245,6 +247,14 @@ export const createOAuth2Provider: ( options?: OAuth2ProviderOptions | undefined, ) => AuthProviderFactory; +// Warning: (ae-forgotten-export) The symbol "OidcProviderOptions" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "createOidcProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const createOidcProvider: ( + options?: OidcProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createOktaProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 4a9221ce21..998320c8d5 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -19,6 +19,7 @@ export * from './gitlab'; export * from './google'; export * from './microsoft'; export * from './oauth2'; +export * from './oidc'; export * from './okta'; export * from './bitbucket'; export * from './atlassian'; diff --git a/plugins/auth-backend/src/providers/oidc/index.ts b/plugins/auth-backend/src/providers/oidc/index.ts index 8c755cd501..20d014a783 100644 --- a/plugins/auth-backend/src/providers/oidc/index.ts +++ b/plugins/auth-backend/src/providers/oidc/index.ts @@ -15,4 +15,3 @@ */ export { createOidcProvider } from './provider'; -export type { OidcProviderOptions } from './provider'; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index ac5035e9a5..da96ae1a25 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -24,7 +24,8 @@ import { setupServer } from 'msw/node'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; import { OAuthAdapter } from '../../lib/oauth'; import { AuthProviderFactoryOptions } from '../types'; -import { createOidcProvider, OidcAuthProvider } from './provider'; +import { createOidcProvider, OidcAuthProvider, Options } from './provider'; +import { getVoidLogger } from '@backstage/backend-common'; const issuerMetadata = { issuer: 'https://oidc.test', @@ -42,7 +43,23 @@ const issuerMetadata = { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; -const clientMetadata = { +const catalogIdentityClient = { + findUser: jest.fn(), +}; +const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), +}; + +const clientMetadata: Options = { + authHandler: async input => ({ + profile: { + displayName: input.userinfo.email, + }, + }), + catalogIdentityClient: catalogIdentityClient as unknown as any, + logger: getVoidLogger(), + tokenIssuer: tokenIssuer as unknown as any, callbackUrl: 'https://oidc.test/callback', clientId: 'testclientid', clientSecret: 'testclientsecret', @@ -160,7 +177,7 @@ describe('OidcAuthProvider', () => { ...clientMetadata, metadataUrl: 'https://oidc.test/.well-known/openid-configuration', }, - }); + } as any); const options = { globalConfig: { appUrl: 'https://oidc.test', diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 092ffa7099..e56dfc265b 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -16,28 +16,37 @@ import express from 'express'; import { - Issuer, Client, + Issuer, Strategy as OidcStrategy, TokenSet, UserinfoResponse, } from 'openid-client'; import { - OAuthAdapter, - OAuthProviderOptions, - OAuthHandlers, - OAuthResponse, - OAuthEnvironmentHandler, - OAuthStartRequest, encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, OAuthRefreshRequest, + OAuthResponse, + OAuthStartRequest, } from '../../lib/oauth'; import { executeFrameHandlerStrategy, executeRedirectStrategy, + makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + AuthHandler, + AuthProviderFactory, + RedirectInfo, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken?: string; @@ -58,6 +67,11 @@ export type Options = OAuthProviderOptions & { scope?: string; prompt?: string; tokenSignedResponseAlg?: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class OidcAuthProvider implements OAuthHandlers { @@ -65,10 +79,21 @@ export class OidcAuthProvider implements OAuthHandlers { private readonly scope?: string; private readonly prompt?: string; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + constructor(options: Options) { this.implementation = this.setupStrategy(options); this.scope = options.scope; this.prompt = options.prompt; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; } async start(req: OAuthStartRequest): Promise { @@ -97,19 +122,8 @@ export class OidcAuthProvider implements OAuthHandlers { result: { userinfo, tokenset }, privateInfo, } = strategyResponse; - const identityResponse = await this.populateIdentity({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - providerInfo: { - idToken: tokenset.id_token, - accessToken: tokenset.access_token || '', - scope: tokenset.scope || '', - expiresInSeconds: tokenset.expires_in, - }, - }); + + const identityResponse = await this.handleResult({ tokenset, userinfo }); return { response: identityResponse, refreshToken: privateInfo.refreshToken, @@ -123,17 +137,7 @@ export class OidcAuthProvider implements OAuthHandlers { throw new Error('Refresh failed'); } const profile = await client.userinfo(tokenset.access_token); - - return this.populateIdentity({ - providerInfo: { - accessToken: tokenset.access_token, - refreshToken: tokenset.refresh_token, - expiresInSeconds: tokenset.expires_in, - idToken: tokenset.id_token, - scope: tokenset.scope || '', - }, - profile, - }); + return this.handleResult({ tokenset, userinfo: profile }); } private async setupStrategy(options: Options): Promise { @@ -177,26 +181,70 @@ export class OidcAuthProvider implements OAuthHandlers { // 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; - - if (!profile.email) { - throw new Error('Profile does not contain an email'); + private async handleResult(result: AuthResult): Promise { + const { profile } = await this.authHandler(result); + const response: OAuthResponse = { + providerInfo: { + idToken: result.tokenset.id_token, + accessToken: result.tokenset.access_token!, + refreshToken: result.tokenset.refresh_token, + scope: result.tokenset.scope!, + expiresInSeconds: result.tokenset.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 OidcProviderOptions = {}; +export const oAuth2DefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Profile contained no email'); + } + const userId = profile.email.split('@')[0]; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + return { id: userId, token }; +}; + +export type OidcProviderOptions = { + authHandler?: AuthHandler; + + signIn?: { + resolver?: SignInResolver; + }; +}; export const createOidcProvider = ( - _options?: OidcProviderOptions, + options?: OidcProviderOptions, ): 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'); @@ -207,6 +255,24 @@ export const createOidcProvider = ( ); const scope = envConfig.getOptionalString('scope'); const prompt = envConfig.getOptionalString('prompt'); + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ userinfo, tokenset }) => ({ + profile: makeProfileInfo({ ...userinfo } as any, tokenset.id_token), // todo some required values does not exist in oidc response + }); + const signInResolverFn = + options?.signIn?.resolver ?? oAuth2DefaultSignInResolver; + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); const provider = new OidcAuthProvider({ clientId, @@ -216,6 +282,11 @@ export const createOidcProvider = ( metadataUrl, scope, prompt, + signInResolver, + authHandler, + logger, + tokenIssuer, + catalogIdentityClient, }); return OAuthAdapter.fromConfig(globalConfig, provider, {