test: add unit tests for jwks access
Signed-off-by: Ryan Hanchett <ryan.hanchett@invitae.com>
This commit is contained in:
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { JWKSHandler } from './jwks';
|
||||
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
// Since this is re-used in several tests, I wonder if it should get refactored
|
||||
// into @backstage/backend-test-utils
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
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;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
|
||||
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(pair.privateKey);
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
|
||||
describe('JWKSHandler', () => {
|
||||
let factory: FakeTokenFactory;
|
||||
let mockSubject: string;
|
||||
const keyDurationSeconds = 5;
|
||||
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubject = 'test_subject';
|
||||
|
||||
factory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies token with valid entry', async () => {
|
||||
const validEntry = {
|
||||
uri: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithms: ['RS256'],
|
||||
issuers: [mockBaseUrl],
|
||||
audiences: ['backstage'],
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({ subject: mockSubject });
|
||||
});
|
||||
|
||||
it('skips invalid entry and continues verification', async () => {
|
||||
const invalidEntry = {
|
||||
uri: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithms: ['RS256'],
|
||||
issuers: ['fakeIssuer'],
|
||||
audiences: ['fakeAud'],
|
||||
};
|
||||
|
||||
const validEntry = {
|
||||
uri: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithms: ['RS256'],
|
||||
issuers: ['multiple-issuers', mockBaseUrl],
|
||||
audiences: ['multiple-audiences', 'backstage'],
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry));
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({ subject: mockSubject });
|
||||
});
|
||||
|
||||
it('returns undefined if no valid entry found', async () => {
|
||||
const invalidEntry1 = {
|
||||
uri: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithms: ['RS256'],
|
||||
issuers: [mockBaseUrl],
|
||||
audiences: [],
|
||||
};
|
||||
|
||||
const invalidEntry2 = {
|
||||
uri: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithms: ['HS256'],
|
||||
issuers: [],
|
||||
audiences: ['backstage'],
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry1));
|
||||
jwksHandler.add(new ConfigReader(invalidEntry2));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
uri: 'https://exampl e.com/jwks',
|
||||
}),
|
||||
);
|
||||
}).toThrow('Illegal URI, must be a set of non-space characters');
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
uri: 'https://example.com/jwks\n',
|
||||
}),
|
||||
);
|
||||
}).toThrow('Illegal URI, must be a set of non-space characters');
|
||||
});
|
||||
|
||||
it('gracefully handles no added tokens', async () => {
|
||||
const handler = new JWKSHandler();
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
+4
-8
@@ -26,7 +26,7 @@ import { TokenHandler } from './types';
|
||||
export class JWKSHandler implements TokenHandler {
|
||||
#entries: Array<{
|
||||
algorithms: string[];
|
||||
audiences: string[];
|
||||
audiences: string[] | string;
|
||||
issuers: string[];
|
||||
uri: string;
|
||||
}> = [];
|
||||
@@ -34,22 +34,18 @@ export class JWKSHandler implements TokenHandler {
|
||||
add(options: Config) {
|
||||
const algorithms = options.getOptionalStringArray('algorithms') ?? [];
|
||||
const issuers = options.getOptionalStringArray('issuers') ?? [];
|
||||
const audiences = options.getOptionalStringArray('audiences') ?? [];
|
||||
// if audience is unset, an empty string is valid, but an empty array is not
|
||||
const audiences = options.getOptionalStringArray('audiences') ?? '';
|
||||
const uri = options.getString('uri');
|
||||
|
||||
if (!uri.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
if (!issuers.every(issuer => issuer.match(/^\S+$/))) {
|
||||
throw new Error('Illegal issuer, must be a set of non-space characters');
|
||||
throw new Error('Illegal URI, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
this.#entries.push({ algorithms, audiences, issuers, uri });
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
// not sure if we would need to support multiple jwks entries, but implementing to match static/legacy token handlers
|
||||
for (const entry of this.#entries) {
|
||||
try {
|
||||
const jwks = createRemoteJWKSet(new URL(entry.uri));
|
||||
|
||||
Reference in New Issue
Block a user