Update jose to 4.6.0

Signed-off-by: Andy Caruso <macaruso@gmail.com>
This commit is contained in:
Andy Caruso
2022-03-14 09:56:34 -07:00
committed by blam
parent 5ca122317a
commit 1624b52168
17 changed files with 267 additions and 248 deletions
+5 -1
View File
@@ -25,8 +25,9 @@
"dependencies": {
"@backstage/backend-common": "^0.13.2-next.1",
"@backstage/config": "^1.0.0",
"@backstage/catalog-model": "^0.13.0",
"@backstage/errors": "^1.0.0",
"jose": "^1.27.1",
"jose": "^4.6.0",
"node-fetch": "^2.6.7",
"winston": "^3.2.1"
},
@@ -35,6 +36,9 @@
"msw": "^0.35.0",
"uuid": "^8.0.0"
},
"jest": {
"testEnvironment": "node"
},
"files": [
"dist"
]
+27 -52
View File
@@ -15,11 +15,16 @@
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { JSONWebKey, JWK, JWS, JWT } from 'jose';
import {
SignJWT,
generateKeyPair,
decodeProtectedHeader,
exportJWK,
} from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { v4 as uuid } from 'uuid';
import { IdentityClient } from './IdentityClient';
import { v4 as uuid } from 'uuid';
interface AnyJWK extends Record<string, string> {
use: 'sig';
@@ -45,12 +50,11 @@ class FakeTokenFactory {
ent?: string[];
};
}): Promise<string> {
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
this.keys.push(key.toJWK(false) as unknown as AnyJWK);
const pair = await generateKeyPair('ES256');
const publicKey = await exportJWK(pair.publicKey);
const kid = uuid();
publicKey.kid = kid;
this.keys.push(publicKey as AnyJWK);
const iss = this.options.issuer;
const sub = params.claims.sub;
@@ -59,10 +63,14 @@ class FakeTokenFactory {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.options.keyDurationSeconds;
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
.setProtectedHeader({ alg: 'ES256', ent: ent, kid: kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(pair.privateKey);
}
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
@@ -71,10 +79,8 @@ class FakeTokenFactory {
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
const header = decodeProtectedHeader(jwt);
return header.kid ?? '';
}
const server = setupServer();
@@ -98,7 +104,7 @@ describe('IdentityClient', () => {
afterEach(() => server.resetHandlers());
beforeEach(() => {
client = IdentityClient.create({ discovery, issuer: mockBaseUrl });
client = IdentityClient.create({ discovery, issuer: mockBaseUrl }, 0);
factory = new FakeTokenFactory({
issuer: mockBaseUrl,
keyDurationSeconds,
@@ -118,13 +124,6 @@ describe('IdentityClient', () => {
);
});
it('should use the correct endpoint', async () => {
await factory.issueToken({ claims: { sub: 'foo' } });
const keys = await factory.listPublicKeys();
const response = await (client as any).listPublicKeys();
expect(response).toEqual(keys);
});
it('should throw on undefined header', async () => {
return expect(async () => {
await client.authenticate(undefined);
@@ -186,7 +185,7 @@ describe('IdentityClient', () => {
it('should accept token from new key', async () => {
const fixedTime = Date.now();
jest
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2);
const token1 = await factory.issueToken({ claims: { sub: 'foo1' } });
@@ -197,7 +196,7 @@ describe('IdentityClient', () => {
// Ignore thrown error
}
// Move forward in time where the signing key has been rotated
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
spy.mockRestore();
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(token);
expect(response).toEqual({
@@ -232,33 +231,9 @@ describe('IdentityClient', () => {
});
describe('listPublicKeys', () => {
const defaultServiceResponse: {
keys: JSONWebKey[];
} = {
keys: [
{
crv: 'P-256',
x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls',
y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw',
kty: 'EC',
kid: 'kid-a',
alg: 'ES256',
use: 'sig',
},
],
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => {
return res(ctx.json(defaultServiceResponse));
}),
);
});
it('should use the correct endpoint', async () => {
const response = await (client as any).listPublicKeys();
expect(response).toEqual(defaultServiceResponse);
const url = (client as any).endpoint as URL;
expect(url.toString()).toMatch(`${mockBaseUrl}/.well-known/jwks.json`);
});
});
});
+41 -85
View File
@@ -16,12 +16,15 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { AuthenticationError } from '@backstage/errors';
import { JSONWebKey, JWK, JWKS, JWT } from 'jose';
import fetch from 'node-fetch';
import {
createRemoteJWKSet,
jwtVerify,
FlattenedJWSInput,
JWSHeaderParameters,
} from 'jose';
import { GetKeyFunction } from 'jose/dist/types/types';
import { BackstageIdentityResponse } from './types';
const CLOCK_MARGIN_S = 10;
/**
* An identity client to interact with auth-backend and authenticate Backstage
* tokens
@@ -32,27 +35,37 @@ const CLOCK_MARGIN_S = 10;
export class IdentityClient {
private readonly discovery: PluginEndpointDiscovery;
private readonly issuer: string;
private keyStore: JWKS.KeyStore;
private keyStoreUpdated: number;
private keyStore?: GetKeyFunction<JWSHeaderParameters, FlattenedJWSInput>;
private endpoint?: URL;
/**
* Create a new {@link IdentityClient} instance.
*/
static create(options: {
discovery: PluginEndpointDiscovery;
issuer: string;
}): IdentityClient {
return new IdentityClient(options);
static create(
options: {
discovery: PluginEndpointDiscovery;
issuer: string;
},
cooldownMs = 30000,
): IdentityClient {
return new IdentityClient(options, cooldownMs);
}
private constructor(options: {
discovery: PluginEndpointDiscovery;
issuer: string;
}) {
private constructor(
options: {
discovery: PluginEndpointDiscovery;
issuer: string;
},
cooldownMs: number,
) {
this.discovery = options.discovery;
this.issuer = options.issuer;
this.keyStore = new JWKS.KeyStore();
this.keyStoreUpdated = 0;
this.discovery.getBaseUrl('auth').then(url => {
this.endpoint = new URL(`${url}/.well-known/jwks.json`);
this.keyStore = createRemoteJWKSet(this.endpoint, {
cooldownDuration: cooldownMs,
});
});
}
/**
@@ -67,91 +80,34 @@ export class IdentityClient {
if (!token) {
throw new AuthenticationError('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
if (!key) {
throw new AuthenticationError('No signing key matching token found');
}
// Verify token claims and signature
// Note: Claims must match those set by TokenFactory when issuing tokens
// Note: verify throws if verification fails
const decoded = JWT.IdToken.verify(token, key, {
if (!this.keyStore) {
throw new AuthenticationError('No keystore exists');
}
const decoded = await jwtVerify(token, this.keyStore, {
algorithms: ['ES256'],
audience: 'backstage',
issuer: this.issuer,
}) as { sub: string; ent: string[] };
});
// Verified, return the matching user as BackstageIdentity
// TODO: Settle internal user format/properties
if (!decoded.sub) {
if (!decoded.payload.sub) {
throw new AuthenticationError('No user sub found in token');
}
const user: BackstageIdentityResponse = {
id: decoded.payload.sub,
token,
identity: {
type: 'user',
userEntityRef: decoded.sub,
ownershipEntityRefs: decoded.ent ?? [],
userEntityRef: decoded.payload.sub,
ownershipEntityRefs: decoded.payload.ent
? [decoded.payload.ent as string]
: [],
},
};
return user;
}
/**
* Returns the public signing key matching the given jwt token,
* or null if no matching key was found
*/
private async getKey(rawJwtToken: string): Promise<JWK.Key | null> {
const { header, payload } = JWT.decode(rawJwtToken, {
complete: true,
}) as {
header: { kid: string };
payload: { iat: number };
};
// Refresh public keys if needed
// Add a small margin in case clocks are out of sync
const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid });
const issuedAfterLastRefresh =
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
if (!keyStoreHasKey && issuedAfterLastRefresh) {
await this.refreshKeyStore();
}
return this.keyStore.get({ kid: header.kid });
}
/**
* Lists public part of keys used to sign Backstage Identity tokens
*/
private async listPublicKeys(): Promise<{
keys: JSONWebKey[];
}> {
const url = `${await this.discovery.getBaseUrl(
'auth',
)}/.well-known/jwks.json`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
const publicKeys: { keys: JSONWebKey[] } = await response.json();
return publicKeys;
}
/**
* Fetches public keys and caches them locally
*/
private async refreshKeyStore(): Promise<void> {
const now = Date.now() / 1000;
const publicKeys = await this.listPublicKeys();
this.keyStore = JWKS.asKeyStore({
keys: publicKeys.keys.map(key => key as JSONWebKey),
});
this.keyStoreUpdated = now;
}
}