remove jwt-decode from aws-alb provider

Signed-off-by: Jamie Klassen <jamie.klassen@broadcom.com>
This commit is contained in:
Jamie Klassen
2024-01-30 11:25:04 -05:00
parent d4cc552ab1
commit d309cadf47
7 changed files with 130 additions and 100 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-aws-alb-provider': patch
---
Refactored to use the `jose` library for JWT handling.
@@ -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"
@@ -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<any>;
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',
@@ -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<any>;
jest.mock('node-fetch', () => ({
__esModule: true,
default: async () => {
return {
text: async () => {
return mockKey();
},
};
},
}));
const jwtMock = jwtDecoder as jest.Mocked<any>;
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',
@@ -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<string, string> = jwtDecoder(idToken);
const decoded: Record<string, string> = decodeJwt(idToken) as {
email?: string;
picture?: string;
name?: string;
};
if (!email && decoded.email) {
email = decoded.email;
}
-1
View File
@@ -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",
+2 -18
View File
@@ -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"