Merge pull request #23993 from backstage/mob/auth-service-to-service

Auth: granular service-to-service tokens
This commit is contained in:
Patrik Oldsberg
2024-04-06 15:41:16 +02:00
committed by GitHub
28 changed files with 911 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Added placeholder for `listPublicServiceKeys()` in the `AuthService` returned by `createLegacyAuthAdapters`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---
Added a new required `listPublicServiceKeys` to `AuthService`.
+9
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Added mock of the new `listPublicServiceKeys` method for `AuthService`.
@@ -0,0 +1,50 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function up(knex) {
await knex.schema.createTable(
'backstage_backend_public_keys__keys',
table => {
table
.string('id')
.primary()
.notNullable()
.comment('The unique ID of a public key');
table.text('key').notNullable().comment('JSON serialized public key');
// Expiration is stored as a string for simplicity, all checks are done client-side
table
.string('expires_at')
.notNullable()
.comment('The time that the key expires');
},
);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('backstage_backend_public_keys__keys');
};
+8 -4
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"
},
@@ -97,6 +100,7 @@
"files": [
"dist",
"config.d.ts",
"alpha"
"alpha",
"migrations/**/*.{js,d.ts}"
]
}
@@ -0,0 +1,127 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TestDatabaseId,
TestDatabases,
mockServices,
} from '@backstage/backend-test-utils';
import { DatabaseKeyStore, TABLE } from './DatabaseKeyStore';
const testKey = {
kid: 'test-key',
kty: 'RSA',
e: 'ABC',
n: 'test',
};
const testKey2 = {
kid: 'test-key-2',
kty: 'RSA',
e: 'XYZ',
n: 'test',
};
describe('DatabaseKeyStore', () => {
const databases = TestDatabases.create();
async function createDatabaseKeyStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
const logger = mockServices.logger.mock();
return {
knex,
logger,
keyStore: await DatabaseKeyStore.create({
database: { getClient: async () => knex },
logger,
}),
};
}
describe.each(databases.eachSupportedId())('%p', databaseId => {
it('should insert a key into the database and list it', async () => {
const { keyStore } = await createDatabaseKeyStore(databaseId);
const expiresAt = new Date(Date.now() + 3600_000);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt,
});
await expect(keyStore.listKeys()).resolves.toEqual({
keys: [{ id: testKey.kid, key: testKey, expiresAt }],
});
});
it('should automatically exclude and remove expired keys when listing', async () => {
const { keyStore, knex, logger } = await createDatabaseKeyStore(
databaseId,
);
const expiresAt = new Date(Date.now() + 3600_000);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt,
});
await keyStore.addKey({
id: testKey2.kid,
key: testKey2,
expiresAt: new Date(0),
});
await expect(knex(TABLE).select('id')).resolves.toEqual([
{ id: testKey.kid },
{ id: testKey2.kid },
]);
expect(logger.info).not.toHaveBeenCalled();
await expect(keyStore.listKeys()).resolves.toEqual({
keys: [{ id: testKey.kid, key: testKey, expiresAt }],
});
expect(logger.info).toHaveBeenCalledWith(
"Removing expired plugin service keys, 'test-key-2'",
);
// Key deletion happens async, so give it a bit of time to complete
await new Promise(resolve => setTimeout(resolve, 500));
await expect(knex(TABLE).select('id')).resolves.toEqual([
{ id: testKey.kid },
]);
});
it('should fail to insert with invalid date', async () => {
const { keyStore } = await createDatabaseKeyStore(databaseId);
await expect(keyStore.listKeys()).resolves.toEqual({ keys: [] });
await expect(
keyStore.addKey({
id: testKey.kid,
key: testKey,
expiresAt: new Date(NaN),
}),
).rejects.toThrow('Invalid time value');
});
});
});
@@ -0,0 +1,119 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseService, LoggerService } from '@backstage/backend-plugin-api';
import { DateTime } from 'luxon';
import { Knex } from 'knex';
import { JsonObject } from '@backstage/types';
import { resolvePackagePath } from '@backstage/backend-common';
import { KeyStore } from './types';
const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations';
/** @internal */
export const TABLE = 'backstage_backend_public_keys__keys';
type Row = {
id: string;
key: string;
expires_at: string;
};
export function applyDatabaseMigrations(knex: Knex): Promise<void> {
const migrationsDir = resolvePackagePath(
'@backstage/backend-app-api',
'migrations',
);
return knex.migrate.latest({
directory: migrationsDir,
tableName: MIGRATIONS_TABLE,
});
}
/** @internal */
export class DatabaseKeyStore implements KeyStore {
static async create(options: {
database: DatabaseService;
logger: LoggerService;
}) {
const { database, logger } = options;
const client = await database.getClient();
if (!database.migrations?.skip) {
await applyDatabaseMigrations(client);
}
return new DatabaseKeyStore(client, logger);
}
private constructor(
private readonly client: Knex,
private readonly logger: LoggerService,
) {}
async addKey(options: {
id: string;
key: JsonObject & { kid: string };
expiresAt: Date;
}) {
await this.client<Row>(TABLE).insert({
id: options.key.kid,
key: JSON.stringify(options.key),
expires_at: options.expiresAt.toISOString(),
});
}
async listKeys() {
const rows = await this.client<Row>(TABLE).select();
const keys = rows.map(row => ({
id: row.id,
key: JSON.parse(row.key),
expiresAt: new Date(row.expires_at),
}));
const validKeys = [];
const expiredKeys = [];
for (const key of keys) {
if (DateTime.fromJSDate(key.expiresAt) < DateTime.local()) {
expiredKeys.push(key);
} else {
validKeys.push(key);
}
}
// Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e
if (expiredKeys.length > 0) {
const kids = expiredKeys.map(({ key }) => key.kid);
this.logger.info(
`Removing expired plugin service keys, '${kids.join("', '")}'`,
);
// We don't await this, just let it run in the background
this.client<Row>(TABLE)
.delete()
.whereIn('id', kids)
.catch(error => {
this.logger.error(
'Failed to remove expired plugin service keys',
error,
);
});
}
return { keys: validKeys };
}
}
@@ -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<JWSHeaderParameters, FlattenedJWSInput>;
#keyStoreUpdated: number = 0;
constructor(private readonly getEndpoint: () => Promise<URL>) {}
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<void> {
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;
}
}
}
@@ -0,0 +1,277 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import {
decodeJwt,
exportJWK,
generateKeyPair,
JWK,
importJWK,
SignJWT,
decodeProtectedHeader,
} from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { InternalKey, KeyStore } from './types';
import { AuthenticationError } from '@backstage/errors';
import { jwtVerify } from 'jose';
import { tokenTypes } from '@backstage/plugin-auth-node';
import { JwksClient } from './JwksClient';
const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
type Options = {
ownPluginId: string;
publicKeyStore: KeyStore;
discovery: DiscoveryService;
logger: LoggerService;
/** Expiration time of signing keys in seconds */
keyDurationSeconds: number;
/** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
* Must match one of the algorithms defined for IdentityClient.
* When setting a different algorithm, check if the `key` field
* of the `signing_keys` table can fit the length of the generated keys.
* If not, add a knex migration file in the migrations folder.
* More info on supported algorithms: https://github.com/panva/jose */
algorithm?: string;
};
export class PluginTokenHandler {
private privateKeyPromise?: Promise<JWK>;
private keyExpiry?: Date;
private jwksMap = new Map<string, JwksClient>();
// Tracking state for isTargetPluginSupported
private supportedTargetPlugins = new Set<string>();
private targetPluginInflightChecks = new Map<string, Promise<boolean>>();
static create(options: Options) {
return new PluginTokenHandler(
options.logger,
options.ownPluginId,
options.publicKeyStore,
options.keyDurationSeconds,
options.algorithm ?? 'ES256',
options.discovery,
);
}
private constructor(
readonly logger: LoggerService,
readonly ownPluginId: string,
readonly publicKeyStore: KeyStore,
readonly keyDurationSeconds: number,
readonly algorithm: string,
readonly discovery: DiscoveryService,
) {}
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
try {
const { typ } = decodeProtectedHeader(token);
if (typ !== tokenTypes.plugin.typParam) {
return undefined;
}
} catch {
return undefined;
}
const pluginId = String(decodeJwt(token).sub);
if (!pluginId) {
throw new AuthenticationError('Invalid plugin token: missing subject');
}
if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
throw new AuthenticationError(
'Invalid plugin token: forbidden subject format',
);
}
const jwksClient = await this.getJwksClient(pluginId);
await jwksClient.refreshKeyStore(token); // TODO(Rugvip): Refactor so that this isn't needed
const { payload } = await jwtVerify<{ sub: string }>(
token,
jwksClient.getKey,
{
typ: tokenTypes.plugin.typParam,
audience: this.ownPluginId,
requiredClaims: ['iat', 'exp', 'sub', 'aud'],
},
).catch(e => {
throw new AuthenticationError('Invalid plugin token', e);
});
return { subject: payload.sub };
}
async issueToken(options: {
pluginId: string;
targetPluginId: string;
}): Promise<{ token: string }> {
const key = await this.getKey();
const sub = options.pluginId;
const aud = options.targetPluginId;
const iat = Math.floor(Date.now() / 1000);
const exp = iat + this.keyDurationSeconds;
const claims = { sub, aud, iat, exp };
const token = await new SignJWT(claims)
.setProtectedHeader({
typ: tokenTypes.plugin.typParam,
alg: this.algorithm,
kid: key.kid,
})
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
return { token };
}
async isTargetPluginSupported(targetPluginId: string): Promise<boolean> {
if (this.supportedTargetPlugins.has(targetPluginId)) {
return true;
}
const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
if (inFlight) {
return inFlight;
}
const doCheck = async () => {
try {
const res = await fetch(
`${await this.discovery.getBaseUrl(
targetPluginId,
)}/.backstage/auth/v1/jwks.json`,
);
if (res.status === 404) {
return false;
}
if (!res.ok) {
throw new Error(`Failed to fetch jwks.json, ${res.status}`);
}
const data = await res.json();
if (!data.keys) {
throw new Error(`Invalid jwks.json response, missing keys`);
}
this.supportedTargetPlugins.add(targetPluginId);
return true;
} catch (error) {
this.logger.error('Unexpected failure for target JWKS check', error);
return false;
} finally {
this.targetPluginInflightChecks.delete(targetPluginId);
}
};
const check = doCheck();
this.targetPluginInflightChecks.set(targetPluginId, check);
return check;
}
private async getJwksClient(pluginId: string) {
const client = this.jwksMap.get(pluginId);
if (client) {
return client;
}
// Double check that the target plugin has a valid JWKS endpoint, otherwise avoid creating a remote key set
if (!(await this.isTargetPluginSupported(pluginId))) {
throw new AuthenticationError(
'Target plugin does not support self-signed tokens',
);
}
const newClient = new JwksClient(async () => {
return new URL(
`${await this.discovery.getBaseUrl(
pluginId,
)}/.backstage/auth/v1/jwks.json`,
);
});
this.jwksMap.set(pluginId, newClient);
return newClient;
}
private async getKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (
this.keyExpiry &&
DateTime.fromJSDate(this.keyExpiry) > DateTime.local()
) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
const keyExpiry = DateTime.utc()
.plus({
seconds: this.keyDurationSeconds,
})
.toJSDate();
this.keyExpiry = keyExpiry;
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
const kid = uuid();
const key = await generateKeyPair(this.algorithm);
const publicKey = await exportJWK(key.publicKey);
const privateKey = await exportJWK(key.privateKey);
publicKey.kid = privateKey.kid = kid;
publicKey.alg = privateKey.alg = this.algorithm;
// We're not allowed to use the key until it has been successfully stored
// TODO: some token verification implementations aggressively cache the list of keys, and
// don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${kid}`);
await this.publicKeyStore.addKey({
id: kid,
key: publicKey as InternalKey,
expiresAt: keyExpiry,
});
// At this point we are allowed to start using the new key
return privateKey;
})();
this.privateKeyPromise = promise;
try {
// If we fail to generate a new key, we need to clear the state so that
// the next caller will try to generate another key.
await promise;
} catch (error) {
this.logger.error(`Failed to generate new signing key, ${error}`);
delete this.keyExpiry;
delete this.privateKeyPromise;
}
return promise;
}
}
@@ -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<JWSHeaderParameters, FlattenedJWSInput>;
#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<void> {
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;
}
}
}
@@ -28,6 +28,10 @@ import {
import { AuthenticationError } from '@backstage/errors';
import { decodeJwt } from 'jose';
import { UserTokenHandler } from './UserTokenHandler';
import { PluginTokenHandler } from './PluginTokenHandler';
import { JsonObject } from '@backstage/types';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { KeyStore } from './types';
/** @internal */
export type InternalBackstageCredentials<TPrincipal = unknown> =
@@ -107,16 +111,15 @@ class DefaultAuthService implements AuthService {
private readonly userTokenHandler: UserTokenHandler,
private readonly pluginId: string,
private readonly disableDefaultAuthPolicy: boolean,
private readonly publicKeyStore: KeyStore,
private readonly pluginTokenHandler: PluginTokenHandler,
) {}
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
async authenticate(token: string): Promise<BackstageCredentials> {
const { sub, aud } = decodeJwt(token);
// Legacy service-to-service token
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createCredentialsWithServicePrincipal('external:backstage-plugin');
const pluginResult = await this.pluginTokenHandler.verifyToken(token);
if (pluginResult) {
return createCredentialsWithServicePrincipal(pluginResult.subject);
}
const userResult = await this.userTokenHandler.verifyToken(token);
@@ -128,6 +131,13 @@ class DefaultAuthService implements AuthService {
);
}
// Legacy service-to-service token
const { sub, aud } = decodeJwt(token);
if (sub === 'backstage-server' && !aud) {
await this.tokenManager.authenticate(token);
return createCredentialsWithServicePrincipal('external:backstage-plugin');
}
throw new AuthenticationError('Unknown token');
}
@@ -166,6 +176,7 @@ class DefaultAuthService implements AuthService {
onBehalfOf: BackstageCredentials;
targetPluginId: string;
}): Promise<{ token: string }> {
const { targetPluginId } = options;
const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
const { type } = internalForward.principal;
@@ -178,9 +189,20 @@ class DefaultAuthService implements AuthService {
return { token: '' };
}
// check whether a plugin support the new auth system
// by checking the public keys endpoint existance.
switch (type) {
// TODO: Check whether the principal is ourselves
case 'service':
if (
await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId)
) {
return this.pluginTokenHandler.issueToken({
pluginId: this.pluginId,
targetPluginId,
});
}
// If the target plugin does not support the new auth service, fall back to using old token format
return this.tokenManager.getToken();
case 'user':
if (!internalForward.token) {
@@ -208,6 +230,11 @@ class DefaultAuthService implements AuthService {
return this.userTokenHandler.createLimitedUserToken(backstageToken);
}
async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> {
const { keys } = await this.publicKeyStore.listKeys();
return { keys: keys.map(({ key }) => key) };
}
#getJwtExpiration(token: string) {
const { exp } = decodeJwt(token);
if (!exp) {
@@ -225,23 +252,36 @@ export const authServiceFactory = createServiceFactory({
logger: coreServices.rootLogger,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
database: coreServices.database,
// Re-using the token manager makes sure that we use the same generated keys for
// development as plugins that have not yet been migrated. It's important that this
// keeps working as long as there are plugins that have not been migrated to the
// new auth services in the new backend system.
tokenManager: coreServices.tokenManager,
},
async factory({ config, discovery, plugin, tokenManager }) {
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
const disableDefaultAuthPolicy = Boolean(
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
),
);
const publicKeyStore = await DatabaseKeyStore.create({ database, logger });
return new DefaultAuthService(
tokenManager,
new UserTokenHandler({ discovery }),
plugin.getId(),
disableDefaultAuthPolicy,
publicKeyStore,
PluginTokenHandler.create({
ownPluginId: plugin.getId(),
keyDurationSeconds: 60 * 60,
logger,
publicKeyStore,
discovery,
}),
);
},
});
@@ -0,0 +1,30 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
export type KeyStore = {
addKey(key: KeyPayload): Promise<any>;
listKeys(): Promise<{ keys: KeyPayload[] }>;
};
export type KeyPayload = {
id: string;
key: InternalKey;
expiresAt: Date;
};
export type InternalKey = JsonObject & { kid: string };
@@ -0,0 +1,32 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthService } from '@backstage/backend-plugin-api';
import express from 'express';
import Router from 'express-promise-router';
export function createAuthIntegrationRouter(options: {
auth: AuthService;
}): express.Router {
const router = Router();
router.get('/.backstage/auth/v1/jwks.json', async (_req, res) => {
const { keys } = await options.auth.listPublicServiceKeys();
res.json({ keys });
});
return router;
}
@@ -23,6 +23,7 @@ import { Handler } from 'express';
import PromiseRouter from 'express-promise-router';
import { createLifecycleMiddleware } from './createLifecycleMiddleware';
import { createCredentialsBarrier } from './createCredentialsBarrier';
import { createAuthIntegrationRouter } from './createAuthIntegrationRouter';
/**
* @public
@@ -38,23 +39,36 @@ export interface HttpRouterFactoryOptions {
export const httpRouterServiceFactory = createServiceFactory(
(options?: HttpRouterFactoryOptions) => ({
service: coreServices.httpRouter,
initialization: 'always',
deps: {
plugin: coreServices.pluginMetadata,
config: coreServices.rootConfig,
lifecycle: coreServices.lifecycle,
rootHttpRouter: coreServices.rootHttpRouter,
auth: coreServices.auth,
httpAuth: coreServices.httpAuth,
},
async factory({ httpAuth, config, plugin, rootHttpRouter, lifecycle }) {
async factory({
auth,
httpAuth,
config,
plugin,
rootHttpRouter,
lifecycle,
}) {
const getPath = options?.getPath ?? (id => `/api/${id}`);
const path = getPath(plugin.getId());
const router = PromiseRouter();
rootHttpRouter.use(path, router);
const credentialsBarrier = createCredentialsBarrier({ httpAuth, config });
const credentialsBarrier = createCredentialsBarrier({
httpAuth,
config,
});
router.use(createLifecycleMiddleware({ lifecycle }));
router.use(createAuthIntegrationRouter({ auth }));
router.use(credentialsBarrier.middleware);
return {
@@ -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');
});
});
@@ -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) {
+3 -3
View File
@@ -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:^",
@@ -55,6 +55,10 @@ export interface AuthService {
credentials: BackstageCredentials,
type: TType,
): credentials is BackstageCredentials<BackstagePrincipalTypes[TType]>;
// (undocumented)
listPublicServiceKeys(): Promise<{
keys: JsonObject[];
}>;
}
// @public (undocumented)
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
/**
* @public
*/
@@ -91,4 +93,8 @@ export interface AuthService {
getLimitedUserToken(
credentials: BackstageCredentials<BackstageUserPrincipal>,
): Promise<{ token: string; expiresAt: Date }>;
listPublicServiceKeys(): Promise<{
keys: JsonObject[];
}>;
}
@@ -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');
}
}
@@ -207,6 +207,7 @@ export namespace mockServices {
isPrincipal: jest.fn() as any,
getPluginRequestToken: jest.fn(),
getLimitedUserToken: jest.fn(),
listPublicServiceKeys: jest.fn(),
}));
}
+2 -1
View File
@@ -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
```
+5 -1
View File
@@ -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);
}
+25 -7
View File
@@ -34,6 +34,7 @@ import mysql from 'mysql2/promise';
import pgtools from 'pgtools';
import { findPaths } from '@backstage/cli-common';
import { OptionValues } from 'commander';
// eslint-disable-next-line no-restricted-syntax
const paths = findPaths(__dirname);
@@ -45,7 +46,7 @@ const templatePackagePaths = [
'packages/create-app/templates/default-app/packages/backend/package.json.hbs',
];
export async function run() {
export async function run(opts: OptionValues) {
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
print(`CLI E2E test root: ${rootDir}\n`);
@@ -109,8 +110,12 @@ export async function run() {
// runner will be destroyed anyway
print('All tests successful');
} else {
print('All tests successful, removing test dir');
await fs.remove(rootDir);
if (opts.keep) {
print(`All tests successful, app dir available at ${appDir}`);
} else {
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
}
// Just in case some child process was left hanging
@@ -497,6 +502,8 @@ async function testBackendStart(appDir: string, ...args: string[]) {
env: {
...process.env,
GITHUB_TOKEN: 'abc',
// TODO: Default auth policy is disabled for e2e tests - replace this with external service auth
APP_CONFIG_backend_auth_dangerouslyDisableDefaultAuthPolicy: 'true',
},
});
@@ -542,13 +549,24 @@ async function testBackendStart(appDir: string, ...args: string[]) {
// Skipping the whole block
throw new Error(stderr);
}
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise(resolve => setTimeout(resolve, 1000));
print('Try to fetch entities from the backend');
// Try fetch entities, should be ok
await fetch('http://localhost:7007/api/catalog/entities').then(res =>
res.json(),
);
const res = await fetch('http://localhost:7007/api/catalog/entities');
if (!res.ok) {
throw new Error(
`Failed to fetch entities: ${res.status} ${res.statusText}`,
);
}
const content = await res.text();
try {
JSON.parse(content);
} catch (error) {
throw new Error(
`Failed to parse entities JSON response: ${error}\n${content}`,
);
}
print('Entities fetched successfully');
successful = true;
} catch (error) {
+2 -2
View File
@@ -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';
}>;
}>;
+2 -2
View File
@@ -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',
}),
});
+3
View File
@@ -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