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
+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));