Merge pull request #24681 from ryan-hanchett/feat/add-jwks-access-type-1

feat: add jwks access type to external token handler
This commit is contained in:
Fredrik Adelöw
2024-05-22 15:28:27 +02:00
committed by GitHub
6 changed files with 403 additions and 0 deletions
+41
View File
@@ -131,6 +131,47 @@ export interface Config {
subject: string;
};
}
| {
/**
* This access method consists of a JWKS endpoint that can be used to
* verify JWT tokens.
*
* Callers generate JWT tokens via 3rd party tooling
* and pass them in the Authorization header:
*
* ```
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
* ```
*/
type: 'jwks';
options: {
/**
* Sets the algorithms that should be used to verify the JWT tokens.
* The passed JWTs must have been signed using one of the listed algorithms.
*/
algorithms?: string[];
/**
* Sets the issuers that should be used to verify the JWT tokens.
* Passed JWTs must have an `iss` claim which matches one of the specified issuers.
*/
issuers?: string[];
/**
* Sets the audiences that should be used to verify the JWT tokens.
* The passed JWTs must have an "aud" claim that matches one of the audiences specified,
* or have no audience specified.
*/
audiences?: string[];
/**
* Sets an optional subject prefix. Passes the subject to called plugins.
* Useful for debugging and tracking purposes.
*/
subjectPrefix?: string;
/**
* Sets the URL containing the JWKS endpoint.
*/
url: string;
};
}
>;
};
};
@@ -21,6 +21,7 @@ import {
import { LegacyTokenHandler } from './legacy';
import { StaticTokenHandler } from './static';
import { TokenHandler } from './types';
import { JWKSHandler } from './jwks';
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
const OLD_CONFIG_KEY = 'backend.auth.keys';
@@ -40,9 +41,11 @@ export class ExternalTokenHandler {
const staticHandler = new StaticTokenHandler();
const legacyHandler = new LegacyTokenHandler();
const jwksHandler = new JWKSHandler();
const handlers: Record<string, TokenHandler> = {
static: staticHandler,
legacy: legacyHandler,
jwks: jwksHandler,
};
// Load the new-style handlers
@@ -0,0 +1,223 @@
/*
* 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
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 = {
url: `${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: `external:${mockSubject}` });
});
it('skips invalid entry and continues verification', async () => {
const invalidEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: ['fakeIssuer'],
audiences: ['fakeAud'],
};
const validEntry = {
url: `${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: `external:${mockSubject}` });
});
it('returns undefined if no valid entry found', async () => {
const invalidEntry1 = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: [mockBaseUrl],
audiences: [],
};
const invalidEntry2 = {
url: `${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({
url: 'https://exampl e.com/jwks',
}),
);
}).toThrow('Invalid URL');
expect(() => {
jwksHandler.add(
new ConfigReader({
url: 'https://example.com/jwks\n',
}),
);
}).toThrow('Illegal URL, 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();
});
it('uses custom subject prefix if provided', async () => {
const validEntry = {
url: `${mockBaseUrl}/.well-known/jwks.json`,
algorithms: ['RS256'],
issuers: [mockBaseUrl],
audiences: ['backstage'],
subjectPrefix: 'custom-prefix',
};
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: `external:${validEntry.subjectPrefix}:${mockSubject}`,
});
});
});
@@ -0,0 +1,82 @@
/*
* 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 { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose';
import { Config } from '@backstage/config';
import { TokenHandler } from './types';
/**
* Handles `type: jwks` access.
*
* @internal
*/
export class JWKSHandler implements TokenHandler {
#entries: Array<{
algorithms?: string[];
audiences?: string[];
issuers?: string[];
subjectPrefix?: string;
url: URL;
jwks: JWTVerifyGetKey;
}> = [];
add(options: Config) {
const algorithms = options.getOptionalStringArray('algorithms');
const issuers = options.getOptionalStringArray('issuers');
const audiences = options.getOptionalStringArray('audiences');
const subjectPrefix = options.getOptionalString('subjectPrefix');
const url = new URL(options.getString('url'));
const jwks = createRemoteJWKSet(url);
if (!options.getString('url').match(/^\S+$/)) {
throw new Error('Illegal URL, must be a set of non-space characters');
}
this.#entries.push({
algorithms,
audiences,
issuers,
jwks,
subjectPrefix,
url,
});
}
async verifyToken(token: string) {
for (const entry of this.#entries) {
try {
const {
payload: { sub },
} = await jwtVerify(token, entry.jwks, {
algorithms: entry.algorithms,
issuer: entry.issuers,
audience: entry.audiences,
});
if (sub) {
if (entry.subjectPrefix) {
return { subject: `external:${entry.subjectPrefix}:${sub}` };
}
return { subject: `external:${sub}` };
}
} catch {
continue;
}
}
return undefined;
}
}