From b438fea117748692b4237fb937fa0ca83ef6ce27 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 26 Mar 2024 19:45:31 +0100 Subject: [PATCH 01/30] backend-plugin-api: add publicKeyStore service Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../definitions/PublicKeyStoreService.ts | 29 +++++++++++++++++++ .../src/services/definitions/coreServices.ts | 12 ++++++++ .../src/services/definitions/index.ts | 1 + 3 files changed, 42 insertions(+) create mode 100644 packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts diff --git a/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts b/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts new file mode 100644 index 0000000000..68ba2ad0c9 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts @@ -0,0 +1,29 @@ +/* + * 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'; + +/** + * @public + */ +export interface PublicKeyStoreService { + listKeys(): Promise<{ keys: JsonObject[] }>; + addKey(options: { + id: string; + key: JsonObject; + expiresAt: Date; + }): Promise; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index c760afc7b4..d4770a2b9b 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -132,6 +132,18 @@ export namespace coreServices { import('./PluginMetadataService').PluginMetadataService >({ id: 'core.pluginMetadata' }); + /** + * The service reference for the plugin scoped {@link PublicKeyStoreService}. + * + * @public + */ + export const publicKeyStore = createServiceRef< + import('./PublicKeyStoreService').PublicKeyStoreService + >({ + id: 'core.publicKeyStore', + scope: 'plugin', + }); + /** * The service reference for the root scoped {@link RootHttpRouterService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 8a5176379f..73f2f67296 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -68,3 +68,4 @@ export type { } from './UrlReaderService'; export type { BackstageUserInfo, UserInfoService } from './UserInfoService'; export type { IdentityService } from './IdentityService'; +export type { PublicKeyStoreService } from './PublicKeyStoreService'; From 8d24ced34957549dc17af959bf17739edf84e7d2 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 26 Mar 2024 19:54:01 +0100 Subject: [PATCH 02/30] backend-app-api: add publicKeyStoreServiceFactory Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- packages/backend-app-api/package.json | 9 +- .../auth/authServiceFactory.ts | 27 +++++- .../httpRouter/httpRouterServiceFactory.ts | 23 ++++- .../implementations/publicKeyStore/index.ts | 15 +++ .../publicKeyStoreServiceFactory.ts | 92 +++++++++++++++++++ 5 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts create mode 100644 packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 2339a1a27b..c601bc1b04 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" }, 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..f1e7d4b8ef 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -24,10 +24,13 @@ import { BackstageUserPrincipal, coreServices, createServiceFactory, + DatabaseService, + AnyJWK, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; +import { PluginTokenHandler } from './PluginTokenHandler'; /** @internal */ export type InternalBackstageCredentials = @@ -107,12 +110,18 @@ class DefaultAuthService implements AuthService { private readonly userTokenHandler: UserTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, + private readonly databaseService: DatabaseService, + 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); + // # identify new token + // 1. generate and store public keys in database + // 2. verification of token, by fetching all public keys + // Legacy service-to-service token if (sub === 'backstage-server' && !aud) { await this.tokenManager.authenticate(token); @@ -178,10 +187,16 @@ 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': - return this.tokenManager.getToken(); + return this.pluginTokenHandler.issueToken({ + pluginId: this.pluginId, + targetPluginId: options.targetPluginId, + }); + // return this.tokenManager.getToken(); case 'user': if (!internalForward.token) { throw new Error('User credentials is unexpectedly missing token'); @@ -215,12 +230,17 @@ class DefaultAuthService implements AuthService { } return new Date(exp * 1000); } + + listPublicKeys(): Promise { + return this.pluginTokenHandler.listPublicKeys(); + } } /** @public */ export const authServiceFactory = createServiceFactory({ service: coreServices.auth, deps: { + database: coreServices.database, config: coreServices.rootConfig, logger: coreServices.rootLogger, discovery: coreServices.discovery, @@ -231,7 +251,7 @@ export const authServiceFactory = createServiceFactory({ // new auth services in the new backend system. tokenManager: coreServices.tokenManager, }, - async factory({ config, discovery, plugin, tokenManager }) { + async factory({ config, discovery, plugin, tokenManager, database }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', @@ -242,6 +262,9 @@ export const authServiceFactory = createServiceFactory({ new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, + database, + // TODO(vinzscam): fixme + PluginTokenHandler.create(undefined!), ); }, }); 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..6898396848 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,13 @@ import { Handler } from 'express'; import PromiseRouter from 'express-promise-router'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; +import { JsonObject } from '@backstage/types'; +import { createAuthIntegrationRouter } from '../auth'; + +export interface PublicKeyStoreService { + listKeys(): Promise; + addKey(options: { key: JsonObject; expiresAt: Date }): Promise; +} /** * @public @@ -44,17 +51,29 @@ export const httpRouterServiceFactory = createServiceFactory( lifecycle: coreServices.lifecycle, rootHttpRouter: coreServices.rootHttpRouter, httpAuth: coreServices.httpAuth, + publicKeyStore: coreServices.publicKeyStore, }, - async factory({ httpAuth, config, plugin, rootHttpRouter, lifecycle }) { + async factory({ + httpAuth, + config, + plugin, + rootHttpRouter, + lifecycle, + publicKeyStore, + }) { 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({ publicKeyStore })); router.use(credentialsBarrier.middleware); return { diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts new file mode 100644 index 0000000000..94adcafa53 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts new file mode 100644 index 0000000000..3251f1bf99 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts @@ -0,0 +1,92 @@ +/* + * 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 { + PublicKeyStoreService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { DateTime } from 'luxon'; +import { Knex } from 'knex'; +import { JsonObject } from '@backstage/types'; + +const TABLE = 'signing_keys'; + +type Row = { + id: string; + key: string; + expires_at: string | Date; // Needs parsing to handle different DB implementations +}; + +/** @internal */ +export class DatabaseKeyStore implements PublicKeyStoreService { + constructor(private readonly client: Knex) {} + + async addKey(options: { + id: string; + key: JsonObject & { kid: string }; + expiresAt: Date; + }): Promise { + await this.client(TABLE).insert({ + id: options.key.kid, + key: JSON.stringify(options.key), + // TODO: figure out the best way to format this for the DB + expires_at: DateTime.fromJSDate(options.expiresAt).toSQL()!, + }); + } + + async listKeys(): Promise<{ keys: { key: JsonObject; expiresAt: Date }[] }> { + const rows = await this.client(TABLE).select(); + + // TODO: move over filter/delete the logic from listPublicKeys() in plugins/auth-backend/src/identity/TokenFactory.ts + + return { + keys: rows.map(row => ({ + key: JSON.parse(row.key), + expiresAt: parseDate(row.expires_at), + })), + }; + } + + // async removeKeys(kids: string[]): Promise { + // await this.client(TABLE).delete().whereIn('kid', kids); + // } +} + +export const publicKeyStoreServiceFactory = createServiceFactory({ + service: coreServices.publicKeyStore, + deps: { + database: coreServices.database, + }, + async factory({ database }) { + return new DatabaseKeyStore(await database.getClient()); + }, +}); + +function parseDate(date: string | Date) { + const parsedDate = + typeof date === 'string' + ? DateTime.fromSQL(date, { zone: 'UTC' }) + : DateTime.fromJSDate(date); + + if (!parsedDate.isValid) { + throw new Error( + `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, + ); + } + + return parsedDate.toJSDate(); +} From 6e611a7977e7926260cfc306a1f415882b43daf4 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 26 Mar 2024 19:56:56 +0100 Subject: [PATCH 03/30] backend-app-api: add createAuthIntegrationRouter Signed-off-by: Vincenzo Scamporlino --- .../auth/createAuthIntegrationRouter.ts | 30 +++++++++++++++++++ .../services/implementations/auth/index.ts | 1 + 2 files changed, 31 insertions(+) create mode 100644 packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts b/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts new file mode 100644 index 0000000000..586846cdeb --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.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 { PublicKeyStoreService } from '@backstage/backend-plugin-api'; +import express from 'express'; +import Router from 'express-promise-router'; + +export function createAuthIntegrationRouter(options: { + publicKeyStore: PublicKeyStoreService; +}): express.Router { + const router = Router(); + + router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => { + res.json({ keys: await options.publicKeyStore.listKeys() }); + }); + + return router; +} diff --git a/packages/backend-app-api/src/services/implementations/auth/index.ts b/packages/backend-app-api/src/services/implementations/auth/index.ts index 1b55d46a83..4bcf535e5b 100644 --- a/packages/backend-app-api/src/services/implementations/auth/index.ts +++ b/packages/backend-app-api/src/services/implementations/auth/index.ts @@ -15,3 +15,4 @@ */ export { authServiceFactory } from './authServiceFactory'; +export { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; From 8c861be144569b9a2243f57a87848045bae711b1 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 26 Mar 2024 20:02:55 +0100 Subject: [PATCH 04/30] backend-app-api: fix typings Signed-off-by: Vincenzo Scamporlino --- .../src/services/implementations/auth/authServiceFactory.ts | 5 ----- .../publicKeyStore/publicKeyStoreServiceFactory.ts | 4 ++-- .../src/services/definitions/PublicKeyStoreService.ts | 2 +- yarn.lock | 3 +++ 4 files changed, 6 insertions(+), 8 deletions(-) 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 f1e7d4b8ef..c380040b64 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -25,7 +25,6 @@ import { coreServices, createServiceFactory, DatabaseService, - AnyJWK, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; @@ -230,10 +229,6 @@ class DefaultAuthService implements AuthService { } return new Date(exp * 1000); } - - listPublicKeys(): Promise { - return this.pluginTokenHandler.listPublicKeys(); - } } /** @public */ diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts index 3251f1bf99..cb2c346376 100644 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts @@ -39,7 +39,7 @@ export class DatabaseKeyStore implements PublicKeyStoreService { id: string; key: JsonObject & { kid: string }; expiresAt: Date; - }): Promise { + }) { await this.client(TABLE).insert({ id: options.key.kid, key: JSON.stringify(options.key), @@ -48,7 +48,7 @@ export class DatabaseKeyStore implements PublicKeyStoreService { }); } - async listKeys(): Promise<{ keys: { key: JsonObject; expiresAt: Date }[] }> { + async listKeys() { const rows = await this.client(TABLE).select(); // TODO: move over filter/delete the logic from listPublicKeys() in plugins/auth-backend/src/identity/TokenFactory.ts diff --git a/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts b/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts index 68ba2ad0c9..6f4f80e534 100644 --- a/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts +++ b/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts @@ -20,7 +20,7 @@ import { JsonObject } from '@backstage/types'; * @public */ export interface PublicKeyStoreService { - listKeys(): Promise<{ keys: JsonObject[] }>; + listKeys(): Promise<{ keys: { key: JsonObject; expiresAt: Date }[] }>; addKey(options: { id: string; key: JsonObject; diff --git a/yarn.lock b/yarn.lock index d96b24dbbe..aeaba30baf 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 From 1bb21344e223465191f517997d2cd2d8de00532e Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Tue, 26 Mar 2024 20:06:51 +0100 Subject: [PATCH 05/30] backend-app-api: add PluginTokenHandler Signed-off-by: Vincenzo Scamporlino --- .../auth/PluginTokenHandler.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts 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..81d23b6aef --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.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 { + LoggerService, + PublicKeyStoreService, +} from '@backstage/backend-plugin-api'; +import { exportJWK, generateKeyPair, JWK } from 'jose'; +import { DateTime } from 'luxon'; +import { v4 as uuid } from 'uuid'; + +type Options = { + publicKeyStore: PublicKeyStoreService; + logger: LoggerService; + /** Value of the issuer claim in issued tokens */ + issuer: string; + /** 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; + + static create(options: Options) { + return new PluginTokenHandler( + options.logger, + options.publicKeyStore, + options.keyDurationSeconds, + options.algorithm ?? 'ES256', + ); + } + + private constructor( + readonly logger: LoggerService, + readonly publicKeyStore: PublicKeyStoreService, + readonly keyDurationSeconds: number, + readonly algorithm: string, + ) {} + + async issueToken(options: { + pluginId: string; + targetPluginId: string; + }): Promise<{ token: string }> { + await this.getKey(); + return { token: undefined! }; + } + + 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; + } + + this.keyExpiry = DateTime.utc() + .plus({ + seconds: this.keyDurationSeconds, + }) + .toJSDate(); + 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}`); + this.publicKeyStore.addKey({ + id: kid, + key: publicKey, + expiry: /* TODO */ this.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; + } +} From 7231bc2a71174ace5eea4d9a52ea43c1a7a2fef7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 27 Mar 2024 10:31:45 +0100 Subject: [PATCH 06/30] backend-app-api: pass expiration key Signed-off-by: Vincenzo Scamporlino --- .../services/implementations/auth/PluginTokenHandler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 81d23b6aef..85aedc86ab 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -79,11 +79,13 @@ export class PluginTokenHandler { delete this.privateKeyPromise; } - this.keyExpiry = DateTime.utc() + 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(); @@ -100,10 +102,10 @@ export class PluginTokenHandler { // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. this.logger.info(`Created new signing key ${kid}`); - this.publicKeyStore.addKey({ + await this.publicKeyStore.addKey({ id: kid, key: publicKey, - expiry: /* TODO */ this.keyExpiry, + expiresAt: keyExpiry, }); // At this point we are allowed to start using the new key From e8fd082f3be5a8ddaf41907a2d137109b96987de Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 27 Mar 2024 10:33:02 +0100 Subject: [PATCH 07/30] backend-app-api: pass dependencies Signed-off-by: Vincenzo Scamporlino --- .../auth/authServiceFactory.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) 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 c380040b64..eaef7fb4bf 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -109,7 +109,6 @@ class DefaultAuthService implements AuthService { private readonly userTokenHandler: UserTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, - private readonly databaseService: DatabaseService, private readonly pluginTokenHandler: PluginTokenHandler, ) {} @@ -235,7 +234,6 @@ class DefaultAuthService implements AuthService { export const authServiceFactory = createServiceFactory({ service: coreServices.auth, deps: { - database: coreServices.database, config: coreServices.rootConfig, logger: coreServices.rootLogger, discovery: coreServices.discovery, @@ -245,8 +243,16 @@ export const authServiceFactory = createServiceFactory({ // 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, + publicKeyStore: coreServices.publicKeyStore, }, - async factory({ config, discovery, plugin, tokenManager, database }) { + async factory({ + config, + discovery, + plugin, + tokenManager, + logger, + publicKeyStore, + }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', @@ -257,9 +263,12 @@ export const authServiceFactory = createServiceFactory({ new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, - database, - // TODO(vinzscam): fixme - PluginTokenHandler.create(undefined!), + PluginTokenHandler.create({ + keyDurationSeconds: 60 * 60, + issuer: `plugin:${plugin.getId()}`, + logger, + publicKeyStore, + }), ); }, }); From eca60af9f769454b0bd5a31a882cd3ed20b7bf03 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 27 Mar 2024 11:39:51 +0100 Subject: [PATCH 08/30] backend-defaults: instantiate publicKeyStoreService Signed-off-by: Vincenzo Scamporlino --- packages/backend-app-api/src/services/implementations/index.ts | 1 + .../src/services/implementations/publicKeyStore/index.ts | 2 ++ packages/backend-defaults/src/CreateBackend.ts | 2 ++ 3 files changed, 5 insertions(+) diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index a1114ab3e3..5f19e4970c 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -25,6 +25,7 @@ export * from './identity'; export * from './lifecycle'; export * from './logger'; export * from './permissions'; +export * from './publicKeyStore'; export * from './rootHttpRouter'; export * from './rootLifecycle'; export * from './rootLogger'; diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts index 94adcafa53..2e604cbe18 100644 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export { publicKeyStoreServiceFactory } from './publicKeyStoreServiceFactory'; diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 5495665dd8..63f39b18a5 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -35,6 +35,7 @@ import { authServiceFactory, httpAuthServiceFactory, userInfoServiceFactory, + publicKeyStoreServiceFactory, } from '@backstage/backend-app-api'; export const defaultServiceFactories = [ @@ -49,6 +50,7 @@ export const defaultServiceFactories = [ lifecycleServiceFactory(), loggerServiceFactory(), permissionsServiceFactory(), + publicKeyStoreServiceFactory(), rootHttpRouterServiceFactory(), rootLifecycleServiceFactory(), rootLoggerServiceFactory(), From f1a0569506443ebee19d7512c06e1f3f7a427d39 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 27 Mar 2024 11:59:02 +0100 Subject: [PATCH 09/30] backend-app-api: add signing keys migration Signed-off-by: Vincenzo Scamporlino --- .../migrations/20240327104803_public_keys.js | 43 +++++++++++++++++++ packages/backend-app-api/package.json | 3 +- .../publicKeyStoreServiceFactory.ts | 27 +++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 packages/backend-app-api/migrations/20240327104803_public_keys.js 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..4498729eb9 --- /dev/null +++ b/packages/backend-app-api/migrations/20240327104803_public_keys.js @@ -0,0 +1,43 @@ +/* + * 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('signing_keys', table => { + table + .string('id') + .primary() + .notNullable() + .comment('The unique ID of a public key'); + + table.text('keys').notNullable().comment('JSON serialized public key'); + + table.timestamp('expires_at').notNullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + return knex.schema.dropTable('signing_keys'); +}; diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index c601bc1b04..d72b94281e 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -100,6 +100,7 @@ "files": [ "dist", "config.d.ts", - "alpha" + "alpha", + "migrations/**/*.{js,d.ts}" ] } diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts index cb2c346376..58400bacf1 100644 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts @@ -15,6 +15,7 @@ */ import { + DatabaseService, PublicKeyStoreService, coreServices, createServiceFactory, @@ -22,6 +23,7 @@ import { import { DateTime } from 'luxon'; import { Knex } from 'knex'; import { JsonObject } from '@backstage/types'; +import { resolvePackagePath } from '@backstage/backend-common'; const TABLE = 'signing_keys'; @@ -33,7 +35,17 @@ type Row = { /** @internal */ export class DatabaseKeyStore implements PublicKeyStoreService { - constructor(private readonly client: Knex) {} + private constructor(private readonly client: Knex) {} + + static async create(options: { database: DatabaseService }) { + const { database } = options; + + const client = await database.getClient(); + if (!database.migrations?.skip) { + await applyDatabaseMigrations(client); + } + return new DatabaseKeyStore(client); + } async addKey(options: { id: string; @@ -72,7 +84,7 @@ export const publicKeyStoreServiceFactory = createServiceFactory({ database: coreServices.database, }, async factory({ database }) { - return new DatabaseKeyStore(await database.getClient()); + return DatabaseKeyStore.create({ database }); }, }); @@ -90,3 +102,14 @@ function parseDate(date: string | Date) { return parsedDate.toJSDate(); } + +export function applyDatabaseMigrations(knex: Knex): Promise { + const migrationsDir = resolvePackagePath( + '@backstage/backend-app-api', + 'migrations', + ); + + return knex.migrate.latest({ + directory: migrationsDir, + }); +} From ed99c79cf1401c0e362247d8cb2b34e3d1ea091e Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 27 Mar 2024 12:57:43 +0100 Subject: [PATCH 10/30] backend-app-api: test service to service auth Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../migrations/20240327104803_public_keys.js | 23 +-- .../auth/PluginTokenHandler.ts | 31 +++- .../auth/authServiceFactory.ts | 9 +- .../publicKeyStoreServiceFactory.ts | 7 +- packages/backend-next/package.json | 9 +- packages/backend-next/src/index.ts | 137 +++++++++++++----- 6 files changed, 161 insertions(+), 55 deletions(-) diff --git a/packages/backend-app-api/migrations/20240327104803_public_keys.js b/packages/backend-app-api/migrations/20240327104803_public_keys.js index 4498729eb9..378209c9a2 100644 --- a/packages/backend-app-api/migrations/20240327104803_public_keys.js +++ b/packages/backend-app-api/migrations/20240327104803_public_keys.js @@ -21,17 +21,20 @@ * @returns { Promise } */ exports.up = async function up(knex) { - await knex.schema.createTable('signing_keys', table => { - table - .string('id') - .primary() - .notNullable() - .comment('The unique ID of a public key'); + 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('keys').notNullable().comment('JSON serialized public key'); + table.text('key').notNullable().comment('JSON serialized public key'); - table.timestamp('expires_at').notNullable(); - }); + table.timestamp('expires_at').notNullable(); + }, + ); }; /** @@ -39,5 +42,5 @@ exports.up = async function up(knex) { * @returns { Promise } */ exports.down = async function down(knex) { - return knex.schema.dropTable('signing_keys'); + return knex.schema.dropTable('backstage_backend_public_keys__keys'); }; diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 85aedc86ab..2f7f8098f4 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -15,10 +15,11 @@ */ import { + BackstageCredentials, LoggerService, PublicKeyStoreService, } from '@backstage/backend-plugin-api'; -import { exportJWK, generateKeyPair, JWK } from 'jose'; +import { exportJWK, generateKeyPair, JWK, importJWK, SignJWT } from 'jose'; import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; @@ -58,12 +59,35 @@ export class PluginTokenHandler { readonly algorithm: string, ) {} + async verifyToken(token: string): Promise<{ subject: string }> { + return { subject: 'is me' }; + } + async issueToken(options: { pluginId: string; targetPluginId: string; }): Promise<{ token: string }> { - await this.getKey(); - return { token: undefined! }; + const key = await this.getKey(); + + const iss = 'backstage-plugin'; + const sub = options.pluginId; + const aud = options.targetPluginId; + const iat = Math.floor(Date.now() / 1000); + const exp = iat + this.keyDurationSeconds; + + this.logger.info(`Issuing token for ${sub} with audentice ${aud}`); + + const claims = { iss, sub, aud, iat, exp }; + const token = await new SignJWT(claims) + .setProtectedHeader({ alg: this.algorithm, kid: key.kid }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(await importJWK(key)); + + return { token }; } private async getKey(): Promise { @@ -102,6 +126,7 @@ export class PluginTokenHandler { // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. this.logger.info(`Created new signing key ${kid}`); + console.log(`DEBUG: publicKey=`, publicKey); await this.publicKeyStore.addKey({ id: kid, key: publicKey, 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 eaef7fb4bf..27f98ffb0a 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -114,7 +114,14 @@ class DefaultAuthService implements AuthService { // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { sub, aud } = decodeJwt(token); + const { sub, aud, iss } = decodeJwt(token); + console.log(`DEBUG: iss=`, iss); + + if (iss === 'backstage-plugin') { + console.log('DO THE STUFF!'); + const { subject } = await this.pluginTokenHandler.verifyToken(token); + return createCredentialsWithServicePrincipal(subject); + } // # identify new token // 1. generate and store public keys in database diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts index 58400bacf1..718aa641ed 100644 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts @@ -25,7 +25,8 @@ import { Knex } from 'knex'; import { JsonObject } from '@backstage/types'; import { resolvePackagePath } from '@backstage/backend-common'; -const TABLE = 'signing_keys'; +const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations'; +const TABLE = 'backstage_backend_public_keys__keys'; type Row = { id: string; @@ -40,6 +41,8 @@ export class DatabaseKeyStore implements PublicKeyStoreService { static async create(options: { database: DatabaseService }) { const { database } = options; + console.log(`DEBUG: ###### CREATING STORE`); + const client = await database.getClient(); if (!database.migrations?.skip) { await applyDatabaseMigrations(client); @@ -52,6 +55,7 @@ export class DatabaseKeyStore implements PublicKeyStoreService { key: JsonObject & { kid: string }; expiresAt: Date; }) { + console.log(`DEBUG: STORING KEY`, options); await this.client(TABLE).insert({ id: options.key.kid, key: JSON.stringify(options.key), @@ -111,5 +115,6 @@ export function applyDatabaseMigrations(knex: Knex): Promise { return knex.migrate.latest({ directory: migrationsDir, + tableName: MIGRATIONS_TABLE, }); } diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index afd591a336..12d1a7872b 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:^", @@ -66,7 +66,8 @@ "@backstage/plugin-signals-backend": "workspace:^", "@backstage/plugin-sonarqube-backend": "workspace:^", "@backstage/plugin-techdocs-backend": "workspace:^", - "@backstage/plugin-todo-backend": "workspace:^" + "@backstage/plugin-todo-backend": "workspace:^", + "express-promise-router": "^4.1.0" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 4965462b2c..47f1308c89 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -15,48 +15,113 @@ */ import { createBackend } from '@backstage/backend-defaults'; +import { createBackendPlugin } from '@backstage/backend-plugin-api'; +import Router from 'express-promise-router'; +import { coreServices } from '@backstage/backend-plugin-api'; const backend = createBackend(); -backend.add(import('@backstage/plugin-auth-backend')); -backend.add(import('./authModuleGithubProvider')); -backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); +backend.add( + createBackendPlugin({ + pluginId: 'receiver', + register(reg) { + reg.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + }, + async init({ httpRouter, httpAuth }) { + const router = Router(); + router.get('/hello', async (req, res) => { + console.log('Got hello'); + const credentials = await httpAuth.credentials(req); + console.log('Got credentials', credentials); -backend.add(import('@backstage/plugin-adr-backend')); -backend.add(import('@backstage/plugin-app-backend/alpha')); -backend.add(import('@backstage/plugin-azure-devops-backend')); -backend.add(import('@backstage/plugin-badges-backend')); -backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); -backend.add( - import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), + res.json({ hello: 'world' }); + }); + httpRouter.use(router); + }, + }); + }, + }), ); -backend.add(import('@backstage/plugin-catalog-backend/alpha')); -backend.add(import('@backstage/plugin-devtools-backend')); -backend.add(import('@backstage/plugin-entity-feedback-backend')); -backend.add(import('@backstage/plugin-jenkins-backend')); -backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); -backend.add(import('@backstage/plugin-lighthouse-backend')); -backend.add(import('@backstage/plugin-linguist-backend')); -backend.add(import('@backstage/plugin-playlist-backend')); -backend.add(import('@backstage/plugin-nomad-backend')); + backend.add( - import('@backstage/plugin-permission-backend-module-allow-all-policy'), + createBackendPlugin({ + pluginId: 'caller', + register(reg) { + reg.registerInit({ + deps: { + rootLifecycle: coreServices.rootLifecycle, + auth: coreServices.auth, + discovery: coreServices.discovery, + httpRouter: coreServices.httpRouter, + }, + async init({ rootLifecycle, auth, discovery }) { + rootLifecycle.addStartupHook(async () => { + const { token } = await auth.getPluginRequestToken({ + onBehalfOf: await auth.getOwnServiceCredentials(), + targetPluginId: 'receiver', + }); + console.log(`DEBUG: token=`, token); + const res = await fetch( + `${await discovery.getBaseUrl('receiver')}/hello`, + { + headers: { + authorization: `Bearer ${token}`, + }, + }, + ); + console.log( + `DEBUG: res ${res.status} ${res.statusText}`, + await res.json(), + ); + }); + }, + }); + }, + }), ); -backend.add(import('@backstage/plugin-permission-backend/alpha')); -backend.add(import('@backstage/plugin-proxy-backend/alpha')); -backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); -backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); -backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); -backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); -backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); -backend.add( - import('@backstage/plugin-catalog-backend-module-backstage-openapi'), -); -backend.add(import('@backstage/plugin-search-backend/alpha')); -backend.add(import('@backstage/plugin-techdocs-backend/alpha')); -backend.add(import('@backstage/plugin-todo-backend')); -backend.add(import('@backstage/plugin-sonarqube-backend')); -backend.add(import('@backstage/plugin-signals-backend')); -backend.add(import('@backstage/plugin-notifications-backend')); + +// backend.add(import('@backstage/plugin-auth-backend')); +// backend.add(import('./authModuleGithubProvider')); +// backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); + +// backend.add(import('@backstage/plugin-adr-backend')); +// backend.add(import('@backstage/plugin-app-backend/alpha')); +// backend.add(import('@backstage/plugin-azure-devops-backend')); +// backend.add(import('@backstage/plugin-badges-backend')); +// backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); +// backend.add( +// import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), +// ); +// backend.add(import('@backstage/plugin-catalog-backend/alpha')); +// backend.add(import('@backstage/plugin-devtools-backend')); +// backend.add(import('@backstage/plugin-entity-feedback-backend')); +// backend.add(import('@backstage/plugin-jenkins-backend')); +// backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); +// backend.add(import('@backstage/plugin-lighthouse-backend')); +// backend.add(import('@backstage/plugin-linguist-backend')); +// backend.add(import('@backstage/plugin-playlist-backend')); +// backend.add(import('@backstage/plugin-nomad-backend')); +// backend.add( +// import('@backstage/plugin-permission-backend-module-allow-all-policy'), +// ); +// backend.add(import('@backstage/plugin-permission-backend/alpha')); +// backend.add(import('@backstage/plugin-proxy-backend/alpha')); +// backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); +// backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); +// backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); +// backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); +// backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); +// backend.add( +// import('@backstage/plugin-catalog-backend-module-backstage-openapi'), +// ); +// backend.add(import('@backstage/plugin-search-backend/alpha')); +// backend.add(import('@backstage/plugin-techdocs-backend/alpha')); +// backend.add(import('@backstage/plugin-todo-backend')); +// backend.add(import('@backstage/plugin-sonarqube-backend')); +// backend.add(import('@backstage/plugin-signals-backend')); +// backend.add(import('@backstage/plugin-notifications-backend')); backend.start(); From 8c0401a1d5b9f84a01528b4b28e0d3d754adfe73 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Apr 2024 11:17:22 +0200 Subject: [PATCH 11/30] backend-app-api: move listPublicServiceKeys to authService Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../services/implementations/auth/authServiceFactory.ts | 5 ++++- .../implementations/auth/createAuthIntegrationRouter.ts | 7 ++++--- .../implementations/httpRouter/httpRouterServiceFactory.ts | 6 ++++-- packages/backend-next/src/index.ts | 2 +- .../src/services/definitions/AuthService.ts | 6 ++++++ 5 files changed, 19 insertions(+), 7 deletions(-) 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 27f98ffb0a..17f78c1adc 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -24,12 +24,12 @@ import { BackstageUserPrincipal, coreServices, createServiceFactory, - DatabaseService, } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { decodeJwt } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; import { PluginTokenHandler } from './PluginTokenHandler'; +import { JsonObject } from '@backstage/types'; /** @internal */ export type InternalBackstageCredentials = @@ -111,6 +111,9 @@ class DefaultAuthService implements AuthService { private readonly disableDefaultAuthPolicy: boolean, private readonly pluginTokenHandler: PluginTokenHandler, ) {} + listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + throw new Error('Method not implemented.'); + } // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { diff --git a/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts b/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts index 586846cdeb..0fd417b611 100644 --- a/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts +++ b/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts @@ -13,17 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublicKeyStoreService } from '@backstage/backend-plugin-api'; +import { AuthService } from '@backstage/backend-plugin-api'; import express from 'express'; import Router from 'express-promise-router'; export function createAuthIntegrationRouter(options: { - publicKeyStore: PublicKeyStoreService; + auth: AuthService; }): express.Router { const router = Router(); router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => { - res.json({ keys: await options.publicKeyStore.listKeys() }); + 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 6898396848..7c50dd500d 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -45,21 +45,23 @@ 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, publicKeyStore: coreServices.publicKeyStore, }, async factory({ + auth, httpAuth, config, plugin, rootHttpRouter, lifecycle, - publicKeyStore, }) { const getPath = options?.getPath ?? (id => `/api/${id}`); const path = getPath(plugin.getId()); @@ -73,7 +75,7 @@ export const httpRouterServiceFactory = createServiceFactory( }); router.use(createLifecycleMiddleware({ lifecycle })); - router.use(createAuthIntegrationRouter({ publicKeyStore })); + router.use(createAuthIntegrationRouter({ auth })); router.use(credentialsBarrier.middleware); return { diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 47f1308c89..ee99e0f551 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -55,7 +55,7 @@ backend.add( rootLifecycle: coreServices.rootLifecycle, auth: coreServices.auth, discovery: coreServices.discovery, - httpRouter: coreServices.httpRouter, + // httpRouter: coreServices.httpRouter, }, async init({ rootLifecycle, auth, discovery }) { rootLifecycle.addStartupHook(async () => { 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[]; + }>; } From 6ce253d7ca579aaa1753d3220731492d377056d5 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Apr 2024 14:21:11 +0200 Subject: [PATCH 12/30] backend-app-api: remove publicKeyStoreService implementation Signed-off-by: Vincenzo Scamporlino --- .../DatabaseKeyStore.ts} | 21 ++-------- .../auth/PluginTokenHandler.ts | 31 +++++++++----- .../implementations/auth/PublicKeysClient.ts | 41 +++++++++++++++++++ .../auth/authServiceFactory.ts | 14 +++---- .../services/implementations/auth/types.ts} | 23 ++++++----- .../httpRouter/httpRouterServiceFactory.ts | 7 ---- .../src/services/implementations/index.ts | 1 - .../implementations/publicKeyStore/index.ts | 17 -------- .../src/auth/createLegacyAuthAdapters.ts | 5 +++ .../backend-defaults/src/CreateBackend.ts | 2 - .../src/services/definitions/coreServices.ts | 12 ------ .../src/services/definitions/index.ts | 1 - 12 files changed, 88 insertions(+), 87 deletions(-) rename packages/backend-app-api/src/services/implementations/{publicKeyStore/publicKeyStoreServiceFactory.ts => auth/DatabaseKeyStore.ts} (86%) create mode 100644 packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts rename packages/{backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts => backend-app-api/src/services/implementations/auth/types.ts} (71%) delete mode 100644 packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts similarity index 86% rename from packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts rename to packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts index 718aa641ed..83b3d98cc6 100644 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/publicKeyStoreServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts @@ -14,16 +14,12 @@ * limitations under the License. */ -import { - DatabaseService, - PublicKeyStoreService, - coreServices, - createServiceFactory, -} from '@backstage/backend-plugin-api'; +import { DatabaseService } 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'; const TABLE = 'backstage_backend_public_keys__keys'; @@ -35,7 +31,7 @@ type Row = { }; /** @internal */ -export class DatabaseKeyStore implements PublicKeyStoreService { +export class DatabaseKeyStore implements KeyStore { private constructor(private readonly client: Knex) {} static async create(options: { database: DatabaseService }) { @@ -71,6 +67,7 @@ export class DatabaseKeyStore implements PublicKeyStoreService { return { keys: rows.map(row => ({ + id: row.id, key: JSON.parse(row.key), expiresAt: parseDate(row.expires_at), })), @@ -82,16 +79,6 @@ export class DatabaseKeyStore implements PublicKeyStoreService { // } } -export const publicKeyStoreServiceFactory = createServiceFactory({ - service: coreServices.publicKeyStore, - deps: { - database: coreServices.database, - }, - async factory({ database }) { - return DatabaseKeyStore.create({ database }); - }, -}); - function parseDate(date: string | Date) { const parsedDate = typeof date === 'string' diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 2f7f8098f4..4a1b17d3b9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -14,17 +14,23 @@ * limitations under the License. */ +import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api'; import { - BackstageCredentials, - LoggerService, - PublicKeyStoreService, -} from '@backstage/backend-plugin-api'; -import { exportJWK, generateKeyPair, JWK, importJWK, SignJWT } from 'jose'; + decodeJwt, + exportJWK, + generateKeyPair, + JWK, + importJWK, + SignJWT, +} from 'jose'; import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; +import { InternalKey, KeyStore } from './types'; +import { DefaultPublicKeysClient, PublicKeysClient } from './PublicKeysClient'; type Options = { - publicKeyStore: PublicKeyStoreService; + publicKeyStore: KeyStore; + discovery: DiscoveryService; logger: LoggerService; /** Value of the issuer claim in issued tokens */ issuer: string; @@ -42,6 +48,7 @@ type Options = { export class PluginTokenHandler { private privateKeyPromise?: Promise; private keyExpiry?: Date; + private publicKeysClient: PublicKeysClient; static create(options: Options) { return new PluginTokenHandler( @@ -49,17 +56,21 @@ export class PluginTokenHandler { options.publicKeyStore, options.keyDurationSeconds, options.algorithm ?? 'ES256', + options.discovery, ); } private constructor( readonly logger: LoggerService, - readonly publicKeyStore: PublicKeyStoreService, + readonly publicKeyStore: KeyStore, readonly keyDurationSeconds: number, readonly algorithm: string, - ) {} + discovery: DiscoveryService, + ) { + this.publicKeysClient = new DefaultPublicKeysClient(discovery); + } - async verifyToken(token: string): Promise<{ subject: string }> { + async verifyToken(_token: string): Promise<{ subject: string }> { return { subject: 'is me' }; } @@ -129,7 +140,7 @@ export class PluginTokenHandler { console.log(`DEBUG: publicKey=`, publicKey); await this.publicKeyStore.addKey({ id: kid, - key: publicKey, + key: publicKey as InternalKey, expiresAt: keyExpiry, }); diff --git a/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts b/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts new file mode 100644 index 0000000000..6040e64c15 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts @@ -0,0 +1,41 @@ +/* + * 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 } from '@backstage/backend-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; + +export class DefaultPublicKeysClient implements PublicKeysClient { + constructor(private readonly discovery: DiscoveryService) {} + + // TODO: cache stuff + async listPublicKeys(pluginId: string) { + const response = await fetch( + `${await this.discovery.getBaseUrl( + pluginId, + )}/.backstage/auth/v1/jwks.json`, + ); + + if (response.ok) { + return response.json(); + } + throw await ResponseError.fromResponse(response); + } +} + +export type PublicKeysClient = { + listPublicKeys(pluginId: string): Promise<{ keys: JsonObject[] }>; +}; 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 17f78c1adc..3522da63e2 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -30,6 +30,7 @@ import { decodeJwt } from 'jose'; import { UserTokenHandler } from './UserTokenHandler'; import { PluginTokenHandler } from './PluginTokenHandler'; import { JsonObject } from '@backstage/types'; +import { DatabaseKeyStore } from './DatabaseKeyStore'; /** @internal */ export type InternalBackstageCredentials = @@ -248,26 +249,21 @@ 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, - publicKeyStore: coreServices.publicKeyStore, }, - async factory({ - config, - discovery, - plugin, - tokenManager, - logger, - publicKeyStore, - }) { + async factory({ config, discovery, plugin, tokenManager, logger, database }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( 'backend.auth.dangerouslyDisableDefaultAuthPolicy', ), ); + + const publicKeyStore = await DatabaseKeyStore.create({ database }); return new DefaultAuthService( tokenManager, new UserTokenHandler({ discovery }), diff --git a/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts b/packages/backend-app-api/src/services/implementations/auth/types.ts similarity index 71% rename from packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts rename to packages/backend-app-api/src/services/implementations/auth/types.ts index 6f4f80e534..b47ff2588d 100644 --- a/packages/backend-plugin-api/src/services/definitions/PublicKeyStoreService.ts +++ b/packages/backend-app-api/src/services/implementations/auth/types.ts @@ -16,14 +16,15 @@ import { JsonObject } from '@backstage/types'; -/** - * @public - */ -export interface PublicKeyStoreService { - listKeys(): Promise<{ keys: { key: JsonObject; expiresAt: Date }[] }>; - addKey(options: { - id: string; - key: JsonObject; - expiresAt: Date; - }): Promise; -} +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/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index 7c50dd500d..30878e3e22 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -23,14 +23,8 @@ import { Handler } from 'express'; import PromiseRouter from 'express-promise-router'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; -import { JsonObject } from '@backstage/types'; import { createAuthIntegrationRouter } from '../auth'; -export interface PublicKeyStoreService { - listKeys(): Promise; - addKey(options: { key: JsonObject; expiresAt: Date }): Promise; -} - /** * @public */ @@ -53,7 +47,6 @@ export const httpRouterServiceFactory = createServiceFactory( rootHttpRouter: coreServices.rootHttpRouter, auth: coreServices.auth, httpAuth: coreServices.httpAuth, - publicKeyStore: coreServices.publicKeyStore, }, async factory({ auth, diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 5f19e4970c..a1114ab3e3 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -25,7 +25,6 @@ export * from './identity'; export * from './lifecycle'; export * from './logger'; export * from './permissions'; -export * from './publicKeyStore'; export * from './rootHttpRouter'; export * from './rootLifecycle'; export * from './rootLogger'; diff --git a/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts b/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts deleted file mode 100644 index 2e604cbe18..0000000000 --- a/packages/backend-app-api/src/services/implementations/publicKeyStore/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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. - */ - -export { publicKeyStoreServiceFactory } from './publicKeyStoreServiceFactory'; 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-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 63f39b18a5..5495665dd8 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -35,7 +35,6 @@ import { authServiceFactory, httpAuthServiceFactory, userInfoServiceFactory, - publicKeyStoreServiceFactory, } from '@backstage/backend-app-api'; export const defaultServiceFactories = [ @@ -50,7 +49,6 @@ export const defaultServiceFactories = [ lifecycleServiceFactory(), loggerServiceFactory(), permissionsServiceFactory(), - publicKeyStoreServiceFactory(), rootHttpRouterServiceFactory(), rootLifecycleServiceFactory(), rootLoggerServiceFactory(), diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index d4770a2b9b..c760afc7b4 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -132,18 +132,6 @@ export namespace coreServices { import('./PluginMetadataService').PluginMetadataService >({ id: 'core.pluginMetadata' }); - /** - * The service reference for the plugin scoped {@link PublicKeyStoreService}. - * - * @public - */ - export const publicKeyStore = createServiceRef< - import('./PublicKeyStoreService').PublicKeyStoreService - >({ - id: 'core.publicKeyStore', - scope: 'plugin', - }); - /** * The service reference for the root scoped {@link RootHttpRouterService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 73f2f67296..8a5176379f 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -68,4 +68,3 @@ export type { } from './UrlReaderService'; export type { BackstageUserInfo, UserInfoService } from './UserInfoService'; export type { IdentityService } from './IdentityService'; -export type { PublicKeyStoreService } from './PublicKeyStoreService'; From 701c51abc92192115ae1dfeeb6594af0255815c3 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Apr 2024 15:09:46 +0200 Subject: [PATCH 13/30] backend-app-api: verify service-to-service token Signed-off-by: Vincenzo Scamporlino --- .../auth/PluginTokenHandler.ts | 44 +++++++++++++++---- .../implementations/auth/PublicKeysClient.ts | 41 ----------------- .../auth/authServiceFactory.ts | 12 ++++- 3 files changed, 46 insertions(+), 51 deletions(-) delete mode 100644 packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 4a1b17d3b9..fa6337d8e1 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -26,7 +26,8 @@ import { import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { InternalKey, KeyStore } from './types'; -import { DefaultPublicKeysClient, PublicKeysClient } from './PublicKeysClient'; +import { AuthenticationError } from '@backstage/errors'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; type Options = { publicKeyStore: KeyStore; @@ -48,7 +49,7 @@ type Options = { export class PluginTokenHandler { private privateKeyPromise?: Promise; private keyExpiry?: Date; - private publicKeysClient: PublicKeysClient; + private jwksMap: Record> = {}; static create(options: Options) { return new PluginTokenHandler( @@ -65,13 +66,30 @@ export class PluginTokenHandler { readonly publicKeyStore: KeyStore, readonly keyDurationSeconds: number, readonly algorithm: string, - discovery: DiscoveryService, - ) { - this.publicKeysClient = new DefaultPublicKeysClient(discovery); - } + readonly discovery: DiscoveryService, + ) {} - async verifyToken(_token: string): Promise<{ subject: string }> { - return { subject: 'is me' }; + async verifyToken(token: string): Promise<{ subject: string }> { + const claims = decodeJwt(token); + const pluginId = claims.sub; + if (!pluginId) { + throw new AuthenticationError('Invalid subject'); + } + + const JWKS = await this.getJWKS(pluginId); + try { + const { payload, protectedHeader } = await jwtVerify(token, JWKS, { + issuer: 'backstage-plugin', + // TODO(vinzscam): add audience verification + }); + + console.log('payload', payload); + console.log('protected header', protectedHeader); + return { subject: 'is me' }; + } catch (e) { + // TODO(vinzscam): here we need to handle errors properly + throw e; + } } async issueToken(options: { @@ -101,6 +119,16 @@ export class PluginTokenHandler { return { token }; } + private async getJWKS(pluginId: string) { + if (!this.jwksMap[pluginId]) { + const url = `${await this.discovery.getBaseUrl( + pluginId, + )}/.backstage/auth/v1/jwks.json`; + console.log('fetching from', url); + this.jwksMap[pluginId] = createRemoteJWKSet(new URL(url)); + } + return this.jwksMap[pluginId]; + } private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { diff --git a/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts b/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts deleted file mode 100644 index 6040e64c15..0000000000 --- a/packages/backend-app-api/src/services/implementations/auth/PublicKeysClient.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 } from '@backstage/backend-plugin-api'; -import { ResponseError } from '@backstage/errors'; -import { JsonObject } from '@backstage/types'; - -export class DefaultPublicKeysClient implements PublicKeysClient { - constructor(private readonly discovery: DiscoveryService) {} - - // TODO: cache stuff - async listPublicKeys(pluginId: string) { - const response = await fetch( - `${await this.discovery.getBaseUrl( - pluginId, - )}/.backstage/auth/v1/jwks.json`, - ); - - if (response.ok) { - return response.json(); - } - throw await ResponseError.fromResponse(response); - } -} - -export type PublicKeysClient = { - listPublicKeys(pluginId: string): Promise<{ keys: JsonObject[] }>; -}; 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 3522da63e2..3b7a7322cb 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -31,6 +31,7 @@ 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 = @@ -110,10 +111,13 @@ class DefaultAuthService implements AuthService { private readonly userTokenHandler: UserTokenHandler, private readonly pluginId: string, private readonly disableDefaultAuthPolicy: boolean, + private readonly publicKeyStore: KeyStore, private readonly pluginTokenHandler: PluginTokenHandler, ) {} - listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { - throw new Error('Method not implemented.'); + + async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { + const { keys } = await this.publicKeyStore.listKeys(); + return { keys: keys.map(({ key }) => key) }; } // allowLimitedAccess is currently ignored, since we currently always use the full user tokens @@ -256,6 +260,7 @@ export const authServiceFactory = createServiceFactory({ // new auth services in the new backend system. tokenManager: coreServices.tokenManager, }, + async factory({ config, discovery, plugin, tokenManager, logger, database }) { const disableDefaultAuthPolicy = Boolean( config.getOptionalBoolean( @@ -264,16 +269,19 @@ export const authServiceFactory = createServiceFactory({ ); const publicKeyStore = await DatabaseKeyStore.create({ database }); + return new DefaultAuthService( tokenManager, new UserTokenHandler({ discovery }), plugin.getId(), disableDefaultAuthPolicy, + publicKeyStore, PluginTokenHandler.create({ keyDurationSeconds: 60 * 60, issuer: `plugin:${plugin.getId()}`, logger, publicKeyStore, + discovery, }), ); }, From 112eca573f51c8294dd5d92507d9ce36b18b0d4d Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Apr 2024 15:10:10 +0200 Subject: [PATCH 14/30] backend-test-utils: add missing mock methods Signed-off-by: Vincenzo Scamporlino --- .../backend-test-utils/src/next/services/MockAuthService.ts | 5 +++++ .../backend-test-utils/src/next/services/mockServices.ts | 1 + yarn.lock | 1 + 3 files changed, 7 insertions(+) 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/yarn.lock b/yarn.lock index aeaba30baf..d1e6c584b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27585,6 +27585,7 @@ __metadata: "@backstage/plugin-sonarqube-backend": "workspace:^" "@backstage/plugin-techdocs-backend": "workspace:^" "@backstage/plugin-todo-backend": "workspace:^" + express-promise-router: ^4.1.0 languageName: unknown linkType: soft From e1afe84d75f4357df02e43102ffd2075dd4c3a3a Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Wed, 3 Apr 2024 21:38:45 +0200 Subject: [PATCH 15/30] backend-app-api: return valid subject Signed-off-by: Vincenzo Scamporlino --- .../src/services/implementations/auth/PluginTokenHandler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index fa6337d8e1..5a20368dfd 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -83,9 +83,12 @@ export class PluginTokenHandler { // TODO(vinzscam): add audience verification }); + if (!payload.sub) { + throw new AuthenticationError('Missing subject'); + } console.log('payload', payload); console.log('protected header', protectedHeader); - return { subject: 'is me' }; + return { subject: payload.sub }; } catch (e) { // TODO(vinzscam): here we need to handle errors properly throw e; @@ -124,7 +127,6 @@ export class PluginTokenHandler { const url = `${await this.discovery.getBaseUrl( pluginId, )}/.backstage/auth/v1/jwks.json`; - console.log('fetching from', url); this.jwksMap[pluginId] = createRemoteJWKSet(new URL(url)); } return this.jwksMap[pluginId]; From 130b215629915cc599c8ecb935f701f10f984c40 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 13:41:52 +0200 Subject: [PATCH 16/30] backend-app-api: final service to service refactoring Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../auth/PluginTokenHandler.ts | 65 +++++++++++-------- .../auth/authServiceFactory.ts | 29 ++++----- .../services/implementations/auth/index.ts | 1 - .../createAuthIntegrationRouter.ts | 1 + .../httpRouter/httpRouterServiceFactory.ts | 2 +- plugins/auth-node/src/types.ts | 4 +- 6 files changed, 53 insertions(+), 49 deletions(-) rename packages/backend-app-api/src/services/implementations/{auth => httpRouter}/createAuthIntegrationRouter.ts (99%) diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 5a20368dfd..1436412c3e 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -22,19 +22,22 @@ import { 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 { createRemoteJWKSet, jwtVerify } from 'jose'; +import { tokenTypes } from '@backstage/plugin-auth-node'; + +const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i; type Options = { + ownPluginId: string; publicKeyStore: KeyStore; discovery: DiscoveryService; logger: LoggerService; - /** Value of the issuer claim in issued tokens */ - issuer: string; /** Expiration time of signing keys in seconds */ keyDurationSeconds: number; /** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256. @@ -54,6 +57,7 @@ export class PluginTokenHandler { static create(options: Options) { return new PluginTokenHandler( options.logger, + options.ownPluginId, options.publicKeyStore, options.keyDurationSeconds, options.algorithm ?? 'ES256', @@ -63,36 +67,43 @@ export class PluginTokenHandler { 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 }> { - const claims = decodeJwt(token); - const pluginId = claims.sub; + 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 subject'); + 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 JWKS = await this.getJWKS(pluginId); - try { - const { payload, protectedHeader } = await jwtVerify(token, JWKS, { - issuer: 'backstage-plugin', - // TODO(vinzscam): add audience verification - }); + const { payload } = await jwtVerify<{ sub: string }>(token, JWKS, { + typ: tokenTypes.plugin.typParam, + audience: this.ownPluginId, + requiredClaims: ['iat', 'exp', 'sub', 'aud'], + }).catch(e => { + throw new AuthenticationError('Invalid plugin token', e); + }); - if (!payload.sub) { - throw new AuthenticationError('Missing subject'); - } - console.log('payload', payload); - console.log('protected header', protectedHeader); - return { subject: payload.sub }; - } catch (e) { - // TODO(vinzscam): here we need to handle errors properly - throw e; - } + return { subject: payload.sub }; } async issueToken(options: { @@ -101,18 +112,18 @@ export class PluginTokenHandler { }): Promise<{ token: string }> { const key = await this.getKey(); - const iss = 'backstage-plugin'; const sub = options.pluginId; const aud = options.targetPluginId; const iat = Math.floor(Date.now() / 1000); const exp = iat + this.keyDurationSeconds; - this.logger.info(`Issuing token for ${sub} with audentice ${aud}`); - - const claims = { iss, sub, aud, iat, exp }; + const claims = { sub, aud, iat, exp }; const token = await new SignJWT(claims) - .setProtectedHeader({ alg: this.algorithm, kid: key.kid }) - .setIssuer(iss) + .setProtectedHeader({ + typ: tokenTypes.plugin.typParam, + alg: this.algorithm, + kid: key.kid, + }) .setAudience(aud) .setSubject(sub) .setIssuedAt(iat) 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 3b7a7322cb..f9b3181d61 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -122,23 +122,9 @@ class DefaultAuthService implements AuthService { // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { - const { sub, aud, iss } = decodeJwt(token); - console.log(`DEBUG: iss=`, iss); - - if (iss === 'backstage-plugin') { - console.log('DO THE STUFF!'); - const { subject } = await this.pluginTokenHandler.verifyToken(token); - return createCredentialsWithServicePrincipal(subject); - } - - // # identify new token - // 1. generate and store public keys in database - // 2. verification of token, by fetching all public keys - - // 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); @@ -150,6 +136,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'); } @@ -277,8 +270,8 @@ export const authServiceFactory = createServiceFactory({ disableDefaultAuthPolicy, publicKeyStore, PluginTokenHandler.create({ + ownPluginId: plugin.getId(), keyDurationSeconds: 60 * 60, - issuer: `plugin:${plugin.getId()}`, logger, publicKeyStore, discovery, diff --git a/packages/backend-app-api/src/services/implementations/auth/index.ts b/packages/backend-app-api/src/services/implementations/auth/index.ts index 4bcf535e5b..1b55d46a83 100644 --- a/packages/backend-app-api/src/services/implementations/auth/index.ts +++ b/packages/backend-app-api/src/services/implementations/auth/index.ts @@ -15,4 +15,3 @@ */ export { authServiceFactory } from './authServiceFactory'; -export { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; diff --git a/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts similarity index 99% rename from packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts rename to packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts index 0fd417b611..94650199f3 100644 --- a/packages/backend-app-api/src/services/implementations/auth/createAuthIntegrationRouter.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createAuthIntegrationRouter.ts @@ -24,6 +24,7 @@ export function createAuthIntegrationRouter(options: { router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => { const { keys } = await options.auth.listPublicServiceKeys(); + res.json({ keys }); }); 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 30878e3e22..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,7 +23,7 @@ import { Handler } from 'express'; import PromiseRouter from 'express-promise-router'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; import { createCredentialsBarrier } from './createCredentialsBarrier'; -import { createAuthIntegrationRouter } from '../auth'; +import { createAuthIntegrationRouter } from './createAuthIntegrationRouter'; /** * @public 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', }), }); From ad8f173b78e7d2d9c46a970f1c4cfd272d951049 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 14:07:47 +0200 Subject: [PATCH 17/30] backend-app-api: move jwks to JwksClient Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../implementations/auth/JwksClient.ts | 75 +++++++++++++++++++ .../auth/PluginTokenHandler.ts | 49 ++++++++---- .../implementations/auth/UserTokenHandler.ts | 58 ++------------ 3 files changed, 116 insertions(+), 66 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/JwksClient.ts diff --git a/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts b/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts new file mode 100644 index 0000000000..44f0082424 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/JwksClient.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthenticationError } from '@backstage/errors'; +import { + createRemoteJWKSet, + decodeJwt, + decodeProtectedHeader, + FlattenedJWSInput, + JWSHeaderParameters, +} from 'jose'; +import { GetKeyFunction } from 'jose/dist/types/types'; + +const CLOCK_MARGIN_S = 10; + +export class JwksClient { + #keyStore?: GetKeyFunction; + #keyStoreUpdated: number = 0; + + constructor(private readonly getEndpoint: () => Promise) {} + + get getKey() { + if (!this.#keyStore) { + throw new AuthenticationError( + 'refreshKeyStore must be called before jwksClient.getKey', + ); + } + return this.#keyStore; + } + + /** + * If the last keystore refresh is stale, update the keystore URL to the latest + */ + async refreshKeyStore(rawJwtToken: string): Promise { + const payload = await decodeJwt(rawJwtToken); + const header = await decodeProtectedHeader(rawJwtToken); + + // Refresh public keys if needed + let keyStoreHasKey; + try { + if (this.#keyStore) { + // Check if the key is present in the keystore + const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); + keyStoreHasKey = await this.#keyStore(header, { + payload: rawPayload, + signature: rawSignature, + }); + } + } catch (error) { + keyStoreHasKey = false; + } + // Refresh public key URL if needed + // Add a small margin in case clocks are out of sync + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S; + if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) { + const endpoint = await this.getEndpoint(); + this.#keyStore = createRemoteJWKSet(endpoint); + this.#keyStoreUpdated = Date.now() / 1000; + } + } +} diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 1436412c3e..15ffcac980 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -28,8 +28,9 @@ import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { InternalKey, KeyStore } from './types'; import { AuthenticationError } from '@backstage/errors'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { jwtVerify } from 'jose'; import { tokenTypes } from '@backstage/plugin-auth-node'; +import { JwksClient } from './JwksClient'; const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i; @@ -52,7 +53,7 @@ type Options = { export class PluginTokenHandler { private privateKeyPromise?: Promise; private keyExpiry?: Date; - private jwksMap: Record> = {}; + private jwksMap = new Map(); static create(options: Options) { return new PluginTokenHandler( @@ -94,12 +95,18 @@ export class PluginTokenHandler { ); } - const JWKS = await this.getJWKS(pluginId); - const { payload } = await jwtVerify<{ sub: string }>(token, JWKS, { - typ: tokenTypes.plugin.typParam, - audience: this.ownPluginId, - requiredClaims: ['iat', 'exp', 'sub', 'aud'], - }).catch(e => { + const jwksClient = await this.getJwksClient(pluginId); + await jwksClient.refreshKeyStore(token); // TODO(Rugvip): Refactor so that this isn't needed + + const { payload } = await jwtVerify<{ sub: string }>( + token, + jwksClient.getKey, + { + typ: tokenTypes.plugin.typParam, + audience: this.ownPluginId, + requiredClaims: ['iat', 'exp', 'sub', 'aud'], + }, + ).catch(e => { throw new AuthenticationError('Invalid plugin token', e); }); @@ -133,15 +140,25 @@ export class PluginTokenHandler { return { token }; } - private async getJWKS(pluginId: string) { - if (!this.jwksMap[pluginId]) { - const url = `${await this.discovery.getBaseUrl( - pluginId, - )}/.backstage/auth/v1/jwks.json`; - this.jwksMap[pluginId] = createRemoteJWKSet(new URL(url)); + private async getJwksClient(pluginId: string) { + // TODO(Rugvip): Make this more resilient to forged tokens, making sure we don't fill up the map + const client = this.jwksMap.get(pluginId); + if (client) { + return client; } - return this.jwksMap[pluginId]; + + const newClient = new JwksClient(async () => { + return new URL( + `${await this.discovery.getBaseUrl( + pluginId, + )}/.backstage/auth/v1/jwks.json`, + ); + }); + + this.jwksMap.set(pluginId, newClient); + return newClient; } + private async getKey(): Promise { // Make sure that we only generate one key at a time if (this.privateKeyPromise) { @@ -178,7 +195,7 @@ export class PluginTokenHandler { // the new one. This also needs to be implemented cross-service though, meaning new services // that boot up need to be able to grab an existing key to use for signing. this.logger.info(`Created new signing key ${kid}`); - console.log(`DEBUG: publicKey=`, publicKey); + await this.publicKeyStore.addKey({ id: kid, key: publicKey as InternalKey, diff --git a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts index 3002096b1e..e1223d3bfb 100644 --- a/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/UserTokenHandler.ts @@ -14,23 +14,17 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { DiscoveryService } from '@backstage/backend-plugin-api'; import { AuthenticationError } from '@backstage/errors'; import { tokenTypes } from '@backstage/plugin-auth-node'; import { base64url, - createRemoteJWKSet, decodeJwt, decodeProtectedHeader, - FlattenedJWSInput, - JWSHeaderParameters, jwtVerify, JWTVerifyOptions, } from 'jose'; -import { GetKeyFunction } from 'jose/dist/types/types'; - -const CLOCK_MARGIN_S = 10; +import { JwksClient } from './JwksClient'; /** * An identity client to interact with auth-backend and authenticate Backstage @@ -39,15 +33,15 @@ const CLOCK_MARGIN_S = 10; * @internal */ export class UserTokenHandler { - readonly #discovery: PluginEndpointDiscovery; + readonly #jwksClient: JwksClient; readonly #algorithms?: string[]; - #keyStore?: GetKeyFunction; - #keyStoreUpdated: number = 0; - constructor(options: { discovery: DiscoveryService }) { - this.#discovery = options.discovery; this.#algorithms = ['ES256']; // TODO: configurable? + this.#jwksClient = new JwksClient(async () => { + const url = await options.discovery.getBaseUrl('auth'); + return new URL(`${url}/.well-known/jwks.json`); + }); } async verifyToken(token: string) { @@ -56,15 +50,12 @@ export class UserTokenHandler { return undefined; } - await this.refreshKeyStore(token); - if (!this.#keyStore) { - throw new AuthenticationError('No keystore exists'); - } + await this.#jwksClient.refreshKeyStore(token); // Verify a limited token, ensuring the necessarily claims are present and token type is correct const { payload } = await jwtVerify( token, - this.#keyStore, + this.#jwksClient.getKey, verifyOpts, ).catch(e => { throw new AuthenticationError('Invalid token', e); @@ -160,37 +151,4 @@ export class UserTokenHandler { return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1000) }; } - - /** - * If the last keystore refresh is stale, update the keystore URL to the latest - */ - private async refreshKeyStore(rawJwtToken: string): Promise { - const payload = await decodeJwt(rawJwtToken); - const header = await decodeProtectedHeader(rawJwtToken); - - // Refresh public keys if needed - let keyStoreHasKey; - try { - if (this.#keyStore) { - // Check if the key is present in the keystore - const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); - keyStoreHasKey = await this.#keyStore(header, { - payload: rawPayload, - signature: rawSignature, - }); - } - } catch (error) { - keyStoreHasKey = false; - } - // Refresh public key URL if needed - // Add a small margin in case clocks are out of sync - const issuedAfterLastRefresh = - payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S; - if (!this.#keyStore || (!keyStoreHasKey && issuedAfterLastRefresh)) { - const url = await this.#discovery.getBaseUrl('auth'); - const endpoint = new URL(`${url}/.well-known/jwks.json`); - this.#keyStore = createRemoteJWKSet(endpoint); - this.#keyStoreUpdated = Date.now() / 1000; - } - } } From 39de4a792445a1d176e3d6957576852f7fdfea75 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 14:34:30 +0200 Subject: [PATCH 18/30] backend-app-api: detect legacy auth Co-authored-by: Patrik Oldsberg Signed-off-by: Vincenzo Scamporlino --- .../auth/PluginTokenHandler.ts | 56 ++++++++++++++++++- .../auth/authServiceFactory.ts | 16 ++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts index 15ffcac980..2872e7f1f9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts +++ b/packages/backend-app-api/src/services/implementations/auth/PluginTokenHandler.ts @@ -55,6 +55,10 @@ export class PluginTokenHandler { 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, @@ -140,13 +144,63 @@ export class PluginTokenHandler { 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) { - // TODO(Rugvip): Make this more resilient to forged tokens, making sure we don't fill up the map const client = this.jwksMap.get(pluginId); if (client) { return client; } + // 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( 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 f9b3181d61..f6dd2902b9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -181,6 +181,7 @@ class DefaultAuthService implements AuthService { onBehalfOf: BackstageCredentials; targetPluginId: string; }): Promise<{ token: string }> { + const { targetPluginId } = options; const internalForward = toInternalBackstageCredentials(options.onBehalfOf); const { type } = internalForward.principal; @@ -198,11 +199,16 @@ class DefaultAuthService implements AuthService { switch (type) { // TODO: Check whether the principal is ourselves case 'service': - return this.pluginTokenHandler.issueToken({ - pluginId: this.pluginId, - targetPluginId: options.targetPluginId, - }); - // return this.tokenManager.getToken(); + 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) { throw new Error('User credentials is unexpectedly missing token'); From ea84d25b72ac5137f22716c83c687c03aa4e7943 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 14:38:11 +0200 Subject: [PATCH 19/30] backend-next: rollback auth example Signed-off-by: Vincenzo Scamporlino --- packages/backend-next/package.json | 3 +- packages/backend-next/src/index.ts | 137 ++++++++--------------------- yarn.lock | 1 - 3 files changed, 37 insertions(+), 104 deletions(-) diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index 12d1a7872b..340223ca0f 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -66,8 +66,7 @@ "@backstage/plugin-signals-backend": "workspace:^", "@backstage/plugin-sonarqube-backend": "workspace:^", "@backstage/plugin-techdocs-backend": "workspace:^", - "@backstage/plugin-todo-backend": "workspace:^", - "express-promise-router": "^4.1.0" + "@backstage/plugin-todo-backend": "workspace:^" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index ee99e0f551..4965462b2c 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -15,113 +15,48 @@ */ import { createBackend } from '@backstage/backend-defaults'; -import { createBackendPlugin } from '@backstage/backend-plugin-api'; -import Router from 'express-promise-router'; -import { coreServices } from '@backstage/backend-plugin-api'; const backend = createBackend(); +backend.add(import('@backstage/plugin-auth-backend')); +backend.add(import('./authModuleGithubProvider')); +backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); + +backend.add(import('@backstage/plugin-adr-backend')); +backend.add(import('@backstage/plugin-app-backend/alpha')); +backend.add(import('@backstage/plugin-azure-devops-backend')); +backend.add(import('@backstage/plugin-badges-backend')); +backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); backend.add( - createBackendPlugin({ - pluginId: 'receiver', - register(reg) { - reg.registerInit({ - deps: { - httpRouter: coreServices.httpRouter, - httpAuth: coreServices.httpAuth, - }, - async init({ httpRouter, httpAuth }) { - const router = Router(); - router.get('/hello', async (req, res) => { - console.log('Got hello'); - const credentials = await httpAuth.credentials(req); - console.log('Got credentials', credentials); - - res.json({ hello: 'world' }); - }); - httpRouter.use(router); - }, - }); - }, - }), + import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), ); - +backend.add(import('@backstage/plugin-catalog-backend/alpha')); +backend.add(import('@backstage/plugin-devtools-backend')); +backend.add(import('@backstage/plugin-entity-feedback-backend')); +backend.add(import('@backstage/plugin-jenkins-backend')); +backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); +backend.add(import('@backstage/plugin-lighthouse-backend')); +backend.add(import('@backstage/plugin-linguist-backend')); +backend.add(import('@backstage/plugin-playlist-backend')); +backend.add(import('@backstage/plugin-nomad-backend')); backend.add( - createBackendPlugin({ - pluginId: 'caller', - register(reg) { - reg.registerInit({ - deps: { - rootLifecycle: coreServices.rootLifecycle, - auth: coreServices.auth, - discovery: coreServices.discovery, - // httpRouter: coreServices.httpRouter, - }, - async init({ rootLifecycle, auth, discovery }) { - rootLifecycle.addStartupHook(async () => { - const { token } = await auth.getPluginRequestToken({ - onBehalfOf: await auth.getOwnServiceCredentials(), - targetPluginId: 'receiver', - }); - console.log(`DEBUG: token=`, token); - const res = await fetch( - `${await discovery.getBaseUrl('receiver')}/hello`, - { - headers: { - authorization: `Bearer ${token}`, - }, - }, - ); - console.log( - `DEBUG: res ${res.status} ${res.statusText}`, - await res.json(), - ); - }); - }, - }); - }, - }), + import('@backstage/plugin-permission-backend-module-allow-all-policy'), ); - -// backend.add(import('@backstage/plugin-auth-backend')); -// backend.add(import('./authModuleGithubProvider')); -// backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); - -// backend.add(import('@backstage/plugin-adr-backend')); -// backend.add(import('@backstage/plugin-app-backend/alpha')); -// backend.add(import('@backstage/plugin-azure-devops-backend')); -// backend.add(import('@backstage/plugin-badges-backend')); -// backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed')); -// backend.add( -// import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), -// ); -// backend.add(import('@backstage/plugin-catalog-backend/alpha')); -// backend.add(import('@backstage/plugin-devtools-backend')); -// backend.add(import('@backstage/plugin-entity-feedback-backend')); -// backend.add(import('@backstage/plugin-jenkins-backend')); -// backend.add(import('@backstage/plugin-kubernetes-backend/alpha')); -// backend.add(import('@backstage/plugin-lighthouse-backend')); -// backend.add(import('@backstage/plugin-linguist-backend')); -// backend.add(import('@backstage/plugin-playlist-backend')); -// backend.add(import('@backstage/plugin-nomad-backend')); -// backend.add( -// import('@backstage/plugin-permission-backend-module-allow-all-policy'), -// ); -// backend.add(import('@backstage/plugin-permission-backend/alpha')); -// backend.add(import('@backstage/plugin-proxy-backend/alpha')); -// backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); -// backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); -// backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); -// backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); -// backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); -// backend.add( -// import('@backstage/plugin-catalog-backend-module-backstage-openapi'), -// ); -// backend.add(import('@backstage/plugin-search-backend/alpha')); -// backend.add(import('@backstage/plugin-techdocs-backend/alpha')); -// backend.add(import('@backstage/plugin-todo-backend')); -// backend.add(import('@backstage/plugin-sonarqube-backend')); -// backend.add(import('@backstage/plugin-signals-backend')); -// backend.add(import('@backstage/plugin-notifications-backend')); +backend.add(import('@backstage/plugin-permission-backend/alpha')); +backend.add(import('@backstage/plugin-proxy-backend/alpha')); +backend.add(import('@backstage/plugin-scaffolder-backend/alpha')); +backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); +backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha')); +backend.add(import('@backstage/plugin-search-backend-module-explore/alpha')); +backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha')); +backend.add( + import('@backstage/plugin-catalog-backend-module-backstage-openapi'), +); +backend.add(import('@backstage/plugin-search-backend/alpha')); +backend.add(import('@backstage/plugin-techdocs-backend/alpha')); +backend.add(import('@backstage/plugin-todo-backend')); +backend.add(import('@backstage/plugin-sonarqube-backend')); +backend.add(import('@backstage/plugin-signals-backend')); +backend.add(import('@backstage/plugin-notifications-backend')); backend.start(); diff --git a/yarn.lock b/yarn.lock index d1e6c584b8..aeaba30baf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27585,7 +27585,6 @@ __metadata: "@backstage/plugin-sonarqube-backend": "workspace:^" "@backstage/plugin-techdocs-backend": "workspace:^" "@backstage/plugin-todo-backend": "workspace:^" - express-promise-router: ^4.1.0 languageName: unknown linkType: soft From bce08790ca50c5c4e2c858ed2ebcc4ee207ba1d7 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Thu, 4 Apr 2024 20:44:46 +0200 Subject: [PATCH 20/30] backend-app-api: auth changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/moody-bats-train.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/moody-bats-train.md 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. From 4f2aafb86c9b5aaa8174c9ca3087a0b7597358f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 14:38:57 +0200 Subject: [PATCH 21/30] backend-app-api: update DatabaseKeyStore to trim expired keys Signed-off-by: Patrik Oldsberg --- .../migrations/20240327104803_public_keys.js | 5 +- .../auth/DatabaseKeyStore.test.ts | 124 +++++++++++++ .../implementations/auth/DatabaseKeyStore.ts | 163 +++++++++++------- .../auth/authServiceFactory.ts | 2 +- 4 files changed, 226 insertions(+), 68 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts diff --git a/packages/backend-app-api/migrations/20240327104803_public_keys.js b/packages/backend-app-api/migrations/20240327104803_public_keys.js index 378209c9a2..b3ee96300c 100644 --- a/packages/backend-app-api/migrations/20240327104803_public_keys.js +++ b/packages/backend-app-api/migrations/20240327104803_public_keys.js @@ -32,7 +32,10 @@ exports.up = async function up(knex) { table.text('key').notNullable().comment('JSON serialized public key'); - table.timestamp('expires_at').notNullable(); + table + .timestamp('expires_at') + .notNullable() + .comment('The time that the key expires'); }, ); }; 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..f0c0d186c5 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts @@ -0,0 +1,124 @@ +/* + * 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'", + ); + + 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('Failed to format public key expiration date'); + }); + }); +}); diff --git a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts index 83b3d98cc6..62e11ad991 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DatabaseService } from '@backstage/backend-plugin-api'; +import { DatabaseService, LoggerService } from '@backstage/backend-plugin-api'; import { DateTime } from 'luxon'; import { Knex } from 'knex'; import { JsonObject } from '@backstage/types'; @@ -22,7 +22,8 @@ import { resolvePackagePath } from '@backstage/backend-common'; import { KeyStore } from './types'; const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations'; -const TABLE = 'backstage_backend_public_keys__keys'; +/** @internal */ +export const TABLE = 'backstage_backend_public_keys__keys'; type Row = { id: string; @@ -30,70 +31,6 @@ type Row = { expires_at: string | Date; // Needs parsing to handle different DB implementations }; -/** @internal */ -export class DatabaseKeyStore implements KeyStore { - private constructor(private readonly client: Knex) {} - - static async create(options: { database: DatabaseService }) { - const { database } = options; - - console.log(`DEBUG: ###### CREATING STORE`); - - const client = await database.getClient(); - if (!database.migrations?.skip) { - await applyDatabaseMigrations(client); - } - return new DatabaseKeyStore(client); - } - - async addKey(options: { - id: string; - key: JsonObject & { kid: string }; - expiresAt: Date; - }) { - console.log(`DEBUG: STORING KEY`, options); - await this.client(TABLE).insert({ - id: options.key.kid, - key: JSON.stringify(options.key), - // TODO: figure out the best way to format this for the DB - expires_at: DateTime.fromJSDate(options.expiresAt).toSQL()!, - }); - } - - async listKeys() { - const rows = await this.client(TABLE).select(); - - // TODO: move over filter/delete the logic from listPublicKeys() in plugins/auth-backend/src/identity/TokenFactory.ts - - return { - keys: rows.map(row => ({ - id: row.id, - key: JSON.parse(row.key), - expiresAt: parseDate(row.expires_at), - })), - }; - } - - // async removeKeys(kids: string[]): Promise { - // await this.client(TABLE).delete().whereIn('kid', kids); - // } -} - -function parseDate(date: string | Date) { - const parsedDate = - typeof date === 'string' - ? DateTime.fromSQL(date, { zone: 'UTC' }) - : DateTime.fromJSDate(date); - - if (!parsedDate.isValid) { - throw new Error( - `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, - ); - } - - return parsedDate.toJSDate(); -} - export function applyDatabaseMigrations(knex: Knex): Promise { const migrationsDir = resolvePackagePath( '@backstage/backend-app-api', @@ -105,3 +42,97 @@ export function applyDatabaseMigrations(knex: Knex): Promise { 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; + }) { + const expiresAt = DateTime.fromJSDate(options.expiresAt).toSQL(); + if (!expiresAt) { + throw new Error('Failed to format public key expiration date'); + } + await this.client(TABLE).insert({ + id: options.key.kid, + key: JSON.stringify(options.key), + expires_at: expiresAt, + }); + } + + async listKeys() { + const rows = await this.client(TABLE).select(); + const keys = rows.map(row => ({ + id: row.id, + key: JSON.parse(row.key), + expiresAt: this.#parseDate(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 }; + } + + #parseDate(date: string | Date) { + const parsedDate = + typeof date === 'string' + ? DateTime.fromSQL(date, { zone: 'UTC' }) + : DateTime.fromJSDate(date); + + if (!parsedDate.isValid) { + throw new Error( + `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, + ); + } + + return parsedDate.toJSDate(); + } +} 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 f6dd2902b9..f2b9bb7cd4 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -267,7 +267,7 @@ export const authServiceFactory = createServiceFactory({ ), ); - const publicKeyStore = await DatabaseKeyStore.create({ database }); + const publicKeyStore = await DatabaseKeyStore.create({ database, logger }); return new DefaultAuthService( tokenManager, From ae38fbb6b894821346a880269cc04a456853c0c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 16:04:37 +0200 Subject: [PATCH 22/30] backend-app-api: update DatabaseKeyStore to store expiration as string Signed-off-by: Patrik Oldsberg --- .../migrations/20240327104803_public_keys.js | 3 ++- .../auth/DatabaseKeyStore.test.ts | 2 +- .../implementations/auth/DatabaseKeyStore.ts | 25 +++---------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/backend-app-api/migrations/20240327104803_public_keys.js b/packages/backend-app-api/migrations/20240327104803_public_keys.js index b3ee96300c..d4292c775b 100644 --- a/packages/backend-app-api/migrations/20240327104803_public_keys.js +++ b/packages/backend-app-api/migrations/20240327104803_public_keys.js @@ -32,8 +32,9 @@ exports.up = async function up(knex) { table.text('key').notNullable().comment('JSON serialized public key'); + // Expiration is stored as a string for simplicity, all checks are done client-side table - .timestamp('expires_at') + .string('expires_at') .notNullable() .comment('The time that the key expires'); }, 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 index f0c0d186c5..010cb68a11 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts @@ -118,7 +118,7 @@ describe('DatabaseKeyStore', () => { key: testKey, expiresAt: new Date(NaN), }), - ).rejects.toThrow('Failed to format public key expiration date'); + ).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 index 62e11ad991..6a1536fd18 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.ts @@ -28,7 +28,7 @@ export const TABLE = 'backstage_backend_public_keys__keys'; type Row = { id: string; key: string; - expires_at: string | Date; // Needs parsing to handle different DB implementations + expires_at: string; }; export function applyDatabaseMigrations(knex: Knex): Promise { @@ -68,14 +68,10 @@ export class DatabaseKeyStore implements KeyStore { key: JsonObject & { kid: string }; expiresAt: Date; }) { - const expiresAt = DateTime.fromJSDate(options.expiresAt).toSQL(); - if (!expiresAt) { - throw new Error('Failed to format public key expiration date'); - } await this.client(TABLE).insert({ id: options.key.kid, key: JSON.stringify(options.key), - expires_at: expiresAt, + expires_at: options.expiresAt.toISOString(), }); } @@ -84,7 +80,7 @@ export class DatabaseKeyStore implements KeyStore { const keys = rows.map(row => ({ id: row.id, key: JSON.parse(row.key), - expiresAt: this.#parseDate(row.expires_at), + expiresAt: new Date(row.expires_at), })); const validKeys = []; @@ -120,19 +116,4 @@ export class DatabaseKeyStore implements KeyStore { return { keys: validKeys }; } - - #parseDate(date: string | Date) { - const parsedDate = - typeof date === 'string' - ? DateTime.fromSQL(date, { zone: 'UTC' }) - : DateTime.fromJSDate(date); - - if (!parsedDate.isValid) { - throw new Error( - `Failed to parse date, reason: ${parsedDate.invalidReason}, explanation: ${parsedDate.invalidExplanation}`, - ); - } - - return parsedDate.toJSDate(); - } } From 007e7eaefa2a76ef8fc9b2858ed896a1491fbf1b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 16:15:53 +0200 Subject: [PATCH 23/30] changesets: added changesets for listPublicServiceKeys addition to AuthService Signed-off-by: Patrik Oldsberg --- .changeset/brave-candles-knock.md | 5 +++++ .changeset/kind-turkeys-travel.md | 5 +++++ .changeset/red-years-double.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/brave-candles-knock.md create mode 100644 .changeset/kind-turkeys-travel.md create mode 100644 .changeset/red-years-double.md 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/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`. From a5f71d00ff06aa672c194823dd4b57af7b1256bc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 16:17:49 +0200 Subject: [PATCH 24/30] backend-app-api: reorder auth service methods in implementation Signed-off-by: Patrik Oldsberg --- .../implementations/auth/authServiceFactory.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 f2b9bb7cd4..e0ecf16092 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -115,11 +115,6 @@ class DefaultAuthService implements AuthService { private readonly pluginTokenHandler: PluginTokenHandler, ) {} - async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> { - const { keys } = await this.publicKeyStore.listKeys(); - return { keys: keys.map(({ key }) => key) }; - } - // allowLimitedAccess is currently ignored, since we currently always use the full user tokens async authenticate(token: string): Promise { const pluginResult = await this.pluginTokenHandler.verifyToken(token); @@ -235,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) { From 318c0b760a913dd9b9736fc7fed0be1c01df7490 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 16:28:56 +0200 Subject: [PATCH 25/30] api report updates for service token improvements Signed-off-by: Patrik Oldsberg --- packages/backend-plugin-api/api-report.md | 4 ++++ plugins/auth-node/api-report.md | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 6f6f630850..5d646f3f60 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/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'; }>; }>; From d627a67acff58ad95cc91a40ae59ce1295be587e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 17:01:09 +0200 Subject: [PATCH 26/30] backend-app-api: fix race end DatabaseKeyStore tests Signed-off-by: Patrik Oldsberg --- .../src/services/implementations/auth/DatabaseKeyStore.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 index 010cb68a11..136017efdb 100644 --- a/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/DatabaseKeyStore.test.ts @@ -102,6 +102,9 @@ describe('DatabaseKeyStore', () => { "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 }, ]); From 9d74e68f79cfae59507d1464194cb3af8002eabd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 5 Apr 2024 17:28:12 +0200 Subject: [PATCH 27/30] e2e-test: added --keep option + error message improvements Signed-off-by: Patrik Oldsberg --- packages/e2e-test/cli-report.md | 3 ++- packages/e2e-test/src/commands/index.ts | 6 +++++- packages/e2e-test/src/commands/run.ts | 28 +++++++++++++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) 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..161109267b 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 @@ -546,9 +551,20 @@ async function testBackendStart(appDir: string, ...args: string[]) { 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) { From 40548a634d0fc4e1ea7b15facb475ecb0431c070 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Apr 2024 11:57:11 +0200 Subject: [PATCH 28/30] backend-app-api: add routing test for DefaultRootHttpRouter Signed-off-by: Patrik Oldsberg --- .../DefaultRootHttpRouter.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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'); + }); }); From 7395364e3b7d89297df4b9b0bfe63867115ad6ad Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Apr 2024 12:27:16 +0200 Subject: [PATCH 29/30] e2e-test: disable default auth policy for backend testing Signed-off-by: Patrik Oldsberg --- packages/e2e-test/src/commands/run.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 161109267b..b6758616e9 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -502,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', }, }); From 31ee337a508b75023b7c8b22f1ffbbf189649b0a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 6 Apr 2024 15:23:33 +0200 Subject: [PATCH 30/30] e2e-test: delay backend check Signed-off-by: Patrik Oldsberg --- packages/e2e-test/src/commands/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index b6758616e9..111c638146 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -549,7 +549,7 @@ 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