diff --git a/.changeset/big-teachers-count.md b/.changeset/big-teachers-count.md new file mode 100644 index 0000000000..5a8c632ab5 --- /dev/null +++ b/.changeset/big-teachers-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +**DEPRECATION**: The `AuthProviderFactoryOptions` type has been deprecated, as the options are now instead inlined in the `AuthProviderFactory` type. This will make it possible to more easily introduce new options in the future without a possibly breaking change. diff --git a/.changeset/four-onions-pretend.md b/.changeset/four-onions-pretend.md new file mode 100644 index 0000000000..db38cc2178 --- /dev/null +++ b/.changeset/four-onions-pretend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +**DEPRECATION**: The `getEntityClaims` helper has been deprecated, with `getDefaultOwnershipEntityRefs` being added to replace it. diff --git a/.changeset/loud-bags-run.md b/.changeset/loud-bags-run.md new file mode 100644 index 0000000000..24d9c7d288 --- /dev/null +++ b/.changeset/loud-bags-run.md @@ -0,0 +1,63 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +**DEPRECATION**: All `createProvider` and `*SignInResolver` have been deprecated. Instead, a single `providers` object is exported which contains all built-in auth providers. + +If you have a setup that currently looks for example like this: + +```ts +import { + createRouter, + defaultAuthProviderFactories, + createGoogleProvider, + googleEmailSignInResolver, +} from '@backstage/plugin-auth-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + ...env, + providerFactories: { + ...defaultAuthProviderFactories, + google: createGoogleProvider({ + signIn: { + resolver: googleEmailSignInResolver, + }, + }), + }, + }); +} +``` + +You would migrate it to something like this: + +```ts +import { + createRouter, + providers, + defaultAuthProviderFactories, +} from '@backstage/plugin-auth-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + ...env, + providerFactories: { + ...defaultAuthProviderFactories, + google: providers.google.create({ + signIn: { + resolver: + providers.google.resolvers.emailMatchingUserEntityAnnotation(), + }, + }), + }, + }); +} +``` diff --git a/.changeset/neat-countries-hammer.md b/.changeset/neat-countries-hammer.md new file mode 100644 index 0000000000..a9b39f44cf --- /dev/null +++ b/.changeset/neat-countries-hammer.md @@ -0,0 +1,58 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +**DEPRECATION** The `AuthResolverContext` has received a number of changes, which is the context used by auth handlers and sign-in resolvers. + +The following fields deprecated: `logger`, `tokenIssuer`, `catalogIdentityClient`. If you need to access the `logger`, you can do so through a closure instead. The `tokenIssuer` has been replaced with an `issueToken` method, which is available directory on the context. The `catalogIdentityClient` has been replaced by the `signInWithCatalogUser` method, as well as the lower level `findCatalogUser` method and `getDefaultOwnershipEntityRefs` helper. + +It should be possible to migrate most sign-in resolvers to more or less only use `signInWithCatalogUser`, for example an email lookup resolver like this one: + +```ts +async ({ profile }, ctx) => { + if (!profile.email) { + throw new Error('Profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'acme.org/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; +``` + +can be migrated to the following: + +```ts +async ({ profile }, ctx) => { + if (!profile.email) { + throw new Error('Profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'acme.org/email': profile.email, + }, + }); +}; +``` + +While a direct entity name lookup using a user ID might look like this: + +```ts +async ({ result: { fullProfile } }, ctx) => { + return ctx.signInWithCatalogUser({ + entityRef: { + name: fullProfile.userId, + }, + }); +}; +``` + +If you want more control over the way that users are looked up, ownership is assigned, or tokens are issued, you can use a combination of the `findCatalogUser`, `getDefaultOwnershipEntityRefs`, and `issueToken` instead. diff --git a/.changeset/six-ravens-behave.md b/.changeset/six-ravens-behave.md new file mode 100644 index 0000000000..ad3ee8815d --- /dev/null +++ b/.changeset/six-ravens-behave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +**BREAKING**: All auth providers have had their default sign-in resolvers removed. This means that if you want to use a particular provider for sign-in, you must provide an explicit sign-in resolver. For more information on how to configure sign-in resolvers, see the [sign-in resolver documentation](https://backstage.io/docs/auth/identity-resolver). diff --git a/.changeset/sour-kiwis-impress.md b/.changeset/sour-kiwis-impress.md new file mode 100644 index 0000000000..44c7e50fe1 --- /dev/null +++ b/.changeset/sour-kiwis-impress.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added exports of the following types: `AuthProviderConfig`, `StateEncoder`, `TokenParams`, `AwsAlbResult`. diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index da8eedcabc..8a60b4b4bd 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-auth-backend'; +import { + createRouter, + providers, + defaultAuthProviderFactories, +} from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -27,5 +31,64 @@ export default async function createPlugin( database: env.database, discovery: env.discovery, tokenManager: env.tokenManager, + providerFactories: { + ...defaultAuthProviderFactories, + + // NOTE: DO NOT add this many resolvers in your own instance! + // It is important that each real user always gets resolved to + // the same sign-in identity. The code below will not do that. + // It is here for demo purposes only. + github: providers.github.create({ + signIn: { + resolver: providers.github.resolvers.usernameMatchingUserEntityName(), + }, + }), + gitlab: providers.gitlab.create({ + signIn: { + async resolver({ result: { fullProfile } }, ctx) { + return ctx.signInWithCatalogUser({ + entityRef: { + name: fullProfile.id, + }, + }); + }, + }, + }), + microsoft: providers.microsoft.create({ + signIn: { + resolver: + providers.microsoft.resolvers.emailMatchingUserEntityAnnotation(), + }, + }), + google: providers.google.create({ + signIn: { + resolver: + providers.google.resolvers.emailLocalPartMatchingUserEntityName(), + }, + }), + okta: providers.okta.create({ + signIn: { + resolver: + providers.okta.resolvers.emailMatchingUserEntityAnnotation(), + }, + }), + bitbucket: providers.bitbucket.create({ + signIn: { + resolver: + providers.bitbucket.resolvers.usernameMatchingUserEntityAnnotation(), + }, + }), + onelogin: providers.onelogin.create({ + signIn: { + async resolver({ result: { fullProfile } }, ctx) { + return ctx.signInWithCatalogUser({ + entityRef: { + name: fullProfile.id, + }, + }); + }, + }, + }), + }, }); } diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 4227f1db0a..a01a309dd4 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -9,7 +9,9 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { BackstageSignInResult } from '@backstage/plugin-auth-node'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; import express from 'express'; +import { GetEntitiesRequest } from '@backstage/catalog-client'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -42,9 +44,7 @@ export class AtlassianAuthProvider implements OAuthHandlers { start(req: OAuthStartRequest): Promise; } -// Warning: (ae-missing-release-tag) "AtlassianProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type AtlassianProviderOptions = { authHandler?: AuthHandler; signIn?: { @@ -52,7 +52,7 @@ export type AtlassianProviderOptions = { }; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type Auth0ProviderOptions = { authHandler?: AuthHandler; signIn?: { @@ -71,16 +71,32 @@ export type AuthHandlerResult = { profile: ProfileInfo; }; +// @public (undocumented) +export type AuthProviderConfig = { + baseUrl: string; + appUrl: string; + isOriginAllowed: (origin: string) => boolean; + cookieConfigurer?: CookieConfigurer; +}; + // Warning: (ae-missing-release-tag) "AuthProviderFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type AuthProviderFactory = ( - options: AuthProviderFactoryOptions, -) => AuthProviderRouteHandlers; +export type AuthProviderFactory = (options: { + providerId: string; + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + resolverContext: AuthResolverContext; + tokenManager: TokenManager; + tokenIssuer: TokenIssuer; + discovery: PluginEndpointDiscovery; + catalogApi: CatalogApi; +}) => AuthProviderRouteHandlers; // Warning: (ae-missing-release-tag) "AuthProviderFactoryOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -102,15 +118,40 @@ export interface AuthProviderRouteHandlers { start(req: express.Request, res: express.Response): Promise; } +// @public +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: Exclude; + }; + // @public export type AuthResolverContext = { + logger: Logger; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + issueToken(params: TokenParams): Promise<{ + token: string; + }>; + findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{ + entity: Entity; + }>; + signInWithCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise; }; -// Warning: (ae-missing-release-tag) "AuthResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type AuthResponse = { providerInfo: ProviderInfo; @@ -118,9 +159,7 @@ export type AuthResponse = { backstageIdentity?: BackstageIdentityResponse; }; -// Warning: (ae-missing-release-tag) "AwsAlbProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type AwsAlbProviderOptions = { authHandler?: AuthHandler; signIn: { @@ -128,6 +167,13 @@ export type AwsAlbProviderOptions = { }; }; +// @public (undocumented) +export type AwsAlbResult = { + fullProfile: Profile; + expiresInSeconds?: number; + accessToken: string; +}; + // Warning: (ae-missing-release-tag) "BitbucketOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -159,9 +205,7 @@ export type BitbucketPassportProfile = Profile & { }; }; -// Warning: (ae-missing-release-tag) "BitbucketProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type BitbucketProviderOptions = { authHandler?: AuthHandler; signIn?: { @@ -169,15 +213,11 @@ export type BitbucketProviderOptions = { }; }; -// Warning: (ae-missing-release-tag) "bitbucketUserIdSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const bitbucketUserIdSignInResolver: SignInResolver; +// @public @deprecated (undocumented) +export const bitbucketUserIdSignInResolver: SignInResolver; -// Warning: (ae-missing-release-tag) "bitbucketUsernameSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const bitbucketUsernameSignInResolver: SignInResolver; +// @public @deprecated (undocumented) +export const bitbucketUsernameSignInResolver: SignInResolver; // Warning: (ae-missing-release-tag) "CatalogIdentityClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -201,94 +241,187 @@ export type CookieConfigurer = (ctx: { secure: boolean; }; -// Warning: (ae-missing-release-tag) "createAtlassianProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createAtlassianProvider: ( - options?: AtlassianProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// @public (undocumented) +// @public @deprecated (undocumented) export const createAuth0Provider: ( - options?: Auth0ProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | 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) +// @public @deprecated (undocumented) export const createAwsAlbProvider: ( - options?: AwsAlbProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn: { + resolver: SignInResolver; + }; + } + | undefined, ) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createBitbucketProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createBitbucketProvider: ( - options?: BitbucketProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// @public -export function createGcpIapProvider( - options: GcpIapProviderOptions, -): AuthProviderFactory; +// @public @deprecated (undocumented) +export const createGcpIapProvider: (options: { + authHandler?: AuthHandler | undefined; + signIn: { + resolver: SignInResolver; + }; +}) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createGithubProvider: ( - options?: GithubProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + stateEncoder?: StateEncoder | undefined; + } + | undefined, ) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createGitlabProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createGitlabProvider: ( - options?: GitlabProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createGoogleProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createGoogleProvider: ( - options?: GoogleProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createMicrosoftProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createMicrosoftProvider: ( - options?: MicrosoftProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// Warning: (ae-missing-release-tag) "createOAuth2Provider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const createOAuth2Provider: ( - options?: OAuth2ProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// @public -export const createOauth2ProxyProvider: ( - options: Oauth2ProxyProviderOptions, -) => AuthProviderFactory; +// @public @deprecated (undocumented) +export const createOauth2ProxyProvider: (options: { + authHandler: AuthHandler>; + signIn: { + resolver: SignInResolver>; + }; +}) => AuthProviderFactory; -// 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) +// @public @deprecated (undocumented) export const createOidcProvider: ( - options?: OidcProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | 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) +// @public @deprecated (undocumented) export const createOktaProvider: ( - _options?: OktaProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, ) => AuthProviderFactory; -// @public (undocumented) +// @public @deprecated (undocumented) export const createOneLoginProvider: ( - options?: OneLoginProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | 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) @@ -301,9 +434,18 @@ export function createOriginFilter(config: Config): (origin: string) => boolean; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; -// @public (undocumented) +// @public @deprecated (undocumented) export const createSamlProvider: ( - options?: SamlProviderOptions | undefined, + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | 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) @@ -323,7 +465,7 @@ export const encodeState: (state: OAuthState) => string; // @public (undocumented) export const ensuresXRequestedWith: (req: express.Request) => boolean; -// @public +// @public @deprecated (undocumented) export type GcpIapProviderOptions = { authHandler?: AuthHandler; signIn: { @@ -343,10 +485,12 @@ export type GcpIapTokenInfo = { [key: string]: JsonValue; }; -// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts +// @public +export function getDefaultOwnershipEntityRefs(entity: Entity): string[]; + // Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export function getEntityClaims(entity: UserEntity): TokenParams['claims']; // Warning: (ae-missing-release-tag) "GithubOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -363,68 +507,54 @@ export type GithubOAuthResult = { refreshToken?: string; }; -// Warning: (ae-missing-release-tag) "GithubProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type GithubProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; stateEncoder?: StateEncoder; }; -// Warning: (ae-missing-release-tag) "GitlabProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type GitlabProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// Warning: (ae-missing-release-tag) "googleEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const googleEmailSignInResolver: SignInResolver; -// Warning: (ae-missing-release-tag) "GoogleProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type GoogleProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// Warning: (ae-missing-release-tag) "microsoftEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const microsoftEmailSignInResolver: SignInResolver; -// Warning: (ae-missing-release-tag) "MicrosoftProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type MicrosoftProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// Warning: (ae-missing-release-tag) "OAuth2ProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type OAuth2ProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// @public +// @public @deprecated (undocumented) export type Oauth2ProxyProviderOptions = { authHandler: AuthHandler>; signIn: { @@ -574,30 +704,26 @@ export type OidcAuthResult = { userinfo: UserinfoResponse; }; -// @public +// @public @deprecated (undocumented) export type OidcProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// Warning: (ae-missing-release-tag) "oktaEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export const oktaEmailSignInResolver: SignInResolver; -// Warning: (ae-missing-release-tag) "OktaProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public @deprecated (undocumented) export type OktaProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type OneLoginProviderOptions = { authHandler?: AuthHandler; signIn?: { @@ -626,6 +752,236 @@ export type ProfileInfo = { picture?: string; }; +// @public +export const providers: Readonly<{ + atlassian: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + auth0: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + awsAlb: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn: { + resolver: SignInResolver; + }; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + bitbucket: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + usernameMatchingUserEntityAnnotation(): SignInResolver; + userIdMatchingUserEntityAnnotation(): SignInResolver; + }>; + }>; + gcpIap: Readonly<{ + create: (options: { + authHandler?: AuthHandler | undefined; + signIn: { + resolver: SignInResolver; + }; + }) => AuthProviderFactory; + resolvers: never; + }>; + github: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + stateEncoder?: StateEncoder | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + usernameMatchingUserEntityName: () => SignInResolver; + }>; + }>; + gitlab: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + google: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + emailLocalPartMatchingUserEntityName: () => SignInResolver; + emailMatchingUserEntityAnnotation(): SignInResolver; + }>; + }>; + microsoft: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + emailMatchingUserEntityAnnotation(): SignInResolver; + }>; + }>; + oauth2: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + oauth2Proxy: Readonly<{ + create: (options: { + authHandler: AuthHandler>; + signIn: { + resolver: SignInResolver>; + }; + }) => AuthProviderFactory; + resolvers: never; + }>; + oidc: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + okta: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + emailMatchingUserEntityAnnotation(): SignInResolver; + }>; + }>; + onelogin: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; + saml: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + nameIdMatchingUserEntityName(): SignInResolver; + }>; + }>; +}>; + // Warning: (ae-missing-release-tag) "readState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -656,11 +1012,14 @@ export type SamlAuthResult = { fullProfile: any; }; -// @public (undocumented) +// @public @deprecated (undocumented) +export const samlNameIdEntityNameSignInResolver: SignInResolver; + +// @public @deprecated (undocumented) export type SamlProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; @@ -676,9 +1035,12 @@ export type SignInResolver = ( context: AuthResolverContext, ) => Promise; -// Warning: (ae-missing-release-tag) "TokenIssuer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public +// @public (undocumented) +export type StateEncoder = (req: OAuthStartRequest) => Promise<{ + encodedState: string; +}>; + +// @public @deprecated export type TokenIssuer = { issueToken(params: TokenParams): Promise; listPublicKeys(): Promise<{ @@ -686,6 +1048,14 @@ export type TokenIssuer = { }>; }; +// @public +export type TokenParams = { + claims: { + sub: string; + ent?: string[]; + }; +}; + // Warning: (ae-missing-release-tag) "verifyNonce" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -706,8 +1076,5 @@ export type WebMessageResponse = // Warnings were encountered during analysis: // -// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts -// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts -// src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:118:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts +// src/identity/types.d.ts:38:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index faafd2b68e..5a350284e5 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -22,7 +22,11 @@ export interface AnyJWK extends Record { kty: string; } -/** Parameters used to issue new ID Tokens */ +/** + * Parameters used to issue new ID Tokens + * + * @public + */ export type TokenParams = { /** The claims that will be embedded within the token */ claims: { @@ -33,8 +37,12 @@ export type TokenParams = { }; }; +// TODO(Rugvip): This should at least be made internal /** * A TokenIssuer is able to issue verifiable ID Tokens on demand. + * + * @public + * @deprecated This interface is deprecated and will be removed in a future release. */ export type TokenIssuer = { /** diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index d0cade087e..d51cb3f1eb 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -21,7 +21,7 @@ */ export * from './service/router'; -export type { TokenIssuer } from './identity'; +export type { TokenIssuer, TokenParams } from './identity'; export * from './providers'; // flow package provides 2 functions @@ -32,3 +32,5 @@ export * from './lib/flow'; export * from './lib/oauth'; export * from './lib/catalog'; + +export { getDefaultOwnershipEntityRefs } from './lib/resolvers'; diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index db9b38e2a6..3ff1b5da05 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -21,6 +21,9 @@ import { } from '@backstage/catalog-model'; import { TokenParams } from '../../identity'; +/** + * @deprecated use {@link getDefaultOwnershipEntityRefs} instead + */ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { const userRef = stringifyEntityRef(entity); diff --git a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts new file mode 100644 index 0000000000..1a1fdd7a36 --- /dev/null +++ b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2022 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 { TokenManager } from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import { + DEFAULT_NAMESPACE, + Entity, + parseEntityRef, + RELATION_MEMBER_OF, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../..'; +import { TokenParams } from '../../identity'; +import { AuthResolverContext } from '../../providers'; +import { AuthResolverCatalogUserQuery } from '../../providers/types'; +import { CatalogIdentityClient } from '../catalog'; + +/** + * Uses the default ownership resolution logic to return an array + * of entity refs that the provided entity claims ownership through. + * + * A reference to the entity itself will also be included in the returned array. + * + * @public + */ +export function getDefaultOwnershipEntityRefs(entity: Entity) { + const membershipRefs = + entity.relations + ?.filter( + r => r.type === RELATION_MEMBER_OF && r.targetRef.startsWith('group:'), + ) + .map(r => r.targetRef) ?? []; + + return Array.from(new Set([stringifyEntityRef(entity), ...membershipRefs])); +} + +/** + * @internal + */ +export class CatalogAuthResolverContext implements AuthResolverContext { + static create(options: { + logger: Logger; + catalogApi: CatalogApi; + tokenIssuer: TokenIssuer; + tokenManager: TokenManager; + }): CatalogAuthResolverContext { + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi: options.catalogApi, + tokenManager: options.tokenManager, + }); + return new CatalogAuthResolverContext( + options.logger, + options.tokenIssuer, + catalogIdentityClient, + options.catalogApi, + options.tokenManager, + ); + } + + private constructor( + public readonly logger: Logger, + public readonly tokenIssuer: TokenIssuer, + public readonly catalogIdentityClient: CatalogIdentityClient, + private readonly catalogApi: CatalogApi, + private readonly tokenManager: TokenManager, + ) {} + + async issueToken(params: TokenParams) { + const token = await this.tokenIssuer.issueToken(params); + return { token }; + } + + async findCatalogUser(query: AuthResolverCatalogUserQuery) { + let result: Entity[] | Entity | undefined = undefined; + const { token } = await this.tokenManager.getToken(); + + if ('entityRef' in query) { + const entityRef = parseEntityRef(query.entityRef, { + defaultKind: 'User', + defaultNamespace: DEFAULT_NAMESPACE, + }); + result = await this.catalogApi.getEntityByRef(entityRef, { token }); + } else if ('annotations' in query) { + const filter: Record = { + kind: 'user', + }; + for (const [key, value] of Object.entries(query.annotations)) { + filter[`metadata.annotations.${key}`] = value; + } + const res = await this.catalogApi.getEntities({ filter }, { token }); + result = res.items; + } else if ('filter' in query) { + const res = await this.catalogApi.getEntities( + { filter: query.filter }, + { token }, + ); + result = res.items; + } else { + throw new InputError('Invalid user lookup query'); + } + + if (Array.isArray(result)) { + if (result.length > 1) { + throw new ConflictError('User lookup resulted in multiple matches'); + } + result = result[0]; + } + if (!result) { + throw new NotFoundError('User not found'); + } + + return { entity: result }; + } + + async signInWithCatalogUser(query: AuthResolverCatalogUserQuery) { + const { entity } = await this.findCatalogUser(query); + const ownershipRefs = getDefaultOwnershipEntityRefs(entity); + + const token = await this.tokenIssuer.issueToken({ + claims: { + sub: stringifyEntityRef(entity), + ent: ownershipRefs, + }, + }); + return { token }; + } +} diff --git a/plugins/auth-backend/src/lib/resolvers/index.ts b/plugins/auth-backend/src/lib/resolvers/index.ts new file mode 100644 index 0000000000..c1ca59cb25 --- /dev/null +++ b/plugins/auth-backend/src/lib/resolvers/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 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. + */ + +export { + CatalogAuthResolverContext, + getDefaultOwnershipEntityRefs, +} from './CatalogAuthResolverContext'; diff --git a/plugins/auth-backend/src/providers/atlassian/provider.test.ts b/plugins/auth-backend/src/providers/atlassian/provider.test.ts index 29241dd17b..c41a18aa22 100644 --- a/plugins/auth-backend/src/providers/atlassian/provider.test.ts +++ b/plugins/auth-backend/src/providers/atlassian/provider.test.ts @@ -16,11 +16,9 @@ import { AtlassianAuthProvider } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { getVoidLogger } from '@backstage/backend-common'; -import { TokenIssuer } from '../../identity'; -import { CatalogIdentityClient } from '../../lib/catalog'; import { OAuthResult } from '../../lib/oauth'; import { PassportProfile } from '../../lib/passport/types'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -28,19 +26,8 @@ const mockFrameHandler = jest.spyOn( ) as unknown as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>; describe('createAtlassianProvider', () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new AtlassianAuthProvider({ - logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/atlassian/provider.ts b/plugins/auth-backend/src/providers/atlassian/provider.ts index 26eea420fc..2aa85dab96 100644 --- a/plugins/auth-backend/src/providers/atlassian/provider.ts +++ b/plugins/auth-backend/src/providers/atlassian/provider.ts @@ -37,22 +37,18 @@ import { } from '../../lib/passport'; import { AuthHandler, - AuthProviderFactory, + AuthResolverContext, RedirectInfo, SignInResolver, } from '../types'; import express from 'express'; -import { TokenIssuer } from '../../identity'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { Logger } from 'winston'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; export type AtlassianAuthProviderOptions = OAuthProviderOptions & { scopes: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; export const atlassianDefaultAuthHandler: AuthHandler = async ({ @@ -66,14 +62,10 @@ export class AtlassianAuthProvider implements OAuthHandlers { private readonly _strategy: AtlassianStrategy; private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: AtlassianAuthProviderOptions) { - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; - this.tokenIssuer = options.tokenIssuer; + this.resolverContext = options.resolverContext; this.authHandler = options.authHandler; this.signInResolver = options.signInResolver; @@ -120,12 +112,7 @@ export class AtlassianAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult): Promise { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -143,7 +130,7 @@ export class AtlassianAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -174,6 +161,10 @@ export class AtlassianAuthProvider implements OAuthHandlers { } } +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type AtlassianProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -189,51 +180,59 @@ export type AtlassianProviderOptions = { }; }; -export const createAtlassianProvider = ( - options?: AtlassianProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const scopes = envConfig.getString('scopes'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for atlassian auth + * + * @public + */ +export const atlassian = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const scopes = envConfig.getString('scopes'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const authHandler: AuthHandler = + options?.authHandler ?? atlassianDefaultAuthHandler; + + const provider = new AtlassianAuthProvider({ + clientId, + clientSecret, + scopes, + callbackUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + providerId, + callbackUrl, + }); }); + }, +}); - const authHandler: AuthHandler = - options?.authHandler ?? atlassianDefaultAuthHandler; - - const provider = new AtlassianAuthProvider({ - clientId, - clientSecret, - scopes, - callbackUrl, - authHandler, - signInResolver: options?.signIn?.resolver, - catalogIdentityClient, - logger, - tokenIssuer, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.atlassian.create` instead + */ +export const createAtlassianProvider = atlassian.create; diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index fbd57c0b76..b4446caa95 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -38,13 +38,11 @@ import { } from '../../lib/passport'; import { RedirectInfo, - AuthProviderFactory, AuthHandler, SignInResolver, + AuthResolverContext, } from '../types'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity'; -import { Logger } from 'winston'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; type PrivateInfo = { refreshToken: string; @@ -54,25 +52,19 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & { domain: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; 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; + private readonly resolverContext: AuthResolverContext; constructor(options: Auth0AuthProviderOptions) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this._strategy = new Auth0Strategy( { clientID: options.clientId, @@ -149,12 +141,7 @@ export class Auth0AuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -172,7 +159,7 @@ export class Auth0AuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -180,19 +167,10 @@ export class Auth0AuthProvider implements OAuthHandlers { } } -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 */ +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type Auth0ProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -211,58 +189,68 @@ export type Auth0ProviderOptions = { }; }; -/** @public */ -export const createAuth0Provider = ( - options?: Auth0ProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const domain = envConfig.getString('domain'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for auth0 auth + * + * @public + */ +export const auth0 = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const domain = envConfig.getString('domain'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const signInResolver = options?.signIn?.resolver; + + const provider = new Auth0AuthProvider({ + clientId, + clientSecret, + callbackUrl, + domain, + authHandler, + signInResolver, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + callbackUrl, + }); }); + }, +}); - 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, { - disableRefresh: true, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.auth0.create` instead. + */ +export const createAuth0Provider = auth0.create; diff --git a/plugins/auth-backend/src/providers/aws-alb/index.ts b/plugins/auth-backend/src/providers/aws-alb/index.ts index a8130b3575..710d86ca3d 100644 --- a/plugins/auth-backend/src/providers/aws-alb/index.ts +++ b/plugins/auth-backend/src/providers/aws-alb/index.ts @@ -15,4 +15,4 @@ */ export { createAwsAlbProvider } from './provider'; -export type { AwsAlbProviderOptions } from './provider'; +export type { AwsAlbProviderOptions, AwsAlbResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index eb801fdd46..a917858743 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; + import express from 'express'; import { JWT } from 'jose'; - import { ALB_ACCESS_TOKEN_HEADER, ALB_JWT_HEADER, AwsAlbAuthProvider, } from './provider'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; import { makeProfileInfo } from '../../lib/passport'; +import { AuthResolverContext } from '../types'; +import { AuthenticationError } from '@backstage/errors'; const jwtMock = JWT as jest.Mocked; @@ -66,16 +65,6 @@ beforeEach(() => { }); describe('AwsAlbAuthProvider', () => { - const tokenIssuer: TokenIssuer = { - listPublicKeys: jest.fn(), - async issueToken(params) { - return `token-for-${params.claims.sub}`; - }, - }; - const catalogIdentityClient: CatalogIdentityClient = { - findUser: jest.fn(), - } as unknown as CatalogIdentityClient; - const mockRequest = { header: jest.fn(name => { if (name === ALB_JWT_HEADER) { @@ -115,9 +104,7 @@ describe('AwsAlbAuthProvider', () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -161,9 +148,7 @@ describe('AwsAlbAuthProvider', () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -172,18 +157,16 @@ describe('AwsAlbAuthProvider', () => { }, }); - await provider.refresh(mockRequestWithoutAccessToken, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect( + provider.refresh(mockRequestWithoutAccessToken, mockResponse), + ).rejects.toThrow(AuthenticationError); }); it('JWT is missing', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -192,18 +175,16 @@ describe('AwsAlbAuthProvider', () => { }, }); - await provider.refresh(mockRequestWithoutJwt, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect( + provider.refresh(mockRequestWithoutJwt, mockResponse), + ).rejects.toThrow(AuthenticationError); }); it('JWT is invalid', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -216,18 +197,16 @@ describe('AwsAlbAuthProvider', () => { throw new Error('bad JWT'); }); - await provider.refresh(mockRequest, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('issuer is missing', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -238,17 +217,16 @@ describe('AwsAlbAuthProvider', () => { jwtMock.verify.mockReturnValueOnce({}); - await provider.refresh(mockRequest, mockResponse); - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('issuer is invalid', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -261,17 +239,16 @@ describe('AwsAlbAuthProvider', () => { iss: 'INVALID_ISSUE_URL', }); - await provider.refresh(mockRequest, mockResponse); - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('SignInResolver rejects', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -282,19 +259,16 @@ describe('AwsAlbAuthProvider', () => { jwtMock.verify.mockReturnValueOnce(mockClaims); - await provider.refresh(mockRequest, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.end).toHaveBeenCalledTimes(1); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('AuthHandler rejects', async () => { const provider = new AwsAlbAuthProvider({ region: 'eu-west-1', issuer: 'ISSUER_URL', - logger: getVoidLogger(), - catalogIdentityClient, - tokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async () => { throw new Error(); }, @@ -305,10 +279,9 @@ describe('AwsAlbAuthProvider', () => { jwtMock.verify.mockReturnValueOnce(mockClaims); - await provider.refresh(mockRequest, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.end).toHaveBeenCalledTimes(1); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); }); }); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 5206dc125c..8e021e8817 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -16,8 +16,8 @@ import { AuthHandler, - AuthProviderFactory, AuthProviderRouteHandlers, + AuthResolverContext, AuthResponse, SignInResolver, } from '../types'; @@ -25,15 +25,13 @@ import express from 'express'; import fetch from 'node-fetch'; import * as crypto from 'crypto'; import { KeyObject } from 'crypto'; -import { Logger } from 'winston'; import NodeCache from 'node-cache'; import { JWT } from 'jose'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; import { Profile as PassportProfile } from 'passport'; import { makeProfileInfo } from '../../lib/passport'; import { AuthenticationError } from '@backstage/errors'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; export const ALB_JWT_HEADER = 'x-amzn-oidc-data'; export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken'; @@ -41,11 +39,9 @@ export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken'; type Options = { region: string; issuer?: string; - logger: Logger; authHandler: AuthHandler; signInResolver: SignInResolver; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; + resolverContext: AuthResolverContext; }; export const getJWTHeaders = (input: string): AwsAlbHeaders => { @@ -73,6 +69,7 @@ export type AwsAlbClaims = { iss: string; }; +/** @public */ export type AwsAlbResult = { fullProfile: PassportProfile; expiresInSeconds?: number; @@ -95,9 +92,7 @@ export type AwsAlbResponse = AuthResponse; export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { private readonly region: string; private readonly issuer?: string; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; private readonly keyCache: NodeCache; private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; @@ -107,9 +102,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { this.issuer = options.issuer; this.authHandler = options.authHandler; this.signInResolver = options.signInResolver; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this.keyCache = new NodeCache({ stdTTL: 3600 }); } @@ -123,9 +116,10 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { const response = await this.handleResult(result); res.json(response); } catch (e) { - this.logger.error('Exception occurred during AWS ALB token refresh', e); - res.status(401); - res.end(); + throw new AuthenticationError( + 'Exception occurred during AWS ALB token refresh', + e, + ); } } @@ -182,18 +176,13 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { } private async handleResult(result: AwsAlbResult): Promise { - const context = { - tokenIssuer: this.tokenIssuer, - catalogIdentityClient: this.catalogIdentityClient, - logger: this.logger, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const backstageIdentity = await this.signInResolver( { result, profile, }, - context, + this.resolverContext, ); return { @@ -222,6 +211,10 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { } } +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type AwsAlbProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -240,40 +233,58 @@ export type AwsAlbProviderOptions = { }; }; -export const createAwsAlbProvider = ( - options?: AwsAlbProviderOptions, -): AuthProviderFactory => { - return ({ config, tokenIssuer, catalogApi, logger, tokenManager }) => { - const region = config.getString('region'); - const issuer = config.getOptionalString('iss'); +/** + * Auth provider integration for AWS ALB auth + * + * @public + */ +export const awsAlb = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - if (options?.signIn.resolver === undefined) { - throw new Error( - 'SignInResolver is required to use this authentication provider', - ); - } + /** + * 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; + }; + }) { + return ({ config, resolverContext }) => { + const region = config.getString('region'); + const issuer = config.getOptionalString('iss'); - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + if (options?.signIn.resolver === undefined) { + throw new Error( + 'SignInResolver is required to use this authentication provider', + ); + } - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), - }); + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); - const signInResolver = options?.signIn.resolver; + return new AwsAlbAuthProvider({ + region, + issuer, + signInResolver: options?.signIn.resolver, + authHandler, + resolverContext, + }); + }; + }, +}); - return new AwsAlbAuthProvider({ - region, - issuer, - signInResolver, - authHandler, - tokenIssuer, - catalogIdentityClient, - logger, - }); - }; -}; +/** + * @public + * @deprecated Use `providers.awsAlb.create` instead + */ +export const createAwsAlbProvider = awsAlb.create; diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.test.ts b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts index 690729a5fb..9eeedc3d7b 100644 --- a/plugins/auth-backend/src/providers/bitbucket/provider.test.ts +++ b/plugins/auth-backend/src/providers/bitbucket/provider.test.ts @@ -16,9 +16,7 @@ import { BitbucketAuthProvider, BitbucketOAuthResult } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { getVoidLogger } from '@backstage/backend-common'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -29,19 +27,8 @@ const mockFrameHandler = jest.spyOn( describe('createBitbucketProvider', () => { it('should auth', async () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new BitbucketAuthProvider({ - logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/bitbucket/provider.ts b/plugins/auth-backend/src/providers/bitbucket/provider.ts index 1cc0e60bd8..b149aa6d28 100644 --- a/plugins/auth-backend/src/providers/bitbucket/provider.ts +++ b/plugins/auth-backend/src/providers/bitbucket/provider.ts @@ -17,8 +17,6 @@ import express from 'express'; import passport, { Profile as PassportProfile } from 'passport'; import { Strategy as BitbucketStrategy } from 'passport-bitbucket-oauth2'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { encodeState, OAuthAdapter, @@ -38,13 +36,13 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { - AuthProviderFactory, AuthHandler, RedirectInfo, SignInResolver, + AuthResolverContext, } from '../types'; -import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -53,9 +51,7 @@ type PrivateInfo = { type Options = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; export type BitbucketOAuthResult = { @@ -87,16 +83,12 @@ export class BitbucketAuthProvider implements OAuthHandlers { private readonly _strategy: BitbucketStrategy; private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: Options) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this._strategy = new BitbucketStrategy( { clientID: options.clientId, @@ -174,12 +166,7 @@ export class BitbucketAuthProvider implements OAuthHandlers { private async handleResult(result: BitbucketOAuthResult) { result.fullProfile.avatarUrl = result.fullProfile._json!.links!.avatar!.href; - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -197,7 +184,7 @@ export class BitbucketAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -205,48 +192,10 @@ export class BitbucketAuthProvider implements OAuthHandlers { } } -export const bitbucketUsernameSignInResolver: SignInResolver< - BitbucketOAuthResult -> = async (info, ctx) => { - const { result } = info; - - if (!result.fullProfile.username) { - throw new Error('Bitbucket profile contained no Username'); - } - - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'bitbucket.org/username': result.fullProfile.username, - }, - }); - - const claims = getEntityClaims(entity); - const token = await ctx.tokenIssuer.issueToken({ claims }); - - return { id: entity.metadata.name, entity, token }; -}; - -export const bitbucketUserIdSignInResolver: SignInResolver< - BitbucketOAuthResult -> = async (info, ctx) => { - const { result } = info; - - if (!result.fullProfile.id) { - throw new Error('Bitbucket profile contained no User ID'); - } - - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'bitbucket.org/user-id': result.fullProfile.id, - }, - }); - - const claims = getEntityClaims(entity); - const token = await ctx.tokenIssuer.issueToken({ claims }); - - return { id: entity.metadata.name, entity, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type BitbucketProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -265,54 +214,117 @@ export type BitbucketProviderOptions = { }; }; -export const createBitbucketProvider = ( - options?: BitbucketProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for BitBucket auth + * + * @public + */ +export const bitbucket = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const authHandler: AuthHandler = + options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const provider = new BitbucketAuthProvider({ + clientId, + clientSecret, + callbackUrl, + signInResolver: options?.signIn?.resolver, + authHandler, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, + resolvers: { + /** + * Looks up the user by matching their username to the `bitbucket.org/username` annotation. + */ + usernameMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { result } = info; - const authHandler: AuthHandler = - options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); + if (!result.fullProfile.username) { + throw new Error('Bitbucket profile contained no Username'); + } - const provider = new BitbucketAuthProvider({ - clientId, - clientSecret, - callbackUrl, - signInResolver: options?.signIn?.resolver, - authHandler, - tokenIssuer, - catalogIdentityClient, - logger, - }); + return ctx.signInWithCatalogUser({ + annotations: { + 'bitbucket.org/username': result.fullProfile.username, + }, + }); + }; + }, + /** + * Looks up the user by matching their user ID to the `bitbucket.org/user-id` annotation. + */ + userIdMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { result } = info; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; + if (!result.fullProfile.id) { + throw new Error('Bitbucket profile contained no User ID'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'bitbucket.org/user-id': result.fullProfile.id, + }, + }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.bitbucket.create` instead + */ +export const createBitbucketProvider = bitbucket.create; + +/** + * @public + * @deprecated Use `providers.bitbucket.resolvers.usernameMatchingUserEntityAnnotation()` instead. + */ +export const bitbucketUsernameSignInResolver = + bitbucket.resolvers.usernameMatchingUserEntityAnnotation(); + +/** + * @public + * @deprecated Use `providers.bitbucket.resolvers.userIdMatchingUserEntityAnnotation()` instead. + */ +export const bitbucketUserIdSignInResolver = + bitbucket.resolvers.userIdMatchingUserEntityAnnotation(); diff --git a/plugins/auth-backend/src/providers/createAuthProviderIntegration.ts b/plugins/auth-backend/src/providers/createAuthProviderIntegration.ts new file mode 100644 index 0000000000..c6e9107353 --- /dev/null +++ b/plugins/auth-backend/src/providers/createAuthProviderIntegration.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 { AuthProviderFactory, SignInResolver } from './types'; + +/** + * Creates a standardized representation of an integration with a third-party + * auth provider. + * + * The returned object facilitates the creation of provider instances, and + * supplies built-in sign-in resolvers for the specific provider. + */ +export function createAuthProviderIntegration< + TCreateOptions extends unknown[], + TResolvers extends + | { + [name in string]: (...args: any[]) => SignInResolver; + }, +>(config: { + create: (...args: TCreateOptions) => AuthProviderFactory; + resolvers?: TResolvers; +}): Readonly<{ + create: (...args: TCreateOptions) => AuthProviderFactory; + // If no resolvers are defined, this receives the type `never` + resolvers: Readonly; +}> { + return Object.freeze({ + ...config, + resolvers: Object.freeze(config.resolvers ?? ({} as any)), + }); +} diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts index eb2f5c478d..b1b2a3f66e 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; import express from 'express'; import request from 'supertest'; +import { AuthResolverContext } from '../types'; import { GcpIapProvider } from './provider'; beforeEach(() => { @@ -27,16 +27,13 @@ describe('GcpIapProvider', () => { const authHandler = jest.fn(); const signInResolver = jest.fn(); const tokenValidator = jest.fn(); - const logger = getVoidLogger(); it('runs the happy path', async () => { const provider = new GcpIapProvider({ authHandler, signInResolver, tokenValidator, - tokenIssuer: {} as any, - catalogIdentityClient: {} as any, - logger, + resolverContext: {} as AuthResolverContext, }); // { "sub": "user:default/me", "ent": ["group:default/home"] } diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.ts index 7816f68b76..cb5d5979cd 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.ts @@ -16,14 +16,12 @@ import express from 'express'; import { TokenPayload } from 'google-auth-library'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; import { AuthHandler, - AuthProviderFactory, AuthProviderRouteHandlers, + AuthResolverContext, SignInResolver, } from '../types'; import { @@ -31,35 +29,24 @@ import { defaultAuthHandler, parseRequestToken, } from './helpers'; -import { - GcpIapProviderOptions, - GcpIapResponse, - GcpIapResult, - IAP_JWT_HEADER, -} from './types'; +import { GcpIapResponse, GcpIapResult, IAP_JWT_HEADER } from './types'; export class GcpIapProvider implements AuthProviderRouteHandlers { private readonly authHandler: AuthHandler; private readonly signInResolver: SignInResolver; private readonly tokenValidator: (token: string) => Promise; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: { authHandler: AuthHandler; signInResolver: SignInResolver; tokenValidator: (token: string) => Promise; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }) { this.authHandler = options.authHandler; this.signInResolver = options.signInResolver; this.tokenValidator = options.tokenValidator; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; } async start() {} @@ -71,17 +58,12 @@ export class GcpIapProvider implements AuthProviderRouteHandlers { req.header(IAP_JWT_HEADER), this.tokenValidator, ); - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const backstageIdentity = await this.signInResolver( { profile, result }, - context, + this.resolverContext, ); const response: GcpIapResponse = { @@ -95,32 +77,49 @@ export class GcpIapProvider implements AuthProviderRouteHandlers { } /** - * Creates an auth provider for Google Identity-Aware Proxy. + * Auth provider integration for Google Identity-Aware Proxy auth * * @public */ -export function createGcpIapProvider( - options: GcpIapProviderOptions, -): AuthProviderFactory { - return ({ config, tokenIssuer, catalogApi, logger, tokenManager }) => { - const audience = config.getString('audience'); +export const gcpIap = createAuthProviderIntegration({ + create(options: { + /** + * The profile transformation function used to verify and convert the auth + * response into the profile that will be presented to the user. The default + * implementation just provides the authenticated email that the IAP + * presented. + */ + authHandler?: AuthHandler; - const authHandler = options.authHandler ?? defaultAuthHandler; - const signInResolver = options.signIn.resolver; - const tokenValidator = createTokenValidator(audience); + /** + * Configures sign-in for this provider. + */ + signIn: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; + }) { + return ({ config, resolverContext }) => { + const audience = config.getString('audience'); - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + const authHandler = options.authHandler ?? defaultAuthHandler; + const signInResolver = options.signIn.resolver; + const tokenValidator = createTokenValidator(audience); - return new GcpIapProvider({ - authHandler, - signInResolver, - tokenValidator, - tokenIssuer, - catalogIdentityClient, - logger, - }); - }; -} + return new GcpIapProvider({ + authHandler, + signInResolver, + tokenValidator, + resolverContext, + }); + }; + }, +}); + +/** + * @public + * @deprecated Use `providers.gcpIap.create` instead + */ +export const createGcpIapProvider = gcpIap.create; diff --git a/plugins/auth-backend/src/providers/gcp-iap/types.ts b/plugins/auth-backend/src/providers/gcp-iap/types.ts index 9ef1935442..6e5a022a3d 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/types.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/types.ts @@ -71,9 +71,8 @@ export type GcpIapProviderInfo = { export type GcpIapResponse = AuthResponse; /** - * Options for {@link createGcpIapProvider}. - * * @public + * @deprecated This type has been inlined into the create method and will be removed. */ export type GcpIapProviderOptions = { /** diff --git a/plugins/auth-backend/src/providers/github/provider.test.ts b/plugins/auth-backend/src/providers/github/provider.test.ts index 206df52083..18690a986b 100644 --- a/plugins/auth-backend/src/providers/github/provider.test.ts +++ b/plugins/auth-backend/src/providers/github/provider.test.ts @@ -15,17 +15,11 @@ */ import { Profile as PassportProfile } from 'passport'; -import { getVoidLogger } from '@backstage/backend-common'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { - GithubAuthProvider, - GithubOAuthResult, - githubDefaultSignInResolver, -} from './provider'; +import { GithubAuthProvider, GithubOAuthResult, github } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; import { OAuthStartRequest, encodeState } from '../../lib/oauth'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -38,22 +32,13 @@ const mockFrameHandler = jest.spyOn( >; describe('GithubAuthProvider', () => { - const tokenIssuer: TokenIssuer = { - listPublicKeys: jest.fn(), - async issueToken(params) { - return `token-for-${params.claims.sub}`; - }, - }; - 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, + resolverContext: { + signInWithCatalogUser: jest.fn(({ entityRef }) => ({ + token: `token-for-user:${entityRef.name}`, + })), + } as unknown as AuthResolverContext, + signInResolver: github.resolvers.usernameMatchingUserEntityName(), authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), @@ -92,8 +77,7 @@ describe('GithubAuthProvider', () => { const expected = { backstageIdentity: { - id: 'jimmymarkum', - token: 'token-for-user:default/jimmymarkum', + token: 'token-for-user:jimmymarkum', }, providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', @@ -138,8 +122,7 @@ describe('GithubAuthProvider', () => { const expected = { backstageIdentity: { - id: 'jimmymarkum', - token: 'token-for-user:default/jimmymarkum', + token: 'token-for-user:jimmymarkum', }, providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', @@ -182,8 +165,7 @@ describe('GithubAuthProvider', () => { }; const expected = { backstageIdentity: { - id: 'jimmymarkum', - token: 'token-for-user:default/jimmymarkum', + token: 'token-for-user:jimmymarkum', }, providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', @@ -226,8 +208,7 @@ describe('GithubAuthProvider', () => { const expected = { backstageIdentity: { - id: 'daveboyle', - token: 'token-for-user:default/daveboyle', + token: 'token-for-user:daveboyle', }, providerInfo: { accessToken: @@ -254,6 +235,7 @@ describe('GithubAuthProvider', () => { result: { fullProfile: { id: 'ipd12039', + username: 'daveboyle', provider: 'github', displayName: 'Dave Boyle', }, @@ -271,8 +253,7 @@ describe('GithubAuthProvider', () => { expect(response).toEqual({ response: { backstageIdentity: { - id: 'ipd12039', - token: 'token-for-user:default/ipd12039', + token: 'token-for-user:daveboyle', }, providerInfo: { accessToken: 'a.b.c', @@ -287,6 +268,28 @@ describe('GithubAuthProvider', () => { }); }); + it('should fail if username is not available', async () => { + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + id: 'ipd12039', + provider: 'github', + displayName: 'Dave Boyle', + }, + accessToken: 'a.b.c', + params: { + scope: 'read:user', + expires_in: '123', + }, + }, + privateInfo: { refreshToken: 'refresh-me' }, + }); + + await expect(provider.handler({} as any)).rejects.toThrow( + 'GitHub user profile does not contain a username', + ); + }); + it('should forward a new refresh token on refresh', async () => { const mockRefreshToken = jest.spyOn( helpers, @@ -325,8 +328,7 @@ describe('GithubAuthProvider', () => { expect(result).toEqual({ response: { backstageIdentity: { - id: 'mockuser', - token: 'token-for-user:default/mockuser', + token: 'token-for-user:mockuser', }, profile: { displayName: 'Mocked User', @@ -377,8 +379,7 @@ describe('GithubAuthProvider', () => { expect(result).toEqual({ response: { backstageIdentity: { - id: 'mockuser', - token: 'token-for-user:default/mockuser', + token: 'token-for-user:mockuser', }, profile: { displayName: 'Mocked User', diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index c875f856b6..e018a6cf99 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; -import { Logger } from 'winston'; import { Profile as PassportProfile } from 'passport'; import { Strategy as GithubStrategy } from 'passport-github2'; import { @@ -32,10 +27,10 @@ import { } from '../../lib/passport'; import { RedirectInfo, - AuthProviderFactory, AuthHandler, SignInResolver, StateEncoder, + AuthResolverContext, } from '../types'; import { OAuthAdapter, @@ -46,8 +41,7 @@ import { encodeState, OAuthRefreshRequest, } from '../../lib/oauth'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; const ACCESS_TOKEN_PREFIX = 'access-token.'; @@ -76,27 +70,21 @@ export type GithubAuthProviderOptions = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; stateEncoder: StateEncoder; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; 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; + private readonly resolverContext: AuthResolverContext; private readonly stateEncoder: StateEncoder; constructor(options: GithubAuthProviderOptions) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; this.stateEncoder = options.stateEncoder; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this._strategy = new GithubStrategy( { clientID: options.clientId, @@ -198,12 +186,7 @@ export class GithubAuthProvider implements OAuthHandlers { } private async handleResult(result: GithubOAuthResult) { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const expiresInStr = result.params.expires_in; let expiresInSeconds = @@ -217,7 +200,7 @@ export class GithubAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); // GitHub sessions last longer than Backstage sessions, so if we're using @@ -244,29 +227,10 @@ export class GithubAuthProvider implements OAuthHandlers { } } -export const githubDefaultSignInResolver: SignInResolver< - GithubOAuthResult -> = async (info, ctx) => { - const { fullProfile } = info.result; - - const userId = fullProfile.username || fullProfile.id; - - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type GithubProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -281,7 +245,7 @@ export type GithubProviderOptions = { /** * Maps an auth result to a Backstage identity for the user. */ - resolver?: SignInResolver; + resolver: SignInResolver; }; /** @@ -303,85 +267,123 @@ export type GithubProviderOptions = { stateEncoder?: StateEncoder; }; -export const createGithubProvider = ( - options?: GithubProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const enterpriseInstanceUrl = envConfig - .getOptionalString('enterpriseInstanceUrl') - ?.replace(/\/$/, ''); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const authorizationUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/authorize` - : undefined; - const tokenUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/access_token` - : undefined; - const userProfileUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/api/v3/user` - : undefined; - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for GitHub auth + * + * @public + */ +export const github = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + /** + * 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; + }; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile }) => ({ - profile: makeProfileInfo(fullProfile), + /** + * The state encoder used to encode the 'state' parameter on the OAuth request. + * + * It should return a string that takes the state params (from the request), url encodes the params + * and finally base64 encodes them. + * + * Providing your own stateEncoder will allow you to add addition parameters to the state field. + * + * It is typed as follows: + * `export type StateEncoder = (input: OAuthState) => Promise<{encodedState: string}>;` + * + * Note: the stateEncoder must encode a 'nonce' value and an 'env' value. Without this, the OAuth flow will fail + * (These two values will be set by the req.state by default) + * + * For more information, please see the helper module in ../../oauth/helpers #readState + */ + stateEncoder?: StateEncoder; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const enterpriseInstanceUrl = envConfig + .getOptionalString('enterpriseInstanceUrl') + ?.replace(/\/$/, ''); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + + const stateEncoder: StateEncoder = + options?.stateEncoder ?? + (async ( + req: OAuthStartRequest, + ): Promise<{ encodedState: string }> => { + return { encodedState: encodeState(req.state) }; }); - const signInResolverFn = - options?.signIn?.resolver ?? githubDefaultSignInResolver; - - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + const provider = new GithubAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenUrl, + userProfileUrl, + authorizationUrl, + signInResolver: options?.signIn?.resolver, + authHandler, + stateEncoder, + resolverContext, }); - const stateEncoder: StateEncoder = - options?.stateEncoder ?? - (async (req: OAuthStartRequest): Promise<{ encodedState: string }> => { - return { encodedState: encodeState(req.state) }; + return OAuthAdapter.fromConfig(globalConfig, provider, { + persistScopes: true, + providerId, + callbackUrl, }); - - const provider = new GithubAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenUrl, - userProfileUrl, - authorizationUrl, - signInResolver, - authHandler, - tokenIssuer, - catalogIdentityClient, - stateEncoder, - logger, }); + }, + resolvers: { + /** + * Looks up the user by matching their GitHub username to the entity name. + */ + usernameMatchingUserEntityName: (): SignInResolver => { + return async (info, ctx) => { + const { fullProfile } = info.result; - return OAuthAdapter.fromConfig(globalConfig, provider, { - persistScopes: true, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; + const userId = fullProfile.username; + if (!userId) { + throw new Error(`GitHub user profile does not contain a username`); + } + + return ctx.signInWithCatalogUser({ entityRef: { name: userId } }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.github.create` instead + */ +export const createGithubProvider = github.create; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.test.ts b/plugins/auth-backend/src/providers/gitlab/provider.test.ts index f90de3c75b..169930786b 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.test.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.test.ts @@ -14,13 +14,14 @@ * limitations under the License. */ -import { GitlabAuthProvider, gitlabDefaultSignInResolver } from './provider'; +import { + GitlabAuthProvider, + gitlabUsernameEntityNameSignInResolver, +} from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { PassportProfile } from '../../lib/passport/types'; import { OAuthResult } from '../../lib/oauth'; -import { getVoidLogger } from '@backstage/backend-common'; -import { TokenIssuer } from '../../identity'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -28,22 +29,16 @@ const mockFrameHandler = jest.spyOn( ) as unknown as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>; describe('GitlabAuthProvider', () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new GitlabAuthProvider({ clientId: 'mock', clientSecret: 'mock', callbackUrl: 'mock', baseUrl: 'mock', - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: { + signInWithCatalogUser: jest.fn(async ({ entityRef }) => ({ + token: `token-for-user:${entityRef.name}`, + })), + } as unknown as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, @@ -51,8 +46,7 @@ describe('GitlabAuthProvider', () => { picture: 'http://gitlab.com/lols', }, }), - signInResolver: gitlabDefaultSignInResolver, - logger: getVoidLogger(), + signInResolver: gitlabUsernameEntityNameSignInResolver, }); it('should transform to type OAuthResponse', async () => { @@ -85,7 +79,7 @@ describe('GitlabAuthProvider', () => { }, expect: { backstageIdentity: { - id: 'jimmymarkum', + token: 'token-for-user:jimmymarkum', }, providerInfo: { accessToken: '19xasczxcm9n7gacn9jdgm19me', @@ -127,7 +121,7 @@ describe('GitlabAuthProvider', () => { }, expect: { backstageIdentity: { - id: 'daveboyle', + token: 'token-for-user:daveboyle', }, providerInfo: { accessToken: @@ -189,7 +183,7 @@ describe('GitlabAuthProvider', () => { expect(result).toEqual({ response: { backstageIdentity: { - id: 'mockuser', + token: 'token-for-user:mockuser', }, profile: { displayName: 'Mocked User', diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 59f9a5a4cb..23867f6990 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -14,13 +14,8 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import { Strategy as GitlabStrategy } from 'passport-gitlab2'; -import { Logger } from 'winston'; import { executeRedirectStrategy, executeFrameHandlerStrategy, @@ -31,9 +26,9 @@ import { } from '../../lib/passport'; import { RedirectInfo, - AuthProviderFactory, SignInResolver, AuthHandler, + AuthResolverContext, } from '../types'; import { OAuthAdapter, @@ -46,8 +41,7 @@ import { encodeState, OAuthResult, } from '../../lib/oauth'; -import { TokenIssuer } from '../../identity'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; type PrivateInfo = { refreshToken: string; @@ -57,37 +51,20 @@ export type GitlabAuthProviderOptions = OAuthProviderOptions & { baseUrl: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; -export const gitlabDefaultSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile, result } = info; +export const gitlabUsernameEntityNameSignInResolver: SignInResolver< + OAuthResult +> = async (info, ctx) => { + const { result } = info; - let id = result.fullProfile.id; - - if (profile.email) { - id = profile.email.split('@')[0]; + const id = result.fullProfile.username; + if (!id) { + throw new Error(`GitLab user profile does not contain a username`); } - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: id, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id, token }; + return ctx.signInWithCatalogUser({ entityRef: { name: id } }); }; export const gitlabDefaultAuthHandler: AuthHandler = async ({ @@ -101,14 +78,10 @@ export class GitlabAuthProvider implements OAuthHandlers { private readonly _strategy: GitlabStrategy; private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: GitlabAuthProviderOptions) { - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; - this.tokenIssuer = options.tokenIssuer; + this.resolverContext = options.resolverContext; this.authHandler = options.authHandler; this.signInResolver = options.signInResolver; @@ -179,12 +152,7 @@ export class GitlabAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult): Promise { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -202,7 +170,7 @@ export class GitlabAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -210,6 +178,10 @@ export class GitlabAuthProvider implements OAuthHandlers { } } +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type GitlabProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -227,67 +199,65 @@ export type GitlabProviderOptions = { * the catalog for a single user entity that has a matching `microsoft.com/email` annotation. */ signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createGitlabProvider = ( - options?: GitlabProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getOptionalString('audience'); - const baseUrl = audience || 'https://gitlab.com'; - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for GitLab auth + * + * @public + */ +export const gitlab = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getOptionalString('audience'); + const baseUrl = audience || 'https://gitlab.com'; + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authHandler: AuthHandler = - options?.authHandler ?? gitlabDefaultAuthHandler; + const authHandler: AuthHandler = + options?.authHandler ?? gitlabDefaultAuthHandler; - const signInResolverFn = - options?.signIn?.resolver ?? gitlabDefaultSignInResolver; - - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + const provider = new GitlabAuthProvider({ + clientId, + clientSecret, + callbackUrl, + baseUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, }); - const provider = new GitlabAuthProvider({ - clientId, - clientSecret, - callbackUrl, - baseUrl, - authHandler, - signInResolver, - catalogIdentityClient, - logger, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, +}); - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.gitlab.create` instead + */ +export const createGitlabProvider = gitlab.create; diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index a69a7b0485..2e904c62ad 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -17,9 +17,7 @@ import { GoogleAuthProvider } 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'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -30,19 +28,8 @@ const mockFrameHandler = jest.spyOn( describe('createGoogleProvider', () => { it('should auth', async () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new GoogleAuthProvider({ - logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index e5e5698ec4..68925e7817 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -14,15 +14,9 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { encodeState, OAuthAdapter, @@ -43,12 +37,13 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { - AuthProviderFactory, AuthHandler, + AuthResolverContext, RedirectInfo, SignInResolver, } from '../types'; -import { Logger } from 'winston'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { commonByEmailLocalPartResolver } from '../resolvers'; type PrivateInfo = { refreshToken: string; @@ -57,26 +52,20 @@ type PrivateInfo = { type Options = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; export class GoogleAuthProvider implements OAuthHandlers { - private readonly _strategy: GoogleStrategy; + private readonly strategy: GoogleStrategy; private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; 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 GoogleStrategy( + this.signInResolver = options.signInResolver; + this.resolverContext = options.resolverContext; + this.strategy = new GoogleStrategy( { clientID: options.clientId, clientSecret: options.clientSecret, @@ -109,7 +98,7 @@ export class GoogleAuthProvider implements OAuthHandlers { } async start(req: OAuthStartRequest): Promise { - return await executeRedirectStrategy(req, this._strategy, { + return await executeRedirectStrategy(req, this.strategy, { accessType: 'offline', prompt: 'consent', scope: req.scope, @@ -121,7 +110,7 @@ export class GoogleAuthProvider implements OAuthHandlers { const { result, privateInfo } = await executeFrameHandlerStrategy< OAuthResult, PrivateInfo - >(req, this._strategy); + >(req, this.strategy); return { response: await this.handleResult(result), @@ -132,12 +121,12 @@ export class GoogleAuthProvider implements OAuthHandlers { async refresh(req: OAuthRefreshRequest) { const { accessToken, refreshToken, params } = await executeRefreshTokenStrategy( - this._strategy, + this.strategy, req.refreshToken, req.scope, ); const fullProfile = await executeFetchUserProfileStrategy( - this._strategy, + this.strategy, accessToken, ); @@ -152,12 +141,7 @@ export class GoogleAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -175,7 +159,7 @@ export class GoogleAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -183,69 +167,10 @@ export class GoogleAuthProvider implements OAuthHandlers { } } -export const googleEmailSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Google profile contained no email'); - } - - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'google.com/email': profile.email, - }, - }); - - const claims = getEntityClaims(entity); - const token = await ctx.tokenIssuer.issueToken({ claims }); - - return { id: entity.metadata.name, entity, token }; -}; - -const googleDefaultSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Google profile contained no email'); - } - - let userId: string; - try { - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'google.com/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 entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type GoogleProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -260,67 +185,99 @@ export type GoogleProviderOptions = { /** * Maps an auth result to a Backstage identity for the user. */ - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createGoogleProvider = ( - options?: GoogleProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for Google auth + * + * @public + */ +export const google = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); - const signInResolverFn = - options?.signIn?.resolver ?? googleDefaultSignInResolver; - - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + const provider = new GoogleAuthProvider({ + clientId, + clientSecret, + callbackUrl, + signInResolver: options?.signIn?.resolver, + authHandler, + resolverContext, }); - const provider = new GoogleAuthProvider({ - clientId, - clientSecret, - callbackUrl, - signInResolver, - authHandler, - tokenIssuer, - catalogIdentityClient, - logger, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, + resolvers: { + /** + * Looks up the user by matching their email local part to the entity name. + */ + emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver, + /** + * Looks up the user by matching their email to the `google.com/email` annotation. + */ + emailMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { profile } = info; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; + if (!profile.email) { + throw new Error('Google profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.google.create` instead. + */ +export const createGoogleProvider = google.create; + +/** + * @public + * @deprecated Use `providers.google.resolvers.emailMatchingUserEntityAnnotation()` instead. + */ +export const googleEmailSignInResolver = + google.resolvers.emailMatchingUserEntityAnnotation(); diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 78814ae7e6..1f33a60e55 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -30,24 +30,25 @@ export * from './onelogin'; export * from './saml'; export * from './gcp-iap'; +export { providers } from './providers'; + export { factories as defaultAuthProviderFactories } from './factories'; -// Export the minimal interface required for implementing a -// custom Authorization Handler export type { + AuthProviderConfig, AuthProviderRouteHandlers, AuthProviderFactoryOptions, AuthProviderFactory, AuthHandler, + AuthResolverCatalogUserQuery, AuthResolverContext, AuthHandlerResult, SignInResolver, SignInInfo, CookieConfigurer, + StateEncoder, + AuthResponse, + ProfileInfo, } from './types'; -// These types are needed for a postMessage from the login pop-up -// to the frontend -export type { AuthResponse, ProfileInfo } from './types'; - export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse'; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts index 3a0fc5ad86..e9b4424a95 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -18,11 +18,10 @@ import { MicrosoftAuthProvider } 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'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -87,19 +86,10 @@ const setupHandlers = () => { describe('createMicrosoftProvider', () => { it('should auth', async () => { setupHandlers(); - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; const provider = new MicrosoftAuthProvider({ logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, @@ -131,19 +121,9 @@ describe('createMicrosoftProvider', () => { it('should return the base64 encoded photo data of the profile', async () => { setupHandlers(); - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new MicrosoftAuthProvider({ logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 98f94811bd..5b3e80e679 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -14,15 +14,9 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import passport from 'passport'; import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { encodeState, OAuthAdapter, @@ -43,11 +37,12 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { - AuthProviderFactory, AuthHandler, RedirectInfo, SignInResolver, + AuthResolverContext, } from '../types'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { Logger } from 'winston'; import fetch from 'node-fetch'; @@ -58,9 +53,8 @@ type PrivateInfo = { type Options = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; logger: Logger; + resolverContext: AuthResolverContext; authorizationUrl?: string; tokenUrl?: string; }; @@ -69,16 +63,14 @@ export class MicrosoftAuthProvider implements OAuthHandlers { private readonly _strategy: MicrosoftStrategy; private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: Options) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; this.logger = options.logger; - this.catalogIdentityClient = options.catalogIdentityClient; + this.resolverContext = options.resolverContext; this._strategy = new MicrosoftStrategy( { @@ -147,12 +139,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers { const photo = await this.getUserPhoto(result.accessToken); result.fullProfile.photos = photo ? [{ value: photo }] : undefined; - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -170,87 +157,39 @@ export class MicrosoftAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } return response; } - private getUserPhoto(accessToken: string): Promise { - return new Promise(resolve => { - fetch('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', { - headers: { - Authorization: `Bearer ${accessToken}`, + private async getUserPhoto(accessToken: string): Promise { + try { + const res = await fetch( + 'https://graph.microsoft.com/v1.0/me/photos/48x48/$value', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, }, - }) - .then(response => response.arrayBuffer()) - .then(arrayBuffer => { - const imageUrl = `data:image/jpeg;base64,${Buffer.from( - arrayBuffer, - ).toString('base64')}`; - resolve(imageUrl); - }) - .catch(error => { - this.logger.warn( - `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, - ); - // User profile photo is optional, ignore errors and resolve undefined - resolve(undefined); - }); - }); + ); + const data = await res.buffer(); + + return `data:image/jpeg;base64,${data.toString('base64')}`; + } catch (error) { + this.logger.warn( + `Could not retrieve user profile photo from Microsoft Graph API: ${error}`, + ); + return undefined; + } } } -export const microsoftEmailSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Microsoft profile contained no email'); - } - - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'microsoft.com/email': profile.email, - }, - }); - - const claims = getEntityClaims(entity); - const token = await ctx.tokenIssuer.issueToken({ claims }); - - return { id: entity.metadata.name, entity, token }; -}; - -export const microsoftDefaultSignInResolver: SignInResolver< - OAuthResult -> = async (info, ctx) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Profile contained no email'); - } - - const userId = profile.email.split('@')[0]; - - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type MicrosoftProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -265,73 +204,102 @@ export type MicrosoftProviderOptions = { /** * Maps an auth result to a Backstage identity for the user. */ - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createMicrosoftProvider = ( - options?: MicrosoftProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const tenantId = envConfig.getString('tenantId'); +/** + * Auth provider integration for Microsoft auth + * + * @public + */ +export const microsoft = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; - const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, logger, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantId = envConfig.getString('tenantId'); - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); - const signInResolverFn = - options?.signIn?.resolver ?? microsoftDefaultSignInResolver; - - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + authHandler, + signInResolver: options?.signIn?.resolver, logger, + resolverContext, }); - const provider = new MicrosoftAuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, - authHandler, - signInResolver, - catalogIdentityClient, - logger, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, + resolvers: { + /** + * Looks up the user by matching their email to the `microsoft.com/email` annotation. + */ + emailMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { profile } = info; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; + if (!profile.email) { + throw new Error('Microsoft profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'microsoft.com/email': profile.email, + }, + }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.microsoft.create` instead + */ +export const createMicrosoftProvider = microsoft.create; + +/** + * @public + * @deprecated Use `providers.microsoft.resolvers.emailMatchingUserEntityAnnotation()` instead. + */ +export const microsoftEmailSignInResolver = + microsoft.resolvers.emailMatchingUserEntityAnnotation(); diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts index 61f08bc535..e27713acb3 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts @@ -21,22 +21,14 @@ jest.mock('jose', () => ({ })); jest.mock('@backstage/catalog-client'); +import { AuthenticationError } from '@backstage/errors'; import express from 'express'; import { JWT } from 'jose'; import { Logger } from 'winston'; -import { - AuthHandler, - SignInResolver, - AuthProviderFactoryOptions, -} from '../types'; - -import { CatalogIdentityClient } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity/types'; - +import { AuthHandler, AuthResolverContext, SignInResolver } from '../types'; import { createOauth2ProxyProvider, Oauth2ProxyAuthProvider, - Oauth2ProxyProviderOptions, OAuth2ProxyResult, OAUTH2_PROXY_JWT_HEADER, } from './provider'; @@ -76,10 +68,10 @@ describe('Oauth2ProxyAuthProvider', () => { provider = new Oauth2ProxyAuthProvider({ authHandler, - logger, signInResolver, - catalogIdentityClient: {} as CatalogIdentityClient, - tokenIssuer: {} as TokenIssuer, + resolverContext: { + _: 'resolver-context', + } as unknown as AuthResolverContext, }); }); @@ -103,17 +95,17 @@ describe('Oauth2ProxyAuthProvider', () => { it('should throw an error when auth header is missing', async () => { mockRequest.header.mockReturnValue(undefined); - await provider.refresh(mockRequest, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('should throw an error if the bearer token is invalid', async () => { mockRequest.header.mockReturnValue('Basic asdf='); - await provider.refresh(mockRequest, mockResponse); - - expect(mockResponse.status).toHaveBeenCalledWith(401); + await expect(provider.refresh(mockRequest, mockResponse)).rejects.toThrow( + AuthenticationError, + ); }); it('should return if auth header is set and valid', async () => { @@ -156,7 +148,7 @@ describe('Oauth2ProxyAuthProvider', () => { fullProfile: decodedToken, }, }, - { catalogIdentityClient: {}, logger, tokenIssuer: {} }, + { _: 'resolver-context' }, ); expect(mockResponse.json).toHaveBeenCalledWith({ backstageIdentity: { @@ -187,18 +179,15 @@ describe('Oauth2ProxyAuthProvider', () => { }); it('should create a valid provider', async () => { - const providerOptions = { + const factory = createOauth2ProxyProvider({ authHandler, signIn: { resolver: signInResolver }, - } as Oauth2ProxyProviderOptions; - const factoryOptions = { + }); + const handler = factory({ logger, catalogApi: {}, tokenIssuer: {}, - } as unknown as AuthProviderFactoryOptions; - - const factory = createOauth2ProxyProvider(providerOptions); - const handler = factory(factoryOptions); + } as any); await handler.refresh!(mockRequest, mockResponse); expect(mockRequest.header).toBeCalledWith(OAUTH2_PROXY_JWT_HEADER); diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts index 8c4dcc3249..374811198f 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.ts @@ -15,20 +15,18 @@ */ import express from 'express'; -import { Logger } from 'winston'; import { AuthenticationError } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { AuthHandler, SignInResolver, - AuthProviderFactory, AuthProviderRouteHandlers, AuthResponse, + AuthResolverContext, } from '../types'; -import { CatalogIdentityClient } from '../../lib/catalog'; import { JWT } from 'jose'; -import { TokenIssuer } from '../../identity/types'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; export const OAUTH2_PROXY_JWT_HEADER = 'X-OAUTH2-PROXY-ID-TOKEN'; @@ -51,9 +49,8 @@ export type OAuth2ProxyResult = { }; /** - * Options for the oauth2-proxy provider factory - * * @public + * @deprecated This type has been inlined into the create method and will be removed. */ export type Oauth2ProxyProviderOptions = { /** @@ -73,28 +70,22 @@ export type Oauth2ProxyProviderOptions = { }; interface Options { - logger: Logger; + resolverContext: AuthResolverContext; signInResolver: SignInResolver>; authHandler: AuthHandler>; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; } export class Oauth2ProxyAuthProvider implements AuthProviderRouteHandlers { - private readonly logger: Logger; - private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly resolverContext: AuthResolverContext; private readonly signInResolver: SignInResolver< OAuth2ProxyResult >; private readonly authHandler: AuthHandler>; - private readonly tokenIssuer: TokenIssuer; constructor(options: Options) { - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; - this.tokenIssuer = options.tokenIssuer; + this.resolverContext = options.resolverContext; this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; } @@ -106,17 +97,10 @@ export class Oauth2ProxyAuthProvider async refresh(req: express.Request, res: express.Response): Promise { try { const result = this.getResult(req); - const response = await this.handleResult(result); - res.json(response); } catch (e) { - this.logger.error( - `Exception occurred during ${OAUTH2_PROXY_JWT_HEADER} refresh`, - e, - ); - res.status(401); - res.end(); + throw new AuthenticationError('Refresh failed', e); } } @@ -127,20 +111,14 @@ export class Oauth2ProxyAuthProvider private async handleResult( result: OAuth2ProxyResult, ): Promise> { - const ctx = { - logger: this.logger, - tokenIssuer: this.tokenIssuer, - catalogIdentityClient: this.catalogIdentityClient, - }; - - const { profile } = await this.authHandler(result, ctx); + const { profile } = await this.authHandler(result, this.resolverContext); const backstageSignInResult = await this.signInResolver( { result, profile, }, - ctx, + this.resolverContext, ); return { @@ -174,26 +152,41 @@ export class Oauth2ProxyAuthProvider } /** - * Factory function for oauth2-proxy auth provider + * Auth provider integration for oauth2-proxy auth * * @public */ -export const createOauth2ProxyProvider = - ( - options: Oauth2ProxyProviderOptions, - ): AuthProviderFactory => - ({ catalogApi, logger, tokenIssuer, tokenManager }) => { - const signInResolver = options.signIn.resolver; - const authHandler = options.authHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); - return new Oauth2ProxyAuthProvider({ - logger, - signInResolver, - authHandler, - tokenIssuer, - catalogIdentityClient, - }); - }; +export const oauth2Proxy = createAuthProviderIntegration({ + create(options: { + /** + * Configure an auth handler to generate a profile for 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>; + }; + }) { + return ({ resolverContext }) => { + const signInResolver = options.signIn.resolver; + const authHandler = options.authHandler; + return new Oauth2ProxyAuthProvider({ + resolverContext, + signInResolver, + authHandler, + }); + }; + }, +}); + +/** + * @public + * @deprecated Use `providers.oauth2Proxy.create` instead + */ +export const createOauth2ProxyProvider = oauth2Proxy.create; diff --git a/plugins/auth-backend/src/providers/oauth2/provider.test.ts b/plugins/auth-backend/src/providers/oauth2/provider.test.ts index 7030d91343..23e08dd729 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.test.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.test.ts @@ -17,9 +17,7 @@ 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'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -30,19 +28,8 @@ const mockFrameHandler = jest.spyOn( 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, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index d09337f5ad..8b0eac948a 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; @@ -42,13 +38,11 @@ import { } from '../../lib/passport'; import { AuthHandler, - AuthProviderFactory, + AuthResolverContext, RedirectInfo, SignInResolver, } from '../types'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity'; -import { Logger } from 'winston'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; type PrivateInfo = { refreshToken: string; @@ -57,12 +51,10 @@ type PrivateInfo = { export type OAuth2AuthProviderOptions = OAuthProviderOptions & { signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; authorizationUrl: string; tokenUrl: string; scope?: string; - logger: Logger; + resolverContext: AuthResolverContext; includeBasicAuth?: boolean; }; @@ -70,16 +62,12 @@ 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; + private readonly resolverContext: AuthResolverContext; constructor(options: OAuth2AuthProviderOptions) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this._strategy = new OAuth2Strategy( { @@ -167,12 +155,7 @@ export class OAuth2AuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -190,7 +173,7 @@ export class OAuth2AuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -202,109 +185,77 @@ export class OAuth2AuthProvider implements OAuthHandlers { } } -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 entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type OAuth2ProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createOAuth2Provider = ( - options?: OAuth2ProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = envConfig.getString('authorizationUrl'); - const tokenUrl = envConfig.getString('tokenUrl'); - const scope = envConfig.getOptionalString('scope'); - const includeBasicAuth = envConfig.getOptionalBoolean('includeBasicAuth'); - const disableRefresh = - envConfig.getOptionalBoolean('disableRefresh') ?? false; +/** + * Auth provider integration for generic OAuth2 auth + * + * @public + */ +export const oauth2 = createAuthProviderIntegration({ + create(options?: { + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = envConfig.getString('authorizationUrl'); + const tokenUrl = envConfig.getString('tokenUrl'); + const scope = envConfig.getOptionalString('scope'); + const includeBasicAuth = + envConfig.getOptionalBoolean('includeBasicAuth'); + const disableRefresh = + envConfig.getOptionalBoolean('disableRefresh') ?? false; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); + 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, + callbackUrl, + signInResolver: options?.signIn?.resolver, + authHandler, + authorizationUrl, + tokenUrl, + scope, + includeBasicAuth, + resolverContext, }); - const provider = new OAuth2AuthProvider({ - clientId, - clientSecret, - tokenIssuer, - catalogIdentityClient, - callbackUrl, - signInResolver, - authHandler, - authorizationUrl, - tokenUrl, - scope, - logger, - includeBasicAuth, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh, + providerId, + callbackUrl, + }); }); + }, +}); - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.oauth2.create` instead + */ +export const createOAuth2Provider = oauth2.create; diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index d9902b600e..5312789a28 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -23,9 +23,8 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; import { OAuthAdapter } from '../../lib/oauth'; -import { AuthProviderFactoryOptions } from '../types'; import { createOidcProvider, OidcAuthProvider, Options } from './provider'; -import { getVoidLogger } from '@backstage/backend-common'; +import { AuthResolverContext } from '../types'; const issuerMetadata = { issuer: 'https://oidc.test', @@ -43,23 +42,13 @@ const issuerMetadata = { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; -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, + resolverContext: {} as AuthResolverContext, callbackUrl: 'https://oidc.test/callback', clientId: 'testclientid', clientSecret: 'testclientsecret', @@ -178,14 +167,13 @@ describe('OidcAuthProvider', () => { metadataUrl: 'https://oidc.test/.well-known/openid-configuration', }, } as any); - const options = { + const provider = createOidcProvider()({ globalConfig: { appUrl: 'https://oidc.test', baseUrl: 'https://oidc.test', }, config, - } as AuthProviderFactoryOptions; - const provider = createOidcProvider()(options) as OAuthAdapter; + } as any) as OAuthAdapter; expect(provider.start).toBeDefined(); // Cast provider as any here to be able to inspect private members await (provider as any).handlers.get('testEnv').handlers.implementation; diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index b4f34cd127..ed6bdf8c34 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import { Client, @@ -43,13 +39,11 @@ import { } from '../../lib/passport'; import { AuthHandler, - AuthProviderFactory, + AuthResolverContext, RedirectInfo, SignInResolver, } from '../types'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity'; -import { Logger } from 'winston'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; type PrivateInfo = { refreshToken?: string; @@ -76,9 +70,7 @@ export type Options = OAuthProviderOptions & { tokenSignedResponseAlg?: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; export class OidcAuthProvider implements OAuthHandlers { @@ -88,9 +80,7 @@ export class OidcAuthProvider implements OAuthHandlers { private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; - private readonly tokenIssuer: TokenIssuer; - private readonly catalogIdentityClient: CatalogIdentityClient; - private readonly logger: Logger; + private readonly resolverContext: AuthResolverContext; constructor(options: Options) { this.implementation = this.setupStrategy(options); @@ -98,9 +88,7 @@ export class OidcAuthProvider implements OAuthHandlers { this.prompt = options.prompt; this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; } async start(req: OAuthStartRequest): Promise { @@ -186,12 +174,7 @@ 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 handleResult(result: OidcAuthResult): Promise { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { idToken: result.tokenset.id_token, @@ -207,7 +190,7 @@ export class OidcAuthProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -215,122 +198,80 @@ export class OidcAuthProvider implements OAuthHandlers { } } -export const oidcDefaultSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Profile contained no email'); - } - - const userId = profile.email.split('@')[0]; - - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - /** - * OIDC provider callback options. An auth handler and a sign in resolver - * can be passed while creating a OIDC provider. - * - * authHandler : called after sign in was successful, a new object must be returned which includes a profile - * signInResolver: called after sign in was successful, expects to return a new {@link @backstage/plugin-auth-node#BackstageSignInResult} - * - * Both options are optional. There is fallback for authHandler where the default handler expect an e-mail explicitly - * otherwise it throws an error - * * @public + * @deprecated This type has been inlined into the create method and will be removed. */ export type OidcProviderOptions = { authHandler?: AuthHandler; signIn?: { - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createOidcProvider = ( - options?: OidcProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const metadataUrl = envConfig.getString('metadataUrl'); - const tokenSignedResponseAlg = envConfig.getOptionalString( - 'tokenSignedResponseAlg', - ); - const scope = envConfig.getOptionalString('scope'); - const prompt = envConfig.getOptionalString('prompt'); - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); +/** + * Auth provider integration for generic OpenID Connect auth + * + * @public + */ +export const oidc = createAuthProviderIntegration({ + create(options?: { + authHandler?: AuthHandler; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ userinfo }) => ({ - profile: { - displayName: userinfo.name, - email: userinfo.email, - picture: userinfo.picture, - }, - }); - const signInResolverFn = - options?.signIn?.resolver ?? oidcDefaultSignInResolver; - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const metadataUrl = envConfig.getString('metadataUrl'); + const tokenSignedResponseAlg = envConfig.getOptionalString( + 'tokenSignedResponseAlg', + ); + const scope = envConfig.getOptionalString('scope'); + const prompt = envConfig.getOptionalString('prompt'); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ userinfo }) => ({ + profile: { + displayName: userinfo.name, + email: userinfo.email, + picture: userinfo.picture, + }, + }); + + const provider = new OidcAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + metadataUrl, + scope, + prompt, + signInResolver: options?.signIn?.resolver, + authHandler, + resolverContext, }); - const provider = new OidcAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenSignedResponseAlg, - metadataUrl, - scope, - prompt, - signInResolver, - authHandler, - logger, - tokenIssuer, - catalogIdentityClient, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, +}); - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.oidc.create` instead + */ +export const createOidcProvider = oidc.create; diff --git a/plugins/auth-backend/src/providers/okta/provider.test.ts b/plugins/auth-backend/src/providers/okta/provider.test.ts index 939981e81c..468c4cdcc8 100644 --- a/plugins/auth-backend/src/providers/okta/provider.test.ts +++ b/plugins/auth-backend/src/providers/okta/provider.test.ts @@ -17,9 +17,7 @@ import { OktaAuthProvider } 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'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -30,19 +28,8 @@ const mockFrameHandler = jest.spyOn( describe('createOktaProvider', () => { it('should auth', async () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new OktaAuthProvider({ - logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 26f586c578..b83e640731 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import { OAuthAdapter, @@ -41,15 +37,13 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { - AuthProviderFactory, AuthHandler, RedirectInfo, SignInResolver, + AuthResolverContext, } from '../types'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { StateStore } from 'passport-oauth2'; -import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; -import { TokenIssuer } from '../../identity'; -import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -59,18 +53,14 @@ export type OktaAuthProviderOptions = OAuthProviderOptions & { audience: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; export class OktaAuthProvider 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; + private readonly strategy: any; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly resolverContext: AuthResolverContext; /** * Due to passport-okta-oauth forcing options.state = true, @@ -80,7 +70,7 @@ export class OktaAuthProvider implements OAuthHandlers { * passport-oauth2, which is the StateStore implementation used when options.state = false, * allowing us to avoid using express-session in order to integrate with Okta. */ - private _store: StateStore = { + private store: StateStore = { store(_req: express.Request, cb: any) { cb(null, null); }, @@ -90,20 +80,18 @@ export class OktaAuthProvider implements OAuthHandlers { }; constructor(options: OktaAuthProviderOptions) { - this._signInResolver = options.signInResolver; - this._authHandler = options.authHandler; - this._tokenIssuer = options.tokenIssuer; - this._catalogIdentityClient = options.catalogIdentityClient; - this._logger = options.logger; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.resolverContext = options.resolverContext; - this._strategy = new OktaStrategy( + this.strategy = new OktaStrategy( { clientID: options.clientId, clientSecret: options.clientSecret, callbackURL: options.callbackUrl, audience: options.audience, passReqToCallback: false as true, - store: this._store, + store: this.store, response_type: 'code', }, ( @@ -130,7 +118,7 @@ export class OktaAuthProvider implements OAuthHandlers { } async start(req: OAuthStartRequest): Promise { - return await executeRedirectStrategy(req, this._strategy, { + return await executeRedirectStrategy(req, this.strategy, { accessType: 'offline', prompt: 'consent', scope: req.scope, @@ -142,7 +130,7 @@ export class OktaAuthProvider implements OAuthHandlers { const { result, privateInfo } = await executeFrameHandlerStrategy< OAuthResult, PrivateInfo - >(req, this._strategy); + >(req, this.strategy); return { response: await this.handleResult(result), @@ -153,13 +141,13 @@ export class OktaAuthProvider implements OAuthHandlers { async refresh(req: OAuthRefreshRequest) { const { accessToken, refreshToken, params } = await executeRefreshTokenStrategy( - this._strategy, + this.strategy, req.refreshToken, req.scope, ); const fullProfile = await executeFetchUserProfileStrategy( - this._strategy, + this.strategy, accessToken, ); @@ -174,12 +162,7 @@ export class OktaAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const context = { - logger: this._logger, - catalogIdentityClient: this._catalogIdentityClient, - tokenIssuer: this._tokenIssuer, - }; - const { profile } = await this._authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -191,13 +174,13 @@ export class OktaAuthProvider implements OAuthHandlers { profile, }; - if (this._signInResolver) { - response.backstageIdentity = await this._signInResolver( + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( { result, profile, }, - context, + this.resolverContext, ); } @@ -205,57 +188,10 @@ export class OktaAuthProvider implements OAuthHandlers { } } -export const oktaEmailSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Okta profile contained no email'); - } - - const entity = await ctx.catalogIdentityClient.findUser({ - annotations: { - 'okta.com/email': profile.email, - }, - }); - - const claims = getEntityClaims(entity); - const token = await ctx.tokenIssuer.issueToken({ claims }); - - return { id: entity.metadata.name, entity, token }; -}; - -export const oktaDefaultSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const { profile } = info; - - if (!profile.email) { - throw new Error('Okta profile contained no email'); - } - - // TODO(Rugvip): Hardcoded to the local part of the email for now - const userId = profile.email.split('@')[0]; - - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: userId, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id: userId, token }; -}; - +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type OktaProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -270,76 +206,104 @@ export type OktaProviderOptions = { /** * Maps an auth result to a Backstage identity for the user. */ - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -export const createOktaProvider = ( - _options?: OktaProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for Okta auth + * + * @public + */ +export const okta = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - // This is a safe assumption as `passport-okta-oauth` uses the audience - // as the base for building the authorization, token, and user info URLs. - // https://github.com/fischerdan/passport-okta-oauth/blob/ea9ac42d/lib/passport-okta-oauth/oauth2.js#L12-L14 - if (!audience.startsWith('https://')) { - throw new Error("URL for 'audience' must start with 'https://'."); - } + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); + // This is a safe assumption as `passport-okta-oauth` uses the audience + // as the base for building the authorization, token, and user info URLs. + // https://github.com/fischerdan/passport-okta-oauth/blob/ea9ac42d/lib/passport-okta-oauth/oauth2.js#L12-L14 + if (!audience.startsWith('https://')) { + throw new Error("URL for 'audience' must start with 'https://'."); + } - const authHandler: AuthHandler = _options?.authHandler - ? _options.authHandler - : async ({ fullProfile, params }) => ({ - profile: makeProfileInfo(fullProfile, params.id_token), - }); + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); - const signInResolverFn = - _options?.signIn?.resolver ?? oktaDefaultSignInResolver; - - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + const provider = new OktaAuthProvider({ + audience, + clientId, + clientSecret, + callbackUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, }); - const provider = new OktaAuthProvider({ - audience, - clientId, - clientSecret, - callbackUrl, - authHandler, - signInResolver, - tokenIssuer, - catalogIdentityClient, - logger, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, + resolvers: { + /** + * Looks up the user by matching their email to the `okta.com/email` annotation. + */ + emailMatchingUserEntityAnnotation(): SignInResolver { + return async (info, ctx) => { + const { profile } = info; - return OAuthAdapter.fromConfig(globalConfig, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'okta.com/email': profile.email, + }, + }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.okta.create` instead + */ +export const createOktaProvider = okta.create; + +/** + * @public + * @deprecated Use `providers.okta.resolvers.emailMatchingUserEntityAnnotation()` instead. + */ +export const oktaEmailSignInResolver = + okta.resolvers.emailMatchingUserEntityAnnotation(); diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts index e14e8548a0..06a57e4560 100644 --- a/plugins/auth-backend/src/providers/onelogin/provider.ts +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -38,13 +38,11 @@ import { } from '../../lib/passport'; import { RedirectInfo, - AuthProviderFactory, AuthHandler, SignInResolver, + AuthResolverContext, } from '../types'; -import { CatalogIdentityClient } from '../../lib/catalog'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; type PrivateInfo = { refreshToken: string; @@ -54,25 +52,19 @@ export type Options = OAuthProviderOptions & { issuer: string; signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; }; 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; + private readonly resolverContext: AuthResolverContext; constructor(options: Options) { this.signInResolver = options.signInResolver; this.authHandler = options.authHandler; - this.tokenIssuer = options.tokenIssuer; - this.catalogIdentityClient = options.catalogIdentityClient; - this.logger = options.logger; + this.resolverContext = options.resolverContext; this._strategy = new OneLoginStrategy( { issuer: options.issuer, @@ -148,12 +140,7 @@ export class OneLoginProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: OAuthResponse = { providerInfo: { @@ -171,7 +158,7 @@ export class OneLoginProvider implements OAuthHandlers { result, profile, }, - context, + this.resolverContext, ); } @@ -179,19 +166,10 @@ export class OneLoginProvider implements OAuthHandlers { } } -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 */ +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type OneLoginProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -210,58 +188,66 @@ export type OneLoginProviderOptions = { }; }; -/** @public */ -export const createOneLoginProvider = ( - options?: OneLoginProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const issuer = envConfig.getString('issuer'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; +/** + * Auth provider integration for OneLogin auth + * + * @public + */ +export const onelogin = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const issuer = envConfig.getString('issuer'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); + + const provider = new OneLoginProvider({ + clientId, + clientSecret, + callbackUrl, + issuer, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + callbackUrl, + }); }); + }, +}); - 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, { - disableRefresh: false, - providerId, - tokenIssuer, - callbackUrl, - }); - }); -}; +/** + * @public + * @deprecated Use `providers.onelogin.create` instead + */ +export const createOneLoginProvider = onelogin.create; diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts new file mode 100644 index 0000000000..3d49e2bbbb --- /dev/null +++ b/plugins/auth-backend/src/providers/providers.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2022 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 { atlassian } from './atlassian/provider'; +import { auth0 } from './auth0/provider'; +import { awsAlb } from './aws-alb/provider'; +import { bitbucket } from './bitbucket/provider'; +import { gcpIap } from './gcp-iap/provider'; +import { github } from './github/provider'; +import { gitlab } from './gitlab/provider'; +import { google } from './google/provider'; +import { microsoft } from './microsoft/provider'; +import { oauth2 } from './oauth2/provider'; +import { oauth2Proxy } from './oauth2-proxy/provider'; +import { oidc } from './oidc/provider'; +import { okta } from './okta/provider'; +import { onelogin } from './onelogin/provider'; +import { saml } from './saml/provider'; + +/** + * All built-in auth provider integrations. + * + * @public + */ +export const providers = Object.freeze({ + atlassian, + auth0, + awsAlb, + bitbucket, + gcpIap, + github, + gitlab, + google, + microsoft, + oauth2, + oauth2Proxy, + oidc, + okta, + onelogin, + saml, +}); diff --git a/plugins/auth-backend/src/providers/resolvers.ts b/plugins/auth-backend/src/providers/resolvers.ts new file mode 100644 index 0000000000..8a2785099f --- /dev/null +++ b/plugins/auth-backend/src/providers/resolvers.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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 { SignInResolver } from './types'; + +/** + * A common sign-in resolver that looks up the user using the local part of + * their email address as the entity name. + */ +export const commonByEmailLocalPartResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Login failed, user profile does not contain an email'); + } + const [localPart] = profile.email.split('@'); + + return ctx.signInWithCatalogUser({ + entityRef: { name: localPart }, + }); +}; diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts index 0aea660941..8d36e17d29 100644 --- a/plugins/auth-backend/src/providers/saml/index.ts +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -export { createSamlProvider } from './provider'; +export { + createSamlProvider, + samlNameIdEntityNameSignInResolver, +} 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 4458d423ef..4f5076b410 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; import express from 'express'; import { SamlConfig } from 'passport-saml/lib/passport-saml/types'; import { @@ -32,16 +28,14 @@ import { } from '../../lib/passport'; import { AuthProviderRouteHandlers, - AuthProviderFactory, AuthHandler, SignInResolver, AuthResponse, + AuthResolverContext, } 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'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { AuthenticationError, isError } from '@backstage/errors'; import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse'; /** @public */ @@ -52,9 +46,7 @@ export type SamlAuthResult = { type Options = SamlConfig & { signInResolver?: SignInResolver; authHandler: AuthHandler; - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + resolverContext: AuthResolverContext; appUrl: string; }; @@ -62,18 +54,14 @@ 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 resolverContext: AuthResolverContext; 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.resolverContext = options.resolverContext; this.strategy = new SamlStrategy({ ...options }, (( fullProfile: SamlProfile, done: PassportDoneCallback, @@ -97,18 +85,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { res: express.Response, ): Promise { try { - const context = { - logger: this.logger, - catalogIdentityClient: this.catalogIdentityClient, - tokenIssuer: this.tokenIssuer, - }; - const { result } = await executeFrameHandlerStrategy( req, this.strategy, ); - const { profile } = await this.authHandler(result, context); + const { profile } = await this.authHandler(result, this.resolverContext); const response: AuthResponse<{}> = { profile, @@ -121,7 +103,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { result, profile, }, - context, + this.resolverContext, ); response.backstageIdentity = @@ -148,31 +130,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers { } } -const samlDefaultSignInResolver: SignInResolver = async ( - info, - ctx, -) => { - const id = info.result.fullProfile.nameID; - - const entityRef = stringifyEntityRef({ - kind: 'User', - namespace: DEFAULT_NAMESPACE, - name: id, - }); - - const token = await ctx.tokenIssuer.issueToken({ - claims: { - sub: entityRef, - ent: [entityRef], - }, - }); - - return { id, token }; -}; - type SignatureAlgorithm = 'sha1' | 'sha256' | 'sha512'; -/** @public */ +/** + * @public + * @deprecated This type has been inlined into the create method and will be removed. + */ export type SamlProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -187,70 +150,96 @@ export type SamlProviderOptions = { /** * Maps an auth result to a Backstage identity for the user. */ - resolver?: SignInResolver; + resolver: SignInResolver; }; }; -/** @public */ -export const createSamlProvider = ( - options?: SamlProviderOptions, -): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - tokenIssuer, - tokenManager, - catalogApi, - logger, - }) => { - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); +/** + * Auth provider integration for SAML auth + * + * @public + */ +export const saml = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; - const authHandler: AuthHandler = options?.authHandler - ? options.authHandler - : async ({ fullProfile }) => ({ - profile: { - email: fullProfile.email, - displayName: fullProfile.displayName, - }, - }); + /** + * 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; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => { + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: { + email: fullProfile.email, + displayName: fullProfile.displayName, + }, + }); - const signInResolverFn = - options?.signIn?.resolver ?? samlDefaultSignInResolver; + return new SamlAuthProvider({ + callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, + entryPoint: config.getString('entryPoint'), + logoutUrl: config.getOptionalString('logoutUrl'), + audience: config.getOptionalString('audience'), + issuer: config.getString('issuer'), + cert: config.getString('cert'), + privateKey: config.getOptionalString('privateKey'), + authnContext: config.getOptionalStringArray('authnContext'), + identifierFormat: config.getOptionalString('identifierFormat'), + decryptionPvk: config.getOptionalString('decryptionPvk'), + signatureAlgorithm: config.getOptionalString('signatureAlgorithm') as + | SignatureAlgorithm + | undefined, + digestAlgorithm: config.getOptionalString('digestAlgorithm'), + acceptedClockSkewMs: config.getOptionalNumber('acceptedClockSkewMs'), - const signInResolver: SignInResolver = info => - signInResolverFn(info, { - catalogIdentityClient, - tokenIssuer, - logger, + appUrl: globalConfig.appUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, }); + }; + }, + resolvers: { + /** + * Looks up the user by matching their nameID to the entity name. + */ + nameIdMatchingUserEntityName(): SignInResolver { + return async (info, ctx) => { + const id = info.result.fullProfile.nameID; - return new SamlAuthProvider({ - callbackUrl: `${globalConfig.baseUrl}/${providerId}/handler/frame`, - entryPoint: config.getString('entryPoint'), - logoutUrl: config.getOptionalString('logoutUrl'), - audience: config.getOptionalString('audience'), - issuer: config.getString('issuer'), - cert: config.getString('cert'), - privateKey: config.getOptionalString('privateKey'), - authnContext: config.getOptionalStringArray('authnContext'), - identifierFormat: config.getOptionalString('identifierFormat'), - decryptionPvk: config.getOptionalString('decryptionPvk'), - signatureAlgorithm: config.getOptionalString('signatureAlgorithm') as - | SignatureAlgorithm - | undefined, - digestAlgorithm: config.getOptionalString('digestAlgorithm'), - acceptedClockSkewMs: config.getOptionalNumber('acceptedClockSkewMs'), + if (!id) { + throw new AuthenticationError('No nameID found in SAML response'); + } - tokenIssuer, - appUrl: globalConfig.appUrl, - authHandler, - signInResolver, - logger, - catalogIdentityClient, - }); - }; -}; + return ctx.signInWithCatalogUser({ + entityRef: { name: id }, + }); + }; + }, + }, +}); + +/** + * @public + * @deprecated Use `providers.saml.create` instead + */ +export const createSamlProvider = saml.create; + +/** + * @public + * @deprecated Use `providers.saml.resolvers.nameIdMatchingUserEntityName()` instead. + */ +export const samlNameIdEntityNameSignInResolver = + saml.resolvers.nameIdMatchingUserEntityName(); diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 5bd52f0c94..128dea12ef 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,7 +18,7 @@ import { PluginEndpointDiscovery, TokenManager, } from '@backstage/backend-common'; -import { CatalogApi } from '@backstage/catalog-client'; +import { CatalogApi, GetEntitiesRequest } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { BackstageIdentityResponse, @@ -26,9 +26,42 @@ import { } from '@backstage/plugin-auth-node'; import express from 'express'; import { Logger } from 'winston'; -import { TokenIssuer } from '../identity/types'; +import { TokenIssuer, TokenParams } from '../identity/types'; import { OAuthStartRequest } from '../lib/oauth/types'; import { CatalogIdentityClient } from '../lib/catalog'; +import { Entity } from '@backstage/catalog-model'; + +/** + * A query for a single user in the catalog. + * + * If `entityRef` is used, the default kind is `'User'`. + * + * If `annotations` are used, all annotations must be present and + * match the provided value exactly. Only entities of kind `'User'` will be considered. + * + * If `filter` are used they are passed on as they are to the `CatalogApi`. + * + * Regardless of the query method, the query must match exactly one entity + * in the catalog, or an error will be thrown. + * + * @public + */ +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: Exclude; + }; /** * The context that is used for auth processing. @@ -36,9 +69,36 @@ import { CatalogIdentityClient } from '../lib/catalog'; * @public */ export type AuthResolverContext = { - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; + /** @deprecated Will be removed from the context, access it via a closure instead if needed */ logger: Logger; + /** @deprecated Use the `issueToken` method instead */ + tokenIssuer: TokenIssuer; + /** @deprecated Use the `findCatalogUser` and `signInWithCatalogUser` methods instead, and the `getDefaultOwnershipEntityRefs` helper */ + catalogIdentityClient: CatalogIdentityClient; + + /** + * Issues a Backstage token using the provided parameters. + */ + issueToken(params: TokenParams): Promise<{ token: string }>; + + /** + * Finds a single user in the catalog using the provided query. + * + * See {@link AuthResolverCatalogUserQuery} for details. + */ + findCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise<{ entity: Entity }>; + + /** + * Finds a single user in the catalog using the provided query, and then + * issues an identity for that user using default ownership resolution. + * + * See {@link AuthResolverCatalogUserQuery} for details. + */ + signInWithCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise; }; /** @@ -54,6 +114,7 @@ export type CookieConfigurer = (ctx: { callbackUrl: string; }) => { domain: string; path: string; secure: boolean }; +/** @public */ export type AuthProviderConfig = { /** * The protocol://domain[:port] where the app is hosted. This is used to construct the @@ -143,6 +204,9 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } +/** + * @deprecated This type is deprecated and will be removed in a future release. + */ export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -154,10 +218,24 @@ export type AuthProviderFactoryOptions = { catalogApi: CatalogApi; }; -export type AuthProviderFactory = ( - options: AuthProviderFactoryOptions, -) => AuthProviderRouteHandlers; +export type AuthProviderFactory = (options: { + providerId: string; + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + resolverContext: AuthResolverContext; + /** @deprecated This field has been deprecated and needs to be passed directly to the auth provider instead */ + tokenManager: TokenManager; + /** @deprecated This field has been deprecated and needs to be passed directly to the auth provider instead */ + tokenIssuer: TokenIssuer; + /** @deprecated This field has been deprecated and needs to be passed directly to the auth provider instead */ + discovery: PluginEndpointDiscovery; + /** @deprecated This field has been deprecated and needs to be passed directly to the auth provider instead */ + catalogApi: CatalogApi; +}) => AuthProviderRouteHandlers; + +/** @public */ export type AuthResponse = { providerInfo: ProviderInfo; profile: ProfileInfo; @@ -245,6 +323,7 @@ export type AuthHandler = ( context: AuthResolverContext, ) => Promise; +/** @public */ export type StateEncoder = ( req: OAuthStartRequest, ) => Promise<{ encodedState: string }>; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index ec1a52ffc9..80b8a24022 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -34,6 +34,7 @@ import { createOidcRouter, TokenFactory, KeyStores } from '../identity'; import session from 'express-session'; import passport from 'passport'; import { Minimatch } from 'minimatch'; +import { CatalogAuthResolverContext } from '../lib/resolvers'; type ProviderFactories = { [s: string]: AuthProviderFactory }; @@ -122,6 +123,12 @@ export async function createRouter( tokenIssuer, discovery, catalogApi, + resolverContext: CatalogAuthResolverContext.create({ + logger, + catalogApi, + tokenIssuer, + tokenManager, + }), }); const r = Router();