diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index e596363729..db59354839 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -31,6 +31,20 @@ type Options = { keyDuration: number; }; +/** + * A token issuer that is able to issue tokens in a distributed system + * backed by a single database. Tokens are issued using lazily generated + * signing keys, where each running instance of the auth service uses its own + * signing key. + * + * The public parts of the keys are all stored in the shared key storage, + * and any of the instances of the auth service will return the full list + * of public keys that are currently in storage. + * + * Signing keys are automatically rotated at the same interval as the token + * duration. Expired keys are kept in storage until there are no valid tokens + * in circulation that could have been signed by that key. + */ export class TokenFactory implements TokenIssuer { private readonly issuer: string; private readonly logger: Logger; @@ -64,6 +78,9 @@ export class TokenFactory implements TokenIssuer { }); } + // This will be called by other services that want to verify ID tokens. + // It is important that it returns a list of all public keys that could + // have been used to sign tokens that have not yet expired. async listPublicKeys(): Promise<{ keys: AnyJWK[] }> { const { items: keys } = await this.keyStore.listKeys(); @@ -80,6 +97,7 @@ export class TokenFactory implements TokenIssuer { } } + // Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e if (expiredKeys.length > 0) { const kids = expiredKeys.map(({ key }) => key.kid); @@ -96,6 +114,7 @@ export class TokenFactory implements TokenIssuer { } private async getKey(): Promise { + // Make sure that we only generate one key at a time if (this.privateKeyPromise) { if (this.keyExpiry && Date.now() < this.keyExpiry) { return this.privateKeyPromise; @@ -106,15 +125,23 @@ export class TokenFactory implements TokenIssuer { this.keyExpiry = Date.now() + this.keyDuration * MS_IN_S; const promise = (async () => { + // This generates a new signing key to be used to sign tokens until the next key rotation const key = await JWK.generate('EC', 'P-256', { use: 'sig', kid: uuid(), alg: 'ES256', }); + // 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 ${key.kid}`); await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK); + // At this point we are allowed to start using the new key return key as JSONWebKey; })();