Update public key URL when key lookup fails and mutex dev key

Signed-off-by: Andy Caruso <macaruso@gmail.com>
This commit is contained in:
Andy Caruso
2022-03-30 19:55:30 -07:00
committed by blam
parent 1e0a61da9d
commit 999eb151f1
4 changed files with 98 additions and 17 deletions
@@ -39,6 +39,8 @@ class NoopTokenManager implements TokenManager {
export class ServerTokenManager implements TokenManager {
private verificationKeys: Uint8Array[];
private signingKey: Uint8Array;
private privateKeyPromise?: Promise<void>;
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 }> {
+1
View File
@@ -33,6 +33,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.17.0-next.1",
"lodash": "^4.17.21",
"msw": "^0.35.0",
"uuid": "^8.0.0"
},
+26 -3
View File
@@ -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();
});
});
+42 -7
View File
@@ -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<JWSHeaderParameters, FlattenedJWSInput>;
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<void> {
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;
}
}
}