From acaa350fe59e78ece2a0cfde1345faf391a4f597 Mon Sep 17 00:00:00 2001 From: Erik Larsson Date: Sun, 10 Jan 2021 02:19:45 +0100 Subject: [PATCH] Added more IdentityClient tests --- .../src/identity/IdentityClient.test.ts | 219 ++++++++++++++++-- .../src/identity/IdentityClient.ts | 12 +- 2 files changed, 212 insertions(+), 19 deletions(-) diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts index 7e208fed56..b209d1bac8 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -14,11 +14,55 @@ * limitations under the License. */ +import { JWT, JSONWebKey } from 'jose'; +import { utc } from 'moment'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { JWKECKey } from 'jose'; +import { + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { IdentityClient } from './IdentityClient'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { TokenFactory } from './TokenFactory'; +import { KeyStore, AnyJWK, StoredKey } from './types'; + +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; +} const server = setupServer(); const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; @@ -33,6 +77,9 @@ const discovery: PluginEndpointDiscovery = { describe('IdentityClient', () => { let client: IdentityClient; + let factory: TokenFactory; + let keyStore: KeyStore; + const keyDurationSeconds = 5; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterAll(() => server.close()); @@ -40,11 +87,166 @@ describe('IdentityClient', () => { beforeEach(() => { client = new IdentityClient({ discovery, issuer: mockBaseUrl }); + keyStore = new MemoryKeyStore(); + factory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: keyStore, + keyDurationSeconds, + logger, + }); + }); + + describe('authenticate', () => { + beforeEach(() => { + server.use( + rest.get( + `${mockBaseUrl}/.well-known/jwks.json`, + async (_, res, ctx) => { + const keys = await factory.listPublicKeys(); + return res(ctx.json(keys)); + }, + ), + ); + }); + + it('should use the correct endpoint', async () => { + await factory.issueToken({ claims: { sub: 'foo' } }); + const keys = await factory.listPublicKeys(); + const response = await client.listPublicKeys(); + expect(response).toEqual(keys); + }); + + it('should throw on undefined header', async () => { + return expect(async () => { + await client.authenticate(undefined); + }).rejects.toThrow(); + }); + + it('should accept fresh token', async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(`Bearer ${token}`); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should throw on incorrect issuer', async () => { + const hackerFactory = new TokenFactory({ + issuer: 'hacker', + keyStore, + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(`Bearer ${token}`); + }).rejects.toThrow(); + }); + + it('should throw on expired token', async () => { + return expect(async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token = await factory.issueToken({ + claims: { sub: 'foo' }, + }); + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + await client.authenticate(`Bearer ${token}`); + }).rejects.toThrow(); + }); + + it('should throw on incorrect signing key', async () => { + const hackerFactory = new TokenFactory({ + issuer: mockBaseUrl, + keyStore: new MemoryKeyStore(), + keyDurationSeconds, + logger, + }); + return expect(async () => { + const token = await hackerFactory.issueToken({ + claims: { sub: 'foo' }, + }); + await client.authenticate(`Bearer ${token}`); + }).rejects.toThrow(); + }); + + it('should accept token from new key', async () => { + const fixedTime = Date.now(); + jest + .spyOn(Date, 'now') + .mockImplementation(() => fixedTime - keyDurationSeconds * 1000 * 2); + const token1 = await factory.issueToken({ claims: { sub: 'foo1' } }); + try { + // This throws as token has already expired + await client.authenticate(`Bearer ${token1}`); + } catch (_err) { + // Ignore thrown error + } + // Move forward in time where the signing key has been rotated + jest.spyOn(Date, 'now').mockImplementation(() => fixedTime); + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const response = await client.authenticate(`Bearer ${token}`); + expect(response).toEqual({ id: 'foo', idToken: token }); + }); + + it('should not be fooled by the none algorithm', async () => { + return expect(async () => { + const token = await factory.issueToken({ claims: { sub: 'foo' } }); + const header = btoa( + JSON.stringify({ alg: 'none', kid: jwtKid(token) }), + ); + const payload = btoa( + JSON.stringify({ + iss: mockBaseUrl, + sub: 'foo', + aud: 'backstage', + iat: Date.now() / 1000, + exp: Date.now() / 1000 + 60000, + }), + ); + const fakeToken = `${header}.${payload}.`; + return await client.authenticate(`Bearer ${fakeToken}`); + }).rejects.toThrow(); + }); + }); + + describe('getBearerToken', () => { + it('should return null on undefined input', async () => { + const token = IdentityClient.getBearerToken(undefined); + expect(token).toBeNull(); + }); + + it('should return null on malformed input', async () => { + const token = IdentityClient.getBearerToken('malformed'); + expect(token).toBeNull(); + }); + + it('should return null on unexpected scheme', async () => { + const token = IdentityClient.getBearerToken('Basic token'); + expect(token).toBeNull(); + }); + + it('should return Bearer token', async () => { + const token = IdentityClient.getBearerToken('Bearer token'); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite extra space', async () => { + const token = IdentityClient.getBearerToken('Bearer \n token '); + expect(token).toEqual('token'); + }); + + it('should return Bearer token despite unconventionial case', async () => { + const token = IdentityClient.getBearerToken('bEARER token'); + expect(token).toEqual('token'); + }); }); describe('listPublicKeys', () => { const defaultServiceResponse: { - keys: JWKECKey[]; + keys: JSONWebKey[]; } = { keys: [ { @@ -52,16 +254,7 @@ describe('IdentityClient', () => { x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls', y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw', kty: 'EC', - kid: 'fecd6d82-224f-43d6-b174-9a48bc42ae44', - alg: 'ES256', - use: 'sig', - }, - { - crv: 'P-256', - x: 'PYGrR5otsNwGdwQC4Ob6sbkVc80jEwsPMUI25y7eUpY', - y: 'GB_mlRnp6METpCW5yUWHpPprZaPJ5sK6RD5nEo1FYac', - kty: 'EC', - kid: 'c5d8c8a8-ca83-43ef-baca-3fccc198901d', + kid: 'kid-a', alg: 'ES256', use: 'sig', }, diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts index 610d43f7ef..df885f9888 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -15,7 +15,7 @@ */ import fetch from 'cross-fetch'; -import { JWK, JWT, JWKS, JWKECKey } from 'jose'; +import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; import { BackstageIdentity } from '../providers'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -112,7 +112,7 @@ export class IdentityClient { * Lists public part of keys used to sign Backstage Identity tokens */ async listPublicKeys(): Promise<{ - keys: JWKECKey[]; + keys: JSONWebKey[]; }> { const url = `${await this.discovery.getBaseUrl( 'auth', @@ -125,7 +125,7 @@ export class IdentityClient { throw new Error(message); } - const publicKeys: { keys: JWKECKey[] } = await response.json(); + const publicKeys: { keys: JSONWebKey[] } = await response.json(); return publicKeys; } @@ -136,9 +136,9 @@ export class IdentityClient { private async refreshKeyStore(): Promise { const now = Date.now() / 1000; const publicKeys = await this.listPublicKeys(); - this.keyStore = new JWKS.KeyStore( - publicKeys.keys.map(key => JWK.asKey(key)), - ); + this.keyStore = JWKS.asKeyStore({ + keys: publicKeys.keys.map(key => key as JSONWebKey), + }); this.keyStoreUpdated = now; } }