diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts new file mode 100644 index 0000000000..a7e7760dcf --- /dev/null +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { utc } from 'moment'; +import { TokenFactory } from './TokenFactory'; +import { getVoidLogger } from '@backstage/backend-common'; +import { KeyStore, AnyJWK, StoredKey } from './types'; +import { JWKS, JSONWebKey, JWT } from 'jose'; + +const logger = getVoidLogger(); + +class MemoryKeyStore implements KeyStore { + private readonly keys = new Map< + string, + { createdAt: moment.Moment; key: string } + >(); + + async addKey(key: AnyJWK): Promise { + this.keys.set(key.kid, { + createdAt: utc(), + key: JSON.stringify(key), + }); + } + + async removeKeys(kids: string[]): Promise { + for (const kid of kids) { + this.keys.delete(kid); + } + } + + async listKeys(): Promise<{ items: StoredKey[] }> { + return { + items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({ + createdAt, + key: JSON.parse(keyStr), + })), + }; + } +} + +function jwtKid(jwt: string): string { + const { header } = JWT.decode(jwt, { complete: true }) as { + header: { kid: string }; + }; + return header.kid; +} + +describe('TokenFactory', () => { + it('should issue valid tokens signed by a listed key', async () => { + const keyDuration = 5; + const factory = new TokenFactory({ + issuer: 'my-issuer', + keyStore: new MemoryKeyStore(), + keyDuration, + logger, + }); + + await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] }); + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + + const { keys } = await factory.listPublicKeys(); + const keyStore = JWKS.asKeyStore({ + keys: keys.map(key => key as JSONWebKey), + }); + + const payload = JWT.verify(token, keyStore) as object & { + iat: number; + exp: number; + }; + expect(payload).toEqual({ + iss: 'my-issuer', + aud: 'backstage', + sub: 'foo', + iat: expect.any(Number), + exp: expect.any(Number), + }); + expect(payload.exp).toBe(payload.iat + keyDuration * 1000); + }); + + it('should generate new signing keys when the current one expires', async () => { + const fixedTime = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + + const factory = new TokenFactory({ + issuer: 'my-issuer', + keyStore: new MemoryKeyStore(), + keyDuration: 5, + logger, + }); + + const token1 = await factory.issueToken({ claims: { sub: 'foo' } }); + const token2 = await factory.issueToken({ claims: { sub: 'foo' } }); + expect(jwtKid(token1)).toBe(jwtKid(token2)); + + await expect(factory.listPublicKeys()).resolves.toEqual({ + keys: [ + expect.objectContaining({ + kid: jwtKid(token1), + }), + ], + }); + + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime + 60000); + + await expect(factory.listPublicKeys()).resolves.toEqual({ + keys: [], + }); + + const token3 = await factory.issueToken({ claims: { sub: 'foo' } }); + expect(jwtKid(token3)).not.toBe(jwtKid(token2)); + + await expect(factory.listPublicKeys()).resolves.toEqual({ + keys: [ + expect.objectContaining({ + kid: jwtKid(token3), + }), + ], + }); + }); +}); diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 42ce93721b..e596363729 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -19,6 +19,8 @@ import { JSONWebKey, JWK, JWS } from 'jose'; import { Logger } from 'winston'; import { v4 as uuid } from 'uuid'; +const MS_IN_S = 1000; + type Options = { logger: Logger; /** Value of the issuer claim in issued tokens */ @@ -51,8 +53,8 @@ export class TokenFactory implements TokenIssuer { const iss = this.issuer; const sub = params.claims.sub; const aud = 'backstage'; - const iat = (Date.now() / 1000) | 0; - const exp = iat + 3600; + const iat = (Date.now() / MS_IN_S) | 0; + const exp = iat + this.keyDuration * MS_IN_S; this.logger.info(`Issuing token for ${sub}`); @@ -102,7 +104,7 @@ export class TokenFactory implements TokenIssuer { delete this.privateKeyPromise; } - this.keyExpiry = Date.now() + this.keyDuration * 1000; + this.keyExpiry = Date.now() + this.keyDuration * MS_IN_S; const promise = (async () => { const key = await JWK.generate('EC', 'P-256', { use: 'sig',