diff --git a/.changeset/famous-monkeys-count.md b/.changeset/famous-monkeys-count.md new file mode 100644 index 0000000000..c5151b38c3 --- /dev/null +++ b/.changeset/famous-monkeys-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Add support for JWKS tokens in ExternalTokenHandler. diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 4213f8c0f9..a728bf064b 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -80,6 +80,55 @@ header: Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW ``` +## JWKS Token Auth + +This access method allows for external caller token authentication using configured +JSON Web Key Sets (JWKS). This is useful for callers that are authenticating to our +instance of Backstage with third-party tools, such as Auth0. + +You can configure this access method by adding one or more entries of type `jwks` +to the `backend.auth.externalAccess` app-config key: + +```yaml title="in e.g. app-config.production.yaml" +backend: + auth: + externalAccess: + - type: jwks + options: + url: https://example.com/.well-known/jwks.json + issuers: + - https://example.com + algorithms: + - RS256 + audiences: + - example + subjectPrefix: custom-prefix + - type: jwks + options: + url: https://another-example.com/.well-known/jwks.json + issuers: + - https://example.com +``` + +The URL should point at an unauthenticated endpoint that returns the JWKS. + +Issuers specifies the issuer(s) of the JWT that the authenticating app will accept. +Passed JWTs must have an `iss` claim which matches one of the specified issuers. + +Algorithms specifies the algorithm(s) that are used to verify the JWT. The passed JWTs +must have been signed using one of the listed algorithms. + +Audiences specify the intended audience(s) of the JWT. The passed JWTs must have an "aud" +claim that matches one of the audiences specified, or have no audience specified. + +For additional details regarding the JWKS configuration, please consult your authentication +provider's documentation. + +The subject returned from the token verification will become part of the +credentials object that the request recipient plugins get. All subjects will have the prefix +`external:`, but you can also provide a custom subjectPrefix which will get appended before the +subject returned from your JWKS service (ex. `external:custom-prefix:sub`). + ## Legacy Tokens Plugins and backends that are _not_ on the new backend system use a legacy token diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts index f84493828a..5517af4f98 100644 --- a/packages/backend-app-api/config.d.ts +++ b/packages/backend-app-api/config.d.ts @@ -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; + }; + } >; }; }; diff --git a/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts index 588a1f1794..79ad8dd3c4 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/ExternalTokenHandler.ts @@ -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 = { static: staticHandler, legacy: legacyHandler, + jwks: jwksHandler, }; // Load the new-style handlers diff --git a/packages/backend-app-api/src/services/implementations/auth/external/jwks.test.ts b/packages/backend-app-api/src/services/implementations/auth/external/jwks.test.ts new file mode 100644 index 0000000000..4cbdcb1cb0 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.test.ts @@ -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 { + use: 'sig'; + alg: string; + kid: string; + kty: string; +} +// Simplified copy of TokenFactory in @backstage/plugin-auth-backend +class FakeTokenFactory { + private readonly keys = new Array(); + + constructor( + private readonly options: { + issuer: string; + keyDurationSeconds: number; + }, + ) {} + + async issueToken(params: { + claims: { + sub: string; + ent?: string[]; + }; + }): Promise { + 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}`, + }); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts new file mode 100644 index 0000000000..ea62b3880c --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -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; + } +}