backend-app-api: add publicKeyStoreServiceFactory

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2024-03-26 19:54:01 +01:00
parent b438fea117
commit 8d24ced349
5 changed files with 159 additions and 7 deletions
+6 -3
View File
@@ -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"
},
@@ -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<TPrincipal = unknown> =
@@ -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<BackstageCredentials> {
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<AnyJWK[]> {
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!),
);
},
});
@@ -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<JsonObject[]>;
addKey(options: { key: JsonObject; expiresAt: Date }): Promise<void>;
}
/**
* @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 {
@@ -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.
*/
@@ -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<void> {
await this.client<Row>(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<Row>(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<void> {
// 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();
}