From 0de80576e3d11725216d04a21c65ab44758250ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Tue, 5 Oct 2021 09:20:07 +0200 Subject: [PATCH 1/3] auth-backend: update aws-alb provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Frinnström --- .../src/providers/aws-alb/provider.test.ts | 265 +++++++++++++----- .../src/providers/aws-alb/provider.ts | 264 +++++++++++++---- plugins/auth-backend/src/providers/index.ts | 1 + plugins/auth-backend/src/providers/types.ts | 14 - 4 files changed, 399 insertions(+), 145 deletions(-) diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts index ca4e521316..2f5ffa1833 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -17,8 +17,14 @@ import { getVoidLogger } from '@backstage/backend-common'; import express from 'express'; import { JWT } from 'jose'; -import { AwsAlbAuthProvider } from './provider'; -import { AuthResponse } from '../types'; +import { + ALB_ACCESSTOKEN_HEADER, + ALB_JWT_HEADER, + AwsAlbAuthProvider, +} from './provider'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; +import { makeProfileInfo } from '../../lib/passport'; const jwtMock = JWT as jest.Mocked; @@ -29,9 +35,21 @@ yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== -----END PUBLIC KEY----- `; }; +const mockJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IktFWV9JRCIsImlzcyI6IklTU1VFUl9VUkwifQ.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlVzZXIgTmFtZSIsImlhdCI6MTUxNjIzOTAyMn0.uMCSBGhij1xn5pnot8XgD-huQuTIBOFGs6kkW_p_X94'; +const mockAccessToken = 'ACCESS_TOKEN'; +const mockClaims = { + sub: '1234567890', + name: 'User Name', + family_name: 'Name', + given_name: 'User', + picture: 'PICTURE_URL', + email: 'user.name@email.test', + exp: 1632833763, + iss: 'ISSUER_URL', +}; jest.mock('jose'); - jest.mock('cross-fetch', () => ({ __esModule: true, default: async () => { @@ -43,53 +61,48 @@ jest.mock('cross-fetch', () => ({ }, })); -const identityResolutionCallbackMock = async (): Promise> => { - return { - backstageIdentity: { - id: 'foo', - idToken: '', - }, - profile: { - displayName: 'Foo Bar', - }, - providerInfo: {}, - }; -}; - -const identityResolutionCallbackRejectedMock = async (): Promise< - AuthResponse -> => { - throw new Error('failed'); -}; - beforeEach(() => { jest.clearAllMocks(); }); -describe('AwsALBAuthProvider', () => { - const catalogApi = { - addLocation: jest.fn(), - removeLocationById: jest.fn(), - getEntities: jest.fn(), - getOriginLocationByEntity: jest.fn(), - getLocationByEntity: jest.fn(), - getLocationById: jest.fn(), - removeEntityByUid: jest.fn(), - getEntityByName: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), +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(() => { - return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo'; - }), - } as unknown as express.Request; - const mockRequestWithoutJwt = { - header: jest.fn(() => { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return mockJwt; + } else if (name === ALB_ACCESSTOKEN_HEADER) { + return mockAccessToken; + } return undefined; }), } as unknown as express.Request; + const mockRequestWithoutJwt = { + header: jest.fn(name => { + if (name === ALB_ACCESSTOKEN_HEADER) { + return mockAccessToken; + } + return undefined; + }), + } as unknown as express.Request; + const mockRequestWithoutAccessToken = { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return mockJwt; + } + return undefined; + }), + } as unknown as express.Request; + const mockResponse = { end: jest.fn(), header: () => jest.fn(), @@ -97,38 +110,78 @@ describe('AwsALBAuthProvider', () => { status: jest.fn(), } as unknown as express.Response; - describe('should transform to type OAuthResponse', () => { + describe('should transform to type AwsAlbResponse', () => { it('when JWT is valid and identity is resolved successfully', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, }); - jwtMock.verify.mockImplementationOnce(() => ({ - sub: 'foo', - })); + jwtMock.verify.mockReturnValueOnce(mockClaims); await provider.refresh(mockRequest, mockResponse); expect(mockResponse.json).toHaveBeenCalledWith({ backstageIdentity: { - id: 'foo', - idToken: '', + id: 'user.name', + token: 'TOKEN', }, profile: { - displayName: 'Foo Bar', + displayName: 'User Name', + email: 'user.name@email.test', + picture: 'PICTURE_URL', + }, + providerInfo: { + accessToken: mockAccessToken, + expiresInSeconds: mockClaims.exp, }, - providerInfo: {}, }); }); }); + describe('should fail when', () => { + it('Access token is missing', async () => { + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, + }); + + await provider.refresh(mockRequestWithoutAccessToken, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + it('JWT is missing', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, }); await provider.refresh(mockRequestWithoutJwt, mockResponse); @@ -137,10 +190,18 @@ describe('AwsALBAuthProvider', () => { }); it('JWT is invalid', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, }); jwtMock.verify.mockImplementationOnce(() => { @@ -152,11 +213,19 @@ describe('AwsALBAuthProvider', () => { expect(mockResponse.status).toHaveBeenCalledWith(401); }); - it('issuer is invalid', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foobar', + it('issuer is missing', async () => { + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, }); jwtMock.verify.mockReturnValueOnce({}); @@ -165,14 +234,68 @@ describe('AwsALBAuthProvider', () => { expect(mockResponse.status).toHaveBeenCalledWith(401); }); - it('identity resolution callback rejects', async () => { - const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackRejectedMock, - issuer: 'foo', + it('issuer is invalid', async () => { + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, }); - jwtMock.verify.mockReturnValueOnce({}); + jwtMock.verify.mockReturnValueOnce({ + iss: 'INVALID_ISSUE_URL', + }); + + await provider.refresh(mockRequest, mockResponse); + expect(mockResponse.status).toHaveBeenCalledWith(401); + }); + + it('SignInResolver rejects', async () => { + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + signInResolver: async () => { + throw new Error(); + }, + }); + + jwtMock.verify.mockReturnValueOnce(mockClaims); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + }); + + it('AuthHandler rejects', async () => { + const provider = new AwsAlbAuthProvider({ + region: 'eu-west-1', + issuer: 'ISSUER_URL', + logger: getVoidLogger(), + catalogIdentityClient, + tokenIssuer, + authHandler: async () => { + throw new Error(); + }, + signInResolver: async () => { + return { id: 'user.name', token: 'TOKEN' }; + }, + }); + + jwtMock.verify.mockReturnValueOnce(mockClaims); await provider.refresh(mockRequest, mockResponse); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index bd16ae1960..04b192d562 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -14,9 +14,11 @@ * limitations under the License. */ import { + AuthHandler, AuthProviderFactoryOptions, AuthProviderRouteHandlers, - ExperimentalIdentityResolver, + AuthResponse, + SignInResolver, } from '../types'; import express from 'express'; import fetch from 'cross-fetch'; @@ -25,66 +27,101 @@ import { KeyObject } from 'crypto'; import { Logger } from 'winston'; import NodeCache from 'node-cache'; import { JWT } from 'jose'; -import { CatalogApi } from '@backstage/catalog-client'; +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'; -const ALB_JWT_HEADER = 'x-amzn-oidc-data'; -/** - * A callback function that receives a verified JWT and returns a UserEntity - * @param {payload} The verified JWT payload - */ -type AwsAlbAuthProviderOptions = { +export const ALB_JWT_HEADER = 'x-amzn-oidc-data'; +export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken'; + +type Options = { region: string; issuer?: string; - identityResolutionCallback: ExperimentalIdentityResolver; + logger: Logger; + authHandler: AuthHandler; + signInResolver: SignInResolver; + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; }; -export const getJWTHeaders = (input: string) => { + +export const getJWTHeaders = (input: string): AwsAlbHeaders => { const encoded = input.split('.')[0]; return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); }; -export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { - private logger: Logger; - private readonly catalogClient: CatalogApi; - private options: AwsAlbAuthProviderOptions; - private readonly keyCache: NodeCache; +export type AwsAlbHeaders = { + alg: string; + kid: string; + signer: string; + iss: string; + client: string; + exp: number; +}; - constructor( - logger: Logger, - catalogClient: CatalogApi, - options: AwsAlbAuthProviderOptions, - ) { - this.logger = logger; - this.catalogClient = catalogClient; - this.options = options; +export type AwsAlbClaims = { + sub: string; + name: string; + family_name: string; + given_name: string; + picture: string; + email: string; + exp: number; + iss: string; +}; + +export type AwsAlbResult = { + fullProfile: PassportProfile; + expiresInSeconds?: number; + accessToken: string; +}; + +export type AwsAlbProviderInfo = { + /** + * An access token issued for the signed in user. + */ + accessToken: string; + /** + * Expiry of the access token in seconds. + */ + expiresInSeconds?: number; +}; + +export type AwsAlbResponse = AuthResponse; + +export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { + private readonly region: string; + private readonly issuer?: string; + private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; + private readonly keyCache: NodeCache; + private readonly authHandler: AuthHandler; + private readonly signInResolver: SignInResolver; + + constructor(options: Options) { + this.region = options.region; + 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.keyCache = new NodeCache({ stdTTL: 3600 }); } + frameHandler(): Promise { return Promise.resolve(undefined); } async refresh(req: express.Request, res: express.Response): Promise { - const jwt = req.header(ALB_JWT_HEADER); - if (jwt !== undefined) { - try { - const headers = getJWTHeaders(jwt); - const key = await this.getKey(headers.kid); - const payload = JWT.verify(jwt, key); - - if (this.options.issuer && headers.iss !== this.options.issuer) { - throw new Error('issuer mismatch on JWT'); - } - - const resolvedEntity = await this.options.identityResolutionCallback( - payload, - this.catalogClient, - ); - res.json(resolvedEntity); - } catch (e) { - this.logger.error('exception occurred during JWT processing', e); - res.status(401); - res.end(); - } - } else { + try { + const result = await this.getResult(req); + 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(); } @@ -94,13 +131,85 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { return Promise.resolve(undefined); } + private async getResult(req: express.Request): Promise { + const jwt = req.header(ALB_JWT_HEADER); + const accessToken = req.header(ALB_ACCESSTOKEN_HEADER); + + if (jwt === undefined) { + throw new AuthenticationError( + `Missing ALB OIDC header: ${ALB_JWT_HEADER}`, + ); + } + + if (accessToken === undefined) { + throw new AuthenticationError( + `Missing ALB OIDC header: ${ALB_ACCESSTOKEN_HEADER}`, + ); + } + + try { + const headers = getJWTHeaders(jwt); + const key = await this.getKey(headers.kid); + const claims = JWT.verify(jwt, key) as AwsAlbClaims; + + if (this.issuer && claims.iss !== this.issuer) { + throw new AuthenticationError('Issuer mismatch on JWT token'); + } + + const fullProfile: PassportProfile = { + provider: 'unknown', + id: claims.sub, + displayName: claims.name, + username: claims.email.split('@')[0].toLowerCase(), + name: { + familyName: claims.family_name, + givenName: claims.given_name, + }, + emails: [{ value: claims.email.toLowerCase() }], + photos: [{ value: claims.picture }], + }; + + return { + fullProfile, + expiresInSeconds: claims.exp, + accessToken, + }; + } catch (e) { + throw new Error(`Exception occurred during JWT processing: ${e}`); + } + } + + private async handleResult(result: AwsAlbResult): Promise { + const { profile } = await this.authHandler(result); + const backstageIdentity = await this.signInResolver( + { + result, + profile, + }, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, + }, + ); + + return { + providerInfo: { + accessToken: result.accessToken, + expiresInSeconds: result.expiresInSeconds, + }, + backstageIdentity, + profile, + }; + } + async getKey(keyId: string): Promise { const optionalCacheKey = this.keyCache.get(keyId); if (optionalCacheKey) { return crypto.createPublicKey(optionalCacheKey); } const keyText: string = await fetch( - `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, + `https://public-keys.auth.elb.${this.region}.amazonaws.com/${keyId}`, ).then(response => response.text()); const keyValue = crypto.createPublicKey(keyText); this.keyCache.set(keyId, keyValue.export({ format: 'pem', type: 'spki' })); @@ -108,26 +217,61 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { } } -export type AwsAlbProviderOptions = {}; +export type AwsAlbProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; -export const createAwsAlbProvider = (_options?: AwsAlbProviderOptions) => { + /** + * 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; + }; +}; + +export const createAwsAlbProvider = (options?: AwsAlbProviderOptions) => { return ({ - logger, - catalogApi, config, - identityResolver, + tokenIssuer, + catalogApi, + logger, }: AuthProviderFactoryOptions) => { const region = config.getString('region'); const issuer = config.getOptionalString('iss'); - if (identityResolver !== undefined) { - return new AwsAlbAuthProvider(logger, catalogApi, { - region, - issuer, - identityResolutionCallback: identityResolver, - }); + + if (options?.signIn.resolver === undefined) { + throw new Error( + 'SignInResolver is required to use this authentication provider', + ); } - throw new Error( - 'Identity resolver is required to use this authentication provider', - ); + + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + + const signInResolver = options?.signIn.resolver; + + return new AwsAlbAuthProvider({ + region, + issuer, + signInResolver, + authHandler, + tokenIssuer, + catalogIdentityClient, + logger, + }); }; }; diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 90466a8094..e63616ab91 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -21,6 +21,7 @@ export * from './microsoft'; export * from './oauth2'; export * from './okta'; export * from './bitbucket'; +export * from './aws-alb'; export { factories as defaultAuthProviderFactories } from './factories'; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 6f6df5d904..7f7b841c68 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -119,19 +119,6 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } -/** - * EXPERIMENTAL - this will almost certainly break in a future release. - * - * Used to resolve an identity from auth information in some auth providers. - */ -export type ExperimentalIdentityResolver = ( - /** - * An object containing information specific to the auth provider. - */ - payload: object, - catalogApi: CatalogApi, -) => Promise>; - export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -140,7 +127,6 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; - identityResolver?: ExperimentalIdentityResolver; }; export type AuthProviderFactory = ( From 0cfeea8f8f350e406c9a3257defcd5f79ef642e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Wed, 6 Oct 2021 08:09:57 +0200 Subject: [PATCH 2/3] Add changeset and update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Frinnström --- .changeset/breezy-dolphins-lay.md | 8 ++ .../docs/tutorials/aws-alb-aad-oidc-auth.md | 76 +++++++++++++------ 2 files changed, 60 insertions(+), 24 deletions(-) create mode 100644 .changeset/breezy-dolphins-lay.md diff --git a/.changeset/breezy-dolphins-lay.md b/.changeset/breezy-dolphins-lay.md new file mode 100644 index 0000000000..0197979531 --- /dev/null +++ b/.changeset/breezy-dolphins-lay.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +AWS-ALB: update provider to the latest changes described [here](https://backstage.io/docs/auth/identity-resolver). + +This removes the `ExperimentalIdentityResolver` type in favor of `SignInResolver` and `AuthHandler`. +The AWS ALB provider can now be configured in the same way as the Google provider in the example. diff --git a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md index b121bab25f..6b1b8cb4e7 100644 --- a/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md +++ b/contrib/docs/tutorials/aws-alb-aad-oidc-auth.md @@ -100,17 +100,16 @@ const app = createApp({ ### Backend -When using ALB auth it is not possible to leverage the built-in auth config discovery mechanism implemented in the app created by default; bespoke logic needs to be implemented. +When using ALB auth you can configure it as described [here](https://backstage.io/docs/auth/identity-resolver). -- replace the content of `packages/backend/plugin/auth.ts` with the below +- replace the content of `packages/backend/plugin/auth.ts` with the below and tweak it according to your needs. ```ts import { createRouter, - AuthResponse, - AuthProviderFactoryOptions, - defaultAuthProviderFactories, + createAwsAlbProvider, } from '@backstage/plugin-auth-backend'; +import { Router } from 'express'; import { PluginEnvironment } from '../types'; export default async function createPlugin({ @@ -118,30 +117,59 @@ export default async function createPlugin({ database, config, discovery, -}: PluginEnvironment) { - const identityResolver = (payload: any): Promise> => { - return Promise.resolve({ - providerInfo: {}, - profile: { - email: payload.email, - displayName: payload.name, - picture: payload.picture, - }, - backstageIdentity: { - id: payload.email, - }, - }); - }; - const providerFactories = { - awsalb: (options: AuthProviderFactoryOptions) => - defaultAuthProviderFactories.awsalb({ ...options, identityResolver }), - }; +}: PluginEnvironment): Promise { return await createRouter({ logger, config, database, discovery, - providerFactories, + providerFactories: { + awsalb: createAwsAlbProvider({ + authHandler: async ({ fullProfile }) => { + let email: string | undefined = undefined; + if (fullProfile.emails && fullProfile.emails.length > 0) { + const [firstEmail] = fullProfile.emails; + email = firstEmail.value; + } + + let picture: string | undefined = undefined; + if (fullProfile.photos && fullProfile.photos.length > 0) { + const [firstPhoto] = fullProfile.photos; + picture = firstPhoto.value; + } + + const displayName: string | undefined = + fullProfile.displayName ?? fullProfile.username ?? fullProfile.id; + + return { + profile: { + email, + picture, + displayName, + }, + }; + }, + signIn: { + resolver: async ({ profile: { email } }, ctx) => { + const [id] = email?.split('@') ?? ''; + // Fetch from an external system that returns entity claims like: + // ['user:default/breanna.davison', ...] + const ent = [`user:default/${id}`]; + + // Resolve group membership from the Backstage catalog + const fullEnt = + await ctx.catalogIdentityClient.resolveCatalogMembership({ + entityRefs: [id].concat(ent), + logger: ctx.logger, + }); + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent: fullEnt }, + }); + return { id, token }; + }, + }, + }), + }, }); } ``` From f29ad9fe6d08e63acd8c335b8448fa7f6f22283c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Frinnstr=C3=B6m?= Date: Wed, 6 Oct 2021 08:13:19 +0200 Subject: [PATCH 3/3] Update api-report.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mattias Frinnström --- plugins/auth-backend/api-report.md | 30 +++++++++++++++---- .../src/providers/aws-alb/provider.ts | 13 ++++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 0b9f0eb8c4..0bdd73f3e8 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; @@ -32,7 +34,6 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; - identityResolver?: ExperimentalIdentityResolver; }; // Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag @@ -74,6 +75,16 @@ export type AuthResponse = { backstageIdentity?: BackstageIdentity; }; +// 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) +export type AwsAlbProviderOptions = { + authHandler?: AuthHandler; + signIn: { + resolver: SignInResolver; + }; +}; + // Warning: (ae-missing-release-tag) "BackstageIdentity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -135,6 +146,13 @@ export const bitbucketUserIdSignInResolver: SignInResolver // @public (undocumented) export const bitbucketUsernameSignInResolver: SignInResolver; +// 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) +export const createAwsAlbProvider: ( + options?: AwsAlbProviderOptions | 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) @@ -526,9 +544,9 @@ export type WebMessageResponse = // // src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts // 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/bitbucket/provider.d.ts:61:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts -// src/providers/bitbucket/provider.d.ts:69:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative +// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AuthHandler" 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/aws-alb/provider.d.ts:85:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts +// src/providers/types.d.ts:99:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts +// src/providers/types.d.ts:121:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative ``` diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 04b192d562..ac5db5e69f 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -15,7 +15,7 @@ */ import { AuthHandler, - AuthProviderFactoryOptions, + AuthProviderFactory, AuthProviderRouteHandlers, AuthResponse, SignInResolver, @@ -235,13 +235,10 @@ export type AwsAlbProviderOptions = { }; }; -export const createAwsAlbProvider = (options?: AwsAlbProviderOptions) => { - return ({ - config, - tokenIssuer, - catalogApi, - logger, - }: AuthProviderFactoryOptions) => { +export const createAwsAlbProvider = ( + options?: AwsAlbProviderOptions, +): AuthProviderFactory => { + return ({ config, tokenIssuer, catalogApi, logger }) => { const region = config.getString('region'); const issuer = config.getOptionalString('iss');