auth: add a static keypair method for plugin-to-plugin auth

Co-authored-by: blam <ben@blam.sh>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2024-06-12 09:39:34 +02:00
parent 5c50d66192
commit e25e467e60
18 changed files with 923 additions and 155 deletions
+39
View File
@@ -0,0 +1,39 @@
---
'@backstage/backend-app-api': patch
---
Added a new static key based method for plugin-to-plugin auth. This is useful for example if you are running readonly service nodes that cannot use a database for the default public-key signature scheme outlined in [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution). Most users should want to stay on the more secure zero-config database signature scheme.
You can generate a public and private key pair using `openssl`.
- First generate a private key using the ES256 algorithm
```sh
openssl ecparam -name prime256v1 -genkey -out private.ec.key
```
- Convert it to PKCS#8 format
```sh
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key
```
- Extract the public key
```sh
openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key
```
After this you have the files `private.key` and `public.key`. Put them in a place where you know their absolute paths, and then set up your app-config accordingly:
```yaml
backend:
auth:
keyStore:
type: static
static:
keys:
- publicKeyFile: /absolute/path/to/public.key
privateKeyFile: /absolute/path/to/private.key
keyId: some-custom-id
```
+3
View File
@@ -170,3 +170,6 @@ knip.json
# Schemathesis temporary files
.hypothesis/
.cassettes/
# Locally generated keys
*.key
+54
View File
@@ -76,6 +76,60 @@ and when your deployment is behind a secure ingress like a VPN.
External callers cannot leverage this flow; it's only used internally by backend
plugins calling other backend plugins.
### Static Keys for Plugin-to-Plugin Auth
In some special circumstances, such as when running worker nodes on readonly
database replicas, you may wish to opt out of the standard database based
public-key scheme. As an alternative, you can put static keys in your config
that are used for token signing and validation.
You can make keys using the `openssl` command line utility.
- First generate a private key using the ES256 algorithm:
```sh
openssl ecparam -name prime256v1 -genkey -out private.ec.key
```
- Convert it to PKCS#8 format:
```sh
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key
```
- Extract the public key:
```sh
openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key
```
After this you have the files `private.key` and `public.key`. Put them in a
place where you know their absolute paths, and then set up your app-config
accordingly:
```yaml
backend:
auth:
keyStore:
type: static
static:
keys:
- publicKeyFile: /absolute/path/to/public.key
privateKeyFile: /absolute/path/to/private.key
keyId: some-custom-id
```
As long as all your nodes have this same config with the same set of keys, they
will now be able to successfully communicate with each other without touching the
database.
You'll note that the `keys` value is an array, which is useful for key rotation.
The first entry will always be used for signing, but any of the subsequent
entries will also be used for token validation. This lets you have a period of
time where tokens signed by the previous top entry are still accepted by
receivers, by just inserting your new key pair as the top entry and leaving the
old ones intact.
## Static Tokens
This access method consists of random static tokens that can be handed out to
+31
View File
@@ -33,6 +33,37 @@ export interface Config {
*/
dangerouslyDisableDefaultAuthPolicy?: boolean;
/** Controls how to store keys for plugin-to-plugin auth */
keyStore?:
| { type: 'database' }
| {
type: 'static';
static: {
/**
* Must be declared at least once and the first one will be used for signing.
*/
keys: Array<{
/**
* Path to the public key file in the SPKI format. Should be an absolute path.
*/
publicKeyFile: string;
/**
* Path to the matching private key file in the PKCS#8 format. Should be an absolute path.
*/
privateKeyFile: string;
/**
* ID to uniquely identify this key within the JWK set.
*/
keyId: string;
/**
* JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
* Must match the algorithm used to generate the keys in the provided files
*/
algorithm?: string;
}>;
};
};
/**
* Configures methods of external access, ie ways for callers outside of
* the Backstage ecosystem to get authorized for access to APIs that do
@@ -27,15 +27,15 @@ import { AuthenticationError, ForwardedError } from '@backstage/errors';
import { JsonObject } from '@backstage/types';
import { decodeJwt } from 'jose';
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { UserTokenHandler } from './user/UserTokenHandler';
import {
createCredentialsWithNonePrincipal,
createCredentialsWithServicePrincipal,
createCredentialsWithUserPrincipal,
toInternalBackstageCredentials,
} from './helpers';
import { KeyStore } from './types';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { PluginKeySource } from './plugin/keys/types';
import { UserTokenHandler } from './user/UserTokenHandler';
/** @internal */
export class DefaultAuthService implements AuthService {
@@ -46,7 +46,7 @@ export class DefaultAuthService implements AuthService {
private readonly tokenManager: TokenManager,
private readonly pluginId: string,
private readonly disableDefaultAuthPolicy: boolean,
private readonly publicKeyStore: KeyStore,
private readonly pluginKeySource: PluginKeySource,
) {}
// allowLimitedAccess is currently ignored, since we currently always use the full user tokens
@@ -209,7 +209,7 @@ export class DefaultAuthService implements AuthService {
}
async listPublicServiceKeys(): Promise<{ keys: JsonObject[] }> {
const { keys } = await this.publicKeyStore.listKeys();
const { keys } = await this.pluginKeySource.listKeys();
return { keys: keys.map(({ key }) => key) };
}
@@ -18,11 +18,11 @@ import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { DefaultAuthService } from './DefaultAuthService';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { UserTokenHandler } from './user/UserTokenHandler';
import { ExternalTokenHandler } from './external/ExternalTokenHandler';
import { PluginTokenHandler } from './plugin/PluginTokenHandler';
import { createPluginKeySource } from './plugin/keys/createPluginKeySource';
import { UserTokenHandler } from './user/UserTokenHandler';
/** @public */
export const authServiceFactory = createServiceFactory({
@@ -40,27 +40,32 @@ export const authServiceFactory = createServiceFactory({
tokenManager: coreServices.tokenManager,
},
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
const disableDefaultAuthPolicy = Boolean(
const disableDefaultAuthPolicy =
config.getOptionalBoolean(
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
),
);
) ?? false;
const publicKeyStore = await DatabaseKeyStore.create({
const keyDuration = { hours: 1 };
const keySource = await createPluginKeySource({
config,
database,
logger,
keyDuration,
});
const userTokens = UserTokenHandler.create({
discovery,
});
const pluginTokens = PluginTokenHandler.create({
ownPluginId: plugin.getId(),
keyDuration: { hours: 1 },
logger,
publicKeyStore,
keySource,
keyDuration,
discovery,
});
const externalTokens = ExternalTokenHandler.create({
ownPluginId: plugin.getId(),
config,
@@ -74,7 +79,7 @@ export const authServiceFactory = createServiceFactory({
tokenManager,
plugin.getId(),
disableDefaultAuthPolicy,
publicKeyStore,
keySource,
);
},
});
@@ -19,23 +19,36 @@ import { PluginTokenHandler } from './PluginTokenHandler';
import { decodeJwt } from 'jose';
describe('PluginTokenHandler', () => {
const mockPublicKey = {
kty: 'EC',
x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI',
y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro',
crv: 'P-256',
kid: 'mock',
alg: 'ES256',
};
const mockPrivateKey = {
...mockPublicKey,
d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI',
};
beforeEach(() => {
jest.useRealTimers();
});
it('issues token with correct expiration of token and generated key', async () => {
it('runs issueToken', async () => {
jest.useFakeTimers({
now: new Date(0),
});
const addKeyMock = jest.fn();
const getKeyMock = jest.fn(async () => mockPrivateKey);
const handler = PluginTokenHandler.create({
discovery: mockServices.discovery(),
keyDuration: { seconds: 10 },
logger: mockServices.logger.mock(),
ownPluginId: 'test',
publicKeyStore: {
addKey: addKeyMock,
keySource: {
getPrivateSigningKey: getKeyMock,
listKeys: jest.fn(),
},
});
@@ -45,38 +58,13 @@ describe('PluginTokenHandler', () => {
targetPluginId: 'other',
});
const payload = decodeJwt(token);
expect(payload.iat).toBe(0);
expect(payload.exp).toBe(10);
expect(addKeyMock).toHaveBeenCalledTimes(1);
expect(addKeyMock).toHaveBeenCalledWith({
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(30_000),
expect(payload).toMatchObject({
iat: 0,
exp: 10,
sub: 'test',
aud: 'other',
});
jest.advanceTimersByTime(5_000);
await handler.issueToken({
pluginId: 'test',
targetPluginId: 'other',
});
expect(addKeyMock).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10_000);
await handler.issueToken({
pluginId: 'test',
targetPluginId: 'other',
});
expect(addKeyMock).toHaveBeenCalledTimes(2);
expect(addKeyMock.mock.calls[0][0].id).not.toBe(
addKeyMock.mock.calls[1][0].id,
);
expect(addKeyMock).toHaveBeenNthCalledWith(2, {
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(45_000),
});
expect(getKeyMock).toHaveBeenCalledTimes(1);
});
});
@@ -15,51 +15,36 @@
*/
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import {
decodeJwt,
exportJWK,
generateKeyPair,
JWK,
importJWK,
SignJWT,
decodeProtectedHeader,
} from 'jose';
import { v4 as uuid } from 'uuid';
import { InternalKey, KeyStore } from '../types';
import { decodeJwt, importJWK, SignJWT, decodeProtectedHeader } from 'jose';
import { AuthenticationError } from '@backstage/errors';
import { jwtVerify } from 'jose';
import { tokenTypes } from '@backstage/plugin-auth-node';
import { JwksClient } from '../JwksClient';
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
import { PluginKeySource } from './keys/types';
/**
* The margin for how many times longer we make the public key available
* compared to how long we use the private key to sign new tokens.
*/
const KEY_EXPIRATION_MARGIN_FACTOR = 3;
const SECONDS_IN_MS = 1000;
const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
type Options = {
ownPluginId: string;
publicKeyStore: KeyStore;
keyDuration: HumanDuration;
keySource: PluginKeySource;
discovery: DiscoveryService;
logger: LoggerService;
/** Expiration time of signing keys */
keyDuration: HumanDuration;
/** JWS "alg" (Algorithm) Header Parameter value. Defaults to ES256.
/**
* 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 */
* 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
@@ -70,9 +55,9 @@ export class PluginTokenHandler {
return new PluginTokenHandler(
options.logger,
options.ownPluginId,
options.publicKeyStore,
Math.round(durationToMilliseconds(options.keyDuration) / 1000),
options.keySource,
options.algorithm ?? 'ES256',
Math.round(durationToMilliseconds(options.keyDuration) / 1000),
options.discovery,
);
}
@@ -80,9 +65,9 @@ export class PluginTokenHandler {
private constructor(
private readonly logger: LoggerService,
private readonly ownPluginId: string,
private readonly publicKeyStore: KeyStore,
private readonly keyDurationSeconds: number,
private readonly keySource: PluginKeySource,
private readonly algorithm: string,
private readonly keyDurationSeconds: number,
private readonly discovery: DiscoveryService,
) {}
@@ -132,7 +117,7 @@ export class PluginTokenHandler {
onBehalfOf?: { token: string; expiresAt: Date };
}): Promise<{ token: string }> {
const { pluginId, targetPluginId, onBehalfOf } = options;
const key = await this.getKey();
const key = await this.keySource.getPrivateSigningKey();
const sub = pluginId;
const aud = targetPluginId;
@@ -229,65 +214,4 @@ export class PluginTokenHandler {
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 && this.keyExpiry.getTime() > Date.now()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
this.keyExpiry = new Date(
Date.now() + this.keyDurationSeconds * SECONDS_IN_MS,
);
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: new Date(
Date.now() +
this.keyDurationSeconds *
SECONDS_IN_MS *
KEY_EXPIRATION_MARGIN_FACTOR,
),
});
// 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;
}
}
@@ -34,6 +34,8 @@ const testKey2 = {
n: 'test',
};
jest.setTimeout(60_000);
describe('DatabaseKeyStore', () => {
const databases = TestDatabases.create();
@@ -19,12 +19,13 @@ import {
LoggerService,
resolvePackagePath,
} from '@backstage/backend-plugin-api';
import { DateTime } from 'luxon';
import { Knex } from 'knex';
import { JsonObject } from '@backstage/types';
import { Knex } from 'knex';
import { DateTime } from 'luxon';
import { KeyStore } from './types';
const MIGRATIONS_TABLE = 'backstage_backend_public_keys__knex_migrations';
/** @internal */
export const TABLE = 'backstage_backend_public_keys__keys';
@@ -0,0 +1,77 @@
/*
* 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 { mockServices } from '@backstage/backend-test-utils';
import { DatabasePluginKeySource } from './DatabasePluginKeySource';
describe('DatabasePluginKeySource', () => {
beforeEach(() => {
jest.useRealTimers();
});
it('issues token with correct expiration of token and generated key', async () => {
jest.useFakeTimers({
now: new Date(0),
});
const keyStore = {
addKey: jest.fn(),
listKeys: jest.fn(),
};
const source = new DatabasePluginKeySource(
keyStore,
mockServices.logger.mock(),
10,
'ES256',
);
const key = await source.getPrivateSigningKey();
expect(key).toMatchObject({
alg: 'ES256',
d: expect.any(String),
});
expect(keyStore.addKey).toHaveBeenCalledTimes(1);
expect(keyStore.addKey).toHaveBeenCalledWith({
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(30_000),
});
jest.advanceTimersByTime(5_000);
let newKey = await source.getPrivateSigningKey();
expect(keyStore.addKey).toHaveBeenCalledTimes(1);
expect(newKey).toBe(key);
jest.advanceTimersByTime(10_000);
newKey = await source.getPrivateSigningKey();
expect(keyStore.addKey).toHaveBeenCalledTimes(2);
expect(newKey).not.toBe(key);
expect(keyStore.addKey.mock.calls[0][0].id).not.toBe(
keyStore.addKey.mock.calls[1][0].id,
);
expect(keyStore.addKey).toHaveBeenNthCalledWith(2, {
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(45_000),
});
});
});
@@ -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 { DatabaseService, LoggerService } from '@backstage/backend-plugin-api';
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
import { JWK, exportJWK, generateKeyPair } from 'jose';
import { v4 as uuid } from 'uuid';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { InternalKey, KeyPayload, KeyStore } from './types';
import { PluginKeySource } from './types';
const SECONDS_IN_MS = 1000;
/**
* The margin for how many times longer we make the public key available
* compared to how long we use the private key to sign new tokens.
*/
const KEY_EXPIRATION_MARGIN_FACTOR = 3;
export class DatabasePluginKeySource implements PluginKeySource {
private privateKeyPromise?: Promise<JWK>;
private keyExpiry?: Date;
constructor(
private readonly keyStore: KeyStore,
private readonly logger: LoggerService,
private readonly keyDurationSeconds: number,
private readonly algorithm: string,
) {}
public static async create(options: {
logger: LoggerService;
database: DatabaseService;
keyDuration: HumanDuration;
algorithm?: string;
}): Promise<PluginKeySource> {
const keyStore = await DatabaseKeyStore.create({
database: options.database,
logger: options.logger,
});
return new DatabasePluginKeySource(
keyStore,
options.logger,
Math.round(durationToMilliseconds(options.keyDuration) / 1000),
options.algorithm ?? 'ES256',
);
}
async getPrivateSigningKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
this.keyExpiry = new Date(
Date.now() + this.keyDurationSeconds * SECONDS_IN_MS,
);
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.keyStore.addKey({
id: kid,
key: publicKey as InternalKey,
expiresAt: new Date(
Date.now() +
this.keyDurationSeconds *
SECONDS_IN_MS *
KEY_EXPIRATION_MARGIN_FACTOR,
),
});
// 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;
}
listKeys(): Promise<{ keys: KeyPayload[] }> {
return this.keyStore.listKeys();
}
}
@@ -0,0 +1,92 @@
/*
* Copyright 2023 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 { StaticConfigPluginKeySource } from './StaticConfigPluginKeySource';
import { Config, ConfigReader } from '@backstage/config';
import { createMockDirectory } from '@backstage/backend-test-utils';
const privateKey = `
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY
Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm
hQRjvYdO+Mg/3FJss6FaExESTzhPSr3X+be/exarkTMchbDXNEdCKwpn
-----END PRIVATE KEY-----
`.trim();
const publicKey = `
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92
ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw==
-----END PUBLIC KEY-----
`.trim();
describe('StaticConfigPluginKeySource', () => {
let sourceConfig: Config;
const sourceDir = createMockDirectory();
beforeAll(() => {
sourceDir.setContent({
'public.pem': publicKey,
'private.pem': privateKey,
});
const publicKeyPath = sourceDir.resolve('public.pem');
const privateKeyPath = sourceDir.resolve('private.pem');
sourceConfig = new ConfigReader({
type: 'static',
static: {
keys: [
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '1',
algorithm: 'ES256',
},
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '2',
// skipping explicit alg
},
],
},
});
});
it('should provide keys from disk', async () => {
const staticKeyStore = await StaticConfigPluginKeySource.create({
sourceConfig,
keyDuration: { hours: 1 },
});
const keys = await staticKeyStore.listKeys();
expect(keys.keys.length).toEqual(2);
expect(keys.keys[0].key).toMatchObject({
kid: '1',
alg: 'ES256',
});
expect(keys.keys[1].key).toMatchObject({
kid: '2',
alg: 'ES256',
});
const pk = await staticKeyStore.getPrivateSigningKey();
expect(pk).toMatchObject({
kid: '1',
alg: 'ES256',
});
expect(pk.d).toBeDefined();
});
});
@@ -0,0 +1,177 @@
/*
* 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 { Config } from '@backstage/config';
import { HumanDuration, durationToMilliseconds } from '@backstage/types';
import { promises as fs } from 'fs';
import { JWK, exportJWK, importPKCS8, importSPKI } from 'jose';
import { KeyLike } from 'jose/dist/types/types';
import { KeyPayload } from './types';
import { PluginKeySource } from './types';
export type KeyPair = {
publicKey: JWK;
privateKey: JWK;
keyId: string;
};
export type StaticKeyConfig = {
publicKeyFile: string;
privateKeyFile: string;
keyId: string;
algorithm: string;
};
const DEFAULT_ALGORITHM = 'ES256';
const SECONDS_IN_MS = 1000;
/**
* Key source that loads predefined public/private key pairs from disk.
*
* The private key should be represented using the PKCS#8 format,
* while the public key should be in the SPKI format.
*
* @remarks
*
* You can generate a public and private key pair, using
* openssl:
*
* Generate a private key using the ES256 algorithm
* ```sh
* openssl ecparam -name prime256v1 -genkey -out private.ec.key
* ```
* Convert it to PKCS#8 format
* ```sh
* openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key
* ```
* Extract the public key
* ```sh
* openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key
* ```
*
* Provide the paths to private.key and public.key as the respective
* private and public key paths in the `create` method.
*/
export class StaticConfigPluginKeySource implements PluginKeySource {
private constructor(
private readonly keyPairs: KeyPair[],
private readonly keyDurationSeconds: number,
) {}
public static async create(options: {
sourceConfig: Config;
keyDuration: HumanDuration;
}): Promise<PluginKeySource> {
const keyConfigs = options.sourceConfig
.getConfigArray('static.keys')
.map(c => {
const staticKeyConfig: StaticKeyConfig = {
publicKeyFile: c.getString('publicKeyFile'),
privateKeyFile: c.getString('privateKeyFile'),
keyId: c.getString('keyId'),
algorithm: c.getOptionalString('algorithm') ?? DEFAULT_ALGORITHM,
};
return staticKeyConfig;
});
const keyPairs = await Promise.all(
keyConfigs.map(async k => await this.loadKeyPair(k)),
);
if (keyPairs.length < 1) {
throw new Error(
'At least one key pair must be provided in static.keys, when the static key store type is used',
);
}
return new StaticConfigPluginKeySource(
keyPairs,
durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS,
);
}
async getPrivateSigningKey(): Promise<JWK> {
return this.keyPairs[0].privateKey;
}
async listKeys(): Promise<{ keys: KeyPayload[] }> {
const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k));
return { keys };
}
private static async loadKeyPair(options: StaticKeyConfig): Promise<KeyPair> {
const algorithm = options.algorithm;
const keyId = options.keyId;
const publicKey = await this.loadPublicKeyFromFile(
options.publicKeyFile,
keyId,
algorithm,
);
const privateKey = await this.loadPrivateKeyFromFile(
options.privateKeyFile,
keyId,
algorithm,
);
return { publicKey, privateKey, keyId };
}
private static async loadPublicKeyFromFile(
path: string,
keyId: string,
algorithm: string,
): Promise<JWK> {
return this.loadKeyFromFile(path, keyId, algorithm, importSPKI);
}
private static async loadPrivateKeyFromFile(
path: string,
keyId: string,
algorithm: string,
): Promise<JWK> {
return this.loadKeyFromFile(path, keyId, algorithm, importPKCS8);
}
private static async loadKeyFromFile(
path: string,
keyId: string,
algorithm: string,
importer: (content: string, algorithm: string) => Promise<KeyLike>,
): Promise<JWK> {
const content = await fs.readFile(path, { encoding: 'utf8', flag: 'r' });
const key = await importer(content, algorithm);
const jwk = await exportJWK(key);
jwk.kid = keyId;
jwk.alg = algorithm;
return jwk;
}
private keyPairToStoredKey(keyPair: KeyPair): KeyPayload {
const publicKey = {
...keyPair.publicKey,
kid: keyPair.keyId,
};
return {
key: publicKey,
id: keyPair.keyId,
expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS),
};
}
}
@@ -0,0 +1,170 @@
/*
* 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 { createPluginKeySource } from './createPluginKeySource';
import {
TestDatabases,
createMockDirectory,
mockServices,
} from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
jest.setTimeout(60_000);
describe('createPluginKeySource', () => {
const databases = TestDatabases.create();
const mockDir = createMockDirectory();
it.each(databases.eachSupportedId())(
'works for implicit database (no config), %p',
async databaseId => {
const knex = await databases.init(databaseId);
const getClient = jest.fn(async () => knex);
const source = await createPluginKeySource({
config: new ConfigReader({}),
database: mockServices.database.mock({ getClient }),
logger: mockServices.logger.mock(),
keyDuration: { seconds: 10 },
});
expect(getClient).toHaveBeenCalled();
const key = await source.getPrivateSigningKey();
expect(key).toMatchObject({
alg: 'ES256',
d: expect.any(String),
});
const keys = await source.listKeys();
expect(keys).toEqual({
keys: [
expect.objectContaining({
id: key.kid,
key: expect.objectContaining({
kid: key.kid,
}),
}),
],
});
},
);
it.each(databases.eachSupportedId())(
'works for explicit database, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const getClient = jest.fn(async () => knex);
const source = await createPluginKeySource({
config: new ConfigReader({
backend: { auth: { keyStore: { type: 'database' } } },
}),
database: mockServices.database.mock({ getClient }),
logger: mockServices.logger.mock(),
keyDuration: { seconds: 10 },
});
expect(getClient).toHaveBeenCalled();
const key = await source.getPrivateSigningKey();
expect(key).toMatchObject({
alg: 'ES256',
d: expect.any(String),
});
const keys = await source.listKeys();
expect(keys).toEqual({
keys: [
expect.objectContaining({
id: key.kid,
key: expect.objectContaining({
kid: key.kid,
}),
}),
],
});
},
);
it('works for static', async () => {
const privateKey = `
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR8Ja2ppMEgOm1KeY
Kpje00U1luybndt6yC263vcgeKqhRANCAAS+slUrS9JXgtHB1RcDnmlveuu4H3Zm
hQRjvYdO+Mg/3FJss6FaExESTzhPSr3X+be/exarkTMchbDXNEdCKwpn
-----END PRIVATE KEY-----
`.trim();
const publicKey = `
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvrJVK0vSV4LRwdUXA55pb3rruB92
ZoUEY72HTvjIP9xSbLOhWhMREk84T0q91/m3v3sWq5EzHIWw1zRHQisKZw==
-----END PUBLIC KEY-----
`.trim();
mockDir.setContent({
'public.pem': publicKey,
'private.pem': privateKey,
});
const publicKeyPath = mockDir.resolve('public.pem');
const privateKeyPath = mockDir.resolve('private.pem');
const getClient = jest.fn();
const source = await createPluginKeySource({
config: new ConfigReader({
backend: {
auth: {
keyStore: {
type: 'static',
static: {
keys: [
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '1',
},
],
},
},
},
},
}),
database: mockServices.database.mock({ getClient }),
logger: mockServices.logger.mock(),
keyDuration: { seconds: 10 },
});
expect(getClient).not.toHaveBeenCalled();
const keys = await source.listKeys();
expect(keys.keys.length).toEqual(1);
expect(keys.keys[0].key).toMatchObject({
kid: '1',
alg: 'ES256',
});
const pk = await source.getPrivateSigningKey();
expect(pk).toMatchObject({
kid: '1',
alg: 'ES256',
d: expect.any(String),
});
});
});
@@ -0,0 +1,56 @@
/*
* 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,
RootConfigService,
} from '@backstage/backend-plugin-api';
import { HumanDuration } from '@backstage/types';
import { DatabasePluginKeySource } from './DatabasePluginKeySource';
import { StaticConfigPluginKeySource } from './StaticConfigPluginKeySource';
import { PluginKeySource } from './types';
const CONFIG_ROOT_KEY = 'backend.auth.keyStore';
export async function createPluginKeySource(options: {
config: RootConfigService;
database: DatabaseService;
logger: LoggerService;
keyDuration: HumanDuration;
algorithm?: string;
}): Promise<PluginKeySource> {
const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY);
const type = keyStoreConfig?.getOptionalString('type') ?? 'database';
if (!keyStoreConfig || type === 'database') {
return DatabasePluginKeySource.create({
database: options.database,
logger: options.logger,
keyDuration: options.keyDuration,
algorithm: options.algorithm,
});
} else if (type === 'static') {
return StaticConfigPluginKeySource.create({
sourceConfig: keyStoreConfig,
keyDuration: options.keyDuration,
});
}
throw new Error(
`Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`,
);
}
@@ -0,0 +1,36 @@
/*
* 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 { JWK } from 'jose';
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 };
export interface PluginKeySource {
getPrivateSigningKey(): Promise<JWK>;
listKeys(): Promise<{ keys: KeyPayload[] }>;
}
@@ -15,20 +15,6 @@
*/
import { BackstageCredentials } from '@backstage/backend-plugin-api';
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 };
/** @internal */
export type InternalBackstageCredentials<TPrincipal = unknown> =