feat: add static token issuer
Signed-off-by: rtriesscheijn <rtriesscheijn@bol.com>
This commit is contained in:
@@ -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.
|
||||
@@ -258,6 +258,7 @@ onboarding
|
||||
Onboarding
|
||||
OpenSearch
|
||||
OpenShift
|
||||
openssl
|
||||
orgs
|
||||
padding
|
||||
paddings
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+16
-1
@@ -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;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user