Merge pull request #10300 from backstage/rugvip/default-resolvers
auth-backend: remove all default sign-in resolvers
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
**DEPRECATION**: The `getEntityClaims` helper has been deprecated, with `getDefaultOwnershipEntityRefs` being added to replace it.
|
||||
@@ -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(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Added exports of the following types: `AuthProviderConfig`, `StateEncoder`, `TokenParams`, `AwsAlbResult`.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user