From 999eb151f12f4152dd7b6b1672e09494622d8244 Mon Sep 17 00:00:00 2001 From: Andy Caruso Date: Wed, 30 Mar 2022 19:55:30 -0700 Subject: [PATCH] Update public key URL when key lookup fails and mutex dev key Signed-off-by: Andy Caruso --- .../src/tokens/ServerTokenManager.ts | 36 +++++++++++--- plugins/auth-node/package.json | 1 + plugins/auth-node/src/IdentityClient.test.ts | 29 +++++++++-- plugins/auth-node/src/IdentityClient.ts | 49 ++++++++++++++++--- 4 files changed, 98 insertions(+), 17 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index b0512aed3a..484b99ca4d 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -39,6 +39,8 @@ class NoopTokenManager implements TokenManager { export class ServerTokenManager implements TokenManager { private verificationKeys: Uint8Array[]; private signingKey: Uint8Array; + private privateKeyPromise?: Promise; + private logger: Logger; static noop(): TokenManager { return new NoopTokenManager(); @@ -49,7 +51,10 @@ export class ServerTokenManager implements TokenManager { const keys = config.getOptionalConfigArray('backend.auth.keys'); if (keys?.length) { - return new ServerTokenManager(keys.map(key => key.getString('secret'))); + return new ServerTokenManager( + keys.map(key => key.getString('secret')), + logger, + ); } if (process.env.NODE_ENV !== 'development') { throw new Error( @@ -60,15 +65,16 @@ export class ServerTokenManager implements TokenManager { logger.warn( 'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY.', ); - return new ServerTokenManager([]); + return new ServerTokenManager([], logger); } - private constructor(secrets: string[]) { + private constructor(secrets: string[], logger: Logger) { if (!secrets.length && process.env.NODE_ENV !== 'development') { throw new Error( 'No secrets provided when constructing ServerTokenManager', ); } + this.logger = logger; this.verificationKeys = secrets.map(s => base64url.decode(s)); this.signingKey = this.verificationKeys[0]; } @@ -80,10 +86,26 @@ export class ServerTokenManager implements TokenManager { 'Key generation is not supported outside of the dev environment', ); } - const secret = await generateSecret('HS256'); - const jwk = await exportJWK(secret); - this.verificationKeys.push(base64url.decode(jwk.k ?? '')); - this.signingKey = this.verificationKeys[0]; + if (this.privateKeyPromise) { + return this.privateKeyPromise; + } + const promise = (async () => { + const secret = await generateSecret('HS256'); + const jwk = await exportJWK(secret); + this.verificationKeys.push(base64url.decode(jwk.k ?? '')); + this.signingKey = this.verificationKeys[0]; + return; + })(); + + try { + // If we fail to generate a new key, we need to clear the state so that + // the next caller will try to generate another key. + await promise; + } catch (error) { + this.logger.error(`Failed to generate new key, ${error}`); + delete this.privateKeyPromise; + } + return promise; } async getToken(): Promise<{ token: string }> { diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 446e20ec9a..419ac8674c 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@backstage/cli": "^0.17.0-next.1", + "lodash": "^4.17.21", "msw": "^0.35.0", "uuid": "^8.0.0" }, diff --git a/plugins/auth-node/src/IdentityClient.test.ts b/plugins/auth-node/src/IdentityClient.test.ts index c4802d57c9..7ef0b0c2e2 100644 --- a/plugins/auth-node/src/IdentityClient.test.ts +++ b/plugins/auth-node/src/IdentityClient.test.ts @@ -21,6 +21,7 @@ import { decodeProtectedHeader, exportJWK, } from 'jose'; +import { cloneDeep } from 'lodash'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { IdentityClient } from './IdentityClient'; @@ -249,10 +250,23 @@ describe('IdentityClient', () => { }).rejects.toThrow(); }); - it('should use an updated endpoint', async () => { + it('should use an updated endpoint when the key is not found', async () => { const updatedURL = 'http://backstage:9191/an-updated-base'; const getBaseUrl = discovery.getBaseUrl; const getExternalBaseUrl = discovery.getExternalBaseUrl; + // Generate a key and sign a token with it + await factory.issueToken({ claims: { sub: 'foo' } }); + // Only return the key from a single token + const singleKey = cloneDeep(await factory.listPublicKeys()); + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + return res(ctx.json(singleKey)); + }, + ), + ); + // Update the discovery endpoint to point to a new URL discovery.getBaseUrl = async () => { return updatedURL; }; @@ -265,20 +279,29 @@ describe('IdentityClient', () => { return res(ctx.json(keys)); }), ); - const token = await factory.issueToken({ claims: { sub: 'foo' } }); + // Advance time + const future_11s = Date.now() + 11 * 1000; + const dateSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => future_11s); + // Issue a new token + const token = await factory.issueToken({ claims: { sub: 'foo2' } }); const response = await client.authenticate(token); + // Verify that the endpoint was updated. const url = (client as any).endpoint as URL; expect(url.toString()).toMatch(`${updatedURL}/.well-known/jwks.json`); expect(response).toEqual({ token: token, identity: { type: 'user', - userEntityRef: 'foo', + userEntityRef: 'foo2', ownershipEntityRefs: [], }, }); + // Restore the discovery endpoint and time discovery.getBaseUrl = getBaseUrl; discovery.getExternalBaseUrl = getExternalBaseUrl; + dateSpy.mockClear(); }); }); diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/IdentityClient.ts index 3498fb463e..cfe802d184 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/IdentityClient.ts @@ -18,13 +18,17 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { AuthenticationError } from '@backstage/errors'; import { createRemoteJWKSet, + decodeJwt, jwtVerify, FlattenedJWSInput, JWSHeaderParameters, + decodeProtectedHeader, } from 'jose'; import { GetKeyFunction } from 'jose/dist/types/types'; import { BackstageIdentityResponse } from './types'; +const CLOCK_MARGIN_S = 10; + /** * An identity client to interact with auth-backend and authenticate Backstage * tokens @@ -37,6 +41,7 @@ export class IdentityClient { private readonly issuer: string; private keyStore?: GetKeyFunction; private endpoint?: URL; + private keyStoreUpdated: number = 0; /** * Create a new {@link IdentityClient} instance. @@ -57,6 +62,7 @@ export class IdentityClient { this.discovery.getBaseUrl('auth').then(url => { this.endpoint = new URL(`${url}/.well-known/jwks.json`); this.keyStore = createRemoteJWKSet(this.endpoint); + this.keyStoreUpdated = Date.now() / 1000; }); } @@ -72,13 +78,6 @@ export class IdentityClient { if (!token) { throw new AuthenticationError('No token specified'); } - // Check if the keystore needs to be updated - const url = await this.discovery.getBaseUrl('auth'); - const endpoint = new URL(`${url}/.well-known/jwks.json`); - if (endpoint !== this.endpoint) { - this.endpoint = endpoint; - this.keyStore = createRemoteJWKSet(this.endpoint); - } // Verify token claims and signature // Note: Claims must match those set by TokenFactory when issuing tokens @@ -86,6 +85,8 @@ export class IdentityClient { if (!this.keyStore) { throw new AuthenticationError('No keystore exists'); } + // Check if the keystore needs to be updated + await this.refreshKeyStore(token); const decoded = await jwtVerify(token, this.keyStore, { algorithms: ['ES256'], audience: 'backstage', @@ -109,4 +110,38 @@ export class IdentityClient { }; return user; } + + /** + * If the last keystore refresh is stale, update the keystore URL to the latest + */ + private async refreshKeyStore(rawJwtToken: string): Promise { + const payload = await decodeJwt(rawJwtToken); + const header = await decodeProtectedHeader(rawJwtToken); + + // Refresh public keys if needed + let keyStoreHasKey; + try { + if (this.keyStore) { + // Check if the key is present in the keystore + const [_, rawPayload, rawSignature] = rawJwtToken.split('.'); + keyStoreHasKey = await this.keyStore(header, { + payload: rawPayload, + signature: rawSignature, + }); + } + } catch (error) { + keyStoreHasKey = false; + } + // Refresh public key URL if needed + // Add a small margin in case clocks are out of sync + const issuedAfterLastRefresh = + payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S; + if (!keyStoreHasKey && issuedAfterLastRefresh) { + const url = await this.discovery.getBaseUrl('auth'); + const endpoint = new URL(`${url}/.well-known/jwks.json`); + this.endpoint = endpoint; + this.keyStore = createRemoteJWKSet(this.endpoint); + this.keyStoreUpdated = Date.now() / 1000; + } + } }