diff --git a/.changeset/brave-candles-knock.md b/.changeset/brave-candles-knock.md new file mode 100644 index 0000000000..1a6f187527 --- /dev/null +++ b/.changeset/brave-candles-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added placeholder for `listPublicServiceKeys()` in the `AuthService` returned by `createLegacyAuthAdapters`. diff --git a/.changeset/kind-turkeys-travel.md b/.changeset/kind-turkeys-travel.md new file mode 100644 index 0000000000..97dc1c6c8d --- /dev/null +++ b/.changeset/kind-turkeys-travel.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': patch +--- + +Added a new required `listPublicServiceKeys` to `AuthService`. diff --git a/.changeset/moody-bats-train.md b/.changeset/moody-bats-train.md new file mode 100644 index 0000000000..3b41d57579 --- /dev/null +++ b/.changeset/moody-bats-train.md @@ -0,0 +1,9 @@ +--- +'@backstage/backend-app-api': patch +--- + +Service-to-service authentication has been improved. + +Each plugin now has the capability to generate its own signing keys for token issuance. The generated public keys are stored in a database, and they are made accessible through a newly created endpoint: `/.backstage/auth/v1/jwks.json`. + +`AuthService` can now issue tokens with a reduced scope using the `getPluginRequestToken` method. This improvement enables plugins to identify the plugin originating the request. diff --git a/.changeset/red-years-double.md b/.changeset/red-years-double.md new file mode 100644 index 0000000000..8a891a48c9 --- /dev/null +++ b/.changeset/red-years-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Added mock of the new `listPublicServiceKeys` method for `AuthService`. diff --git a/packages/backend-app-api/migrations/20240327104803_public_keys.js b/packages/backend-app-api/migrations/20240327104803_public_keys.js new file mode 100644 index 0000000000..d4292c775b --- /dev/null +++ b/packages/backend-app-api/migrations/20240327104803_public_keys.js @@ -0,0 +1,50 @@ +/* + * 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. + */ + +// @ts-check + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + await knex.schema.createTable( + 'backstage_backend_public_keys__keys', + table => { + table + .string('id') + .primary() + .notNullable() + .comment('The unique ID of a public key'); + + table.text('key').notNullable().comment('JSON serialized public key'); + + // Expiration is stored as a string for simplicity, all checks are done client-side + table + .string('expires_at') + .notNullable() + .comment('The time that the key expires'); + }, + ); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + return knex.schema.dropTable('backstage_backend_public_keys__keys'); +}; diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 2339a1a27b..d72b94281e 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -37,12 +37,12 @@ "license": "Apache-2.0", "scripts": { "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "clean": "backstage-cli package clean", - "start": "backstage-cli package start" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-common": "workspace:^", @@ -67,8 +67,10 @@ "fs-extra": "^11.2.0", "helmet": "^6.0.0", "jose": "^5.0.0", + "knex": "^3.0.0", "lodash": "^4.17.21", "logform": "^2.3.2", + "luxon": "^3.0.0", "minimatch": "^9.0.0", "minimist": "^1.2.5", "morgan": "^1.10.0", @@ -76,6 +78,7 @@ "path-to-regexp": "^6.2.1", "selfsigned": "^2.0.0", "stoppable": "^1.1.0", + "uuid": "^9.0.0", "winston": "^3.2.1", "winston-transport": "^4.5.0" }, @@ -97,6 +100,7 @@ "files": [ "dist", "config.d.ts", - "alpha" + "alpha", + "migrations/**/*.{js,d.ts}" ] } diff --git a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts new file mode 100644 index 0000000000..136017efdb --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.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 { + TestDatabaseId, + TestDatabases, + mockServices, +} from '@backstage/backend-test-utils'; +import { DatabaseKeyStore, TABLE } from './DatabaseKeyStore'; + +const testKey = { + kid: 'test-key', + kty: 'RSA', + e: 'ABC', + n: 'test', +}; +const testKey2 = { + kid: 'test-key-2', + kty: 'RSA', + e: 'XYZ', + n: 'test', +}; + +describe('DatabaseKeyStore', () => { + const databases = TestDatabases.create(); + + async function createDatabaseKeyStore(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + const logger = mockServices.logger.mock(); + return { + knex, + logger, + keyStore: await DatabaseKeyStore.create({ + database: { getClient: async () => knex }, + logger, + }), + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + it('should insert a key into the database and list it', async () => { + const { keyStore } = await createDatabaseKeyStore(databaseId); + const expiresAt = new Date(Date.now() + 3600_000); + + await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] }); + + await keyStore.addKey({ + id: testKey.kid, + key: testKey, + expiresAt, + }); + + await expect(keyStore.listKeys()).resolves.toEqual({ + keys: [{ id: testKey.kid, key: testKey, expiresAt }], + }); + }); + + it('should automatically exclude and remove expired keys when listing', async () => { + const { keyStore, knex, logger } = await createDatabaseKeyStore( + databaseId, + ); + const expiresAt = new Date(Date.now() + 3600_000); + + await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] }); + + await keyStore.addKey({ + id: testKey.kid, + key: testKey, + expiresAt, + }); + await keyStore.addKey({ + id: testKey2.kid, + key: testKey2, + expiresAt: new Date(0), + }); + + await expect(knex(TABLE).select('id')).resolves.toEqual([ + { id: testKey.kid }, + { id: testKey2.kid }, + ]); + + expect(logger.info).not.toHaveBeenCalled(); + + await expect(keyStore.listKeys()).resolves.toEqual({ + keys: [{ id: testKey.kid, key: testKey, expiresAt }], + }); + + expect(logger.info).toHaveBeenCalledWith( + "Removing expired plugin service keys, 'test-key-2'", + ); + + // Key deletion happens async, so give it a bit of time to complete + await new Promise(resolve => setTimeout(resolve, 500)); + + await expect(knex(TABLE).select('id')).resolves.toEqual([ + { id: testKey.kid }, + ]); + }); + + it('should fail to insert with invalid date', async () => { + const { keyStore } = await createDatabaseKeyStore(databaseId); + + await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] }); + + await expect( + keyStore.addKey({ + id: testKey.kid, + key: testKey, + expiresAt: new Date(NaN), + }), + ).rejects.toThrow('Invalid time value'); + }); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts new file mode 100644 index 0000000000..6a1536fd18 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts @@ -0,0 +1,119 @@ +/* + * 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 { DateTime } from 'luxon'; +import { Knex } from 'knex'; +import { JsonObject } from '@backstage/types'; +import { resolvePackagePath } from '@backstage/backend-common'; +import { KeyStore } from './types'; + +const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations'; +/** @internal */ +export const TABLE = 'backstage_backend_public_keys__keys'; + +type Row = { + id: string; + key: string; + expires_at: string; +}; + +export function applyDatabaseMigrations(knex: Knex): Promise { + const migrationsDir = resolvePackagePath( + '@backstage/backend-app-api', + 'migrations', + ); + + return knex.migrate.latest({ + directory: migrationsDir, + tableName: MIGRATIONS_TABLE, + }); +} + +/** @internal */ +export class DatabaseKeyStore implements KeyStore { + static async create(options: { + database: DatabaseService; + logger: LoggerService; + }) { + const { database, logger } = options; + + const client = await database.getClient(); + if (!database.migrations?.skip) { + await applyDatabaseMigrations(client); + } + return new DatabaseKeyStore(client, logger); + } + + private constructor( + private readonly client: Knex, + private readonly logger: LoggerService, + ) {} + + async addKey(options: { + id: string; + key: JsonObject & { kid: string }; + expiresAt: Date; + }) { + await this.client(TABLE).insert({ + id: options.key.kid, + key: JSON.stringify(options.key), + expires_at: options.expiresAt.toISOString(), + }); + } + + async listKeys() { + const rows = await this.client(TABLE).select(); + const keys = rows.map(row => ({ + id: row.id, + key: JSON.parse(row.key), + expiresAt: new Date(row.expires_at), + })); + + const validKeys = []; + const expiredKeys = []; + + for (const key of keys) { + if (DateTime.fromJSDate(key.expiresAt) < DateTime.local()) { + expiredKeys.push(key); + } else { + validKeys.push(key); + } + } + + // Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e + if (expiredKeys.length > 0) { + const kids = expiredKeys.map(({ key }) => key.kid); + + this.logger.info( + `Removing expired plugin service keys, '${kids.join("', '")}'`, + ); + + // We don't await this, just let it run in the background + this.client(TABLE) + .delete() + .whereIn('id', kids) + .catch(error => { + this.logger.error( + 'Failed to remove expired plugin service keys', + error, + ); + }); + } + + return { keys: validKeys }; + } +} 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 new file mode 100644 index 0000000000..2872e7f1f9 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -0,0 +1,277 @@ +/* + * 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 { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; +import { + decodeJwt, + exportJWK, + generateKeyPair, + JWK, + importJWK, + SignJWT, + decodeProtectedHeader, +} from 'jose'; +import { DateTime } from 'luxon'; +import { v4 as uuid } from 'uuid'; +import { InternalKey, KeyStore } from './types'; +import { AuthenticationError } from '@backstage/errors'; +import { jwtVerify } from 'jose'; +import { tokenTypes } from '@backstage/plugin-auth-node'; +import { JwksClient } from './JwksClient'; + +const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i; + +type Options = { + ownPluginId: string; + publicKeyStore: KeyStore; + discovery: DiscoveryService; + logger: LoggerService; + /** Expiration time of signing keys in seconds */ + keyDurationSeconds: number; + /** 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 */ + algorithm?: string; +}; + +export class PluginTokenHandler { + private privateKeyPromise?: Promise; + private keyExpiry?: Date; + private jwksMap = new Map(); + + // Tracking state for isTargetPluginSupported + private supportedTargetPlugins = new Set(); + private targetPluginInflightChecks = new Map>(); + + static create(options: Options) { + return new PluginTokenHandler( + options.logger, + options.ownPluginId, + options.publicKeyStore, + options.keyDurationSeconds, + options.algorithm ?? 'ES256', + options.discovery, + ); + } + + private constructor( + readonly logger: LoggerService, + readonly ownPluginId: string, + readonly publicKeyStore: KeyStore, + readonly keyDurationSeconds: number, + readonly algorithm: string, + readonly discovery: DiscoveryService, + ) {} + + async verifyToken(token: string): Promise<{ subject: string } | undefined> { + try { + const { typ } = decodeProtectedHeader(token); + if (typ !== tokenTypes.plugin.typParam) { + return undefined; + } + } catch { + return undefined; + } + + const pluginId = String(decodeJwt(token).sub); + if (!pluginId) { + throw new AuthenticationError('Invalid plugin token: missing subject'); + } + if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) { + throw new AuthenticationError( + 'Invalid plugin token: forbidden subject format', + ); + } + + 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); + }); + + return { subject: payload.sub }; + } + + async issueToken(options: { + pluginId: string; + targetPluginId: string; + }): Promise<{ token: string }> { + const key = await this.getKey(); + + const sub = options.pluginId; + const aud = options.targetPluginId; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + this.keyDurationSeconds; + + const claims = { sub, aud, iat, exp }; + const token = await new SignJWT(claims) + .setProtectedHeader({ + typ: tokenTypes.plugin.typParam, + alg: this.algorithm, + kid: key.kid, + }) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(await importJWK(key)); + + return { token }; + } + + async isTargetPluginSupported(targetPluginId: string): Promise { + if (this.supportedTargetPlugins.has(targetPluginId)) { + return true; + } + const inFlight = this.targetPluginInflightChecks.get(targetPluginId); + if (inFlight) { + return inFlight; + } + + const doCheck = async () => { + try { + const res = await fetch( + `${await this.discovery.getBaseUrl( + targetPluginId, + )}/.backstage/auth/v1/jwks.json`, + ); + if (res.status === 404) { + return false; + } + + if (!res.ok) { + throw new Error(`Failed to fetch jwks.json, ${res.status}`); + } + + const data = await res.json(); + if (!data.keys) { + throw new Error(`Invalid jwks.json response, missing keys`); + } + + this.supportedTargetPlugins.add(targetPluginId); + return true; + } catch (error) { + this.logger.error('Unexpected failure for target JWKS check', error); + return false; + } finally { + this.targetPluginInflightChecks.delete(targetPluginId); + } + }; + + const check = doCheck(); + this.targetPluginInflightChecks.set(targetPluginId, check); + return check; + } + + private async getJwksClient(pluginId: string) { + const client = this.jwksMap.get(pluginId); + if (client) { + return client; + } + + // Double check that the target plugin has a valid JWKS endpoint, otherwise avoid creating a remote key set + if (!(await this.isTargetPluginSupported(pluginId))) { + throw new AuthenticationError( + 'Target plugin does not support self-signed tokens', + ); + } + + 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) { + if ( + this.keyExpiry && + DateTime.fromJSDate(this.keyExpiry) > DateTime.local() + ) { + return this.privateKeyPromise; + } + this.logger.info(`Signing key has expired, generating new key`); + delete this.privateKeyPromise; + } + + const keyExpiry = DateTime.utc() + .plus({ + seconds: this.keyDurationSeconds, + }) + .toJSDate(); + this.keyExpiry = keyExpiry; + + 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: keyExpiry, + }); + + // 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/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; - } - } } 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 d39c57d328..e0ecf16092 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -28,6 +28,10 @@ import { import { AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; +import { PluginTokenHandler } from './PluginTokenHandler'; +import { JsonObject } from '@backstage/types'; +import { DatabaseKeyStore } from './DatabaseKeyStore'; +import { KeyStore } from './types'; /** @internal */ export type InternalBackstageCredentials = @@ -107,16 +111,15 @@ class DefaultAuthService implements AuthService { private readonly userTokenHandler: UserTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, + private readonly publicKeyStore: KeyStore, + private readonly pluginTokenHandler: PluginTokenHandler, ) {} // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { sub, aud } = decodeJwt(token); - - // Legacy service-to-service token - if (sub === 'backstage-server' && !aud) { - await this.tokenManager.authenticate(token); - return createCredentialsWithServicePrincipal('external:backstage-plugin'); + const pluginResult = await this.pluginTokenHandler.verifyToken(token); + if (pluginResult) { + return createCredentialsWithServicePrincipal(pluginResult.subject); } const userResult = await this.userTokenHandler.verifyToken(token); @@ -128,6 +131,13 @@ class DefaultAuthService implements AuthService { ); } + // Legacy service-to-service token + const { sub, aud } = decodeJwt(token); + if (sub === 'backstage-server' && !aud) { + await this.tokenManager.authenticate(token); + return createCredentialsWithServicePrincipal('external:backstage-plugin'); + } + throw new AuthenticationError('Unknown token'); } @@ -166,6 +176,7 @@ class DefaultAuthService implements AuthService { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string }> { + const { targetPluginId } = options; const internalForward = toInternalBackstageCredentials(options.onBehalfOf); const { type } = internalForward.principal; @@ -178,9 +189,20 @@ class DefaultAuthService implements AuthService { return { token: '' }; } + // check whether a plugin support the new auth system + // by checking the public keys endpoint existance. switch (type) { // TODO: Check whether the principal is ourselves case 'service': + if ( + await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId) + ) { + return this.pluginTokenHandler.issueToken({ + pluginId: this.pluginId, + targetPluginId, + }); + } + // If the target plugin does not support the new auth service, fall back to using old token format return this.tokenManager.getToken(); case 'user': if (!internalForward.token) { @@ -208,6 +230,11 @@ class DefaultAuthService implements AuthService { return this.userTokenHandler.createLimitedUserToken(backstageToken); } + async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + const { keys } = await this.publicKeyStore.listKeys(); + return { keys: keys.map(({ key }) => key) }; + } + #getJwtExpiration(token: string) { const { exp } = decodeJwt(token); if (!exp) { @@ -225,23 +252,36 @@ export const authServiceFactory = createServiceFactory({ logger: coreServices.rootLogger, discovery: coreServices.discovery, plugin: coreServices.pluginMetadata, + database: coreServices.database, // Re-using the token manager makes sure that we use the same generated keys for // development as plugins that have not yet been migrated. It's important that this // keeps working as long as there are plugins that have not been migrated to the // new auth services in the new backend system. tokenManager: coreServices.tokenManager, }, - async factory({ config, discovery, plugin, tokenManager }) { + + async factory({ config, discovery, plugin, tokenManager, logger, database }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', ), ); + + const publicKeyStore = await DatabaseKeyStore.create({ database, logger }); + return new DefaultAuthService( tokenManager, new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, + publicKeyStore, + PluginTokenHandler.create({ + ownPluginId: plugin.getId(), + keyDurationSeconds: 60 * 60, + logger, + publicKeyStore, + discovery, + }), ); }, }); diff --git a/packages/backend-app-api/src/services/implementations/auth/types.ts b/packages/backend-app-api/src/services/implementations/auth/types.ts new file mode 100644 index 0000000000..b47ff2588d --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/types.ts @@ -0,0 +1,30 @@ +/* + * 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 { 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 }; diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts new file mode 100644 index 0000000000..94650199f3 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts @@ -0,0 +1,32 @@ +/* + * 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 { AuthService } from '@backstage/backend-plugin-api'; +import express from 'express'; +import Router from 'express-promise-router'; + +export function createAuthIntegrationRouter(options: { + auth: AuthService; +}): express.Router { + const router = Router(); + + router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => { + const { keys } = await options.auth.listPublicServiceKeys(); + + res.json({ keys }); + }); + + return router; +} diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index e72d091b96..5b881dca4c 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -23,6 +23,7 @@ import { Handler } from 'express'; import PromiseRouter from 'express-promise-router'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; +import { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; /** * @public @@ -38,23 +39,36 @@ export interface HttpRouterFactoryOptions { export const httpRouterServiceFactory = createServiceFactory( (options?: HttpRouterFactoryOptions) => ({ service: coreServices.httpRouter, + initialization: 'always', deps: { plugin: coreServices.pluginMetadata, config: coreServices.rootConfig, lifecycle: coreServices.lifecycle, rootHttpRouter: coreServices.rootHttpRouter, + auth: coreServices.auth, httpAuth: coreServices.httpAuth, }, - async factory({ httpAuth, config, plugin, rootHttpRouter, lifecycle }) { + async factory({ + auth, + httpAuth, + config, + plugin, + rootHttpRouter, + lifecycle, + }) { const getPath = options?.getPath ?? (id => `/api/${id}`); const path = getPath(plugin.getId()); const router = PromiseRouter(); rootHttpRouter.use(path, router); - const credentialsBarrier = createCredentialsBarrier({ httpAuth, config }); + const credentialsBarrier = createCredentialsBarrier({ + httpAuth, + config, + }); router.use(createLifecycleMiddleware({ lifecycle })); + router.use(createAuthIntegrationRouter({ auth })); router.use(credentialsBarrier.middleware); return { diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts index b72b30e02e..86c117f7e0 100644 --- a/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts +++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/DefaultRootHttpRouter.test.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import express from 'express'; +import request from 'supertest'; import { DefaultRootHttpRouter } from './DefaultRootHttpRouter'; describe('DefaultRootHttpRouter', () => { @@ -54,4 +56,37 @@ describe('DefaultRootHttpRouter', () => { 'indexPath option may not be an empty string', ); }); + + it('will always prioritize non-index paths', async () => { + const router = DefaultRootHttpRouter.create({ indexPath: '/x' }); + const app = express(); + app.use(router.handler()); + + const routerX = express.Router(); + routerX.get('/a', (_req, res) => res.status(201).end()); + + const routerY = express.Router(); + routerY.get('/a', (_req, res) => res.status(202).end()); + + await request(app).get('/').expect(404); + await request(app).get('/a').expect(404); + await request(app).get('/x/a').expect(404); + await request(app).get('/y/a').expect(404); + + router.use('/x', routerX); + + await request(app).get('/').expect(404); + await request(app).get('/a').expect(201); + await request(app).get('/x/a').expect(201); + await request(app).get('/y/a').expect(404); + + router.use('/y', routerY); + + await request(app).get('/').expect(404); + await request(app).get('/a').expect(201); + await request(app).get('/x/a').expect(201); + await request(app).get('/y/a').expect(202); + + expect('test').toBe('test'); + }); }); diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 64dcc81bc6..0ba23661ef 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -44,6 +44,7 @@ import { } from '@backstage/plugin-auth-node'; import { decodeJwt } from 'jose'; import { PluginEndpointDiscovery } from '../discovery'; +import { JsonObject } from '@backstage/types'; class AuthCompat implements AuthService { constructor( @@ -165,6 +166,10 @@ class AuthCompat implements AuthService { } return new Date(exp * 1000); } + + listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + throw new Error('Not implemented'); + } } function getTokenFromRequest(req: Request) { diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index afd591a336..340223ca0f 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -18,11 +18,11 @@ "backstage" ], "scripts": { - "start": "backstage-cli package start", "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", - "test": "backstage-cli package test", - "clean": "backstage-cli package clean" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 364459352f..21a3bf10f8 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -55,6 +55,10 @@ export interface AuthService { credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; + // (undocumented) + listPublicServiceKeys(): Promise<{ + keys: JsonObject[]; + }>; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 2bcdc975a0..827b4122cd 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { JsonObject } from '@backstage/types'; + /** * @public */ @@ -91,4 +93,8 @@ export interface AuthService { getLimitedUserToken( credentials: BackstageCredentials, ): Promise<{ token: string; expiresAt: Date }>; + + listPublicServiceKeys(): Promise<{ + keys: JsonObject[]; + }>; } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 64dc92747a..4bce87bd2a 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -36,6 +36,7 @@ import { UserTokenPayload, ServiceTokenPayload, } from './mockCredentials'; +import { JsonObject } from '@backstage/types'; /** @internal */ export class MockAuthService implements AuthService { @@ -184,4 +185,8 @@ export class MockAuthService implements AuthService { expiresAt: new Date(Date.now() + 3600_000), }; } + + listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + throw new Error('Not implemented'); + } } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 3ff8a4e017..a858bf7dbf 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -207,6 +207,7 @@ export namespace mockServices { isPrincipal: jest.fn() as any, getPluginRequestToken: jest.fn(), getLimitedUserToken: jest.fn(), + listPublicServiceKeys: jest.fn(), })); } diff --git a/packages/e2e-test/cli-report.md b/packages/e2e-test/cli-report.md index 3a3912b8fc..f72344f859 100644 --- a/packages/e2e-test/cli-report.md +++ b/packages/e2e-test/cli-report.md @@ -12,7 +12,7 @@ Options: -h, --help Commands: - run + run [options] help [command] ``` @@ -22,5 +22,6 @@ Commands: Usage: e2e-test run [options] Options: + --keep -h, --help ``` diff --git a/packages/e2e-test/src/commands/index.ts b/packages/e2e-test/src/commands/index.ts index 95d311427c..fd0ba59c30 100644 --- a/packages/e2e-test/src/commands/index.ts +++ b/packages/e2e-test/src/commands/index.ts @@ -18,5 +18,9 @@ import { Command } from 'commander'; import { run } from './run'; export function registerCommands(program: Command) { - program.command('run').description('Run e2e tests').action(run); + program + .command('run') + .option('--keep', 'Do not remove the temporary dir after tests complete') + .description('Run e2e tests') + .action(run); } diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index a1b35049ab..111c638146 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -34,6 +34,7 @@ import mysql from 'mysql2/promise'; import pgtools from 'pgtools'; import { findPaths } from '@backstage/cli-common'; +import { OptionValues } from 'commander'; // eslint-disable-next-line no-restricted-syntax const paths = findPaths(__dirname); @@ -45,7 +46,7 @@ const templatePackagePaths = [ 'packages/create-app/templates/default-app/packages/backend/package.json.hbs', ]; -export async function run() { +export async function run(opts: OptionValues) { const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); print(`CLI E2E test root: ${rootDir}\n`); @@ -109,8 +110,12 @@ export async function run() { // runner will be destroyed anyway print('All tests successful'); } else { - print('All tests successful, removing test dir'); - await fs.remove(rootDir); + if (opts.keep) { + print(`All tests successful, app dir available at ${appDir}`); + } else { + print('All tests successful, removing test dir'); + await fs.remove(rootDir); + } } // Just in case some child process was left hanging @@ -497,6 +502,8 @@ async function testBackendStart(appDir: string, ...args: string[]) { env: { ...process.env, GITHUB_TOKEN: 'abc', + // TODO: Default auth policy is disabled for e2e tests - replace this with external service auth + APP_CONFIG_backend_auth_dangerouslyDisableDefaultAuthPolicy: 'true', }, }); @@ -542,13 +549,24 @@ async function testBackendStart(appDir: string, ...args: string[]) { // Skipping the whole block throw new Error(stderr); } - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 1000)); print('Try to fetch entities from the backend'); // Try fetch entities, should be ok - await fetch('http://localhost:7007/api/catalog/entities').then(res => - res.json(), - ); + const res = await fetch('http://localhost:7007/api/catalog/entities'); + if (!res.ok) { + throw new Error( + `Failed to fetch entities: ${res.status} ${res.statusText}`, + ); + } + const content = await res.text(); + try { + JSON.parse(content); + } catch (error) { + throw new Error( + `Failed to parse entities JSON response: ${error}\n${content}`, + ); + } print('Entities fetched successfully'); successful = true; } catch (error) { diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index f9d16c4c3a..e79facc7f9 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -648,8 +648,8 @@ export const tokenTypes: Readonly<{ limitedUser: Readonly<{ typParam: 'vnd.backstage.limited-user'; }>; - service: Readonly<{ - typParam: 'vnd.backstage.service'; + plugin: Readonly<{ + typParam: 'vnd.backstage.plugin'; }>; }>; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 8cc48aacc4..39172437ec 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -389,7 +389,7 @@ export const tokenTypes = Object.freeze({ limitedUser: Object.freeze({ typParam: 'vnd.backstage.limited-user', }), - service: Object.freeze({ - typParam: 'vnd.backstage.service', + plugin: Object.freeze({ + typParam: 'vnd.backstage.plugin', }), }); diff --git a/yarn.lock b/yarn.lock index f8bec6c194..571d7e3974 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3300,8 +3300,10 @@ __metadata: helmet: ^6.0.0 http-errors: ^2.0.0 jose: ^5.0.0 + knex: ^3.0.0 lodash: ^4.17.21 logform: ^2.3.2 + luxon: ^3.0.0 minimatch: ^9.0.0 minimist: ^1.2.5 morgan: ^1.10.0 @@ -3311,6 +3313,7 @@ __metadata: selfsigned: ^2.0.0 stoppable: ^1.1.0 supertest: ^6.1.3 + uuid: ^9.0.0 winston: ^3.2.1 winston-transport: ^4.5.0 languageName: unknown