feat: add static token issuer

Signed-off-by: rtriesscheijn <rtriesscheijn@bol.com>
This commit is contained in:
rtriesscheijn
2023-10-16 10:48:48 +02:00
parent 78bc50af22
commit bdf08ad04a
11 changed files with 611 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
Adds the StaticTokenIssuer and StaticKeyStore, an alternative token issuer that can be used to sign the Authorization header using a predefined public/private key pair.
+1
View File
@@ -258,6 +258,7 @@ onboarding
Onboarding
OpenSearch
OpenShift
openssl
orgs
padding
paddings
+47
View File
@@ -406,3 +406,50 @@ providerFactories: {
ghe: providers.github.create(),
},
```
## Configuring token issuers
By default, the Backstage authentication backend generates and manages its own signing keys automatically for any issued
Backstage tokens. However, these keys have a short lifetime and do not persist after instance restarts.
Alternatively, users can provide their own public and private key files to sign issued tokens. This is beneficial in
scenarios where the token verification implementation aggressively caches the list of keys, and doesn't attempt to fetch
new ones even if they encounter an unknown key id. To enable this feature add the following configuration to your config
file:
```yaml
auth:
keyStore:
provider: 'static'
static:
keys:
# Must be declared at least once and the first one will be used for signing */
- keyId: 'primary'
publicKeyFile: /path/to/public.key
privateKeyFile: /path/to/private.key
algorithm: # Optional, algorithm used to generate the keys, defaults to ES256
# More keys can be added so with future key rotations caches already know about it
- keyId: ...
```
The private key should be stored in the PKCS#8 format. The public key should be stored in the SPKI format.
You can generate the public/private key pair, using openssl and the ES256 algorithm by performing the following
steps:
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
```
+4
View File
@@ -158,6 +158,10 @@ To try out SAML, you can use the mock identity provider:
[How to add an auth provider](https://github.com/backstage/backstage/blob/master/docs/auth/add-auth-provider.md)
## Token issuers
[Configuring token issuers](https://github.com/backstage/backstage/blob/master/docs/auth/index.md)
## Links
- [The Backstage homepage](https://backstage.io)
+16 -1
View File
@@ -43,7 +43,7 @@ export interface Config {
/** To control how to store JWK data in auth-backend */
keyStore?: {
provider?: 'database' | 'memory' | 'firestore';
provider?: 'database' | 'memory' | 'firestore' | 'static';
firestore?: {
/** The host to connect to */
host?: string;
@@ -65,6 +65,21 @@ export interface Config {
/** Timeout used for database operations. Defaults to 10000ms */
timeout?: number;
};
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 */
publicKeyFile: string;
/** Path to the matching private key file in the PKCS#8 format */
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;
}>;
};
};
/**
+10 -1
View File
@@ -18,12 +18,12 @@ import { pickBy } from 'lodash';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { AuthDatabase } from '../database/AuthDatabase';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { MemoryKeyStore } from './MemoryKeyStore';
import { KeyStore } from './types';
import { StaticKeyStore, StaticKeyStoreConfig } from './StaticKeyStore';
type Options = {
logger: LoggerService;
@@ -75,6 +75,15 @@ export class KeyStores {
return keyStore;
}
if (provider === 'static') {
const settings = ks?.getOptional<StaticKeyStoreConfig>('static');
if (settings === undefined) {
throw new Error(`Missing configuration for static keyStore provider`);
}
await StaticKeyStore.create(settings);
}
throw new Error(`Unknown KeyStore provider: ${provider}`);
}
}
@@ -0,0 +1,115 @@
/*
* 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 { promises as fs } from 'fs';
import { StaticKeyStore, StaticKeyStoreConfig } from './StaticKeyStore';
import { AnyJWK } from './types';
const publicKeyPath = '/mnt/public.pem';
const privateKeyPath = 'mnt/private.pem';
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();
jest.mock('fs/promises');
describe('StaticKeyStore', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should provide keys from disk', async () => {
fs.readFile = jest.fn().mockImplementation(async (path: string, _: any) => {
if (path === publicKeyPath) {
return Promise.resolve(publicKey);
}
if (path === privateKeyPath) {
return Promise.resolve(privateKey);
}
throw new Error('Unexpected path');
});
const staticKeyStoreConfig: StaticKeyStoreConfig = {
keys: [
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '1',
algorithm: 'ES256',
},
{
publicKeyFile: publicKeyPath,
privateKeyFile: privateKeyPath,
keyId: '2',
algorithm: 'ES256',
},
],
};
const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig);
const keys = await staticKeyStore.listKeys();
expect(keys.items.length).toEqual(2);
expect(keys.items[0].key).toMatchObject({
kid: '1',
alg: 'ES256',
});
expect(keys.items[1].key).toMatchObject({
kid: '2',
alg: 'ES256',
});
const pk = staticKeyStore.getPrivateKey('1');
expect(pk).toMatchObject({
kid: '1',
alg: 'ES256',
});
expect(pk.d).toBeDefined();
});
it('should not allow users to add keys', async () => {
const staticKeyStoreConfig: StaticKeyStoreConfig = { keys: [] };
const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig);
const key: AnyJWK = {
use: 'sig',
alg: 'ES256',
kid: '1',
kty: '1',
};
expect(() => staticKeyStore.addKey(key)).toThrow(
'Cannot add keys to the static key store',
);
});
it('should not allow users to remove keys', async () => {
const staticKeyStoreConfig: StaticKeyStoreConfig = { keys: [] };
const staticKeyStore = await StaticKeyStore.create(staticKeyStoreConfig);
expect(() => staticKeyStore.removeKeys(['1'])).toThrow(
'Cannot remove keys from the static key store',
);
});
});
@@ -0,0 +1,166 @@
/*
* 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 { AnyJWK, KeyStore, StoredKey } from './types';
import { exportJWK, importPKCS8, importSPKI, JWK } from 'jose';
import { KeyLike } from 'jose/dist/types/types';
import { promises as fs } from 'fs';
export type KeyPair = {
publicKey: JWK;
privateKey: JWK;
};
export type StaticKeyConfig = {
publicKeyFile: string;
privateKeyFile: string;
keyId: string;
algorithm?: string;
};
export type StaticKeyStoreConfig = {
keys: StaticKeyConfig[];
};
const DEFAULT_ALGORITHM = 'ES256';
/**
* Key store 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 StaticKeyStore.create(...) method.
*/
export class StaticKeyStore implements KeyStore {
private readonly keyPairs: KeyPair[];
private readonly createdAt: Date;
private constructor(keyPairs: KeyPair[]) {
this.keyPairs = keyPairs;
this.createdAt = new Date();
}
public static async create(
config: StaticKeyStoreConfig,
): Promise<StaticKeyStore> {
const keyPairs = await Promise.all(
config.keys.map(async k => await this.loadKeyPair(k)),
);
return new StaticKeyStore(keyPairs);
}
addKey(_key: AnyJWK): Promise<void> {
throw new Error('Cannot add keys to the static key store');
}
listKeys(): Promise<{ items: StoredKey[] }> {
const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k));
return Promise.resolve({ items: keys });
}
getPrivateKey(keyId: string): JWK {
const keyPair = this.keyPairs.find(k => k.publicKey.kid === keyId);
if (keyPair === undefined) {
throw new Error(`Could not find key with keyId: ${keyId}`);
}
return keyPair.privateKey;
}
removeKeys(_kids: string[]): Promise<void> {
throw new Error('Cannot remove keys from the static key store');
}
private keyPairToStoredKey(keyPair: KeyPair): StoredKey {
const publicKey = {
...keyPair.publicKey,
use: 'sig',
};
return {
key: publicKey as AnyJWK,
createdAt: this.createdAt,
};
}
private static async loadKeyPair(options: StaticKeyConfig): Promise<KeyPair> {
const algorithm = options.algorithm ?? DEFAULT_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 };
}
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;
}
}
@@ -0,0 +1,116 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { StaticTokenIssuer } from './StaticTokenIssuer';
import { createLocalJWKSet, jwtVerify } from 'jose';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { StaticKeyStore } from './StaticKeyStore';
const logger = getVoidLogger();
const entityRef = stringifyEntityRef({
kind: 'User',
namespace: 'default',
name: 'name',
});
jest.mock('fs/promises');
describe('StaticTokenIssuer', () => {
it('should issue valid tokens signed by the first listed key', async () => {
const staticKeyStore = {
listKeys: () => {
return Promise.resolve({
items: [
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '1',
alg: 'ES256',
use: 'sig',
},
},
{
createdAt: new Date(),
key: {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
kid: '2',
alg: 'ES256',
use: 'sig',
},
},
],
});
},
getPrivateKey: (kid: string) => {
expect(kid).toEqual('1');
return {
kty: 'EC',
x: 'vrJVK0vSV4LRwdUXA55pb3rruB92ZoUEY72HTvjIP9w',
y: 'UmyzoVoTERJPOE9Kvdf5t797FquRMxyFsNc0R0IrCmc',
crv: 'P-256',
d: 'R8Ja2ppMEgOm1KeYKpje00U1luybndt6yC263vcgeKo',
kid: '1',
alg: 'ES256',
};
},
};
const keyDurationSeconds = 86400;
const options = {
logger,
issuer: 'my-issuer',
sessionExpirationSeconds: keyDurationSeconds,
};
const issuer = new StaticTokenIssuer(
options,
staticKeyStore as unknown as StaticKeyStore,
);
const token = await issuer.issueToken({
claims: {
sub: entityRef,
ent: [entityRef],
'x-fancy-claim': 'my special claim',
aud: 'this value will be overridden',
},
});
const { keys } = await issuer.listPublicKeys();
const keyStore = createLocalJWKSet({ keys: keys });
const verifyResult = await jwtVerify(token, keyStore);
expect(verifyResult.protectedHeader).toEqual({
kid: '1',
alg: 'ES256',
});
expect(verifyResult.payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: entityRef,
ent: [entityRef],
'x-fancy-claim': 'my special claim',
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(verifyResult.payload.exp).toBe(
verifyResult.payload.iat! + keyDurationSeconds,
);
});
});
@@ -0,0 +1,105 @@
/*
* 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 { AnyJWK, TokenIssuer, TokenParams } from './types';
import { SignJWT, importJWK, JWK } from 'jose';
import { parseEntityRef } from '@backstage/catalog-model';
import { AuthenticationError } from '@backstage/errors';
import { LoggerService } from '@backstage/backend-plugin-api';
import { StaticKeyStore } from './StaticKeyStore';
const MS_IN_S = 1000;
export type Config = {
publicKeyFile: string;
privateKeyFile: string;
keyId: string;
algorithm?: string;
};
export type Options = {
logger: LoggerService;
/** Value of the issuer claim in issued tokens */
issuer: string;
/** Expiration time of the JWT in seconds */
sessionExpirationSeconds: number;
/** id to uniquely identify this key within the JWK set, defaults to '1' */
};
/**
* A token issuer that issues tokens from predefined
* public/private key pair stored in the static key store.
*/
export class StaticTokenIssuer implements TokenIssuer {
private readonly issuer: string;
private readonly logger: LoggerService;
private readonly keyStore: StaticKeyStore;
private readonly sessionExpirationSeconds: number;
public constructor(options: Options, keyStore: StaticKeyStore) {
this.issuer = options.issuer;
this.logger = options.logger;
this.sessionExpirationSeconds = options.sessionExpirationSeconds;
this.keyStore = keyStore;
}
public async issueToken(params: TokenParams): Promise<string> {
const key = await this.getSigningKey();
// TODO: code shared with TokenFactory.ts
const iss = this.issuer;
const { sub, ent, ...additionalClaims } = params.claims;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.sessionExpirationSeconds;
// Validate that the subject claim is a valid EntityRef
try {
parseEntityRef(sub);
} catch (error) {
throw new Error(
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
);
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
if (!key.alg) {
throw new AuthenticationError('No algorithm was provided in the key');
}
return new SignJWT({ ...additionalClaims, iss, sub, ent, aud, iat, exp })
.setProtectedHeader({ alg: key.alg, kid: key.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(await importJWK(key));
}
private async getSigningKey(): Promise<JWK> {
const { items: keys } = await this.keyStore.listKeys();
if (keys.length >= 1) {
return this.keyStore.getPrivateKey(keys[0].key.kid);
}
throw new Error('Keystore should hold at least 1 key');
}
public async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
const { items: keys } = await this.keyStore.listKeys();
return { keys: keys.map(({ key }) => key) };
}
}
+26 -11
View File
@@ -38,6 +38,9 @@ import { Minimatch } from 'minimatch';
import { CatalogAuthResolverContext } from '../lib/resolvers';
import { AuthDatabase } from '../database/AuthDatabase';
import { BACKSTAGE_SESSION_EXPIRATION } from '../lib/session';
import { TokenIssuer } from '../identity/types';
import { StaticTokenIssuer } from '../identity/StaticTokenIssuer';
import { StaticKeyStore } from '../identity/StaticKeyStore';
/** @public */
export type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -75,22 +78,34 @@ export async function createRouter(
const authUrl = await discovery.getExternalBaseUrl('auth');
const authDb = AuthDatabase.create(database);
const sessionExpirationSeconds = BACKSTAGE_SESSION_EXPIRATION;
const keyStore = await KeyStores.fromConfig(config, {
logger,
database: authDb,
});
const keyDurationSeconds = BACKSTAGE_SESSION_EXPIRATION;
const tokenIssuer = new TokenFactory({
issuer: authUrl,
keyStore,
keyDurationSeconds,
logger: logger.child({ component: 'token-factory' }),
algorithm:
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
});
let tokenIssuer: TokenIssuer;
if (keyStore instanceof StaticKeyStore) {
tokenIssuer = new StaticTokenIssuer(
{
logger: logger.child({ component: 'token-factory' }),
issuer: authUrl,
sessionExpirationSeconds: sessionExpirationSeconds,
},
keyStore as StaticKeyStore,
);
} else {
tokenIssuer = new TokenFactory({
issuer: authUrl,
keyStore,
keyDurationSeconds: sessionExpirationSeconds,
logger: logger.child({ component: 'token-factory' }),
algorithm:
tokenFactoryAlgorithm ??
config.getOptionalString('auth.identityTokenAlgorithm'),
});
}
const secret = config.getOptionalString('auth.session.secret');
if (secret) {
router.use(cookieParser(secret));