diff --git a/.changeset/nasty-days-jog.md b/.changeset/nasty-days-jog.md new file mode 100644 index 0000000000..09454609b2 --- /dev/null +++ b/.changeset/nasty-days-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-aws-alb-provider': patch +--- + +Refactored to use the `jose` library for JWT handling. diff --git a/plugins/auth-backend-module-aws-alb-provider/package.json b/plugins/auth-backend-module-aws-alb-provider/package.json index f6238cca5d..d4658dd7ea 100644 --- a/plugins/auth-backend-module-aws-alb-provider/package.json +++ b/plugins/auth-backend-module-aws-alb-provider/package.json @@ -38,14 +38,15 @@ "@backstage/plugin-auth-backend": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "jose": "^4.6.0", - "jwt-decode": "^3.1.0", - "node-cache": "^5.1.2" + "node-cache": "^5.1.2", + "node-fetch": "^2.6.7" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/config": "workspace:^", - "express": "^4.18.2" + "express": "^4.18.2", + "msw": "^2.0.8" }, "files": [ "dist" diff --git a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts index bfcbfce622..5f19d800a0 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts @@ -14,36 +14,30 @@ * limitations under the License. */ +import express from 'express'; +import { SignJWT } from 'jose'; import { ALB_ACCESS_TOKEN_HEADER, ALB_JWT_HEADER, awsAlbAuthenticator, } from './authenticator'; -import { jwtVerify } from 'jose'; -import express from 'express'; -import { AuthenticationError } from '@backstage/errors'; import { Config } from '@backstage/config'; +import { AuthenticationError } from '@backstage/errors'; -const jwtMock = jwtVerify as jest.Mocked; -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'); - -beforeEach(() => { - jest.clearAllMocks(); -}); describe('AwsAlbProvider', () => { + 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: Date.now() + 10000, + iss: 'ISSUER_URL', + }; + const signingKey = new TextEncoder().encode('signingKey'); + let mockJwt: string; const mockRequest = { header: jest.fn(name => { if (name === ALB_JWT_HEADER) { @@ -54,6 +48,16 @@ describe('AwsAlbProvider', () => { return undefined; }), } as unknown as express.Request; + const mockRequestWithInvalidJwt = { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return 'invalid.jwt'; + } else if (name === ALB_ACCESS_TOKEN_HEADER) { + return mockAccessToken; + } + return undefined; + }), + } as unknown as express.Request; const mockRequestWithoutJwt = { header: jest.fn(name => { if (name === ALB_ACCESS_TOKEN_HEADER) { @@ -71,13 +75,20 @@ describe('AwsAlbProvider', () => { }), } as unknown as express.Request; + beforeEach(async () => { + mockJwt = await new SignJWT(mockClaims) + .setProtectedHeader({ alg: 'HS256' }) + .sign(signingKey); + }); + describe('should transform to type AwsAlbResponse', () => { it('when JWT is valid and identity is resolved successfully', async () => { - jwtMock.mockReturnValueOnce(Promise.resolve({ payload: mockClaims })); - const response = await awsAlbAuthenticator.authenticate( { req: mockRequest }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { + issuer: 'ISSUER_URL', + getKey: jest.fn().mockResolvedValue(signingKey), + }, ); expect(response).toEqual({ result: { @@ -119,27 +130,38 @@ describe('AwsAlbProvider', () => { }); it('JWT is invalid', async () => { - jwtMock.mockImplementationOnce(() => { - throw new Error('bad JWT'); - }); - await expect( awsAlbAuthenticator.authenticate( - { req: mockRequest }, + { req: mockRequestWithInvalidJwt }, { issuer: 'ISSUER_URL', getKey: jest.fn() }, ), ).rejects.toThrow( - 'Exception occurred during JWT processing: Error: bad JWT', + 'Exception occurred during JWT processing: JWSInvalid: Invalid Compact JWS', ); }); it('issuer is missing', async () => { - jwtMock.mockReturnValueOnce({}); + const jwt = await new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .sign(signingKey); + const req = { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return jwt; + } else if (name === ALB_ACCESS_TOKEN_HEADER) { + return mockAccessToken; + } + return undefined; + }), + } as unknown as express.Request; await expect( awsAlbAuthenticator.authenticate( - { req: mockRequest }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { req }, + { + issuer: 'ISSUER_URL', + getKey: jest.fn().mockResolvedValue(signingKey), + }, ), ).rejects.toThrow( 'Exception occurred during JWT processing: AuthenticationError: Issuer mismatch on JWT token', @@ -147,14 +169,27 @@ describe('AwsAlbProvider', () => { }); it('issuer is invalid', async () => { - jwtMock.mockReturnValueOnce({ - iss: 'INVALID_ISSUE_URL', - }); + const jwt = await new SignJWT({ iss: 'INVALID_ISSUER_URL' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(signingKey); + const req = { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return jwt; + } else if (name === ALB_ACCESS_TOKEN_HEADER) { + return mockAccessToken; + } + return undefined; + }), + } as unknown as express.Request; await expect( awsAlbAuthenticator.authenticate( - { req: mockRequest }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { req }, + { + issuer: 'ISSUER_URL', + getKey: jest.fn().mockResolvedValue(signingKey), + }, ), ).rejects.toThrow( 'Exception occurred during JWT processing: AuthenticationError: Issuer mismatch on JWT token', diff --git a/plugins/auth-backend-module-aws-alb-provider/src/helpers.test.ts b/plugins/auth-backend-module-aws-alb-provider/src/helpers.test.ts index 07ad118cea..cb103debb0 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/helpers.test.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/helpers.test.ts @@ -1,10 +1,3 @@ -import NodeCache from 'node-cache'; -import { makeProfileInfo, provisionKeyCache } from './helpers'; -import * as crypto from 'crypto'; -import { JWTHeaderParameters } from 'jose'; -import { PassportProfile } from '@backstage/plugin-auth-node'; -import jwtDecoder from 'jwt-decode'; - /* * Copyright 2020 The Backstage Authors * @@ -21,40 +14,47 @@ import jwtDecoder from 'jwt-decode'; * limitations under the License. */ -const mockKey = async () => { - return `-----BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I -yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== ------END PUBLIC KEY----- -`; -}; +import * as crypto from 'crypto'; +import { JWTHeaderParameters, UnsecuredJWT } from 'jose'; +import NodeCache from 'node-cache'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { PassportProfile } from '@backstage/plugin-auth-node'; +import { makeProfileInfo, provisionKeyCache } from './helpers'; + jest.mock('crypto'); const cryptoMock = crypto as jest.Mocked; -jest.mock('node-fetch', () => ({ - __esModule: true, - default: async () => { - return { - text: async () => { - return mockKey(); - }, - }; - }, -})); - -const jwtMock = jwtDecoder as jest.Mocked; -jest.mock('jwt-decode'); describe('helpers', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + const nodeCache = jest.fn() as unknown as NodeCache; nodeCache.set = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + server.use( + http.get( + 'https://public-keys.auth.elb.eu-west-1.amazonaws.com/kid', + () => + new HttpResponse( + `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnuN4LlaJhaUpx+qZFTzYCrSBLk0I +yOlxJ2VW88mLAQGJ7HPAvOdylxZsItMnzCuqNzZvie8m/NJsOjhDncVkrw== +-----END PUBLIC KEY----- +`, + ), + ), + ); }); + it('should create a key', () => { const getKey = provisionKeyCache('eu-west-1', nodeCache); expect(getKey).toBeDefined(); }); + it('should return a key from cache', async () => { const getKey = provisionKeyCache('eu-west-1', nodeCache); @@ -65,6 +65,7 @@ describe('helpers', () => { expect(key).toBe('key'); }); + it('should update cache if key is not found', async () => { const getKey = provisionKeyCache('eu-west-1', nodeCache); @@ -77,6 +78,7 @@ describe('helpers', () => { await getKey({ kid: 'kid' } as unknown as JWTHeaderParameters); expect(nodeCache.set).toHaveBeenCalledWith('kid', 'key'); }); + it('should throw error if key is not found', async () => { const getKey = provisionKeyCache('eu-west-1', nodeCache); @@ -87,6 +89,7 @@ describe('helpers', () => { getKey({ kid: 'kid' } as unknown as JWTHeaderParameters), ).rejects.toThrow(); }); + it('should throw if key is not present in request header', async () => { const getKey = provisionKeyCache('eu-west-1', nodeCache); @@ -119,19 +122,19 @@ describe('makeProfileInfo', () => { }; expect(makeProfileInfo(profile, accessToken)).toEqual(result); }); + it('should return profile info from id token', () => { - jwtMock.mockReturnValueOnce({ - email: 'email', - picture: 'picture', - name: 'displayName', - }); const profile = { name: { familyName: 'familyName', givenName: 'givenName', }, } as PassportProfile; - const idToken = 'idToken'; + const idToken = new UnsecuredJWT({ + email: 'email', + picture: 'picture', + name: 'displayName', + }).encode(); const result = { email: 'email', picture: 'picture', diff --git a/plugins/auth-backend-module-aws-alb-provider/src/helpers.ts b/plugins/auth-backend-module-aws-alb-provider/src/helpers.ts index d97146bb93..e846b2484e 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/helpers.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/helpers.ts @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { PassportProfile } from '@backstage/plugin-auth-node/'; -import { ProfileInfo } from '@backstage/plugin-auth-node'; import { KeyObject } from 'crypto'; -import jwtDecoder from 'jwt-decode'; -import NodeCache from 'node-cache'; import * as crypto from 'crypto'; -import { JWTHeaderParameters } from 'jose'; +import { JWTHeaderParameters, decodeJwt } from 'jose'; +import NodeCache from 'node-cache'; +import fetch from 'node-fetch'; +import { PassportProfile, ProfileInfo } from '@backstage/plugin-auth-node'; import { AuthenticationError } from '@backstage/errors'; export const makeProfileInfo = ( @@ -45,7 +44,11 @@ export const makeProfileInfo = ( if ((!email || !picture || !displayName) && idToken) { try { - const decoded: Record = jwtDecoder(idToken); + const decoded: Record = decodeJwt(idToken) as { + email?: string; + picture?: string; + name?: string; + }; if (!email && decoded.email) { email = decoded.email; } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 25e0651361..f7494247dc 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -92,7 +92,6 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", - "@types/jwt-decode": "^3.1.0", "@types/passport-auth0": "^1.0.5", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", diff --git a/yarn.lock b/yarn.lock index de0169115f..363823b103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4677,8 +4677,9 @@ __metadata: "@backstage/plugin-auth-node": "workspace:^" express: ^4.18.2 jose: ^4.6.0 - jwt-decode: ^3.1.0 + msw: ^2.0.8 node-cache: ^5.1.2 + node-fetch: ^2.6.7 languageName: unknown linkType: soft @@ -4913,7 +4914,6 @@ __metadata: "@types/cookie-parser": ^1.4.2 "@types/express": ^4.17.6 "@types/express-session": ^1.17.2 - "@types/jwt-decode": ^3.1.0 "@types/passport": ^1.0.3 "@types/passport-auth0": ^1.0.5 "@types/passport-github2": ^1.2.4 @@ -18482,15 +18482,6 @@ __metadata: languageName: node linkType: hard -"@types/jwt-decode@npm:^3.1.0": - version: 3.1.0 - resolution: "@types/jwt-decode@npm:3.1.0" - dependencies: - jwt-decode: "*" - checksum: 82ff0b3826e5d9da48be1f11e16fec96e56dd946995edaa100682202b9b7beb30fb04a353cbabd378d3b13a24241b48a0b662a4f181bae7f758147d92368d930 - languageName: node - linkType: hard - "@types/keyv@npm:*, @types/keyv@npm:^3.1.1": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -32363,13 +32354,6 @@ __metadata: languageName: node linkType: hard -"jwt-decode@npm:*, jwt-decode@npm:^3.1.0": - version: 3.1.2 - resolution: "jwt-decode@npm:3.1.2" - checksum: 20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb - languageName: node - linkType: hard - "kafkajs@npm:^2.0.0": version: 2.2.4 resolution: "kafkajs@npm:2.2.4"