diff --git a/.changeset/dry-schools-hear.md b/.changeset/dry-schools-hear.md new file mode 100644 index 0000000000..b234989271 --- /dev/null +++ b/.changeset/dry-schools-hear.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Migrated the SAML provider to implement the `authHandler` and `signIn.resolver` options. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index eb7c53bd6b..d19b2d8995 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -268,6 +268,11 @@ export function createRouter({ providerFactories, }: RouterOptions): Promise; +// @public (undocumented) +export const createSamlProvider: ( + options?: SamlProviderOptions | undefined, +) => AuthProviderFactory; + // Warning: (ae-missing-release-tag) "factories" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -568,6 +573,19 @@ export interface RouterOptions { providerFactories?: ProviderFactories; } +// @public (undocumented) +export type SamlAuthResult = { + fullProfile: any; +}; + +// @public (undocumented) +export type SamlProviderOptions = { + authHandler?: AuthHandler; + signIn?: { + resolver?: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 4a9221ce21..ad4c7be383 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -23,6 +23,7 @@ export * from './okta'; export * from './bitbucket'; export * from './atlassian'; export * from './aws-alb'; +export * from './saml'; export { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts index fa5d9d9a82..0aea660941 100644 --- a/plugins/auth-backend/src/providers/saml/index.ts +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -15,4 +15,4 @@ */ export { createSamlProvider } from './provider'; -export type { SamlProviderOptions } from './provider'; +export type { SamlProviderOptions, SamlAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index a215ebb1e0..3ab00ce401 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -26,31 +26,52 @@ import { executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; -import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; +import { + AuthProviderRouteHandlers, + AuthProviderFactory, + AuthHandler, + SignInResolver, + AuthResponse, +} from '../types'; import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity/types'; import { isError } from '@backstage/errors'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { Logger } from 'winston'; -type SamlInfo = { +/** @public */ +export type SamlAuthResult = { fullProfile: any; }; type Options = SamlConfig & { + signInResolver?: SignInResolver; + authHandler: AuthHandler; tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + logger: Logger; appUrl: string; }; export class SamlAuthProvider implements AuthProviderRouteHandlers { private readonly strategy: SamlStrategy; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; private readonly appUrl: string; constructor(options: Options) { this.appUrl = options.appUrl; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; this.tokenIssuer = options.tokenIssuer; + this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this.strategy = new SamlStrategy({ ...options }, (( fullProfile: SamlProfile, - done: PassportDoneCallback, + done: PassportDoneCallback, ) => { // TODO: There's plenty more validation and profile handling to do here, // this provider is currently only intended to validate the provider pattern @@ -71,27 +92,35 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res: express.Response, ): Promise { try { - const { result } = await executeFrameHandlerStrategy( + const { result } = await executeFrameHandlerStrategy( req, this.strategy, ); - const id = result.fullProfile.nameID; + const { profile } = await this.authHandler(result); - const idToken = await this.tokenIssuer.issueToken({ - claims: { sub: id }, - }); + const response: AuthResponse<{}> = { + profile, + providerInfo: {}, + }; + + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + } return postMessageResponse(res, this.appUrl, { type: 'authorization_response', - response: { - profile: { - email: result.fullProfile.email, - displayName: result.fullProfile.displayName, - }, - providerInfo: {}, - backstageIdentity: { id, idToken }, - }, + response, }); } catch (error) { const { name, message } = isError(error) @@ -105,23 +134,81 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } async logout(_req: express.Request, res: express.Response): Promise { - res.send('noop'); - } - - identifyEnv(): string | undefined { - return undefined; + res.end(); } } +const samlDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const id = info.result.fullProfile.nameID; + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id }, + }); + + return { id, token }; +}; + type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha512'; -export type SamlProviderOptions = {}; +/** @public */ +export type SamlProviderOptions = { + /** + * 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 createSamlProvider = ( - _options?: SamlProviderOptions, + options?: SamlProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer }) => { - const opts = { + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => { + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: { + email: fullProfile.email, + displayName: fullProfile.displayName, + }, + }); + + const signInResolverFn = + options?.signIn?.resolver ?? samlDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); + + return new SamlAuthProvider({ callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, entryPoint: config.getString('entryPoint'), logoutUrl: config.getOptionalString('logoutUrl'), @@ -140,8 +227,10 @@ export const createSamlProvider = ( tokenIssuer, appUrl: globalConfig.appUrl, - }; - - return new SamlAuthProvider(opts); + authHandler, + signInResolver, + logger, + catalogIdentityClient, + }); }; };