Merge pull request #23993 from backstage/mob/auth-service-to-service
Auth: granular service-to-service tokens
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Added placeholder for `listPublicServiceKeys()` in the `AuthService` returned by `createLegacyAuthAdapters`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added a new required `listPublicServiceKeys` to `AuthService`.
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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 };
|
||||
+32
@@ -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;
|
||||
}
|
||||
+16
-2
@@ -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 {
|
||||
|
||||
+35
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user