Merge pull request #4532 from backstage/rugvip/authin
auth-backend: implement sign-in resolver and profile transform for google provider
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Adds support for custom sign-in resolvers and profile transformations for the
|
||||
Google auth provider.
|
||||
|
||||
Adds an `ent` claim in Backstage tokens, with a list of
|
||||
[entity references](https://backstage.io/docs/features/software-catalog/references)
|
||||
related to your signed-in user's identities and groups across multiple systems.
|
||||
|
||||
Adds an optional `providerFactories` argument to the `createRouter` exported by
|
||||
the `auth-backend` plugin.
|
||||
|
||||
Updates `BackstageIdentity` so that
|
||||
|
||||
- `idToken` is deprecated in favor of `token`
|
||||
- An optional `entity` field is added which represents the entity that the user is represented by within Backstage.
|
||||
|
||||
More information:
|
||||
|
||||
- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver)
|
||||
explains the concepts and shows how to implement your own.
|
||||
- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089)
|
||||
RFC contains details about how this affects ownership in the catalog
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
id: identity-resolver
|
||||
title: Identity resolver
|
||||
description: Identity resolvers of Backstage users after they sign-in
|
||||
---
|
||||
|
||||
This guide explains how the identity of a Backstage user is stored inside their
|
||||
Backstage Identity Token and how you can customize the Sign-In resolvers to
|
||||
include identity and group membership information of the user from other
|
||||
external systems. This ultimately helps with determining the ownership of a
|
||||
Backstage entity by a user. The ideas here were originally proposed in the RFC
|
||||
[#4089](https://github.com/backstage/backstage/issues/4089).
|
||||
|
||||
When a user signs in to Backstage, inside the `claims` field of their Backstage
|
||||
Token (which are standard JWT tokens) a special `ent` claim is set. `ent`
|
||||
contains a list of
|
||||
[entity references](../features/software-catalog/references.md), each of which
|
||||
denotes an identity or a membership that is relevant to the user. There is no
|
||||
guarantee that these correspond to actual existing catalog entities.
|
||||
|
||||
Let's take an example sign-in resolver for the Google auth provider and explore
|
||||
how the `ent` field inside `claims` can be set.
|
||||
|
||||
Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom
|
||||
sign-in resolvers and set them for any of the Authentication providers inside
|
||||
`providerFactories` of the `createRouter` imported from the
|
||||
`@backstage/plugin-auth-backend` plugin.
|
||||
|
||||
```ts
|
||||
export default async function createPlugin({
|
||||
...
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
...
|
||||
providerFactories: {
|
||||
google: createGoogleProvider({
|
||||
signIn: {
|
||||
resolver: async ({ profile: { email } }, ctx) => {
|
||||
// Call a custom validator function that checks that the email is
|
||||
// valid and on our own company's domain, and throws an Error if it
|
||||
// isn't
|
||||
validateEmail(email);
|
||||
|
||||
// List of entity references that denote the identity and
|
||||
// membership of the user
|
||||
const ent = [];
|
||||
|
||||
// Let's use the username in the email ID as the user's default
|
||||
// unique identifier inside Backstage.
|
||||
const [id] = email.split('@');
|
||||
ent.push(`User:default/${id}`)
|
||||
|
||||
// Let's call the internal LDAP provider to get a list of groups
|
||||
// that the user belongs to, and add those to the list as well
|
||||
const ldapGroups = await getLdapGroups(email);
|
||||
ldapGroups.forEach(group => ent.push(`Group:default/${group}`))
|
||||
|
||||
// Issue the token containing the entity claims
|
||||
const token = await ctx.tokenIssuer.issueToken({
|
||||
claims: { sub: id, ent },
|
||||
});
|
||||
return { id, token };
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
As you can see, the generated Backstage Token now contains all the claims about
|
||||
the identity and membership of the user. Once the sign-in process is complete,
|
||||
and we need to find out if a user owns an Entity in the Software Catalog, these
|
||||
`ent` claims can be used to determine the ownership.
|
||||
|
||||
According to the RFC, the definition of the ownership of an entity E, for a user
|
||||
U, is as follows:
|
||||
|
||||
- Get all the `ownedBy` relations of E, and call them O
|
||||
- Get all the claims of the user U and call them C
|
||||
- If any C matches any O, return `true`
|
||||
- Get all Group entities that U is a member of, using the regular
|
||||
`memberOf`/`hasMember` relation mechanism, and call them G
|
||||
- If any G matches any O, return `true`
|
||||
- Otherwise, return `false`
|
||||
|
||||
## Default sign-in resolvers
|
||||
|
||||
Of course you don't have to customize the sign-in resolver if you don't need to.
|
||||
The Auth backend plugin comes with a set of default sign-in resolvers which you
|
||||
can use. For example - the Google provider has a default email-based sign-in
|
||||
resolver, which will search the catalog for a single user entity that has a
|
||||
matching `google.com/email` annotation.
|
||||
|
||||
It can be enabled like this
|
||||
|
||||
```tsx
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend';
|
||||
|
||||
export default async function createPlugin({
|
||||
...
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
...
|
||||
providerFactories: {
|
||||
google: createGoogleProvider({
|
||||
signIn: {
|
||||
resolver: googleEmailSignInResolver
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
## AuthHandler
|
||||
|
||||
Similar to a custom sign-in resolver, you can also write a custom auth handler
|
||||
function which is used to verify and convert the auth response into the profile
|
||||
that will be presented to the user. This is where you can customize things like
|
||||
display name and profile picture.
|
||||
|
||||
This is also the place where you can do authorization and validation of the user
|
||||
and throw errors if the user should not be allowed access in Backstage.
|
||||
|
||||
```tsx
|
||||
// File: packages/backend/src/plugins/auth.ts
|
||||
export default async function createPlugin({
|
||||
...
|
||||
}: PluginEnvironment): Promise<Router> {
|
||||
return await createRouter({
|
||||
...
|
||||
providerFactories: {
|
||||
google: createGoogleProvider({
|
||||
authHandler: async ({
|
||||
fullProfile // Type: passport.Profile,
|
||||
idToken // Type: (Optional) string,
|
||||
}) => {
|
||||
// Custom validation code goes here
|
||||
return {
|
||||
profile: {
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -205,6 +205,7 @@
|
||||
},
|
||||
"auth/add-auth-provider",
|
||||
"auth/using-auth",
|
||||
"auth/identity-resolver",
|
||||
"auth/auth-backend",
|
||||
"auth/oauth",
|
||||
"auth/auth-backend-classes",
|
||||
|
||||
@@ -132,6 +132,7 @@ nav:
|
||||
- OneLogin: 'auth/onelogin/provider.md'
|
||||
- Adding authentication providers: 'auth/add-auth-provider.md'
|
||||
- Using authentication and identity: 'auth/using-auth.md'
|
||||
- Sign in resolvers: 'auth/identity-resolver.md'
|
||||
- Auth backend: 'auth/auth-backend.md'
|
||||
- OAuth and OpenID Connect: 'auth/oauth.md'
|
||||
- Auth backend classes: 'auth/auth-backend-classes.md'
|
||||
|
||||
@@ -67,13 +67,14 @@ export class TokenFactory implements TokenIssuer {
|
||||
|
||||
const iss = this.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / MS_IN_S);
|
||||
const exp = iat + this.keyDurationSeconds;
|
||||
|
||||
this.logger.info(`Issuing token for ${sub}`);
|
||||
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
|
||||
|
||||
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
|
||||
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
|
||||
alg: key.alg,
|
||||
kid: key.kid,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export type TokenParams = {
|
||||
claims: {
|
||||
/** The token subject, i.e. User ID */
|
||||
sub: string;
|
||||
/** A list of entity references that the user claims ownership through */
|
||||
ent?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
import { CatalogIdentityClient } from './CatalogIdentityClient';
|
||||
|
||||
describe('CatalogIdentityClient', () => {
|
||||
@@ -29,25 +30,36 @@ describe('CatalogIdentityClient', () => {
|
||||
getLocationByEntity: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
};
|
||||
const tokenIssuer: jest.Mocked<TokenIssuer> = {
|
||||
issueToken: jest.fn(),
|
||||
listPublicKeys: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('passes through the correct search params', async () => {
|
||||
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
|
||||
tokenIssuer.issueToken.mockResolvedValue('my-token');
|
||||
const client = new CatalogIdentityClient({
|
||||
catalogApi: catalogApi as CatalogApi,
|
||||
catalogApi: catalogApi,
|
||||
tokenIssuer: tokenIssuer,
|
||||
});
|
||||
|
||||
client.findUser({ annotations: { key: 'value' } });
|
||||
await client.findUser({ annotations: { key: 'value' } });
|
||||
|
||||
expect(catalogApi.getEntities).toBeCalledWith(
|
||||
expect(catalogApi.getEntities).toHaveBeenCalledWith(
|
||||
{
|
||||
filter: {
|
||||
kind: 'user',
|
||||
'metadata.annotations.key': 'value',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ token: 'my-token' },
|
||||
);
|
||||
expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
|
||||
claims: {
|
||||
sub: 'backstage.io/auth-backend',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
|
||||
type UserQuery = {
|
||||
annotations: Record<string, string>;
|
||||
@@ -27,9 +28,11 @@ type UserQuery = {
|
||||
*/
|
||||
export class CatalogIdentityClient {
|
||||
private readonly catalogApi: CatalogApi;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
|
||||
constructor(options: { catalogApi: CatalogApi }) {
|
||||
constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }) {
|
||||
this.catalogApi = options.catalogApi;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,10 +40,7 @@ export class CatalogIdentityClient {
|
||||
*
|
||||
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
|
||||
*/
|
||||
async findUser(
|
||||
query: UserQuery,
|
||||
options?: { token?: string },
|
||||
): Promise<UserEntity> {
|
||||
async findUser(query: UserQuery): Promise<UserEntity> {
|
||||
const filter: Record<string, string> = {
|
||||
kind: 'user',
|
||||
};
|
||||
@@ -48,7 +48,11 @@ export class CatalogIdentityClient {
|
||||
filter[`metadata.annotations.${key}`] = value;
|
||||
}
|
||||
|
||||
const { items } = await this.catalogApi.getEntities({ filter }, options);
|
||||
// TODO(Rugvip): cache the token
|
||||
const token = await this.tokenIssuer.issueToken({
|
||||
claims: { sub: 'backstage.io/auth-backend' },
|
||||
});
|
||||
const { items } = await this.catalogApi.getEntities({ filter }, { token });
|
||||
|
||||
if (items.length !== 1) {
|
||||
if (items.length > 1) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 {
|
||||
RELATION_MEMBER_OF,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { TokenParams } from '../../identity';
|
||||
|
||||
export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
|
||||
const userRef = stringifyEntityRef(entity);
|
||||
|
||||
const membershipRefs =
|
||||
entity.relations
|
||||
?.filter(
|
||||
r =>
|
||||
r.type === RELATION_MEMBER_OF &&
|
||||
r.target.kind.toLocaleLowerCase('en-US') === 'group',
|
||||
)
|
||||
.map(r => stringifyEntityRef(r.target)) ?? [];
|
||||
|
||||
return {
|
||||
sub: userRef,
|
||||
ent: [userRef, ...membershipRefs],
|
||||
};
|
||||
}
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { CatalogIdentityClient } from './CatalogIdentityClient';
|
||||
export { getEntityClaims } from './helpers';
|
||||
|
||||
@@ -14,5 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createGoogleProvider } from './provider';
|
||||
export {
|
||||
createGoogleProvider,
|
||||
googleDefaultSignInResolver,
|
||||
googleEmailSignInResolver,
|
||||
} from './provider';
|
||||
export type { GoogleProviderOptions } from './provider';
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { 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';
|
||||
|
||||
const mockFrameHandler = (jest.spyOn(
|
||||
helpers,
|
||||
'executeFrameHandlerStrategy',
|
||||
) as unknown) as jest.MockedFunction<
|
||||
() => Promise<{ result: OAuthResult; privateInfo: any }>
|
||||
>;
|
||||
|
||||
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,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: {
|
||||
email: fullProfile.emails![0]!.value,
|
||||
displayName: fullProfile.displayName,
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
}),
|
||||
clientId: 'mock',
|
||||
clientSecret: 'mock',
|
||||
callbackUrl: 'mock',
|
||||
});
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: {
|
||||
fullProfile: {
|
||||
emails: [{ value: 'conrad@example.com' }],
|
||||
displayName: 'Conrad',
|
||||
id: 'conrad',
|
||||
provider: 'google',
|
||||
},
|
||||
params: {
|
||||
id_token: 'idToken',
|
||||
scope: 'scope',
|
||||
expires_in: 123,
|
||||
},
|
||||
accessToken: 'accessToken',
|
||||
},
|
||||
privateInfo: {
|
||||
refreshToken: 'wacka',
|
||||
},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual({
|
||||
providerInfo: {
|
||||
accessToken: 'accessToken',
|
||||
expiresInSeconds: 123,
|
||||
idToken: 'idToken',
|
||||
scope: 'scope',
|
||||
},
|
||||
profile: {
|
||||
email: 'conrad@example.com',
|
||||
displayName: 'Conrad',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,8 @@
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Logger } from 'winston';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
|
||||
import {
|
||||
encodeState,
|
||||
OAuthAdapter,
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
OAuthProviderOptions,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
OAuthStartRequest,
|
||||
OAuthResult,
|
||||
OAuthStartRequest,
|
||||
} from '../../lib/oauth';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
@@ -38,30 +38,40 @@ import {
|
||||
makeProfileInfo,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import { AuthProviderFactory, RedirectInfo } from '../types';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import {
|
||||
AuthProviderFactory,
|
||||
AuthHandler,
|
||||
RedirectInfo,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type Options = OAuthProviderOptions & {
|
||||
logger: Logger;
|
||||
identityClient: CatalogIdentityClient;
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class GoogleAuthProvider implements OAuthHandlers {
|
||||
private readonly _strategy: GoogleStrategy;
|
||||
private readonly logger: Logger;
|
||||
private readonly identityClient: CatalogIdentityClient;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.logger = options.logger;
|
||||
this.identityClient = options.identityClient;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
// TODO: throw error if env variables not set?
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
this._strategy = new GoogleStrategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
@@ -111,18 +121,8 @@ export class GoogleAuthProvider implements OAuthHandlers {
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
|
||||
|
||||
return {
|
||||
response: await this.populateIdentity({
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
}),
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
@@ -133,89 +133,170 @@ export class GoogleAuthProvider implements OAuthHandlers {
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
const profile = makeProfileInfo(fullProfile, params.id_token);
|
||||
|
||||
return this.populateIdentity({
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
private async populateIdentity(
|
||||
response: OAuthResponse,
|
||||
): Promise<OAuthResponse> {
|
||||
const { profile } = response;
|
||||
private async handleResult(result: OAuthResult) {
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Google profile contained no email');
|
||||
}
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
try {
|
||||
const token = await this.tokenIssuer.issueToken({
|
||||
claims: { sub: 'backstage.io/auth-backend' },
|
||||
});
|
||||
const user = await this.identityClient.findUser(
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
annotations: {
|
||||
'google.com/email': profile.email,
|
||||
},
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
backstageIdentity: {
|
||||
id: user.metadata.name,
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
|
||||
);
|
||||
return {
|
||||
...response,
|
||||
backstageIdentity: { id: profile.email.split('@')[0] },
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type GoogleProviderOptions = {};
|
||||
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 };
|
||||
};
|
||||
|
||||
export 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 token = await ctx.tokenIssuer.issueToken({
|
||||
claims: { sub: userId, ent: [`user:default/${userId}`] },
|
||||
});
|
||||
|
||||
return { id: userId, token };
|
||||
};
|
||||
|
||||
export type GoogleProviderOptions = {
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*
|
||||
* Set to `'email'` to use the default email-based sign in resolver, which will search
|
||||
* the catalog for a single user entity that has a matching `google.com/email` annotation.
|
||||
*/
|
||||
signIn?: {
|
||||
resolver?: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
export const createGoogleProvider = (
|
||||
_options?: GoogleProviderOptions,
|
||||
options?: GoogleProviderOptions,
|
||||
): AuthProviderFactory => {
|
||||
return ({
|
||||
providerId,
|
||||
globalConfig,
|
||||
config,
|
||||
logger,
|
||||
tokenIssuer,
|
||||
catalogApi,
|
||||
logger,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
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,
|
||||
logger,
|
||||
signInResolver,
|
||||
authHandler,
|
||||
tokenIssuer,
|
||||
identityClient: new CatalogIdentityClient({ catalogApi }),
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './google';
|
||||
export { factories as defaultAuthProviderFactories } from './factories';
|
||||
|
||||
// Export the minimal interface required for implementing a
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../identity/types';
|
||||
import { CatalogIdentityClient } from '../lib/catalog';
|
||||
|
||||
export type AuthProviderConfig = {
|
||||
/**
|
||||
@@ -148,14 +150,30 @@ export type AuthResponse<ProviderInfo> = {
|
||||
|
||||
export type BackstageIdentity = {
|
||||
/**
|
||||
* The backstage user ID.
|
||||
* An opaque ID that uniquely identifies the user within Backstage.
|
||||
*
|
||||
* This is typically the same as the user entity `metadata.name`.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* An ID token that can be used to authenticate the user within Backstage.
|
||||
* This is deprecated, use `token` instead.
|
||||
* @deprecated
|
||||
*/
|
||||
idToken?: string;
|
||||
|
||||
/**
|
||||
* The token used to authenticate the user within Backstage.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The entity that the user is represented by within Backstage.
|
||||
*
|
||||
* This entity may or may not exist within the Catalog, and it can be used
|
||||
* to read and store additional metadata about the user.
|
||||
*/
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -179,3 +197,38 @@ export type ProfileInfo = {
|
||||
*/
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export type SignInInfo<AuthResult> = {
|
||||
/**
|
||||
* The simple profile passed down for use in the frontend.
|
||||
*/
|
||||
profile: ProfileInfo;
|
||||
|
||||
/**
|
||||
* The authentication result that was received from the authentication provider.
|
||||
*/
|
||||
result: AuthResult;
|
||||
};
|
||||
|
||||
export type SignInResolver<AuthResult> = (
|
||||
info: SignInInfo<AuthResult>,
|
||||
context: {
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
},
|
||||
) => Promise<BackstageIdentity>;
|
||||
|
||||
export type AuthHandlerResult = { profile: ProfileInfo };
|
||||
|
||||
/**
|
||||
* The AuthHandler function is called every time the user authenticates using the provider.
|
||||
*
|
||||
* The handler should return a profile that represents the session for the user in the frontend.
|
||||
*
|
||||
* Throwing an error in the function will cause the authentication to fail, making it
|
||||
* possible to use this function as a way to limit access to a certain group of users.
|
||||
*/
|
||||
export type AuthHandler<AuthResult> = (
|
||||
input: AuthResult,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
|
||||
Reference in New Issue
Block a user