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:
@@ -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
|
||||
```
|
||||
@@ -170,3 +170,6 @@ knip.json
|
||||
# Schemathesis temporary files
|
||||
.hypothesis/
|
||||
.cassettes/
|
||||
|
||||
# Locally generated keys
|
||||
*.key
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+31
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+23
-35
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-89
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ const testKey2 = {
|
||||
n: 'test',
|
||||
};
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('DatabaseKeyStore', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
+3
-2
@@ -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';
|
||||
|
||||
+77
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
+127
@@ -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();
|
||||
}
|
||||
}
|
||||
+92
@@ -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();
|
||||
});
|
||||
});
|
||||
+177
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
@@ -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> =
|
||||
|
||||
Reference in New Issue
Block a user