Merge pull request #24077 from backstage/rugvip/exp

backend-app-api: fix plugin token public key expiration
This commit is contained in:
Patrik Oldsberg
2024-04-08 16:38:03 +02:00
committed by GitHub
2 changed files with 110 additions and 20 deletions
@@ -0,0 +1,82 @@
/*
* Copyright 2024 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 { mockServices } from '@backstage/backend-test-utils';
import { PluginTokenHandler } from './PluginTokenHandler';
import { decodeJwt } from 'jose';
describe('PluginTokenHandler', () => {
beforeEach(() => {
jest.useRealTimers();
});
it('issues token with correct expiration of token and generated key', async () => {
jest.useFakeTimers({
now: new Date(0),
});
const addKeyMock = jest.fn();
const handler = PluginTokenHandler.create({
discovery: mockServices.discovery(),
keyDurationSeconds: 10,
logger: mockServices.logger.mock(),
ownPluginId: 'test',
publicKeyStore: {
addKey: addKeyMock,
listKeys: jest.fn(),
},
});
const { token } = await handler.issueToken({
pluginId: 'test',
targetPluginId: 'other',
});
const payload = decodeJwt(token);
expect(payload.iat).toBe(0);
expect(payload.exp).toBe(10);
expect(addKeyMock).toHaveBeenCalledTimes(1);
expect(addKeyMock).toHaveBeenCalledWith({
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(30_000),
});
jest.advanceTimersByTime(5_000);
await handler.issueToken({
pluginId: 'test',
targetPluginId: 'other',
});
expect(addKeyMock).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(10_000);
await handler.issueToken({
pluginId: 'test',
targetPluginId: 'other',
});
expect(addKeyMock).toHaveBeenCalledTimes(2);
expect(addKeyMock.mock.calls[0][0].id).not.toBe(
addKeyMock.mock.calls[1][0].id,
);
expect(addKeyMock).toHaveBeenNthCalledWith(2, {
id: expect.any(String),
key: expect.any(Object),
expiresAt: new Date(45_000),
});
});
});
@@ -24,7 +24,6 @@ import {
SignJWT,
decodeProtectedHeader,
} from 'jose';
import { DateTime } from 'luxon';
import { v4 as uuid } from 'uuid';
import { InternalKey, KeyStore } from './types';
import { AuthenticationError } from '@backstage/errors';
@@ -32,6 +31,13 @@ import { jwtVerify } from 'jose';
import { tokenTypes } from '@backstage/plugin-auth-node';
import { JwksClient } from './JwksClient';
/**
* The margin for how many times longer we make the public key available
* compared to how long we use the private key to sign new tokens.
*/
const KEY_EXPIRATION_MARGIN_FACTOR = 3;
const SECONDS_IN_MS = 1000;
const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
type Options = {
@@ -71,12 +77,12 @@ export class PluginTokenHandler {
}
private constructor(
readonly logger: LoggerService,
readonly ownPluginId: string,
readonly publicKeyStore: KeyStore,
readonly keyDurationSeconds: number,
readonly algorithm: string,
readonly discovery: DiscoveryService,
private readonly logger: LoggerService,
private readonly ownPluginId: string,
private readonly publicKeyStore: KeyStore,
private readonly keyDurationSeconds: number,
private readonly algorithm: string,
private readonly discovery: DiscoveryService,
) {}
async verifyToken(
@@ -129,10 +135,13 @@ export class PluginTokenHandler {
const sub = pluginId;
const aud = targetPluginId;
const iat = Math.floor(Date.now() / 1000);
const iat = Math.floor(Date.now() / SECONDS_IN_MS);
const ourExp = iat + this.keyDurationSeconds;
const exp = onBehalfOf
? Math.min(ourExp, Math.floor(onBehalfOf.expiresAt.getTime() / 1000))
? Math.min(
ourExp,
Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS),
)
: ourExp;
const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
@@ -223,22 +232,16 @@ export class PluginTokenHandler {
private async getKey(): Promise<JWK> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (
this.keyExpiry &&
DateTime.fromJSDate(this.keyExpiry) > DateTime.local()
) {
if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
const keyExpiry = DateTime.utc()
.plus({
seconds: this.keyDurationSeconds,
})
.toJSDate();
this.keyExpiry = keyExpiry;
this.keyExpiry = new Date(
Date.now() + this.keyDurationSeconds * SECONDS_IN_MS,
);
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
@@ -260,7 +263,12 @@ export class PluginTokenHandler {
await this.publicKeyStore.addKey({
id: kid,
key: publicKey as InternalKey,
expiresAt: keyExpiry,
expiresAt: new Date(
Date.now() +
this.keyDurationSeconds *
SECONDS_IN_MS *
KEY_EXPIRATION_MARGIN_FACTOR,
),
});
// At this point we are allowed to start using the new key