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:
@@ -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!),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+21
-2
@@ -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.
|
||||
*/
|
||||
+92
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user