From aca5158c7f03b3d7439aee9d162891fcb8fb5bed Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 12 Jan 2021 14:50:04 -0800 Subject: [PATCH 01/12] feature: add auth backend for AWS ALB --- plugins/auth-backend/package.json | 8 +- .../src/providers/aws-alb/index.ts | 15 ++ .../src/providers/aws-alb/provider.test.ts | 225 ++++++++++++++++++ .../src/providers/aws-alb/provider.ts | 138 +++++++++++ yarn.lock | 27 ++- 5 files changed, 402 insertions(+), 11 deletions(-) create mode 100644 plugins/auth-backend/src/providers/aws-alb/index.ts create mode 100644 plugins/auth-backend/src/providers/aws-alb/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/aws-alb/provider.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8f88c69487..f8de2bc6f1 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -16,6 +16,11 @@ "url": "https://github.com/backstage/backstage", "directory": "plugins/auth-backend" }, + "jest": { + "moduleNameMapper": { + "jose/jwt/verify": "/../../../node_modules/jose/lib/jwt/verify" + } + }, "keywords": [ "backstage" ], @@ -44,7 +49,7 @@ "fs-extra": "^9.0.0", "got": "^11.5.2", "helmet": "^4.0.0", - "jose": "^1.27.1", + "jose": "^3.5.1", "jwt-decode": "^3.1.0", "knex": "^0.21.6", "moment": "^2.26.0", @@ -59,6 +64,7 @@ "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^2.0.0", + "r2": "^2.0.1", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/plugins/auth-backend/src/providers/aws-alb/index.ts b/plugins/auth-backend/src/providers/aws-alb/index.ts new file mode 100644 index 0000000000..863d6e76e1 --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts new file mode 100644 index 0000000000..55c27fd7ff --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -0,0 +1,225 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import * as jwtVerify from 'jose/jwt/verify'; + +import { AwsAlbAuthProvider } from './provider'; +import { UserEntityV1alpha1 } from '@backstage/catalog-model'; + +const mockKey = async () => { + return `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I +yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== +-----END PUBLIC KEY----- +`; +}; + +jest.mock('r2', () => ({ + __esModule: true, + default: () => { + return { + text: mockKey(), + }; + }, +})); + +jest.mock('jose/jwt/verify', () => { + return { + __esModule: true, + default: jest.fn(), + }; +}); + +const identityResolutionCallbackMock = async (): Promise< + UserEntityV1alpha1 +> => { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'foo', + }, + spec: { + memberOf: [], + profile: { + displayName: 'Foo Bar', + }, + }, + }; +}; + +const identityResolutionCallbackRejectedMock = async (): Promise< + UserEntityV1alpha1 +> => { + throw new Error('failed'); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AwsALBAuthProvider', () => { + const catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const tokenIssuer = { + issueToken: async () => { + return ''; + }, + listPublicKeys: jest.fn(), + }; + + const mockResponseSend = jest.fn(); + const mockRequest = ({ + header: jest.fn(() => { + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzc3VlciI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.zUkMYAuMwC1T0tyHMpxXrkbFDa4aGhB8d9um_tI2hsI'; + }), + } as unknown) as express.Request; + const mockRequestWithoutJwt = ({ + header: jest.fn(() => { + return undefined; + }), + } as unknown) as express.Request; + const mockResponse = ({ + header: () => jest.fn(), + send: mockResponseSend, + } as unknown) as express.Response; + + describe('should transform to type OAuthResponse', () => { + it('when JWT is valid and identity is resolved successfully', async () => { + const provider = new AwsAlbAuthProvider( + getVoidLogger(), + catalogApi, + tokenIssuer, + { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }, + ); + + jwtVerify.default.mockImplementationOnce(async () => { + return { + payload: { + sub: 'foo', + }, + }; + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual({ + backstageIdentity: { + id: 'foo', + idToken: '', + }, + profile: { + displayName: 'Foo Bar', + }, + providerInfo: {}, + }); + }); + }); + describe('should fail when', () => { + it('JWT is missing', async () => { + const provider = new AwsAlbAuthProvider( + getVoidLogger(), + catalogApi, + tokenIssuer, + { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }, + ); + + await provider.refresh(mockRequestWithoutJwt, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('JWT is invalid', async () => { + const provider = new AwsAlbAuthProvider( + getVoidLogger(), + catalogApi, + tokenIssuer, + { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }, + ); + + jwtVerify.default.mockImplementationOnce(async () => { + throw new Error('bad JWT'); + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('issuer is invalid', async () => { + const provider = new AwsAlbAuthProvider( + getVoidLogger(), + catalogApi, + tokenIssuer, + { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foobar', + }, + ); + + jwtVerify.default.mockImplementationOnce(async () => { + return {}; + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + + it('identity resolution callback rejects', async () => { + const provider = new AwsAlbAuthProvider( + getVoidLogger(), + catalogApi, + tokenIssuer, + { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackRejectedMock, + issuer: 'foo', + }, + ); + + jwtVerify.default.mockImplementationOnce(async () => { + return {}; + }); + + await provider.refresh(mockRequest, mockResponse); + + expect(mockResponseSend.mock.calls[0][0]).toEqual(401); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts new file mode 100644 index 0000000000..f00543302b --- /dev/null +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -0,0 +1,138 @@ +/* + * 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 { + AuthProviderFactoryOptions, + AuthProviderRouteHandlers, +} from '../types'; +import { TokenIssuer } from '../../identity'; +import express from 'express'; +import r2 from 'r2'; +import * as crypto from 'crypto'; +import { KeyObject } from 'crypto'; +import { Logger } from 'winston'; +import jwtVerify from 'jose/jwt/verify'; +import { CatalogApi } from '@backstage/catalog-client'; +import { UserEntityV1alpha1 } from '@backstage/catalog-model'; + +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 IdentityResolutionCallback = ( + payload: object, + catalogApi: CatalogApi, +) => Promise; +type AwsAlbAuthProviderOptions = { + region: string; + issuer: string; + identityResolutionCallback: IdentityResolutionCallback; +}; +export const getJWTHeaders = (input: string) => { + 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 tokenIssuer: TokenIssuer; + private options: AwsAlbAuthProviderOptions; + private readonly keyCache: { [key: string]: KeyObject }; + + constructor( + logger: Logger, + catalogClient: CatalogApi, + tokenIssuer: TokenIssuer, + options: AwsAlbAuthProviderOptions, + ) { + this.logger = logger; + this.catalogClient = catalogClient; + this.tokenIssuer = tokenIssuer; + this.options = options; + this.keyCache = {}; + } + 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 verifiedToken = await jwtVerify(jwt, key, {}); + + if ( + this.options.issuer !== '' && + headers.issuer !== this.options.issuer + ) { + throw new Error('issuer mismatch on JWT'); + } + + const resolvedEntity = await this.options.identityResolutionCallback( + verifiedToken.payload, + this.catalogClient, + ); + res.send({ + providerInfo: {}, + profile: resolvedEntity?.spec?.profile, + backstageIdentity: { + id: resolvedEntity?.metadata?.name, + idToken: await this.tokenIssuer.issueToken({ + claims: { sub: resolvedEntity?.metadata?.name }, + }), + }, + }); + } catch (e) { + this.logger.error('exception occurred during JWT processing', e); + res.send(401); + } + } else { + res.send(401); + } + } + + start(): Promise { + return Promise.resolve(undefined); + } + + async getKey(keyId: string): Promise { + if (this.keyCache[keyId]) { + return this.keyCache[keyId]; + } + const keyText: string = await r2( + `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, + ).text; + const keyValue = crypto.createPublicKey(keyText); + this.keyCache[keyId] = keyValue; + return keyValue; + } +} + +export const createAwsAlbProvider = ( + { logger, catalogApi, tokenIssuer, config }: AuthProviderFactoryOptions, + identityResolver: IdentityResolutionCallback, +) => { + const region = config.getString('region'); + const issuer = config.getString('iss'); + return new AwsAlbAuthProvider(logger, catalogApi, tokenIssuer, { + region, + issuer, + identityResolutionCallback: identityResolver, + }); +}; diff --git a/yarn.lock b/yarn.lock index 6710c7e8e7..0098954754 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9781,7 +9781,7 @@ case-sensitive-paths-webpack-plugin@^2.2.0: resolved "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7" integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ== -caseless@~0.12.0: +caseless@^0.12.0, caseless@~0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= @@ -16549,13 +16549,6 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.6.3" -jose@^1.27.1: - version "1.27.1" - resolved "https://registry.npmjs.org/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8" - integrity sha512-VyHM6IJPw0TTGqHVNlPWg16/ASDPAmcChcLqSb3WNBvwWFoWPeFqlmAUCm8/oIG1GjZwAlUDuRKFfycowarcVA== - dependencies: - "@panva/asn1.js" "^1.0.0" - jose@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/jose/-/jose-2.0.2.tgz#fb22385b80c658cc7a0cae05b7086c04c6be49f4" @@ -16563,6 +16556,11 @@ jose@^2.0.2: dependencies: "@panva/asn1.js" "^1.0.0" +jose@^3.5.1: + version "3.5.1" + resolved "https://registry.npmjs.org/jose/-/jose-3.5.1.tgz#adc0d5000dbf64a7f4db171d449dbcf6b556b6f0" + integrity sha512-BQQJafCDvsmtc/LaK57cjfhU/1AKBhJSbJhKPq+uhrjMoV/sh3/dbk9cm08nAeSGS6j+sjhWoY+LZPUXiKzYzw== + joycon@^2.2.5: version "2.2.5" resolved "https://registry.npmjs.org/joycon/-/joycon-2.2.5.tgz#8d4cf4cbb2544d7b7583c216fcdfec19f6be1615" @@ -18824,7 +18822,7 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" -node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.0.0-alpha.8, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -21206,6 +21204,15 @@ quick-lru@^5.1.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +r2@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/r2/-/r2-2.0.1.tgz#94cd802ecfce9a622549c8182032d8e4a2b2e612" + integrity sha512-EEmxoxYCe3LHzAUhRIRxdCKERpeRNmlLj6KLUSORqnK6dWl/K5ShmDGZqM2lRZQeqJgF+wyqk0s1M7SWUveNOQ== + dependencies: + caseless "^0.12.0" + node-fetch "^2.0.0-alpha.8" + typedarray-to-buffer "^3.1.2" + raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" @@ -25002,7 +25009,7 @@ typed-rest-client@^1.8.0: tunnel "0.0.6" underscore "1.8.3" -typedarray-to-buffer@^3.1.5: +typedarray-to-buffer@^3.1.2, typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== From 6ef5428e73bd9b52818f2357422d433be697221f Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 12 Jan 2021 14:53:15 -0800 Subject: [PATCH 02/12] fix: add new processor to index --- plugins/auth-backend/src/providers/aws-alb/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/auth-backend/src/providers/aws-alb/index.ts b/plugins/auth-backend/src/providers/aws-alb/index.ts index 863d6e76e1..f8b5c9e5d7 100644 --- a/plugins/auth-backend/src/providers/aws-alb/index.ts +++ b/plugins/auth-backend/src/providers/aws-alb/index.ts @@ -13,3 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export { createAwsAlbProvider } from './provider'; From 0d6ad133c75d0274695f59fd696718dbaa72f3b9 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 12 Jan 2021 15:17:28 -0800 Subject: [PATCH 03/12] fix: simplify identity resolver --- .../src/providers/aws-alb/provider.test.ts | 115 ++++++------------ .../src/providers/aws-alb/provider.ts | 49 +++----- .../auth-backend/src/providers/factories.ts | 2 + plugins/auth-backend/src/providers/types.ts | 6 + 4 files changed, 68 insertions(+), 104 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 55c27fd7ff..93649ce8d7 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -18,7 +18,9 @@ import express from 'express'; import * as jwtVerify from 'jose/jwt/verify'; import { AwsAlbAuthProvider } from './provider'; -import { UserEntityV1alpha1 } from '@backstage/catalog-model'; +import { AuthResponse } from '../types'; + +const mockedJwtVerify = jwtVerify as jest.Mocked; const mockKey = async () => { return `-----BEGIN PUBLIC KEY----- @@ -44,26 +46,21 @@ jest.mock('jose/jwt/verify', () => { }; }); -const identityResolutionCallbackMock = async (): Promise< - UserEntityV1alpha1 -> => { +const identityResolutionCallbackMock = async (): Promise> => { return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: 'foo', + backstageIdentity: { + id: 'foo', + idToken: '', }, - spec: { - memberOf: [], - profile: { - displayName: 'Foo Bar', - }, + profile: { + displayName: 'Foo Bar', }, + providerInfo: {}, }; }; const identityResolutionCallbackRejectedMock = async (): Promise< - UserEntityV1alpha1 + AuthResponse > => { throw new Error('failed'); }; @@ -83,13 +80,6 @@ describe('AwsALBAuthProvider', () => { getEntityByName: jest.fn(), }; - const tokenIssuer = { - issueToken: async () => { - return ''; - }, - listPublicKeys: jest.fn(), - }; - const mockResponseSend = jest.fn(); const mockRequest = ({ header: jest.fn(() => { @@ -108,18 +98,13 @@ describe('AwsALBAuthProvider', () => { describe('should transform to type OAuthResponse', () => { it('when JWT is valid and identity is resolved successfully', async () => { - const provider = new AwsAlbAuthProvider( - getVoidLogger(), - catalogApi, - tokenIssuer, - { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', - }, - ); + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); - jwtVerify.default.mockImplementationOnce(async () => { + mockedJwtVerify.default.mockImplementationOnce(async () => { return { payload: { sub: 'foo', @@ -143,16 +128,11 @@ describe('AwsALBAuthProvider', () => { }); describe('should fail when', () => { it('JWT is missing', async () => { - const provider = new AwsAlbAuthProvider( - getVoidLogger(), - catalogApi, - tokenIssuer, - { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', - }, - ); + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); await provider.refresh(mockRequestWithoutJwt, mockResponse); @@ -160,18 +140,13 @@ describe('AwsALBAuthProvider', () => { }); it('JWT is invalid', async () => { - const provider = new AwsAlbAuthProvider( - getVoidLogger(), - catalogApi, - tokenIssuer, - { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foo', - }, - ); + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foo', + }); - jwtVerify.default.mockImplementationOnce(async () => { + mockedJwtVerify.default.mockImplementationOnce(async () => { throw new Error('bad JWT'); }); @@ -181,18 +156,13 @@ describe('AwsALBAuthProvider', () => { }); it('issuer is invalid', async () => { - const provider = new AwsAlbAuthProvider( - getVoidLogger(), - catalogApi, - tokenIssuer, - { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackMock, - issuer: 'foobar', - }, - ); + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackMock, + issuer: 'foobar', + }); - jwtVerify.default.mockImplementationOnce(async () => { + mockedJwtVerify.default.mockImplementationOnce(async () => { return {}; }); @@ -202,18 +172,13 @@ describe('AwsALBAuthProvider', () => { }); it('identity resolution callback rejects', async () => { - const provider = new AwsAlbAuthProvider( - getVoidLogger(), - catalogApi, - tokenIssuer, - { - region: 'us-west-2', - identityResolutionCallback: identityResolutionCallbackRejectedMock, - issuer: 'foo', - }, - ); + const provider = new AwsAlbAuthProvider(getVoidLogger(), catalogApi, { + region: 'us-west-2', + identityResolutionCallback: identityResolutionCallbackRejectedMock, + issuer: 'foo', + }); - jwtVerify.default.mockImplementationOnce(async () => { + mockedJwtVerify.default.mockImplementationOnce(async () => { return {}; }); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index f00543302b..568fc4dd46 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -16,30 +16,26 @@ import { AuthProviderFactoryOptions, AuthProviderRouteHandlers, + IdentityResolver, } from '../types'; -import { TokenIssuer } from '../../identity'; import express from 'express'; +// @ts-ignore no types available for R2 import r2 from 'r2'; import * as crypto from 'crypto'; import { KeyObject } from 'crypto'; import { Logger } from 'winston'; import jwtVerify from 'jose/jwt/verify'; import { CatalogApi } from '@backstage/catalog-client'; -import { UserEntityV1alpha1 } from '@backstage/catalog-model'; 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 IdentityResolutionCallback = ( - payload: object, - catalogApi: CatalogApi, -) => Promise; type AwsAlbAuthProviderOptions = { region: string; issuer: string; - identityResolutionCallback: IdentityResolutionCallback; + identityResolutionCallback: IdentityResolver; }; export const getJWTHeaders = (input: string) => { const encoded = input.split('.')[0]; @@ -49,19 +45,16 @@ export const getJWTHeaders = (input: string) => { export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { private logger: Logger; private readonly catalogClient: CatalogApi; - private tokenIssuer: TokenIssuer; private options: AwsAlbAuthProviderOptions; private readonly keyCache: { [key: string]: KeyObject }; constructor( logger: Logger, catalogClient: CatalogApi, - tokenIssuer: TokenIssuer, options: AwsAlbAuthProviderOptions, ) { this.logger = logger; this.catalogClient = catalogClient; - this.tokenIssuer = tokenIssuer; this.options = options; this.keyCache = {}; } @@ -88,16 +81,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { verifiedToken.payload, this.catalogClient, ); - res.send({ - providerInfo: {}, - profile: resolvedEntity?.spec?.profile, - backstageIdentity: { - id: resolvedEntity?.metadata?.name, - idToken: await this.tokenIssuer.issueToken({ - claims: { sub: resolvedEntity?.metadata?.name }, - }), - }, - }); + res.send(resolvedEntity); } catch (e) { this.logger.error('exception occurred during JWT processing', e); res.send(401); @@ -124,15 +108,22 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { } } -export const createAwsAlbProvider = ( - { logger, catalogApi, tokenIssuer, config }: AuthProviderFactoryOptions, - identityResolver: IdentityResolutionCallback, -) => { +export const createAwsAlbProvider = ({ + logger, + catalogApi, + config, + identityResolver, +}: AuthProviderFactoryOptions) => { const region = config.getString('region'); const issuer = config.getString('iss'); - return new AwsAlbAuthProvider(logger, catalogApi, tokenIssuer, { - region, - issuer, - identityResolutionCallback: identityResolver, - }); + if (identityResolver !== undefined) { + return new AwsAlbAuthProvider(logger, catalogApi, { + region, + issuer, + identityResolutionCallback: identityResolver, + }); + } + throw new Error( + 'Identity resolver is required to use this authentication provider', + ); }; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 7bad4f4d81..619fb1c706 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -25,6 +25,7 @@ import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory } from './types'; +import { createAwsAlbProvider } from './aws-alb'; export const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -37,4 +38,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = { oauth2: createOAuth2Provider, oidc: createOidcProvider, onelogin: createOneLoginProvider, + awsalb: createAwsAlbProvider, }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index a40f2a96ee..51c1ee336e 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -112,6 +112,11 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } +export type IdentityResolver = ( + payload: object, + catalogApi: CatalogApi, +) => Promise>; + export type AuthProviderFactoryOptions = { providerId: string; globalConfig: AuthProviderConfig; @@ -120,6 +125,7 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; + identityResolver?: IdentityResolver; }; export type AuthProviderFactory = ( From 0643a3336ab141cd2a87ea31d8bde6f01c32e276 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 12 Jan 2021 15:19:02 -0800 Subject: [PATCH 04/12] chore: add changeset --- .changeset/twelve-ants-sort.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/twelve-ants-sort.md diff --git a/.changeset/twelve-ants-sort.md b/.changeset/twelve-ants-sort.md new file mode 100644 index 0000000000..978047ab8e --- /dev/null +++ b/.changeset/twelve-ants-sort.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add AWS ALB OIDC reverse proxy authentication provider From 2335fa9d174995c800920b76cdfcb117ef308c56 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 12 Jan 2021 15:21:44 -0800 Subject: [PATCH 05/12] chore: add comment around payload passed in identity resolver --- plugins/auth-backend/src/providers/types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 51c1ee336e..a31ef85a7d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -113,6 +113,9 @@ export interface AuthProviderRouteHandlers { } export type IdentityResolver = ( + /** + * An object containing information specific to the auth provider. + */ payload: object, catalogApi: CatalogApi, ) => Promise>; From 1f275fe31a4585bf6beb72e5cd4d3a9ef346cbe3 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Wed, 13 Jan 2021 20:18:07 -0800 Subject: [PATCH 06/12] Upgrade TokenFactory to latest jose library --- plugins/auth-backend/package.json | 8 +++- .../src/identity/TokenFactory.test.ts | 41 +++++++++++++------ .../auth-backend/src/identity/TokenFactory.ts | 40 ++++++++++-------- yarn.lock | 6 +-- 4 files changed, 61 insertions(+), 34 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index f8de2bc6f1..f189f8078f 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -18,7 +18,13 @@ }, "jest": { "moduleNameMapper": { - "jose/jwt/verify": "/../../../node_modules/jose/lib/jwt/verify" + "jose/jwt/sign": "/../node_modules/jose/dist/node/cjs/jwt/sign", + "jose/jwt/verify": "/../node_modules/jose/dist/node/cjs/jwt/verify", + "jose/jwk/from_key_like": "/../node_modules/jose/dist/node/cjs/jwk/from_key_like", + "jose/jwk/parse": "/../node_modules/jose/dist/node/cjs/jwk/parse", + "jose/util/base64url": "/../node_modules/jose/dist/node/cjs/util/base64url", + "jose/util/decode_protected_header": "/../node_modules/jose/dist/node/cjs/util/decode_protected_header", + "jose/util/generate_secret": "/../node_modules/jose/dist/node/cjs/util/generate_secret" } }, "keywords": [ diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 8b719799b1..7058dbb0f5 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -13,12 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { TextDecoder, TextEncoder } from 'util'; + +// These two statements are structured like this because Jest doesn't include these in the default +// test environment, even though they exist in Node. +global.TextEncoder = TextEncoder; +// @ts-ignore +global.TextDecoder = TextDecoder; import { utc } from 'moment'; import { TokenFactory } from './TokenFactory'; import { getVoidLogger } from '@backstage/backend-common'; -import { KeyStore, AnyJWK, StoredKey } from './types'; -import { JWKS, JSONWebKey, JWT } from 'jose'; +import { AnyJWK, KeyStore, StoredKey } from './types'; +import jwtVerify from 'jose/jwt/verify'; +import parseJwk from 'jose/jwk/parse'; +import decodeProtectedHeader, { + ProtectedHeaderParameters, +} from 'jose/util/decode_protected_header'; const logger = getVoidLogger(); @@ -52,10 +63,8 @@ class MemoryKeyStore implements KeyStore { } function jwtKid(jwt: string): string { - const { header } = JWT.decode(jwt, { complete: true }) as { - header: { kid: string }; - }; - return header.kid; + const header = decodeProtectedHeader(jwt) as ProtectedHeaderParameters; + return header.kid as string; } describe('TokenFactory', () => { @@ -72,14 +81,20 @@ describe('TokenFactory', () => { const token = await factory.issueToken({ claims: { sub: 'foo' } }); const { keys } = await factory.listPublicKeys(); - const keyStore = JWKS.asKeyStore({ - keys: keys.map(key => key as JSONWebKey), + const keyMap: { + [key: string]: AnyJWK; + } = {}; + + keys.forEach(key => { + keyMap[key.kid] = key; }); - const payload = JWT.verify(token, keyStore) as object & { - iat: number; - exp: number; - }; + const payload = ( + await jwtVerify(token, async header => { + const kid = header.kid as string; + return await parseJwk(keyMap[kid]); + }) + ).payload; expect(payload).toEqual({ iss: 'my-issuer', aud: 'backstage', @@ -87,7 +102,7 @@ describe('TokenFactory', () => { iat: expect.any(Number), exp: expect.any(Number), }); - expect(payload.exp).toBe(payload.iat + keyDurationSeconds); + expect(payload.exp).toBe((payload.iat as number) + keyDurationSeconds); }); it('should generate new signing keys when the current one expires', async () => { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 36622332e0..8cf7c358d6 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -16,7 +16,10 @@ import moment from 'moment'; import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types'; -import { JSONWebKey, JWK, JWS } from 'jose'; +import parseJwk, { JWK } from 'jose/jwk/parse'; +import SignJWT from 'jose/jwt/sign'; +import generateSecret from 'jose/util/generate_secret'; +import fromKeyLike from 'jose/jwk/from_key_like'; import { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; @@ -53,7 +56,7 @@ export class TokenFactory implements TokenIssuer { private readonly keyDurationSeconds: number; private keyExpiry?: moment.Moment; - private privateKeyPromise?: Promise; + private privateKeyPromise?: Promise; constructor(options: Options) { this.issuer = options.issuer; @@ -64,7 +67,7 @@ export class TokenFactory implements TokenIssuer { async issueToken(params: TokenParams): Promise { const key = await this.getKey(); - + const keyLike = await parseJwk(key); const iss = this.issuer; const sub = params.claims.sub; const aud = 'backstage'; @@ -72,11 +75,9 @@ export class TokenFactory implements TokenIssuer { const exp = iat + this.keyDurationSeconds; this.logger.info(`Issuing token for ${sub}`); - - return JWS.sign({ iss, sub, aud, iat, exp }, key, { - alg: key.alg, - kid: key.kid, - }); + return new SignJWT({ iss, sub, aud, iat, exp }) + .setProtectedHeader({ alg: key.alg, typ: 'JWT', kid: key.kid }) + .sign(keyLike); } // This will be called by other services that want to verify ID tokens. @@ -114,7 +115,7 @@ export class TokenFactory implements TokenIssuer { return { keys: validKeys.map(({ key }) => key) }; } - private async getKey(): Promise { + private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { if (this.keyExpiry?.isAfter()) { @@ -127,11 +128,12 @@ export class TokenFactory implements TokenIssuer { this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds'); const promise = (async () => { // This generates a new signing key to be used to sign tokens until the next key rotation - const key = await JWK.generate('EC', 'P-256', { - use: 'sig', - kid: uuid(), - alg: 'ES256', - }); + + const key = await generateSecret('HS256'); + const kid = uuid(); + const jwk = await fromKeyLike(key); + jwk.kid = kid; + jwk.alg = 'HS256'; // We're not allowed to use the key until it has been successfully stored // TODO: some token verification implementations aggressively cache the list of keys, and @@ -139,11 +141,15 @@ export class TokenFactory implements TokenIssuer { // may want to keep using the existing key for some period of time until we switch to // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. - this.logger.info(`Created new signing key ${key.kid}`); - await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK); + this.logger.info(`Created new signing key ${jwk.kid}`); + await this.keyStore.addKey({ + // @ts-ignore + use: 'sig', + ...jwk, + }); // At this point we are allowed to start using the new key - return key as JSONWebKey; + return jwk; })(); this.privateKeyPromise = promise; diff --git a/yarn.lock b/yarn.lock index 0098954754..8ce5543164 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16550,9 +16550,9 @@ jest@^26.0.1: jest-cli "^26.6.3" jose@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/jose/-/jose-2.0.2.tgz#fb22385b80c658cc7a0cae05b7086c04c6be49f4" - integrity sha512-yD93lsiMA1go/qxSY/vXWBodmIZJIxeB7QhFi8z1yQ3KUwKENqI9UA8VCHlQ5h3x1zWuWZjoY87ByQzkQbIrQg== + version "2.0.3" + resolved "https://registry.npmjs.org/jose/-/jose-2.0.3.tgz#9c931ab3e13e2d16a5b9e6183e60b2fc40a8e1b8" + integrity sha512-L+RlDgjO0Tk+Ki6/5IXCSEnmJCV8iMFZoBuEgu2vPQJJ4zfG/k3CAqZUMKDYNRHIDyy0QidJpOvX0NgpsAqFlw== dependencies: "@panva/asn1.js" "^1.0.0" From dfce32b3567e02d93241fdca623e0ec8193db3fb Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Mon, 18 Jan 2021 14:09:23 -0800 Subject: [PATCH 07/12] Address review comments around algorithm being used, typescript ignore --- plugins/auth-backend/package.json | 8 +----- .../auth-backend/src/identity/TokenFactory.ts | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index f189f8078f..380b7da6b0 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -18,13 +18,7 @@ }, "jest": { "moduleNameMapper": { - "jose/jwt/sign": "/../node_modules/jose/dist/node/cjs/jwt/sign", - "jose/jwt/verify": "/../node_modules/jose/dist/node/cjs/jwt/verify", - "jose/jwk/from_key_like": "/../node_modules/jose/dist/node/cjs/jwk/from_key_like", - "jose/jwk/parse": "/../node_modules/jose/dist/node/cjs/jwk/parse", - "jose/util/base64url": "/../node_modules/jose/dist/node/cjs/util/base64url", - "jose/util/decode_protected_header": "/../node_modules/jose/dist/node/cjs/util/decode_protected_header", - "jose/util/generate_secret": "/../node_modules/jose/dist/node/cjs/util/generate_secret" + "^jose/(.*)$": "/../node_modules/jose/dist/node/cjs/$1" } }, "keywords": [ diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 8cf7c358d6..65f9a07eb1 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -18,7 +18,7 @@ import moment from 'moment'; import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types'; import parseJwk, { JWK } from 'jose/jwk/parse'; import SignJWT from 'jose/jwt/sign'; -import generateSecret from 'jose/util/generate_secret'; +import generateKeyPair from 'jose/util/generate_key_pair'; import fromKeyLike from 'jose/jwk/from_key_like'; import { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; @@ -129,12 +129,20 @@ export class TokenFactory implements TokenIssuer { const promise = (async () => { // This generates a new signing key to be used to sign tokens until the next key rotation - const key = await generateSecret('HS256'); + const key = await generateKeyPair('ES256'); const kid = uuid(); - const jwk = await fromKeyLike(key); - jwk.kid = kid; - jwk.alg = 'HS256'; + const jwk = await fromKeyLike(key.privateKey); + // @ts-ignore https://github.com/microsoft/TypeScript/issues/13195 - + // JOSE Library provides optional for most fields - and TS does not distinguish between missing/undefined. + // Because AnyJWK requires keys to have type "string", this throws a TypeError - though in practice, if the field + // is undefined, JOSE will not send it back as key. + const storedJwk: AnyJWK = { + ...jwk, + alg: 'ES256', + kid: kid, + use: 'sig', + }; // We're not allowed to use the key until it has been successfully stored // TODO: some token verification implementations aggressively cache the list of keys, and // don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we @@ -142,14 +150,9 @@ export class TokenFactory implements TokenIssuer { // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. this.logger.info(`Created new signing key ${jwk.kid}`); - await this.keyStore.addKey({ - // @ts-ignore - use: 'sig', - ...jwk, - }); - + await this.keyStore.addKey(storedJwk); // At this point we are allowed to start using the new key - return jwk; + return storedJwk; })(); this.privateKeyPromise = promise; From 147648a2d105a4d242861dcbe7129ab6ada3878b Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Mon, 18 Jan 2021 14:12:12 -0800 Subject: [PATCH 08/12] Update type definition and docs to be clear about experimental status --- plugins/auth-backend/src/providers/types.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index a31ef85a7d..1a4e7b118a 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -112,7 +112,12 @@ export interface AuthProviderRouteHandlers { logout?(req: express.Request, res: express.Response): Promise; } -export type IdentityResolver = ( +/** + * 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. */ @@ -128,7 +133,7 @@ export type AuthProviderFactoryOptions = { tokenIssuer: TokenIssuer; discovery: PluginEndpointDiscovery; catalogApi: CatalogApi; - identityResolver?: IdentityResolver; + identityResolver?: ExperimentalIdentityResolver; }; export type AuthProviderFactory = ( From 02140eb89ad056c52876352319991b5b95da738d Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Mon, 18 Jan 2021 14:22:38 -0800 Subject: [PATCH 09/12] Add basic node-cache with 1 hour TTL for caching keys retrieved from AWS public key endpoint --- plugins/auth-backend/package.json | 1 + .../src/identity/TokenFactory.test.ts | 2 +- .../src/providers/aws-alb/provider.ts | 16 +++++++++------- yarn.lock | 12 ++++++++++++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 380b7da6b0..ad54e35b28 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -54,6 +54,7 @@ "knex": "^0.21.6", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "openid-client": "^4.2.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 7058dbb0f5..abbeecb40c 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -102,7 +102,7 @@ describe('TokenFactory', () => { iat: expect.any(Number), exp: expect.any(Number), }); - expect(payload.exp).toBe((payload.iat as number) + keyDurationSeconds); + expect(payload.exp).toBe(Number(payload.iat) + keyDurationSeconds); }); it('should generate new signing keys when the current one expires', async () => { diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 568fc4dd46..6bb2e9fe7b 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -16,7 +16,7 @@ import { AuthProviderFactoryOptions, AuthProviderRouteHandlers, - IdentityResolver, + ExperimentalIdentityResolver, } from '../types'; import express from 'express'; // @ts-ignore no types available for R2 @@ -24,6 +24,7 @@ import r2 from 'r2'; import * as crypto from 'crypto'; import { KeyObject } from 'crypto'; import { Logger } from 'winston'; +import NodeCache from 'node-cache'; import jwtVerify from 'jose/jwt/verify'; import { CatalogApi } from '@backstage/catalog-client'; @@ -35,7 +36,7 @@ const ALB_JWT_HEADER = 'x-amzn-oidc-data'; type AwsAlbAuthProviderOptions = { region: string; issuer: string; - identityResolutionCallback: IdentityResolver; + identityResolutionCallback: ExperimentalIdentityResolver; }; export const getJWTHeaders = (input: string) => { const encoded = input.split('.')[0]; @@ -46,7 +47,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { private logger: Logger; private readonly catalogClient: CatalogApi; private options: AwsAlbAuthProviderOptions; - private readonly keyCache: { [key: string]: KeyObject }; + private readonly keyCache: NodeCache; constructor( logger: Logger, @@ -56,7 +57,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { this.logger = logger; this.catalogClient = catalogClient; this.options = options; - this.keyCache = {}; + this.keyCache = new NodeCache({ stdTTL: 3600 }); } frameHandler(): Promise { return Promise.resolve(undefined); @@ -96,14 +97,15 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { } async getKey(keyId: string): Promise { - if (this.keyCache[keyId]) { - return this.keyCache[keyId]; + const optionalCacheKey = this.keyCache.get(keyId); + if (optionalCacheKey) { + return optionalCacheKey; } const keyText: string = await r2( `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, ).text; const keyValue = crypto.createPublicKey(keyText); - this.keyCache[keyId] = keyValue; + this.keyCache.set(keyId, keyValue); return keyValue; } } diff --git a/yarn.lock b/yarn.lock index 8ce5543164..b8531ef248 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10126,6 +10126,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + clone@^1.0.2: version "1.0.4" resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -18806,6 +18811,13 @@ node-addon-api@2.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-dir@^0.1.10: version "0.1.17" resolved "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" From c43d1485560158fc54eb40acc42c8791531be27b Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Mon, 18 Jan 2021 14:39:53 -0800 Subject: [PATCH 10/12] Fix usage of JOSE library in OIDC test as part of JOSE upgrade --- plugins/auth-backend/package.json | 2 +- plugins/auth-backend/src/providers/oidc/provider.test.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index ad54e35b28..a0c7cf1d84 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -18,7 +18,7 @@ }, "jest": { "moduleNameMapper": { - "^jose/(.*)$": "/../node_modules/jose/dist/node/cjs/$1" + "^jose/(.*)$": "/../../../node_modules/jose/dist/node/cjs/$1" } }, "keywords": [ diff --git a/plugins/auth-backend/src/providers/oidc/provider.test.ts b/plugins/auth-backend/src/providers/oidc/provider.test.ts index f2bd8dcb90..ae0ca2da2b 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.test.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.test.ts @@ -13,13 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { TextDecoder, TextEncoder } from 'util'; +// @ts-ignore +global.TextDecoder = TextDecoder; +global.TextEncoder = TextEncoder; import express from 'express'; import { Session } from 'express-session'; import nock from 'nock'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; import { createOidcProvider, OidcAuthProvider } from './provider'; -import { JWT, JWK } from 'jose'; +import UnsecuredJWT from 'jose/jwt/unsecured'; import { AuthProviderFactoryOptions } from '../types'; import { Config } from '@backstage/config'; import { OAuthAdapter } from '../../lib/oauth'; @@ -71,6 +75,7 @@ describe('OidcAuthProvider', () => { const jwt = { sub: 'alice', iss: 'https://oidc.test', + iat: Date.now(), aud: clientMetadata.clientId, exp: Date.now() + 10000, }; @@ -79,7 +84,7 @@ describe('OidcAuthProvider', () => { .reply(200, issuerMetadata) .post('/as/token.oauth2') .reply(200, { - id_token: JWT.sign(jwt, JWK.None), + id_token: new UnsecuredJWT(jwt).encode(), access_token: 'test', authorization_signed_response_alg: 'HS256', }) From d7726cdfebbada542ea5f8ac060e977a8595bed3 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Mon, 18 Jan 2021 17:37:00 -0800 Subject: [PATCH 11/12] Make keyMap generation more concise Co-authored-by: Patrik Oldsberg --- plugins/auth-backend/src/identity/TokenFactory.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index abbeecb40c..e28ded2bf2 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -81,13 +81,7 @@ describe('TokenFactory', () => { const token = await factory.issueToken({ claims: { sub: 'foo' } }); const { keys } = await factory.listPublicKeys(); - const keyMap: { - [key: string]: AnyJWK; - } = {}; - - keys.forEach(key => { - keyMap[key.kid] = key; - }); + const keyMap = Object.fromEntries(keys.map(key => [key.kid, key])); const payload = ( await jwtVerify(token, async header => { From c837adc23a851a3e230df5b4026f799ee39d1ab4 Mon Sep 17 00:00:00 2001 From: Jonah Back Date: Tue, 19 Jan 2021 21:20:49 -0800 Subject: [PATCH 12/12] Address some final review coments about cross-fetch and type-casting --- plugins/auth-backend/package.json | 1 - .../src/identity/TokenFactory.test.ts | 8 +------- plugins/auth-backend/src/identity/TokenFactory.ts | 3 +-- .../src/providers/aws-alb/provider.test.ts | 8 +++++--- .../src/providers/aws-alb/provider.ts | 7 +++---- yarn.lock | 15 +++------------ 6 files changed, 13 insertions(+), 29 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a0c7cf1d84..880b9a33e8 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -65,7 +65,6 @@ "passport-okta-oauth": "^0.0.1", "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^2.0.0", - "r2": "^2.0.1", "uuid": "^8.0.0", "winston": "^3.2.1", "yn": "^4.0.0" diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index abbeecb40c..e28ded2bf2 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -81,13 +81,7 @@ describe('TokenFactory', () => { const token = await factory.issueToken({ claims: { sub: 'foo' } }); const { keys } = await factory.listPublicKeys(); - const keyMap: { - [key: string]: AnyJWK; - } = {}; - - keys.forEach(key => { - keyMap[key.kid] = key; - }); + const keyMap = Object.fromEntries(keys.map(key => [key.kid, key])); const payload = ( await jwtVerify(token, async header => { diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 65f9a07eb1..a1d846d5d7 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -133,7 +133,6 @@ export class TokenFactory implements TokenIssuer { const kid = uuid(); const jwk = await fromKeyLike(key.privateKey); - // @ts-ignore https://github.com/microsoft/TypeScript/issues/13195 - // JOSE Library provides optional for most fields - and TS does not distinguish between missing/undefined. // Because AnyJWK requires keys to have type "string", this throws a TypeError - though in practice, if the field // is undefined, JOSE will not send it back as key. @@ -142,7 +141,7 @@ export class TokenFactory implements TokenIssuer { alg: 'ES256', kid: kid, use: 'sig', - }; + } as AnyJWK; // We're not allowed to use the key until it has been successfully stored // TODO: some token verification implementations aggressively cache the list of keys, and // don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we 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 93649ce8d7..3d27539314 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.test.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.test.ts @@ -30,11 +30,13 @@ yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== `; }; -jest.mock('r2', () => ({ +jest.mock('cross-fetch', () => ({ __esModule: true, - default: () => { + default: async () => { return { - text: mockKey(), + json: async () => { + return mockKey(); + }, }; }, })); diff --git a/plugins/auth-backend/src/providers/aws-alb/provider.ts b/plugins/auth-backend/src/providers/aws-alb/provider.ts index 6bb2e9fe7b..d8ec0cb525 100644 --- a/plugins/auth-backend/src/providers/aws-alb/provider.ts +++ b/plugins/auth-backend/src/providers/aws-alb/provider.ts @@ -19,8 +19,7 @@ import { ExperimentalIdentityResolver, } from '../types'; import express from 'express'; -// @ts-ignore no types available for R2 -import r2 from 'r2'; +import fetch from 'cross-fetch'; import * as crypto from 'crypto'; import { KeyObject } from 'crypto'; import { Logger } from 'winston'; @@ -101,9 +100,9 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers { if (optionalCacheKey) { return optionalCacheKey; } - const keyText: string = await r2( + const keyText: string = await fetch( `https://public-keys.auth.elb.${this.options.region}.amazonaws.com/${keyId}`, - ).text; + ).then(response => response.json()); const keyValue = crypto.createPublicKey(keyText); this.keyCache.set(keyId, keyValue); return keyValue; diff --git a/yarn.lock b/yarn.lock index b8531ef248..e55b0183f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9781,7 +9781,7 @@ case-sensitive-paths-webpack-plugin@^2.2.0: resolved "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7" integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ== -caseless@^0.12.0, caseless@~0.12.0: +caseless@~0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= @@ -18834,7 +18834,7 @@ node-fetch-npm@^2.0.2: json-parse-better-errors "^1.0.0" safe-buffer "^5.1.1" -node-fetch@2.6.1, node-fetch@^2.0.0-alpha.8, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.1.2, node-fetch@^2.2.0, node-fetch@^2.3.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -21216,15 +21216,6 @@ quick-lru@^5.1.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -r2@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/r2/-/r2-2.0.1.tgz#94cd802ecfce9a622549c8182032d8e4a2b2e612" - integrity sha512-EEmxoxYCe3LHzAUhRIRxdCKERpeRNmlLj6KLUSORqnK6dWl/K5ShmDGZqM2lRZQeqJgF+wyqk0s1M7SWUveNOQ== - dependencies: - caseless "^0.12.0" - node-fetch "^2.0.0-alpha.8" - typedarray-to-buffer "^3.1.2" - raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" @@ -25021,7 +25012,7 @@ typed-rest-client@^1.8.0: tunnel "0.0.6" underscore "1.8.3" -typedarray-to-buffer@^3.1.2, typedarray-to-buffer@^3.1.5: +typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==