From ad8f173b78e7d2d9c46a970f1c4cfd272d951049 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 14:07:47 +0200 Subject: [PATCH] backend-app-api: move jwks to JwksClient Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../implementations/auth/JwksClient.ts | 75 +++++++++++++++++++ .../auth/PluginTokenHandler.ts | 49 ++++++++---- .../implementations/auth/UserTokenHandler.ts | 58 ++------------ 3 files changed, 116 insertions(+), 66 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/JwksClient.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts b/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts new file mode 100644 index 0000000000..44f0082424 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts @@ -0,0 +1,75 @@ +/* + * 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 { AuthenticationError } from '@backstage/errors'; +import { + createRemoteJWKSet, + decodeJwt, + decodeProtectedHeader, + FlattenedJWSInput, + JWSHeaderParameters, +} from 'jose'; +import { GetKeyFunction } from 'jose/dist/types/types'; + +const CLOCK_MARGIN_S = 10; + +export class JwksClient { + #keyStore?: GetKeyFunction; + #keyStoreUpdated: number = 0; + + constructor(private readonly getEndpoint: () => Promise) {} + + get getKey() { + if (!this.#keyStore) { + throw new AuthenticationError( + 'refreshKeyStore must be called before jwksClient.getKey', + ); + } + return this.#keyStore; + } + + /** + * If the last keystore refresh is stale, update the keystore URL to the latest + */ + async refreshKeyStore(rawJwtToken: string): Promise { + const payload = await decodeJwt(rawJwtToken); + const header = await decodeProtectedHeader(rawJwtToken); + + // Refresh public keys if needed + let keyStoreHasKey; + try { + if (this.#keyStore) { + // Check if the key is present in the keystore + const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); + keyStoreHasKey = await this.#keyStore(header, { + payload: rawPayload, + signature: rawSignature, + }); + } + } catch (error) { + keyStoreHasKey = false; + } + // Refresh public key URL if needed + // Add a small margin in case clocks are out of sync + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S; + if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) { + const endpoint = await this.getEndpoint(); + this.#keyStore = createRemoteJWKSet(endpoint); + this.#keyStoreUpdated = Date.now() / 1000; + } + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 1436412c3e..15ffcac980 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -28,8 +28,9 @@ import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { InternalKey, KeyStore } from './types'; import { AuthenticationError } from '@backstage/errors'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { jwtVerify } from 'jose'; import { tokenTypes } from '@backstage/plugin-auth-node'; +import { JwksClient } from './JwksClient'; const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i; @@ -52,7 +53,7 @@ type Options = { export class PluginTokenHandler { private privateKeyPromise?: Promise; private keyExpiry?: Date; - private jwksMap: Record> = {}; + private jwksMap = new Map(); static create(options: Options) { return new PluginTokenHandler( @@ -94,12 +95,18 @@ export class PluginTokenHandler { ); } - const JWKS = await this.getJWKS(pluginId); - const { payload } = await jwtVerify<{ sub: string }>(token, JWKS, { - typ: tokenTypes.plugin.typParam, - audience: this.ownPluginId, - requiredClaims: ['iat', 'exp', 'sub', 'aud'], - }).catch(e => { + const jwksClient = await this.getJwksClient(pluginId); + await jwksClient.refreshKeyStore(token); // TODO(Rugvip): Refactor so that this isn't needed + + const { payload } = await jwtVerify<{ sub: string }>( + token, + jwksClient.getKey, + { + typ: tokenTypes.plugin.typParam, + audience: this.ownPluginId, + requiredClaims: ['iat', 'exp', 'sub', 'aud'], + }, + ).catch(e => { throw new AuthenticationError('Invalid plugin token', e); }); @@ -133,15 +140,25 @@ export class PluginTokenHandler { return { token }; } - private async getJWKS(pluginId: string) { - if (!this.jwksMap[pluginId]) { - const url = `${await this.discovery.getBaseUrl( - pluginId, - )}/.backstage/auth/v1/jwks.json`; - this.jwksMap[pluginId] = createRemoteJWKSet(new URL(url)); + private async getJwksClient(pluginId: string) { + // TODO(Rugvip): Make this more resilient to forged tokens, making sure we don't fill up the map + const client = this.jwksMap.get(pluginId); + if (client) { + return client; } - return this.jwksMap[pluginId]; + + const newClient = new JwksClient(async () => { + return new URL( + `${await this.discovery.getBaseUrl( + pluginId, + )}/.backstage/auth/v1/jwks.json`, + ); + }); + + this.jwksMap.set(pluginId, newClient); + return newClient; } + private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { @@ -178,7 +195,7 @@ export class PluginTokenHandler { // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. this.logger.info(`Created new signing key ${kid}`); - console.log(`DEBUG: publicKey=`, publicKey); + await this.publicKeyStore.addKey({ id: kid, key: publicKey as InternalKey, diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts index 3002096b1e..e1223d3bfb 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -14,23 +14,17 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { tokenTypes } from '@backstage/plugin-auth-node'; import { base64url, - createRemoteJWKSet, decodeJwt, decodeProtectedHeader, - FlattenedJWSInput, - JWSHeaderParameters, jwtVerify, JWTVerifyOptions, } from 'jose'; -import { GetKeyFunction } from 'jose/dist/types/types'; - -const CLOCK_MARGIN_S = 10; +import { JwksClient } from './JwksClient'; /** * An identity client to interact with auth-backend and authenticate Backstage @@ -39,15 +33,15 @@ const CLOCK_MARGIN_S = 10; * @internal */ export class UserTokenHandler { - readonly #discovery: PluginEndpointDiscovery; + readonly #jwksClient: JwksClient; readonly #algorithms?: string[]; - #keyStore?: GetKeyFunction; - #keyStoreUpdated: number = 0; - constructor(options: { discovery: DiscoveryService }) { - this.#discovery = options.discovery; this.#algorithms = ['ES256']; // TODO: configurable? + this.#jwksClient = new JwksClient(async () => { + const url = await options.discovery.getBaseUrl('auth'); + return new URL(`${url}/.well-known/jwks.json`); + }); } async verifyToken(token: string) { @@ -56,15 +50,12 @@ export class UserTokenHandler { return undefined; } - await this.refreshKeyStore(token); - if (!this.#keyStore) { - throw new AuthenticationError('No keystore exists'); - } + await this.#jwksClient.refreshKeyStore(token); // Verify a limited token, ensuring the necessarily claims are present and token type is correct const { payload } = await jwtVerify( token, - this.#keyStore, + this.#jwksClient.getKey, verifyOpts, ).catch(e => { throw new AuthenticationError('Invalid token', e); @@ -160,37 +151,4 @@ export class UserTokenHandler { return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; } - - /** - * If the last keystore refresh is stale, update the keystore URL to the latest - */ - private async refreshKeyStore(rawJwtToken: string): Promise { - const payload = await decodeJwt(rawJwtToken); - const header = await decodeProtectedHeader(rawJwtToken); - - // Refresh public keys if needed - let keyStoreHasKey; - try { - if (this.#keyStore) { - // Check if the key is present in the keystore - const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); - keyStoreHasKey = await this.#keyStore(header, { - payload: rawPayload, - signature: rawSignature, - }); - } - } catch (error) { - keyStoreHasKey = false; - } - // Refresh public key URL if needed - // Add a small margin in case clocks are out of sync - const issuedAfterLastRefresh = - payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S; - if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) { - const url = await this.#discovery.getBaseUrl('auth'); - const endpoint = new URL(`${url}/.well-known/jwks.json`); - this.#keyStore = createRemoteJWKSet(endpoint); - this.#keyStoreUpdated = Date.now() / 1000; - } - } }