diff --git a/.changeset/eighty-dancers-heal.md b/.changeset/eighty-dancers-heal.md new file mode 100644 index 0000000000..9286e75d2c --- /dev/null +++ b/.changeset/eighty-dancers-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Update `auth0` and `onelogin` providers to allow for `authHandler` and `signIn.resolver` configuration. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 9ed523c678..29de38a1f4 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -47,6 +47,14 @@ export type AtlassianProviderOptions = { }; }; +// @public (undocumented) +export type Auth0ProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver: SignInResolver; + }; +}; + // @public export type AuthHandler = ( input: AuthResult, @@ -219,6 +227,11 @@ export const createAtlassianProvider: ( options?: AtlassianProviderOptions | undefined, ) => AuthProviderFactory; +// @public (undocumented) +export const createAuth0Provider: ( + options?: Auth0ProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -282,6 +295,11 @@ export const createOktaProvider: ( _options?: OktaProviderOptions | undefined, ) => AuthProviderFactory; +// @public (undocumented) +export const createOneLoginProvider: ( + options?: OneLoginProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "createOriginFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -572,6 +590,14 @@ export type OktaProviderOptions = { }; }; +// @public (undocumented) +export type OneLoginProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "postMessageResponse" 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/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index 7aa98c3e65..f14d1b8fd5 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -36,7 +36,15 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + RedirectInfo, + AuthProviderFactory, + AuthHandler, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -44,12 +52,27 @@ type PrivateInfo = { export type Auth0AuthProviderOptions = OAuthProviderOptions & { domain: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class Auth0AuthProvider implements OAuthHandlers { private readonly _strategy: Auth0Strategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: Auth0AuthProviderOptions) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new Auth0Strategy( { clientID: options.clientId, @@ -98,18 +121,8 @@ export class Auth0AuthProvider 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, }; } @@ -125,53 +138,118 @@ export class Auth0AuthProvider implements OAuthHandlers { this._strategy, accessToken, ); - const profile = makeProfileInfo(fullProfile, params.id_token); - return this.populateIdentity({ - providerInfo: { - accessToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, }); } - // 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, token: '' } }; + return response; } } -export type Auth0ProviderOptions = {}; +const defaultSignInResolver: SignInResolver = async info => { + const { profile } = info; + if (!profile.email) { + throw new Error('Profile does not contain an email'); + } + + const id = profile.email.split('@')[0]; + + return { id, token: '' }; +}; + +/** @public */ +export type Auth0ProviderOptions = { + /** + * 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. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; +}; + +/** @public */ export const createAuth0Provider = ( - _options?: Auth0ProviderOptions, + options?: Auth0ProviderOptions, ): 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'); const domain = envConfig.getString('domain'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver; + const provider = new Auth0AuthProvider({ clientId, clientSecret, callbackUrl, domain, + authHandler, + signInResolver, + tokenIssuer, + catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 9589e97265..3f71dd2c26 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +export * from './atlassian'; +export * from './auth0'; +export * from './aws-alb'; +export * from './bitbucket'; export * from './github'; export * from './gitlab'; export * from './google'; @@ -21,9 +25,7 @@ export * from './microsoft'; export * from './oauth2'; export * from './oidc'; export * from './okta'; -export * from './bitbucket'; -export * from './atlassian'; -export * from './aws-alb'; +export * from './onelogin'; export * from './saml'; export { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts index 66e8b0bfc5..4f52e31f5b 100644 --- a/plugins/auth-backend/src/providers/onelogin/provider.ts +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -36,7 +36,15 @@ import { executeFetchUserProfileStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + RedirectInfo, + AuthProviderFactory, + AuthHandler, + SignInResolver, +} from '../types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../../identity'; type PrivateInfo = { refreshToken: string; @@ -44,12 +52,27 @@ type PrivateInfo = { export type Options = OAuthProviderOptions & { issuer: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class OneLoginProvider implements OAuthHandlers { private readonly _strategy: any; + 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.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new OneLoginStrategy( { issuer: options.issuer, @@ -97,18 +120,8 @@ export class OneLoginProvider 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, }; } @@ -124,51 +137,117 @@ export class OneLoginProvider implements OAuthHandlers { this._strategy, accessToken, ); - const profile = makeProfileInfo(fullProfile, params.id_token); - return this.populateIdentity({ - providerInfo: { - accessToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, }); } - 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('OIDC profile contained no 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, token: '' } }; + return response; } } -export type OneLoginProviderOptions = {}; +const defaultSignInResolver: SignInResolver = async info => { + const { profile } = info; + if (!profile.email) { + throw new Error('OIDC profile contained no email'); + } + + const id = profile.email.split('@')[0]; + + return { id, token: '' }; +}; + +/** @public */ +export type OneLoginProviderOptions = { + /** + * 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. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; +}; + +/** @public */ export const createOneLoginProvider = ( - _options?: OneLoginProviderOptions, + options?: OneLoginProviderOptions, ): 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'); const issuer = envConfig.getString('issuer'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver; + const provider = new OneLoginProvider({ clientId, clientSecret, callbackUrl, issuer, + authHandler, + signInResolver, + tokenIssuer, + catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, {