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(); +}