Merge pull request #10300 from backstage/rugvip/default-resolvers

auth-backend: remove all default sign-in resolvers
This commit is contained in:
Patrik Oldsberg
2022-04-12 13:43:33 +02:00
committed by GitHub
49 changed files with 2486 additions and 1928 deletions
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
**DEPRECATION**: The `getEntityClaims` helper has been deprecated, with `getDefaultOwnershipEntityRefs` being added to replace it.
+63
View File
@@ -0,0 +1,63 @@
---
'@backstage/plugin-auth-backend': patch
---
**DEPRECATION**: All `create<Provider>Provider` and `<provider>*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<Router> {
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<Router> {
return await createRouter({
...env,
providerFactories: {
...defaultAuthProviderFactories,
google: providers.google.create({
signIn: {
resolver:
providers.google.resolvers.emailMatchingUserEntityAnnotation(),
},
}),
},
});
}
```
+58
View File
@@ -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.
+5
View File
@@ -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).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added exports of the following types: `AuthProviderConfig`, `StateEncoder`, `TokenParams`, `AwsAlbResult`.
+64 -1
View File
@@ -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,
},
});
},
},
}),
},
});
}
+495 -128
View File
@@ -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<RedirectInfo>;
}
// 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<OAuthResult>;
signIn?: {
@@ -52,7 +52,7 @@ export type AtlassianProviderOptions = {
};
};
// @public (undocumented)
// @public @deprecated (undocumented)
export type Auth0ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
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<void>;
}
// @public
export type AuthResolverCatalogUserQuery =
| {
entityRef:
| string
| {
kind?: string;
namespace?: string;
name: string;
};
}
| {
annotations: Record<string, string>;
}
| {
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
};
// @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<BackstageSignInResult>;
};
// 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: ProviderInfo;
@@ -118,9 +159,7 @@ export type AuthResponse<ProviderInfo> = {
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<AwsAlbResult>;
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<OAuthResult>;
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<BitbucketOAuthResult>;
// @public @deprecated (undocumented)
export const bitbucketUserIdSignInResolver: SignInResolver<OAuthResult>;
// 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<BitbucketOAuthResult>;
// @public @deprecated (undocumented)
export const bitbucketUsernameSignInResolver: SignInResolver<OAuthResult>;
// 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
// @public (undocumented)
// @public @deprecated (undocumented)
export const createAuth0Provider: (
options?: Auth0ProviderOptions | undefined,
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| 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<AwsAlbResult> | undefined;
signIn: {
resolver: SignInResolver<AwsAlbResult>;
};
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
// @public
export function createGcpIapProvider(
options: GcpIapProviderOptions,
): AuthProviderFactory;
// @public @deprecated (undocumented)
export const createGcpIapProvider: (options: {
authHandler?: AuthHandler<GcpIapResult> | undefined;
signIn: {
resolver: SignInResolver<GcpIapResult>;
};
}) => 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<GithubOAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<GithubOAuthResult>;
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
// @public
export const createOauth2ProxyProvider: <JWTPayload>(
options: Oauth2ProxyProviderOptions<JWTPayload>,
) => AuthProviderFactory;
// @public @deprecated (undocumented)
export const createOauth2ProxyProvider: (options: {
authHandler: AuthHandler<OAuth2ProxyResult<unknown>>;
signIn: {
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
};
}) => 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<OidcAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OidcAuthResult>;
}
| 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<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
// @public (undocumented)
// @public @deprecated (undocumented)
export const createOneLoginProvider: (
options?: OneLoginProviderOptions | undefined,
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| 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<express.Router>;
// @public (undocumented)
// @public @deprecated (undocumented)
export const createSamlProvider: (
options?: SamlProviderOptions | undefined,
options?:
| {
authHandler?: AuthHandler<SamlAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<SamlAuthResult>;
}
| 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<GcpIapResult>;
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<GithubOAuthResult>;
signIn?: {
resolver?: SignInResolver<GithubOAuthResult>;
resolver: SignInResolver<GithubOAuthResult>;
};
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<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
// 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<OAuthResult>;
// 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<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
// 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<OAuthResult>;
// 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<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
// 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<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
// @public
// @public @deprecated (undocumented)
export type Oauth2ProxyProviderOptions<JWTPayload> = {
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
signIn: {
@@ -574,30 +704,26 @@ export type OidcAuthResult = {
userinfo: UserinfoResponse;
};
// @public
// @public @deprecated (undocumented)
export type OidcProviderOptions = {
authHandler?: AuthHandler<OidcAuthResult>;
signIn?: {
resolver?: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OidcAuthResult>;
};
};
// 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<OAuthResult>;
// 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<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
// @public (undocumented)
// @public @deprecated (undocumented)
export type OneLoginProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
@@ -626,6 +752,236 @@ export type ProfileInfo = {
picture?: string;
};
// @public
export const providers: Readonly<{
atlassian: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
auth0: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
awsAlb: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<AwsAlbResult> | undefined;
signIn: {
resolver: SignInResolver<AwsAlbResult>;
};
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
bitbucket: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
usernameMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
userIdMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
gcpIap: Readonly<{
create: (options: {
authHandler?: AuthHandler<GcpIapResult> | undefined;
signIn: {
resolver: SignInResolver<GcpIapResult>;
};
}) => AuthProviderFactory;
resolvers: never;
}>;
github: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<GithubOAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<GithubOAuthResult>;
}
| undefined;
stateEncoder?: StateEncoder | undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
usernameMatchingUserEntityName: () => SignInResolver<GithubOAuthResult>;
}>;
}>;
gitlab: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
google: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
microsoft: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
oauth2: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
oauth2Proxy: Readonly<{
create: (options: {
authHandler: AuthHandler<OAuth2ProxyResult<unknown>>;
signIn: {
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
};
}) => AuthProviderFactory;
resolvers: never;
}>;
oidc: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OidcAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OidcAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
okta: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
onelogin: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: never;
}>;
saml: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<SamlAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<SamlAuthResult>;
}
| undefined;
}
| undefined,
) => AuthProviderFactory;
resolvers: Readonly<{
nameIdMatchingUserEntityName(): SignInResolver<SamlAuthResult>;
}>;
}>;
}>;
// 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<SamlAuthResult>;
// @public @deprecated (undocumented)
export type SamlProviderOptions = {
authHandler?: AuthHandler<SamlAuthResult>;
signIn?: {
resolver?: SignInResolver<SamlAuthResult>;
resolver: SignInResolver<SamlAuthResult>;
};
};
@@ -676,9 +1035,12 @@ export type SignInResolver<TAuthResult> = (
context: AuthResolverContext,
) => Promise<BackstageSignInResult>;
// 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<string>;
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
```
+9 -1
View File
@@ -22,7 +22,11 @@ export interface AnyJWK extends Record<string, string> {
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 = {
/**
+3 -1
View File
@@ -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';
@@ -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);
@@ -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<string, string> = {
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 };
}
}
@@ -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';
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export const atlassianDefaultAuthHandler: AuthHandler<OAuthResult> = async ({
@@ -66,14 +62,10 @@ export class AtlassianAuthProvider implements OAuthHandlers {
private readonly _strategy: AtlassianStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<OAuthResponse> {
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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> =
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<OAuthResult> =
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;
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<OAuthResult> = 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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> = 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<OAuthResult> = 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;
@@ -15,4 +15,4 @@
*/
export { createAwsAlbProvider } from './provider';
export type { AwsAlbProviderOptions } from './provider';
export type { AwsAlbProviderOptions, AwsAlbResult } from './provider';
@@ -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<any>;
@@ -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,
);
});
});
});
@@ -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<AwsAlbResult>;
signInResolver: SignInResolver<AwsAlbResult>;
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<AwsAlbProviderInfo>;
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<AwsAlbResult>;
private readonly signInResolver: SignInResolver<AwsAlbResult>;
@@ -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<AwsAlbResponse> {
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<AwsAlbResult>;
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<AwsAlbResult>;
};
}) {
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<AwsAlbResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const authHandler: AuthHandler<AwsAlbResult> = 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;
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<BitbucketOAuthResult>;
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<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<BitbucketOAuthResult> =
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<OAuthResult> {
return async (info, ctx) => {
const { result } = info;
const authHandler: AuthHandler<BitbucketOAuthResult> =
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<OAuthResult> {
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();
@@ -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<any>;
},
>(config: {
create: (...args: TCreateOptions) => AuthProviderFactory;
resolvers?: TResolvers;
}): Readonly<{
create: (...args: TCreateOptions) => AuthProviderFactory;
// If no resolvers are defined, this receives the type `never`
resolvers: Readonly<string extends keyof TResolvers ? never : TResolvers>;
}> {
return Object.freeze({
...config,
resolvers: Object.freeze(config.resolvers ?? ({} as any)),
});
}
@@ -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"] }
@@ -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<GcpIapResult>;
private readonly signInResolver: SignInResolver<GcpIapResult>;
private readonly tokenValidator: (token: string) => Promise<TokenPayload>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
private readonly resolverContext: AuthResolverContext;
constructor(options: {
authHandler: AuthHandler<GcpIapResult>;
signInResolver: SignInResolver<GcpIapResult>;
tokenValidator: (token: string) => Promise<TokenPayload>;
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<GcpIapResult>;
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<GcpIapResult>;
};
}) {
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;
@@ -71,9 +71,8 @@ export type GcpIapProviderInfo = {
export type GcpIapResponse = AuthResponse<GcpIapProviderInfo>;
/**
* Options for {@link createGcpIapProvider}.
*
* @public
* @deprecated This type has been inlined into the create method and will be removed.
*/
export type GcpIapProviderOptions = {
/**
@@ -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',
@@ -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<GithubOAuthResult>;
authHandler: AuthHandler<GithubOAuthResult>;
stateEncoder: StateEncoder;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
private readonly signInResolver?: SignInResolver<GithubOAuthResult>;
private readonly authHandler: AuthHandler<GithubOAuthResult>;
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<GithubOAuthResult>;
resolver: SignInResolver<GithubOAuthResult>;
};
/**
@@ -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<GithubOAuthResult>;
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<GithubOAuthResult>;
};
const authHandler: AuthHandler<GithubOAuthResult> = 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<GithubOAuthResult> = 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<GithubOAuthResult> = 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<GithubOAuthResult> => {
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;
@@ -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',
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export const gitlabDefaultSignInResolver: SignInResolver<OAuthResult> = 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<OAuthResult> = async ({
@@ -101,14 +78,10 @@ export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<OAuthResponse> {
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<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> =
options?.authHandler ?? gitlabDefaultAuthHandler;
const authHandler: AuthHandler<OAuthResult> =
options?.authHandler ?? gitlabDefaultAuthHandler;
const signInResolverFn =
options?.signIn?.resolver ?? gitlabDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = 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;
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
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<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<RedirectInfo> {
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<OAuthResult> = 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<OAuthResult> = 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<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? googleDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = 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<OAuthResult> {
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();
+7 -6
View File
@@ -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';
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
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<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<string | undefined> {
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<string | undefined> {
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<OAuthResult> = 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<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? microsoftDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = 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<OAuthResult> {
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();
@@ -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<any>({
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<any>;
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);
@@ -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<JWTPayload> = {
};
/**
* 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<JWTPayload> = {
/**
@@ -73,28 +70,22 @@ export type Oauth2ProxyProviderOptions<JWTPayload> = {
};
interface Options<JWTPayload> {
logger: Logger;
resolverContext: AuthResolverContext;
signInResolver: SignInResolver<OAuth2ProxyResult<JWTPayload>>;
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
}
export class Oauth2ProxyAuthProvider<JWTPayload>
implements AuthProviderRouteHandlers
{
private readonly logger: Logger;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly resolverContext: AuthResolverContext;
private readonly signInResolver: SignInResolver<
OAuth2ProxyResult<JWTPayload>
>;
private readonly authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
private readonly tokenIssuer: TokenIssuer;
constructor(options: Options<JWTPayload>) {
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<JWTPayload>
async refresh(req: express.Request, res: express.Response): Promise<void> {
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<JWTPayload>
private async handleResult(
result: OAuth2ProxyResult<JWTPayload>,
): Promise<AuthResponse<{ accessToken: string }>> {
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<JWTPayload>
}
/**
* Factory function for oauth2-proxy auth provider
* Auth provider integration for oauth2-proxy auth
*
* @public
*/
export const createOauth2ProxyProvider =
<JWTPayload>(
options: Oauth2ProxyProviderOptions<JWTPayload>,
): AuthProviderFactory =>
({ catalogApi, logger, tokenIssuer, tokenManager }) => {
const signInResolver = options.signIn.resolver;
const authHandler = options.authHandler;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenManager,
});
return new Oauth2ProxyAuthProvider<JWTPayload>({
logger,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
});
};
export const oauth2Proxy = createAuthProviderIntegration({
create<JWTPayload>(options: {
/**
* Configure an auth handler to generate a profile for the user.
*/
authHandler: AuthHandler<OAuth2ProxyResult<JWTPayload>>;
/**
* 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<OAuth2ProxyResult<JWTPayload>>;
};
}) {
return ({ resolverContext }) => {
const signInResolver = options.signIn.resolver;
const authHandler = options.authHandler;
return new Oauth2ProxyAuthProvider<JWTPayload>({
resolverContext,
signInResolver,
authHandler,
});
};
},
});
/**
* @public
* @deprecated Use `providers.oauth2Proxy.create` instead
*/
export const createOauth2ProxyProvider = oauth2Proxy.create;
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
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<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<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 OAuth2ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
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<OAuthResult>;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenManager,
});
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
}) {
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<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? oAuth2DefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = 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;
@@ -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;
@@ -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<OidcAuthResult>;
authHandler: AuthHandler<OidcAuthResult>;
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<OidcAuthResult>;
private readonly authHandler: AuthHandler<OidcAuthResult>;
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<RedirectInfo> {
@@ -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<OAuthResponse> {
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<OidcAuthResult> = 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<OidcAuthResult>;
signIn?: {
resolver?: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OidcAuthResult>;
};
};
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<OidcAuthResult>;
const authHandler: AuthHandler<OidcAuthResult> = 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<OidcAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
signIn?: {
resolver: SignInResolver<OidcAuthResult>;
};
}) {
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<OidcAuthResult> = 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;
@@ -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,
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export class OktaAuthProvider implements OAuthHandlers {
private readonly _strategy: any;
private readonly _signInResolver?: SignInResolver<OAuthResult>;
private readonly _authHandler: AuthHandler<OAuthResult>;
private readonly _tokenIssuer: TokenIssuer;
private readonly _catalogIdentityClient: CatalogIdentityClient;
private readonly _logger: Logger;
private readonly strategy: any;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<RedirectInfo> {
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<OAuthResult> = 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<OAuthResult> = 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<OAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
};
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<OAuthResult>;
// 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<OAuthResult>;
};
}) {
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<OAuthResult> = _options?.authHandler
? _options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
_options?.signIn?.resolver ?? oktaDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = 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<OAuthResult> {
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();
@@ -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<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
resolverContext: AuthResolverContext;
};
export class OneLoginProvider implements OAuthHandlers {
private readonly _strategy: any;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
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<OAuthResult> = 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<OAuthResult>;
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<OAuthResult>;
};
}) {
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<OAuthResult> = 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<OAuthResult> = 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;
@@ -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,
});
@@ -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<unknown> = 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 },
});
};
@@ -14,5 +14,8 @@
* limitations under the License.
*/
export { createSamlProvider } from './provider';
export {
createSamlProvider,
samlNameIdEntityNameSignInResolver,
} from './provider';
export type { SamlProviderOptions, SamlAuthResult } from './provider';
@@ -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<SamlAuthResult>;
authHandler: AuthHandler<SamlAuthResult>;
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<SamlAuthResult>;
private readonly authHandler: AuthHandler<SamlAuthResult>;
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<SamlAuthResult>,
@@ -97,18 +85,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
res: express.Response,
): Promise<void> {
try {
const context = {
logger: this.logger,
catalogIdentityClient: this.catalogIdentityClient,
tokenIssuer: this.tokenIssuer,
};
const { result } = await executeFrameHandlerStrategy<SamlAuthResult>(
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<SamlAuthResult> = 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<SamlAuthResult>;
resolver: SignInResolver<SamlAuthResult>;
};
};
/** @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<SamlAuthResult>;
const authHandler: AuthHandler<SamlAuthResult> = 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<SamlAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) => {
const authHandler: AuthHandler<SamlAuthResult> = 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<SamlAuthResult> = 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<SamlAuthResult> {
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();
+86 -7
View File
@@ -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<string, string>;
}
| {
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
};
/**
* 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<BackstageSignInResult>;
};
/**
@@ -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<void>;
}
/**
* @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: ProviderInfo;
profile: ProfileInfo;
@@ -245,6 +323,7 @@ export type AuthHandler<TAuthResult> = (
context: AuthResolverContext,
) => Promise<AuthHandlerResult>;
/** @public */
export type StateEncoder = (
req: OAuthStartRequest,
) => Promise<{ encodedState: string }>;
@@ -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();