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] 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 = (