From e25e467e605bcaf77ee9ca5c7d59e3f9475403f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 12 Jun 2024 09:39:34 +0200 Subject: [PATCH] auth: add a static keypair method for plugin-to-plugin auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: blam Signed-off-by: Fredrik Adelöw --- .changeset/metal-apricots-heal.md | 39 ++++ .gitignore | 3 + docs/auth/service-to-service-auth.md | 54 ++++++ packages/backend-app-api/config.d.ts | 31 +++ .../auth/DefaultAuthService.ts | 10 +- .../auth/authServiceFactory.ts | 25 ++- .../auth/plugin/PluginTokenHandler.test.ts | 58 +++--- .../auth/plugin/PluginTokenHandler.ts | 102 ++-------- .../keys}/DatabaseKeyStore.test.ts | 2 + .../{ => plugin/keys}/DatabaseKeyStore.ts | 5 +- .../keys/DatabasePluginKeySource.test.ts | 77 ++++++++ .../plugin/keys/DatabasePluginKeySource.ts | 127 +++++++++++++ .../keys/StaticConfigPluginKeySource.test.ts | 92 +++++++++ .../keys/StaticConfigPluginKeySource.ts | 177 ++++++++++++++++++ .../plugin/keys/createPluginKeySource.test.ts | 170 +++++++++++++++++ .../auth/plugin/keys/createPluginKeySource.ts | 56 ++++++ .../implementations/auth/plugin/keys/types.ts | 36 ++++ .../services/implementations/auth/types.ts | 14 -- 18 files changed, 923 insertions(+), 155 deletions(-) create mode 100644 .changeset/metal-apricots-heal.md rename packages/backend-app-api/src/services/implementations/auth/{ => plugin/keys}/DatabaseKeyStore.test.ts (99%) rename packages/backend-app-api/src/services/implementations/auth/{ => plugin/keys}/DatabaseKeyStore.ts (99%) create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/plugin/keys/types.ts diff --git a/.changeset/metal-apricots-heal.md b/.changeset/metal-apricots-heal.md new file mode 100644 index 0000000000..8cfd72a605 --- /dev/null +++ b/.changeset/metal-apricots-heal.md @@ -0,0 +1,39 @@ +--- +'@backstage/backend-app-api': patch +--- + +Added a new static key based method for plugin-to-plugin auth. This is useful for example if you are running readonly service nodes that cannot use a database for the default public-key signature scheme outlined in [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution). Most users should want to stay on the more secure zero-config database signature scheme. + +You can generate a public and private key pair using `openssl`. + +- First generate a private key using the ES256 algorithm + + ```sh + openssl ecparam -name prime256v1 -genkey -out private.ec.key + ``` + +- Convert it to PKCS#8 format + + ```sh + openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key + ``` + +- Extract the public key + + ```sh + openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key + ``` + +After this you have the files `private.key` and `public.key`. Put them in a place where you know their absolute paths, and then set up your app-config accordingly: + +```yaml +backend: + auth: + keyStore: + type: static + static: + keys: + - publicKeyFile: /absolute/path/to/public.key + privateKeyFile: /absolute/path/to/private.key + keyId: some-custom-id +``` diff --git a/.gitignore b/.gitignore index 5ba3da3148..01c92d5cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,6 @@ knip.json # Schemathesis temporary files .hypothesis/ .cassettes/ + +# Locally generated keys +*.key diff --git a/docs/auth/service-to-service-auth.md b/docs/auth/service-to-service-auth.md index 8c9130173c..fe1b6e40bb 100644 --- a/docs/auth/service-to-service-auth.md +++ b/docs/auth/service-to-service-auth.md @@ -76,6 +76,60 @@ and when your deployment is behind a secure ingress like a VPN. External callers cannot leverage this flow; it's only used internally by backend plugins calling other backend plugins. +### Static Keys for Plugin-to-Plugin Auth + +In some special circumstances, such as when running worker nodes on readonly +database replicas, you may wish to opt out of the standard database based +public-key scheme. As an alternative, you can put static keys in your config +that are used for token signing and validation. + +You can make keys using the `openssl` command line utility. + +- First generate a private key using the ES256 algorithm: + + ```sh + openssl ecparam -name prime256v1 -genkey -out private.ec.key + ``` + +- Convert it to PKCS#8 format: + + ```sh + openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key + ``` + +- Extract the public key: + + ```sh + openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key + ``` + +After this you have the files `private.key` and `public.key`. Put them in a +place where you know their absolute paths, and then set up your app-config +accordingly: + +```yaml +backend: + auth: + keyStore: + type: static + static: + keys: + - publicKeyFile: /absolute/path/to/public.key + privateKeyFile: /absolute/path/to/private.key + keyId: some-custom-id +``` + +As long as all your nodes have this same config with the same set of keys, they +will now be able to successfully communicate with each other without touching the +database. + +You'll note that the `keys` value is an array, which is useful for key rotation. +The first entry will always be used for signing, but any of the subsequent +entries will also be used for token validation. This lets you have a period of +time where tokens signed by the previous top entry are still accepted by +receivers, by just inserting your new key pair as the top entry and leaving the +old ones intact. + ## Static Tokens This access method consists of random static tokens that can be handed out to diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts index 80f97cb4b5..4ddc4a61db 100644 --- a/packages/backend-app-api/config.d.ts +++ b/packages/backend-app-api/config.d.ts @@ -33,6 +33,37 @@ export interface Config { */ dangerouslyDisableDefaultAuthPolicy?: boolean; + /** Controls how to store keys for plugin-to-plugin auth */ + keyStore?: + | { type: 'database' } + | { + type: 'static'; + static: { + /** + * Must be declared at least once and the first one will be used for signing. + */ + keys: Array<{ + /** + * Path to the public key file in the SPKI format. Should be an absolute path. + */ + publicKeyFile: string; + /** + * Path to the matching private key file in the PKCS#8 format. Should be an absolute path. + */ + privateKeyFile: string; + /** + * ID to uniquely identify this key within the JWK set. + */ + keyId: string; + /** + * JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. + * Must match the algorithm used to generate the keys in the provided files + */ + algorithm?: string; + }>; + }; + }; + /** * Configures methods of external access, ie ways for callers outside of * the Backstage ecosystem to get authorized for access to APIs that do diff --git a/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts index b4feb46cce..12ccc5fd00 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DefaultAuthService.ts @@ -27,15 +27,15 @@ import { AuthenticationError, ForwardedError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; import { decodeJwt } from 'jose'; import { ExternalTokenHandler } from './external/ExternalTokenHandler'; -import { PluginTokenHandler } from './plugin/PluginTokenHandler'; -import { UserTokenHandler } from './user/UserTokenHandler'; import { createCredentialsWithNonePrincipal, createCredentialsWithServicePrincipal, createCredentialsWithUserPrincipal, toInternalBackstageCredentials, } from './helpers'; -import { KeyStore } from './types'; +import { PluginTokenHandler } from './plugin/PluginTokenHandler'; +import { PluginKeySource } from './plugin/keys/types'; +import { UserTokenHandler } from './user/UserTokenHandler'; /** @internal */ export class DefaultAuthService implements AuthService { @@ -46,7 +46,7 @@ export class DefaultAuthService implements AuthService { private readonly tokenManager: TokenManager, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, - private readonly publicKeyStore: KeyStore, + private readonly pluginKeySource: PluginKeySource, ) {} // allowLimitedAccess is currently ignored, since we currently always use the full user tokens @@ -209,7 +209,7 @@ export class DefaultAuthService implements AuthService { } async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { - const { keys } = await this.publicKeyStore.listKeys(); + const { keys } = await this.pluginKeySource.listKeys(); return { keys: keys.map(({ key }) => key) }; } diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index ad4fb17d88..2a5a210cb7 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -18,11 +18,11 @@ import { coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { DatabaseKeyStore } from './DatabaseKeyStore'; import { DefaultAuthService } from './DefaultAuthService'; -import { PluginTokenHandler } from './plugin/PluginTokenHandler'; -import { UserTokenHandler } from './user/UserTokenHandler'; import { ExternalTokenHandler } from './external/ExternalTokenHandler'; +import { PluginTokenHandler } from './plugin/PluginTokenHandler'; +import { createPluginKeySource } from './plugin/keys/createPluginKeySource'; +import { UserTokenHandler } from './user/UserTokenHandler'; /** @public */ export const authServiceFactory = createServiceFactory({ @@ -40,27 +40,32 @@ export const authServiceFactory = createServiceFactory({ tokenManager: coreServices.tokenManager, }, async factory({ config, discovery, plugin, tokenManager, logger, database }) { - const disableDefaultAuthPolicy = Boolean( + const disableDefaultAuthPolicy = config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', - ), - ); + ) ?? false; - const publicKeyStore = await DatabaseKeyStore.create({ + const keyDuration = { hours: 1 }; + + const keySource = await createPluginKeySource({ + config, database, logger, + keyDuration, }); const userTokens = UserTokenHandler.create({ discovery, }); + const pluginTokens = PluginTokenHandler.create({ ownPluginId: plugin.getId(), - keyDuration: { hours: 1 }, logger, - publicKeyStore, + keySource, + keyDuration, discovery, }); + const externalTokens = ExternalTokenHandler.create({ ownPluginId: plugin.getId(), config, @@ -74,7 +79,7 @@ export const authServiceFactory = createServiceFactory({ tokenManager, plugin.getId(), disableDefaultAuthPolicy, - publicKeyStore, + keySource, ); }, }); diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts index 424e01660f..ce470eff04 100644 --- a/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.test.ts @@ -19,23 +19,36 @@ import { PluginTokenHandler } from './PluginTokenHandler'; import { decodeJwt } from 'jose'; describe('PluginTokenHandler', () => { + const mockPublicKey = { + kty: 'EC', + x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI', + y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro', + crv: 'P-256', + kid: 'mock', + alg: 'ES256', + }; + const mockPrivateKey = { + ...mockPublicKey, + d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI', + }; + beforeEach(() => { jest.useRealTimers(); }); - it('issues token with correct expiration of token and generated key', async () => { + it('runs issueToken', async () => { jest.useFakeTimers({ now: new Date(0), }); - const addKeyMock = jest.fn(); + const getKeyMock = jest.fn(async () => mockPrivateKey); const handler = PluginTokenHandler.create({ discovery: mockServices.discovery(), keyDuration: { seconds: 10 }, logger: mockServices.logger.mock(), ownPluginId: 'test', - publicKeyStore: { - addKey: addKeyMock, + keySource: { + getPrivateSigningKey: getKeyMock, listKeys: jest.fn(), }, }); @@ -45,38 +58,13 @@ describe('PluginTokenHandler', () => { targetPluginId: 'other', }); const payload = decodeJwt(token); - expect(payload.iat).toBe(0); - expect(payload.exp).toBe(10); - - expect(addKeyMock).toHaveBeenCalledTimes(1); - expect(addKeyMock).toHaveBeenCalledWith({ - id: expect.any(String), - key: expect.any(Object), - expiresAt: new Date(30_000), + expect(payload).toMatchObject({ + iat: 0, + exp: 10, + sub: 'test', + aud: 'other', }); - jest.advanceTimersByTime(5_000); - await handler.issueToken({ - pluginId: 'test', - targetPluginId: 'other', - }); - expect(addKeyMock).toHaveBeenCalledTimes(1); - - jest.advanceTimersByTime(10_000); - await handler.issueToken({ - pluginId: 'test', - targetPluginId: 'other', - }); - expect(addKeyMock).toHaveBeenCalledTimes(2); - - expect(addKeyMock.mock.calls[0][0].id).not.toBe( - addKeyMock.mock.calls[1][0].id, - ); - - expect(addKeyMock).toHaveBeenNthCalledWith(2, { - id: expect.any(String), - key: expect.any(Object), - expiresAt: new Date(45_000), - }); + expect(getKeyMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts index fef33907a5..a401469fdc 100644 --- a/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/PluginTokenHandler.ts @@ -15,51 +15,36 @@ */ import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; -import { - decodeJwt, - exportJWK, - generateKeyPair, - JWK, - importJWK, - SignJWT, - decodeProtectedHeader, -} from 'jose'; -import { v4 as uuid } from 'uuid'; -import { InternalKey, KeyStore } from '../types'; +import { decodeJwt, importJWK, SignJWT, decodeProtectedHeader } from 'jose'; import { AuthenticationError } from '@backstage/errors'; import { jwtVerify } from 'jose'; import { tokenTypes } from '@backstage/plugin-auth-node'; import { JwksClient } from '../JwksClient'; import { HumanDuration, durationToMilliseconds } from '@backstage/types'; +import { PluginKeySource } from './keys/types'; -/** - * The margin for how many times longer we make the public key available - * compared to how long we use the private key to sign new tokens. - */ -const KEY_EXPIRATION_MARGIN_FACTOR = 3; const SECONDS_IN_MS = 1000; const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i; type Options = { ownPluginId: string; - publicKeyStore: KeyStore; + keyDuration: HumanDuration; + keySource: PluginKeySource; discovery: DiscoveryService; logger: LoggerService; - /** Expiration time of signing keys */ - keyDuration: HumanDuration; - /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. + /** + * JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. * Must match one of the algorithms defined for IdentityClient. * When setting a different algorithm, check if the `key` field * of the `signing_keys` table can fit the length of the generated keys. * If not, add a knex migration file in the migrations folder. - * More info on supported algorithms: https://github.com/panva/jose */ + * More info on supported algorithms: https://github.com/panva/jose + */ algorithm?: string; }; export class PluginTokenHandler { - private privateKeyPromise?: Promise; - private keyExpiry?: Date; private jwksMap = new Map(); // Tracking state for isTargetPluginSupported @@ -70,9 +55,9 @@ export class PluginTokenHandler { return new PluginTokenHandler( options.logger, options.ownPluginId, - options.publicKeyStore, - Math.round(durationToMilliseconds(options.keyDuration) / 1000), + options.keySource, options.algorithm ?? 'ES256', + Math.round(durationToMilliseconds(options.keyDuration) / 1000), options.discovery, ); } @@ -80,9 +65,9 @@ export class PluginTokenHandler { private constructor( private readonly logger: LoggerService, private readonly ownPluginId: string, - private readonly publicKeyStore: KeyStore, - private readonly keyDurationSeconds: number, + private readonly keySource: PluginKeySource, private readonly algorithm: string, + private readonly keyDurationSeconds: number, private readonly discovery: DiscoveryService, ) {} @@ -132,7 +117,7 @@ export class PluginTokenHandler { onBehalfOf?: { token: string; expiresAt: Date }; }): Promise<{ token: string }> { const { pluginId, targetPluginId, onBehalfOf } = options; - const key = await this.getKey(); + const key = await this.keySource.getPrivateSigningKey(); const sub = pluginId; const aud = targetPluginId; @@ -229,65 +214,4 @@ export class PluginTokenHandler { 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) { - if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) { - return this.privateKeyPromise; - } - this.logger.info(`Signing key has expired, generating new key`); - delete this.privateKeyPromise; - } - - this.keyExpiry = new Date( - Date.now() + this.keyDurationSeconds * SECONDS_IN_MS, - ); - - const promise = (async () => { - // This generates a new signing key to be used to sign tokens until the next key rotation - const kid = uuid(); - const key = await generateKeyPair(this.algorithm); - const publicKey = await exportJWK(key.publicKey); - const privateKey = await exportJWK(key.privateKey); - publicKey.kid = privateKey.kid = kid; - publicKey.alg = privateKey.alg = this.algorithm; - - // We're not allowed to use the key until it has been successfully stored - // TODO: some token verification implementations aggressively cache the list of keys, and - // don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we - // may want to keep using the existing key for some period of time until we switch to - // 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}`); - - await this.publicKeyStore.addKey({ - id: kid, - key: publicKey as InternalKey, - expiresAt: new Date( - Date.now() + - this.keyDurationSeconds * - SECONDS_IN_MS * - KEY_EXPIRATION_MARGIN_FACTOR, - ), - }); - - // At this point we are allowed to start using the new key - return privateKey; - })(); - - this.privateKeyPromise = promise; - - try { - // If we fail to generate a new key, we need to clear the state so that - // the next caller will try to generate another key. - await promise; - } catch (error) { - this.logger.error(`Failed to generate new signing key, ${error}`); - delete this.keyExpiry; - delete this.privateKeyPromise; - } - - return promise; - } } diff --git a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.test.ts similarity index 99% rename from packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts rename to packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.test.ts index 136017efdb..551cccaf73 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.test.ts @@ -34,6 +34,8 @@ const testKey2 = { n: 'test', }; +jest.setTimeout(60_000); + describe('DatabaseKeyStore', () => { const databases = TestDatabases.create(); diff --git a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.ts similarity index 99% rename from packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts rename to packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.ts index 5bf9244707..aea126d00a 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabaseKeyStore.ts @@ -19,12 +19,13 @@ import { LoggerService, resolvePackagePath, } from '@backstage/backend-plugin-api'; -import { DateTime } from 'luxon'; -import { Knex } from 'knex'; import { JsonObject } from '@backstage/types'; +import { Knex } from 'knex'; +import { DateTime } from 'luxon'; import { KeyStore } from './types'; const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations'; + /** @internal */ export const TABLE = 'backstage_backend_public_keys__keys'; diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.test.ts new file mode 100644 index 0000000000..7a51700a7f --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { mockServices } from '@backstage/backend-test-utils'; +import { DatabasePluginKeySource } from './DatabasePluginKeySource'; + +describe('DatabasePluginKeySource', () => { + beforeEach(() => { + jest.useRealTimers(); + }); + + it('issues token with correct expiration of token and generated key', async () => { + jest.useFakeTimers({ + now: new Date(0), + }); + + const keyStore = { + addKey: jest.fn(), + listKeys: jest.fn(), + }; + + const source = new DatabasePluginKeySource( + keyStore, + mockServices.logger.mock(), + 10, + 'ES256', + ); + + const key = await source.getPrivateSigningKey(); + expect(key).toMatchObject({ + alg: 'ES256', + d: expect.any(String), + }); + + expect(keyStore.addKey).toHaveBeenCalledTimes(1); + expect(keyStore.addKey).toHaveBeenCalledWith({ + id: expect.any(String), + key: expect.any(Object), + expiresAt: new Date(30_000), + }); + + jest.advanceTimersByTime(5_000); + + let newKey = await source.getPrivateSigningKey(); + expect(keyStore.addKey).toHaveBeenCalledTimes(1); + expect(newKey).toBe(key); + + jest.advanceTimersByTime(10_000); + + newKey = await source.getPrivateSigningKey(); + expect(keyStore.addKey).toHaveBeenCalledTimes(2); + expect(newKey).not.toBe(key); + + expect(keyStore.addKey.mock.calls[0][0].id).not.toBe( + keyStore.addKey.mock.calls[1][0].id, + ); + + expect(keyStore.addKey).toHaveBeenNthCalledWith(2, { + id: expect.any(String), + key: expect.any(Object), + expiresAt: new Date(45_000), + }); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.ts new file mode 100644 index 0000000000..37cb1aeb5c --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/DatabasePluginKeySource.ts @@ -0,0 +1,127 @@ +/* + * 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 { DatabaseService, LoggerService } from '@backstage/backend-plugin-api'; +import { HumanDuration, durationToMilliseconds } from '@backstage/types'; +import { JWK, exportJWK, generateKeyPair } from 'jose'; +import { v4 as uuid } from 'uuid'; +import { DatabaseKeyStore } from './DatabaseKeyStore'; +import { InternalKey, KeyPayload, KeyStore } from './types'; +import { PluginKeySource } from './types'; + +const SECONDS_IN_MS = 1000; + +/** + * The margin for how many times longer we make the public key available + * compared to how long we use the private key to sign new tokens. + */ +const KEY_EXPIRATION_MARGIN_FACTOR = 3; + +export class DatabasePluginKeySource implements PluginKeySource { + private privateKeyPromise?: Promise; + private keyExpiry?: Date; + + constructor( + private readonly keyStore: KeyStore, + private readonly logger: LoggerService, + private readonly keyDurationSeconds: number, + private readonly algorithm: string, + ) {} + + public static async create(options: { + logger: LoggerService; + database: DatabaseService; + keyDuration: HumanDuration; + algorithm?: string; + }): Promise { + const keyStore = await DatabaseKeyStore.create({ + database: options.database, + logger: options.logger, + }); + + return new DatabasePluginKeySource( + keyStore, + options.logger, + Math.round(durationToMilliseconds(options.keyDuration) / 1000), + options.algorithm ?? 'ES256', + ); + } + + async getPrivateSigningKey(): Promise { + // Make sure that we only generate one key at a time + if (this.privateKeyPromise) { + if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) { + return this.privateKeyPromise; + } + this.logger.info(`Signing key has expired, generating new key`); + delete this.privateKeyPromise; + } + + this.keyExpiry = new Date( + Date.now() + this.keyDurationSeconds * SECONDS_IN_MS, + ); + + const promise = (async () => { + // This generates a new signing key to be used to sign tokens until the next key rotation + const kid = uuid(); + const key = await generateKeyPair(this.algorithm); + const publicKey = await exportJWK(key.publicKey); + const privateKey = await exportJWK(key.privateKey); + publicKey.kid = privateKey.kid = kid; + publicKey.alg = privateKey.alg = this.algorithm; + + // We're not allowed to use the key until it has been successfully stored + // TODO: some token verification implementations aggressively cache the list of keys, and + // don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we + // may want to keep using the existing key for some period of time until we switch to + // 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}`); + + await this.keyStore.addKey({ + id: kid, + key: publicKey as InternalKey, + expiresAt: new Date( + Date.now() + + this.keyDurationSeconds * + SECONDS_IN_MS * + KEY_EXPIRATION_MARGIN_FACTOR, + ), + }); + + // At this point we are allowed to start using the new key + return privateKey; + })(); + + this.privateKeyPromise = promise; + + try { + // If we fail to generate a new key, we need to clear the state so that + // the next caller will try to generate another key. + await promise; + } catch (error) { + this.logger.error(`Failed to generate new signing key, ${error}`); + delete this.keyExpiry; + delete this.privateKeyPromise; + } + + return promise; + } + + listKeys(): Promise<{ keys: KeyPayload[] }> { + return this.keyStore.listKeys(); + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.test.ts new file mode 100644 index 0000000000..de96a150ec --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2023 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 { StaticConfigPluginKeySource } from './StaticConfigPluginKeySource'; +import { Config, ConfigReader } from '@backstage/config'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +const privateKey = ` +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY +Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm +hQRjvYdO+Mg/3FJss6FaExESTzhPSr3X+be/exarkTMchbDXNEdCKwpn +-----END PRIVATE KEY----- +`.trim(); + +const publicKey = ` +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92 +ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw== +-----END PUBLIC KEY----- +`.trim(); + +describe('StaticConfigPluginKeySource', () => { + let sourceConfig: Config; + const sourceDir = createMockDirectory(); + + beforeAll(() => { + sourceDir.setContent({ + 'public.pem': publicKey, + 'private.pem': privateKey, + }); + + const publicKeyPath = sourceDir.resolve('public.pem'); + const privateKeyPath = sourceDir.resolve('private.pem'); + sourceConfig = new ConfigReader({ + type: 'static', + static: { + keys: [ + { + publicKeyFile: publicKeyPath, + privateKeyFile: privateKeyPath, + keyId: '1', + algorithm: 'ES256', + }, + { + publicKeyFile: publicKeyPath, + privateKeyFile: privateKeyPath, + keyId: '2', + // skipping explicit alg + }, + ], + }, + }); + }); + + it('should provide keys from disk', async () => { + const staticKeyStore = await StaticConfigPluginKeySource.create({ + sourceConfig, + keyDuration: { hours: 1 }, + }); + const keys = await staticKeyStore.listKeys(); + expect(keys.keys.length).toEqual(2); + expect(keys.keys[0].key).toMatchObject({ + kid: '1', + alg: 'ES256', + }); + expect(keys.keys[1].key).toMatchObject({ + kid: '2', + alg: 'ES256', + }); + + const pk = await staticKeyStore.getPrivateSigningKey(); + expect(pk).toMatchObject({ + kid: '1', + alg: 'ES256', + }); + expect(pk.d).toBeDefined(); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.ts new file mode 100644 index 0000000000..9fc46aec2e --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/StaticConfigPluginKeySource.ts @@ -0,0 +1,177 @@ +/* + * 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 { Config } from '@backstage/config'; +import { HumanDuration, durationToMilliseconds } from '@backstage/types'; +import { promises as fs } from 'fs'; +import { JWK, exportJWK, importPKCS8, importSPKI } from 'jose'; +import { KeyLike } from 'jose/dist/types/types'; +import { KeyPayload } from './types'; +import { PluginKeySource } from './types'; + +export type KeyPair = { + publicKey: JWK; + privateKey: JWK; + keyId: string; +}; + +export type StaticKeyConfig = { + publicKeyFile: string; + privateKeyFile: string; + keyId: string; + algorithm: string; +}; + +const DEFAULT_ALGORITHM = 'ES256'; + +const SECONDS_IN_MS = 1000; + +/** + * Key source that loads predefined public/private key pairs from disk. + * + * The private key should be represented using the PKCS#8 format, + * while the public key should be in the SPKI format. + * + * @remarks + * + * You can generate a public and private key pair, using + * openssl: + * + * Generate a private key using the ES256 algorithm + * ```sh + * openssl ecparam -name prime256v1 -genkey -out private.ec.key + * ``` + * Convert it to PKCS#8 format + * ```sh + * openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key + * ``` + * Extract the public key + * ```sh + * openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key + * ``` + * + * Provide the paths to private.key and public.key as the respective + * private and public key paths in the `create` method. + */ +export class StaticConfigPluginKeySource implements PluginKeySource { + private constructor( + private readonly keyPairs: KeyPair[], + private readonly keyDurationSeconds: number, + ) {} + + public static async create(options: { + sourceConfig: Config; + keyDuration: HumanDuration; + }): Promise { + const keyConfigs = options.sourceConfig + .getConfigArray('static.keys') + .map(c => { + const staticKeyConfig: StaticKeyConfig = { + publicKeyFile: c.getString('publicKeyFile'), + privateKeyFile: c.getString('privateKeyFile'), + keyId: c.getString('keyId'), + algorithm: c.getOptionalString('algorithm') ?? DEFAULT_ALGORITHM, + }; + + return staticKeyConfig; + }); + + const keyPairs = await Promise.all( + keyConfigs.map(async k => await this.loadKeyPair(k)), + ); + + if (keyPairs.length < 1) { + throw new Error( + 'At least one key pair must be provided in static.keys, when the static key store type is used', + ); + } + + return new StaticConfigPluginKeySource( + keyPairs, + durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS, + ); + } + + async getPrivateSigningKey(): Promise { + return this.keyPairs[0].privateKey; + } + + async listKeys(): Promise<{ keys: KeyPayload[] }> { + const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k)); + return { keys }; + } + + private static async loadKeyPair(options: StaticKeyConfig): Promise { + const algorithm = options.algorithm; + const keyId = options.keyId; + const publicKey = await this.loadPublicKeyFromFile( + options.publicKeyFile, + keyId, + algorithm, + ); + const privateKey = await this.loadPrivateKeyFromFile( + options.privateKeyFile, + keyId, + algorithm, + ); + + return { publicKey, privateKey, keyId }; + } + + private static async loadPublicKeyFromFile( + path: string, + keyId: string, + algorithm: string, + ): Promise { + return this.loadKeyFromFile(path, keyId, algorithm, importSPKI); + } + + private static async loadPrivateKeyFromFile( + path: string, + keyId: string, + algorithm: string, + ): Promise { + return this.loadKeyFromFile(path, keyId, algorithm, importPKCS8); + } + + private static async loadKeyFromFile( + path: string, + keyId: string, + algorithm: string, + importer: (content: string, algorithm: string) => Promise, + ): Promise { + const content = await fs.readFile(path, { encoding: 'utf8', flag: 'r' }); + const key = await importer(content, algorithm); + const jwk = await exportJWK(key); + jwk.kid = keyId; + jwk.alg = algorithm; + + return jwk; + } + + private keyPairToStoredKey(keyPair: KeyPair): KeyPayload { + const publicKey = { + ...keyPair.publicKey, + kid: keyPair.keyId, + }; + + return { + key: publicKey, + id: keyPair.keyId, + expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS), + }; + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.test.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.test.ts new file mode 100644 index 0000000000..638e0a0e33 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.test.ts @@ -0,0 +1,170 @@ +/* + * 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 { createPluginKeySource } from './createPluginKeySource'; +import { + TestDatabases, + createMockDirectory, + mockServices, +} from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; + +jest.setTimeout(60_000); + +describe('createPluginKeySource', () => { + const databases = TestDatabases.create(); + const mockDir = createMockDirectory(); + + it.each(databases.eachSupportedId())( + 'works for implicit database (no config), %p', + async databaseId => { + const knex = await databases.init(databaseId); + + const getClient = jest.fn(async () => knex); + + const source = await createPluginKeySource({ + config: new ConfigReader({}), + database: mockServices.database.mock({ getClient }), + logger: mockServices.logger.mock(), + keyDuration: { seconds: 10 }, + }); + + expect(getClient).toHaveBeenCalled(); + + const key = await source.getPrivateSigningKey(); + expect(key).toMatchObject({ + alg: 'ES256', + d: expect.any(String), + }); + + const keys = await source.listKeys(); + expect(keys).toEqual({ + keys: [ + expect.objectContaining({ + id: key.kid, + key: expect.objectContaining({ + kid: key.kid, + }), + }), + ], + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'works for explicit database, %p', + async databaseId => { + const knex = await databases.init(databaseId); + + const getClient = jest.fn(async () => knex); + + const source = await createPluginKeySource({ + config: new ConfigReader({ + backend: { auth: { keyStore: { type: 'database' } } }, + }), + database: mockServices.database.mock({ getClient }), + logger: mockServices.logger.mock(), + keyDuration: { seconds: 10 }, + }); + + expect(getClient).toHaveBeenCalled(); + + const key = await source.getPrivateSigningKey(); + expect(key).toMatchObject({ + alg: 'ES256', + d: expect.any(String), + }); + + const keys = await source.listKeys(); + expect(keys).toEqual({ + keys: [ + expect.objectContaining({ + id: key.kid, + key: expect.objectContaining({ + kid: key.kid, + }), + }), + ], + }); + }, + ); + + it('works for static', async () => { + const privateKey = ` + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY + Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm + hQRjvYdO+Mg/3FJss6FaExESTzhPSr3X+be/exarkTMchbDXNEdCKwpn + -----END PRIVATE KEY----- + `.trim(); + + const publicKey = ` + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92 + ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw== + -----END PUBLIC KEY----- + `.trim(); + + mockDir.setContent({ + 'public.pem': publicKey, + 'private.pem': privateKey, + }); + const publicKeyPath = mockDir.resolve('public.pem'); + const privateKeyPath = mockDir.resolve('private.pem'); + + const getClient = jest.fn(); + + const source = await createPluginKeySource({ + config: new ConfigReader({ + backend: { + auth: { + keyStore: { + type: 'static', + static: { + keys: [ + { + publicKeyFile: publicKeyPath, + privateKeyFile: privateKeyPath, + keyId: '1', + }, + ], + }, + }, + }, + }, + }), + database: mockServices.database.mock({ getClient }), + logger: mockServices.logger.mock(), + keyDuration: { seconds: 10 }, + }); + + expect(getClient).not.toHaveBeenCalled(); + + const keys = await source.listKeys(); + expect(keys.keys.length).toEqual(1); + expect(keys.keys[0].key).toMatchObject({ + kid: '1', + alg: 'ES256', + }); + + const pk = await source.getPrivateSigningKey(); + expect(pk).toMatchObject({ + kid: '1', + alg: 'ES256', + d: expect.any(String), + }); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.ts new file mode 100644 index 0000000000..dc756885cd --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/createPluginKeySource.ts @@ -0,0 +1,56 @@ +/* + * 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 { + DatabaseService, + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { HumanDuration } from '@backstage/types'; +import { DatabasePluginKeySource } from './DatabasePluginKeySource'; +import { StaticConfigPluginKeySource } from './StaticConfigPluginKeySource'; +import { PluginKeySource } from './types'; + +const CONFIG_ROOT_KEY = 'backend.auth.keyStore'; + +export async function createPluginKeySource(options: { + config: RootConfigService; + database: DatabaseService; + logger: LoggerService; + keyDuration: HumanDuration; + algorithm?: string; +}): Promise { + const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY); + const type = keyStoreConfig?.getOptionalString('type') ?? 'database'; + + if (!keyStoreConfig || type === 'database') { + return DatabasePluginKeySource.create({ + database: options.database, + logger: options.logger, + keyDuration: options.keyDuration, + algorithm: options.algorithm, + }); + } else if (type === 'static') { + return StaticConfigPluginKeySource.create({ + sourceConfig: keyStoreConfig, + keyDuration: options.keyDuration, + }); + } + + throw new Error( + `Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`, + ); +} diff --git a/packages/backend-app-api/src/services/implementations/auth/plugin/keys/types.ts b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/types.ts new file mode 100644 index 0000000000..b02e650757 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/plugin/keys/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { JWK } from 'jose'; +import { JsonObject } from '@backstage/types'; + +export type KeyStore = { + addKey(key: KeyPayload): Promise; + listKeys(): Promise<{ keys: KeyPayload[] }>; +}; + +export type KeyPayload = { + id: string; + key: InternalKey; + expiresAt: Date; +}; + +export type InternalKey = JsonObject & { kid: string }; + +export interface PluginKeySource { + getPrivateSigningKey(): Promise; + listKeys(): Promise<{ keys: KeyPayload[] }>; +} diff --git a/packages/backend-app-api/src/services/implementations/auth/types.ts b/packages/backend-app-api/src/services/implementations/auth/types.ts index 11a8fcdfcf..08ed6db421 100644 --- a/packages/backend-app-api/src/services/implementations/auth/types.ts +++ b/packages/backend-app-api/src/services/implementations/auth/types.ts @@ -15,20 +15,6 @@ */ import { BackstageCredentials } from '@backstage/backend-plugin-api'; -import { JsonObject } from '@backstage/types'; - -export type KeyStore = { - addKey(key: KeyPayload): Promise; - listKeys(): Promise<{ keys: KeyPayload[] }>; -}; - -export type KeyPayload = { - id: string; - key: InternalKey; - expiresAt: Date; -}; - -export type InternalKey = JsonObject & { kid: string }; /** @internal */ export type InternalBackstageCredentials =