From 03045fc530d8c29cb753ef5b01bc29c2b3585255 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Mon, 6 May 2024 11:51:16 -0700 Subject: [PATCH 01/12] feat: first attempt at adding jwks-auth to external token handlers Signed-off-by: Ryan Hanchett --- docs/auth/service-to-service-auth.md | 32 ++++++++ .../auth/external/ExternalTokenHandler.ts | 3 + .../implementations/auth/external/jwks.ts | 73 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 packages/backend-app-api/src/services/implementations/auth/external/jwks.ts diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index c1d6643769..b9cc6151af 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -81,6 +81,38 @@ header: Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW ``` +## JWKS Token Auth + +This access method allows for external caller token authentication using configured JWKS. +This is useful for callers that are authenticating to your 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: + uri: https://example.com/.well-known/jwks.json + issuers: + - https://example.com + algorithms: + - RS256 + audiences: + - example + - type: jwks + options: + uri: https://another-example.com/.well-known/jwks.json + issuers: + - https://example.com +``` + +The subject returned from the token verification will become part of the +credentials object that the request recipients get. + ## Legacy Tokens Plugins and backends that are _not_ on the new backend system use a legacy token 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.ts b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts new file mode 100644 index 0000000000..6ca7d010b2 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -0,0 +1,73 @@ +/* + * 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 } 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[]; + uri: string; + }> = []; + + add(options: Config) { + const algorithms = options.getOptionalStringArray('algorithms') ?? []; + const issuers = options.getOptionalStringArray('issuers') ?? []; + 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'); + } + + 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)); + const { + payload: { sub }, + } = await jwtVerify(token, jwks, { + algorithms: entry.algorithms, + issuer: entry.issuers, + audience: entry.audiences, + }); + + if (sub) { + return { subject: sub }; + } + } catch { + continue; + } + } + return undefined; + } +} From e978badcebaeab431f53180ce58f4f2ceefb5582 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 7 May 2024 09:55:53 -0700 Subject: [PATCH 02/12] test: add unit tests for jwks access Signed-off-by: Ryan Hanchett --- .../auth/external/jwks.test.ts | 202 ++++++++++++++++++ .../implementations/auth/external/jwks.ts | 12 +- 2 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/external/jwks.test.ts 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..c4d9f37b23 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.test.ts @@ -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 { + 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(); + + 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 = { + 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(); + }); +}); 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 index 6ca7d010b2..34683647df 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -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)); From 23dff40aa2865a52c37f32faae270fa71e8700f8 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 7 May 2024 10:59:31 -0700 Subject: [PATCH 03/12] docs: expand on jwks docs Signed-off-by: Ryan Hanchett --- docs/auth/service-to-service-auth.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index b9cc6151af..9ecd39ba8f 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -83,9 +83,9 @@ Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW ## JWKS Token Auth -This access method allows for external caller token authentication using configured JWKS. -This is useful for callers that are authenticating to your instance of Backstage with -third-party tools, such as Auth0. +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: @@ -110,8 +110,22 @@ backend: - https://example.com ``` +The URI 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 speficies 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 recipients get. +credentials object that the request recipient plugins get. ## Legacy Tokens From 398b82a3685bd0c623b5cf75063ba6d09b66ee9c Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 7 May 2024 13:53:46 -0700 Subject: [PATCH 04/12] chore: add changeset Signed-off-by: Ryan Hanchett --- .changeset/famous-monkeys-count.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/famous-monkeys-count.md 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. From 9b0db3f495e04a8381c91bd1e7d5f3168d1067cc Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 7 May 2024 14:08:45 -0700 Subject: [PATCH 05/12] chore: clean up comments before opening PR Signed-off-by: Ryan Hanchett --- .../src/services/implementations/auth/external/jwks.test.ts | 2 -- .../src/services/implementations/auth/external/jwks.ts | 1 - 2 files changed, 3 deletions(-) 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 index c4d9f37b23..95df6f56ff 100644 --- 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 @@ -28,8 +28,6 @@ interface AnyJWK extends Record { 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(); 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 index 34683647df..5c3738504d 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -34,7 +34,6 @@ export class JWKSHandler implements TokenHandler { add(options: Config) { const algorithms = options.getOptionalStringArray('algorithms') ?? []; const issuers = options.getOptionalStringArray('issuers') ?? []; - // 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'); From 96e30c54ab9dd8c966b9495fdcd8e13a7f6d7545 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Wed, 8 May 2024 09:12:28 -0700 Subject: [PATCH 06/12] fix: rename uri to url Signed-off-by: Ryan Hanchett --- docs/auth/service-to-service-auth.md | 6 +++--- .../src/services/implementations/auth/external/jwks.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 9ecd39ba8f..e678c82477 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -96,7 +96,7 @@ backend: externalAccess: - type: jwks options: - uri: https://example.com/.well-known/jwks.json + url: https://example.com/.well-known/jwks.json issuers: - https://example.com algorithms: @@ -105,12 +105,12 @@ backend: - example - type: jwks options: - uri: https://another-example.com/.well-known/jwks.json + url: https://another-example.com/.well-known/jwks.json issuers: - https://example.com ``` -The URI should point at an unauthenticated endpoint that returns the JWKS. +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. 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 index 5c3738504d..070f33ed53 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -28,26 +28,26 @@ export class JWKSHandler implements TokenHandler { algorithms: string[]; audiences: string[] | string; issuers: string[]; - uri: string; + url: string; }> = []; add(options: Config) { const algorithms = options.getOptionalStringArray('algorithms') ?? []; const issuers = options.getOptionalStringArray('issuers') ?? []; const audiences = options.getOptionalStringArray('audiences') ?? ''; - const uri = options.getString('uri'); + const url = options.getString('url'); - if (!uri.match(/^\S+$/)) { + if (!url.match(/^\S+$/)) { throw new Error('Illegal URI, must be a set of non-space characters'); } - this.#entries.push({ algorithms, audiences, issuers, uri }); + this.#entries.push({ algorithms, audiences, issuers, url }); } async verifyToken(token: string) { for (const entry of this.#entries) { try { - const jwks = createRemoteJWKSet(new URL(entry.uri)); + const jwks = createRemoteJWKSet(new URL(entry.url)); const { payload: { sub }, } = await jwtVerify(token, jwks, { From 8443332f72f5c90bf53724433bcef44ec755bba7 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Wed, 8 May 2024 09:18:06 -0700 Subject: [PATCH 07/12] fix: default to undefined for algo, iss and aud fields if not set in config Signed-off-by: Ryan Hanchett --- .../services/implementations/auth/external/jwks.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index 070f33ed53..dd3df07435 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -25,20 +25,20 @@ import { TokenHandler } from './types'; */ export class JWKSHandler implements TokenHandler { #entries: Array<{ - algorithms: string[]; - audiences: string[] | string; - issuers: string[]; + algorithms: string[] | undefined; + audiences: string[] | undefined; + issuers: string[] | undefined; url: string; }> = []; add(options: Config) { - const algorithms = options.getOptionalStringArray('algorithms') ?? []; - const issuers = options.getOptionalStringArray('issuers') ?? []; - const audiences = options.getOptionalStringArray('audiences') ?? ''; + const algorithms = options.getOptionalStringArray('algorithms'); + const issuers = options.getOptionalStringArray('issuers'); + const audiences = options.getOptionalStringArray('audiences'); const url = options.getString('url'); if (!url.match(/^\S+$/)) { - throw new Error('Illegal URI, must be a set of non-space characters'); + throw new Error('Illegal URL, must be a set of non-space characters'); } this.#entries.push({ algorithms, audiences, issuers, url }); From e5ade53cd97903b7f26e8247ba1fdc52ebdb5656 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Wed, 8 May 2024 09:41:33 -0700 Subject: [PATCH 08/12] feat: add subjectPrefix config Signed-off-by: Ryan Hanchett --- docs/auth/service-to-service-auth.md | 5 +++- .../implementations/auth/external/jwks.ts | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index e678c82477..0b2aa369ec 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -103,6 +103,7 @@ backend: - RS256 audiences: - example + subjectPrefix: custom-prefix - type: jwks options: url: https://another-example.com/.well-known/jwks.json @@ -125,7 +126,9 @@ For additional details regarding the JWKS configuration, please consult your aut provider's documentation. The subject returned from the token verification will become part of the -credentials object that the request recipient plugins get. +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 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 index dd3df07435..d734cbf984 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -25,29 +25,31 @@ import { TokenHandler } from './types'; */ export class JWKSHandler implements TokenHandler { #entries: Array<{ - algorithms: string[] | undefined; - audiences: string[] | undefined; - issuers: string[] | undefined; - url: string; + algorithms?: string[]; + audiences?: string[]; + issuers?: string[]; + subjectPrefix?: string; + url: URL; }> = []; add(options: Config) { const algorithms = options.getOptionalStringArray('algorithms'); const issuers = options.getOptionalStringArray('issuers'); const audiences = options.getOptionalStringArray('audiences'); - const url = options.getString('url'); + const subjectPrefix = options.getOptionalString('subjectPrefix'); + const url = new URL(options.getString('url')); - if (!url.match(/^\S+$/)) { + 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, url }); + this.#entries.push({ algorithms, audiences, issuers, subjectPrefix, url }); } async verifyToken(token: string) { for (const entry of this.#entries) { try { - const jwks = createRemoteJWKSet(new URL(entry.url)); + const jwks = createRemoteJWKSet(entry.url); const { payload: { sub }, } = await jwtVerify(token, jwks, { @@ -57,7 +59,11 @@ export class JWKSHandler implements TokenHandler { }); if (sub) { - return { subject: sub }; + if (entry.subjectPrefix) { + return { subject: `external:${entry.subjectPrefix}:${sub}` }; + } + + return { subject: `external:${sub}` }; } } catch { continue; From e54e0c47c551c1bec224591cb16a90333cd2747b Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Wed, 8 May 2024 09:46:56 -0700 Subject: [PATCH 09/12] test: fix tests, add new test for custom subject prefix Signed-off-by: Ryan Hanchett --- .../auth/external/jwks.test.ts | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) 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 index 95df6f56ff..4cbdcb1cb0 100644 --- 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 @@ -100,7 +100,7 @@ describe('JWKSHandler', () => { it('verifies token with valid entry', async () => { const validEntry = { - uri: `${mockBaseUrl}/.well-known/jwks.json`, + url: `${mockBaseUrl}/.well-known/jwks.json`, algorithms: ['RS256'], issuers: [mockBaseUrl], audiences: ['backstage'], @@ -115,19 +115,19 @@ describe('JWKSHandler', () => { const result = await jwksHandler.verifyToken(token); - expect(result).toEqual({ subject: mockSubject }); + expect(result).toEqual({ subject: `external:${mockSubject}` }); }); it('skips invalid entry and continues verification', async () => { const invalidEntry = { - uri: `${mockBaseUrl}/.well-known/jwks.json`, + url: `${mockBaseUrl}/.well-known/jwks.json`, algorithms: ['RS256'], issuers: ['fakeIssuer'], audiences: ['fakeAud'], }; const validEntry = { - uri: `${mockBaseUrl}/.well-known/jwks.json`, + url: `${mockBaseUrl}/.well-known/jwks.json`, algorithms: ['RS256'], issuers: ['multiple-issuers', mockBaseUrl], audiences: ['multiple-audiences', 'backstage'], @@ -143,19 +143,19 @@ describe('JWKSHandler', () => { const result = await jwksHandler.verifyToken(token); - expect(result).toEqual({ subject: mockSubject }); + expect(result).toEqual({ subject: `external:${mockSubject}` }); }); it('returns undefined if no valid entry found', async () => { const invalidEntry1 = { - uri: `${mockBaseUrl}/.well-known/jwks.json`, + url: `${mockBaseUrl}/.well-known/jwks.json`, algorithms: ['RS256'], issuers: [mockBaseUrl], audiences: [], }; const invalidEntry2 = { - uri: `${mockBaseUrl}/.well-known/jwks.json`, + url: `${mockBaseUrl}/.well-known/jwks.json`, algorithms: ['HS256'], issuers: [], audiences: ['backstage'], @@ -180,21 +180,44 @@ describe('JWKSHandler', () => { expect(() => { jwksHandler.add( new ConfigReader({ - uri: 'https://exampl e.com/jwks', + url: 'https://exampl e.com/jwks', }), ); - }).toThrow('Illegal URI, must be a set of non-space characters'); + }).toThrow('Invalid URL'); expect(() => { jwksHandler.add( new ConfigReader({ - uri: 'https://example.com/jwks\n', + url: 'https://example.com/jwks\n', }), ); - }).toThrow('Illegal URI, must be a set of non-space characters'); + }).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}`, + }); + }); }); From 1afb8d43f421fc4e28b39afffe1ccd6b93221bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 May 2024 09:23:24 +0200 Subject: [PATCH 10/12] Update docs/auth/service-to-service-auth.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/auth/service-to-service-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 0b2aa369ec..246bab4415 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -119,7 +119,7 @@ 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 speficies the intended audience(s) of the JWT. The passed JWTs must have an "aud" +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 From 276da6543d16955e92a8edd5f46c5097b05fb386 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 21 May 2024 09:54:45 -0700 Subject: [PATCH 11/12] fix: create JWKS as part of adding handler instead of in verification step Signed-off-by: Ryan Hanchett --- .../implementations/auth/external/jwks.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 index d734cbf984..ea62b3880c 100644 --- a/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts +++ b/packages/backend-app-api/src/services/implementations/auth/external/jwks.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { jwtVerify, createRemoteJWKSet } from 'jose'; +import { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose'; import { Config } from '@backstage/config'; import { TokenHandler } from './types'; @@ -30,6 +30,7 @@ export class JWKSHandler implements TokenHandler { issuers?: string[]; subjectPrefix?: string; url: URL; + jwks: JWTVerifyGetKey; }> = []; add(options: Config) { @@ -38,21 +39,28 @@ export class JWKSHandler implements TokenHandler { 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, subjectPrefix, url }); + this.#entries.push({ + algorithms, + audiences, + issuers, + jwks, + subjectPrefix, + url, + }); } async verifyToken(token: string) { for (const entry of this.#entries) { try { - const jwks = createRemoteJWKSet(entry.url); const { payload: { sub }, - } = await jwtVerify(token, jwks, { + } = await jwtVerify(token, entry.jwks, { algorithms: entry.algorithms, issuer: entry.issuers, audience: entry.audiences, From 922bdddcfa935220271695bdab241503e58f8207 Mon Sep 17 00:00:00 2001 From: Ryan Hanchett Date: Tue, 21 May 2024 10:05:25 -0700 Subject: [PATCH 12/12] fix: add missing config values to config.d.ts Signed-off-by: Ryan Hanchett --- packages/backend-app-api/config.d.ts | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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; + }; + } >; }; };