chore: Move JWK generation to separate class

This commit is contained in:
Joachim Van Herwegen
2022-10-13 09:58:03 +02:00
parent 37ba404058
commit bc119dbd3e
11 changed files with 264 additions and 55 deletions

View File

@@ -0,0 +1,74 @@
import { createPublicKey } from 'crypto';
import type { KeyObject } from 'crypto';
import { exportJWK, generateKeyPair, importJWK } from 'jose';
import type { AsymmetricSigningAlgorithm } from 'oidc-provider';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import type { AlgJwk, JwkGenerator } from './JwkGenerator';
/**
* Generates a key pair once and then caches it using both an internal variable and a {@link KeyValueStorage}.
* The storage makes sure the keys remain the same between server restarts,
* while the internal variable makes it so the storage doesn't have to be accessed every time a key is needed.
*
* Only the private key is stored in the internal storage, using the `storageKey` parameter.
* The public key is determined based on the private key and then also stored in memory.
*/
export class CachedJwkGenerator implements JwkGenerator {
public readonly alg: AsymmetricSigningAlgorithm;
private readonly key: string;
private readonly storage: KeyValueStorage<string, AlgJwk>;
private privateJwk?: AlgJwk;
private publicJwk?: AlgJwk;
public constructor(alg: AsymmetricSigningAlgorithm, storageKey: string, storage: KeyValueStorage<string, AlgJwk>) {
this.alg = alg;
this.key = storageKey;
this.storage = storage;
}
public async getPrivateKey(): Promise<AlgJwk> {
if (this.privateJwk) {
return this.privateJwk;
}
const jwk = await this.storage.get(this.key);
if (jwk) {
this.privateJwk = jwk;
return jwk;
}
const { privateKey } = await generateKeyPair(this.alg);
// Make sure the JWK is a plain node object for storage
const privateJwk = { ...await exportJWK(privateKey) } as AlgJwk;
privateJwk.alg = this.alg;
await this.storage.set(this.key, privateJwk);
this.privateJwk = privateJwk;
return privateJwk;
}
public async getPublicKey(): Promise<AlgJwk> {
if (this.publicJwk) {
return this.publicJwk;
}
const privateJwk = await this.getPrivateKey();
// The main reason we generate the public key from the private key is, so we don't have to store it.
// This allows our storage to not break previous versions where we only used the private key.
// In practice this results in the same key.
const privateKey = await importJWK(privateJwk);
const publicKey = createPublicKey(privateKey as KeyObject);
const publicJwk = { ...await exportJWK(publicKey) } as AlgJwk;
// These fields get lost during the above proces
publicJwk.alg = privateJwk.alg;
this.publicJwk = publicJwk;
return publicJwk;
}
}

View File

@@ -2,10 +2,9 @@
// import/no-unresolved can't handle jose imports
// tsdoc/syntax can't handle {json} parameter
import { randomBytes } from 'crypto';
import type { JWK } from 'jose';
import { exportJWK, generateKeyPair } from 'jose';
import type { Account,
Adapter,
AsymmetricSigningAlgorithm,
Configuration,
ErrorOut,
KoaContextWithOIDC,
@@ -26,6 +25,7 @@ import { joinUrl } from '../../util/PathUtil';
import type { ClientCredentials } from '../interaction/email-password/credentials/ClientCredentialsAdapterFactory';
import type { InteractionHandler } from '../interaction/InteractionHandler';
import type { AdapterFactory } from '../storage/AdapterFactory';
import type { AlgJwk, JwkGenerator } from './JwkGenerator';
import type { ProviderFactory } from './ProviderFactory';
export interface IdentityProviderFactoryArgs {
@@ -50,9 +50,13 @@ export interface IdentityProviderFactoryArgs {
*/
credentialStorage: KeyValueStorage<string, ClientCredentials>;
/**
* Storage used to store cookie and JWT keys so they can be re-used in case of multithreading.
* Storage used to store cookie keys so they can be re-used in case of multithreading.
*/
storage: KeyValueStorage<string, unknown>;
/**
* Generates the JWK used for signing and decryption.
*/
jwkGenerator: JwkGenerator;
/**
* Extra information will be added to the error output if this is true.
*/
@@ -67,7 +71,6 @@ export interface IdentityProviderFactoryArgs {
responseWriter: ResponseWriter;
}
const JWKS_KEY = 'jwks';
const COOKIES_KEY = 'cookie-secret';
/**
@@ -87,11 +90,11 @@ export class IdentityProviderFactory implements ProviderFactory {
private readonly interactionHandler: InteractionHandler;
private readonly credentialStorage: KeyValueStorage<string, ClientCredentials>;
private readonly storage: KeyValueStorage<string, unknown>;
private readonly jwkGenerator: JwkGenerator;
private readonly showStackTrace: boolean;
private readonly errorHandler: ErrorHandler;
private readonly responseWriter: ResponseWriter;
private readonly jwtAlg = 'ES256';
private provider?: Provider;
/**
@@ -107,6 +110,7 @@ export class IdentityProviderFactory implements ProviderFactory {
this.interactionHandler = args.interactionHandler;
this.credentialStorage = args.credentialStorage;
this.storage = args.storage;
this.jwkGenerator = args.jwkGenerator;
this.showStackTrace = args.showStackTrace;
this.errorHandler = args.errorHandler;
this.responseWriter = args.responseWriter;
@@ -124,10 +128,12 @@ export class IdentityProviderFactory implements ProviderFactory {
* Creates a Provider by building a Configuration using all the stored parameters.
*/
private async createProvider(): Promise<Provider> {
const config = await this.initConfig();
const key = await this.jwkGenerator.getPrivateKey();
const config = await this.initConfig(key);
// Add correct claims to IdToken/AccessToken responses
this.configureClaims(config);
this.configureClaims(config, key.alg);
// Make sure routes are contained in the IDP space
this.configureRoutes(config);
@@ -148,7 +154,7 @@ export class IdentityProviderFactory implements ProviderFactory {
* In the `configureErrors` function below, we configure the `renderError` function of the provider configuration.
* This function is called by the OIDC provider library to render errors,
* but only does this if the accept header is HTML.
* Otherwise, it just returns the error object iself as a JSON object.
* Otherwise, it just returns the error object itself as a JSON object.
* See https://github.com/panva/node-oidc-provider/blob/0fcc112e0a95b3b2dae4eba6da812253277567c9/lib/shared/error_handler.js#L48-L52.
*
* In this function we override the `ctx.accepts` function
@@ -181,7 +187,7 @@ export class IdentityProviderFactory implements ProviderFactory {
* Creates a configuration by copying the internal configuration
* and adding the adapter, default audience and jwks/cookie keys.
*/
private async initConfig(): Promise<Configuration> {
private async initConfig(key: AlgJwk): Promise<Configuration> {
// Create a deep copy
const config: Configuration = JSON.parse(JSON.stringify(this.config));
@@ -193,8 +199,7 @@ export class IdentityProviderFactory implements ProviderFactory {
return factory.createStorageAdapter(name);
};
// Cast necessary due to typing conflict between jose 2.x and 3.x
config.jwks = await this.generateJwks() as any;
config.jwks = { keys: [ key ]};
config.cookies = {
...config.cookies,
keys: await this.generateCookieKeys(),
@@ -209,35 +214,12 @@ export class IdentityProviderFactory implements ProviderFactory {
// Default client settings that might not be defined.
// Mostly relevant for WebID clients.
config.clientDefaults = {
id_token_signed_response_alg: this.jwtAlg,
id_token_signed_response_alg: key.alg,
};
return config;
}
/**
* Generates a JWKS using a single JWK.
* The JWKS will be cached so subsequent calls return the same key.
*/
private async generateJwks(): Promise<{ keys: JWK[] }> {
// Check to see if the keys are already saved
const jwks = await this.storage.get(JWKS_KEY) as { keys: JWK[] } | undefined;
if (jwks) {
return jwks;
}
// If they are not, generate and save them
const { privateKey } = await generateKeyPair(this.jwtAlg);
const jwk = await exportJWK(privateKey);
// Required for Solid authn client
jwk.alg = this.jwtAlg;
// In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object,
// which is why we convert it into a plain object here.
// Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`.
const newJwks = { keys: [{ ...jwk }]};
await this.storage.set(JWKS_KEY, newJwks);
return newJwks;
}
/**
* Generates a cookie secret to be used for cookie signing.
* The key will be cached so subsequent calls return the same key.
@@ -263,9 +245,9 @@ export class IdentityProviderFactory implements ProviderFactory {
}
/**
* Adds the necessary claims the to id and access tokens based on the Solid OIDC spec.
* Adds the necessary claims to the id and access tokens based on the Solid OIDC spec.
*/
private configureClaims(config: Configuration): void {
private configureClaims(config: Configuration, jwtAlg: AsymmetricSigningAlgorithm): void {
// Returns the id_token
// See https://solid.github.io/authentication-panel/solid-oidc/#tokens-id
// Some fields are still missing, see https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1154#issuecomment-1040233385
@@ -303,7 +285,7 @@ export class IdentityProviderFactory implements ProviderFactory {
audience: 'solid',
accessTokenFormat: 'jwt',
jwt: {
sign: { alg: this.jwtAlg },
sign: { alg: jwtAlg },
},
}),
},

View File

@@ -0,0 +1,31 @@
import type { JWK } from 'jose';
import type { AsymmetricSigningAlgorithm } from 'oidc-provider';
/**
* A {@link JWK} where the `alg` parameter is always defined.
*/
export interface AlgJwk extends JWK {
alg: AsymmetricSigningAlgorithm;
}
/**
* Generates an asymmetric JWK.
*
* The functions always need to return the same value.
*/
export interface JwkGenerator {
/**
* The algorithm used for the keys.
*/
readonly alg: AsymmetricSigningAlgorithm;
/**
* @returns The public key of the asymmetric JWK.
*/
getPublicKey: () => Promise<AlgJwk>;
/**
* @returns The private key of the asymmetric JWK.
*/
getPrivateKey: () => Promise<AlgJwk>;
}

View File

@@ -138,7 +138,9 @@ export * from './http/Operation';
export * from './http/UnsecureWebSocketsProtocol';
// Identity/Configuration
export * from './identity/configuration/CachedJwkGenerator';
export * from './identity/configuration/IdentityProviderFactory';
export * from './identity/configuration/JwkGenerator';
export * from './identity/configuration/ProviderFactory';
// Identity/Interaction/Email-Password/Credentials