From 4e6b2319a23e37546208777b60dfc20cee6131b8 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 18:00:33 +0000 Subject: [PATCH] backend-common: include self-reported pluginId in backend token Signed-off-by: MT Lewis --- .../src/tokens/ServerTokenManager.test.ts | 98 ++++++++++++++----- .../src/tokens/ServerTokenManager.ts | 44 +++++---- packages/backend-common/src/tokens/types.ts | 22 ++++- 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index c00f708179..361d98121e 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -29,36 +29,46 @@ describe('ServerTokenManager', () => { describe('getToken', () => { it('should return a token if secret in config exists', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect((await tokenManager.getToken()).token).toBeDefined(); + expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); }); - it('should return an empty string if using a noop TokenManager', async () => { + it('should return a token string if using a noop TokenManager', async () => { const tokenManager = ServerTokenManager.noop(); - expect((await tokenManager.getToken()).token).toBe(''); + expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); }); }); - describe('validateToken', () => { + describe('authenticate', () => { it('should not throw if token is valid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager.getToken(); - expect(() => tokenManager.validateToken(token)).not.toThrow(); + const { token } = await tokenManager.getToken('test-plugin'); + await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); }); - it('should throw if token is invalid', () => { + it('should allow retrieving the pluginId from valid tokens', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect(() => tokenManager.validateToken('random-string')).toThrowError( - /invalid server token/i, - ); + const { token } = await tokenManager.getToken('test-plugin-123'); + + await expect(tokenManager.authenticate(token)).resolves.toEqual({ + pluginId: 'test-plugin-123', + token, + }); + }); + + it('should throw if token is invalid', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + tokenManager.authenticate('random-string'), + ).rejects.toThrowError(/invalid server token/i); }); it('should validate server tokens created by a different instance using the same secret', async () => { const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager1.getToken(); + const { token } = await tokenManager1.getToken('test-plugin'); - expect(() => tokenManager2.validateToken(token)).not.toThrow(); + await expect(tokenManager2.authenticate(token)).resolves.not.toThrow(); }); it('should validate server tokens created using any of the secrets', async () => { @@ -80,11 +90,11 @@ describe('ServerTokenManager', () => { }), ); - const { token: token1 } = await tokenManager1.getToken(); - expect(() => tokenManager3.validateToken(token1)).not.toThrow(); + const { token: token1 } = await tokenManager1.getToken('test-plugin'); + await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow(); - const { token: token2 } = await tokenManager2.getToken(); - expect(() => tokenManager3.validateToken(token2)).not.toThrow(); + const { token: token2 } = await tokenManager2.getToken('test-plugin'); + await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow(); }); it('should throw for server tokens created using a different secret', async () => { @@ -99,9 +109,24 @@ describe('ServerTokenManager', () => { }), ); - const { token } = await tokenManager1.getToken(); + const { token } = await tokenManager1.getToken('test-plugin'); - expect(() => tokenManager2.validateToken(token)).toThrowError( + await expect(tokenManager2.authenticate(token)).rejects.toThrowError( + /invalid server token/i, + ); + }); + + it('should throw for server tokens created using a noop TokenManager', async () => { + const noopTokenManager = ServerTokenManager.noop(); + const tokenManager = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + + const { token } = await noopTokenManager.getToken('test-plugin'); + + await expect(tokenManager.authenticate(token)).rejects.toThrowError( /invalid server token/i, ); }); @@ -115,19 +140,40 @@ describe('ServerTokenManager', () => { }); it('should accept tokens it generates', async () => { - const { token } = await noopTokenManager.getToken(); + const { token } = await noopTokenManager.getToken('test-plugin'); - expect(() => noopTokenManager.validateToken(token)).not.toThrow(); + await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow(); }); - it('should accept arbitrary strings', async () => { - expect(() => - noopTokenManager.validateToken('random-string'), - ).not.toThrow(); + it('should accept tokens generated by other noop token managers', async () => { + const noopTokenManager2 = ServerTokenManager.noop(); + await expect( + noopTokenManager.authenticate( + ( + await noopTokenManager2.getToken('test-plugin') + ).token, + ), + ).resolves.not.toThrow(); }); - it('should accept empty strings', async () => { - expect(() => noopTokenManager.validateToken('')).not.toThrow(); + it('should not accept signed tokens', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + noopTokenManager.authenticate( + ( + await tokenManager.getToken('test-plugin') + ).token, + ), + ).rejects.toThrowError(/invalid server token/i); + }); + + it('should allow retrieving the pluginId from valid tokens', async () => { + const { token } = await noopTokenManager.getToken('test-plugin-123'); + + await expect(noopTokenManager.authenticate(token)).resolves.toEqual({ + pluginId: 'test-plugin-123', + token, + }); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index ea79497b42..01ebf55f15 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -17,7 +17,7 @@ import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; -import { TokenManager } from './types'; +import { ServerIdentity, TokenManager } from './types'; /** * Creates and validates tokens for use during backend-to-backend @@ -26,7 +26,9 @@ import { TokenManager } from './types'; * @public */ export class ServerTokenManager implements TokenManager { - private readonly keyStore: JWKS.KeyStore; + private readonly verificationKeys: JWKS.KeyStore | JWK.NoneKey; + private readonly signingKey: JWK.Key | JWK.NoneKey; + private readonly signingAlgorithm: string | undefined; static noop() { return new ServerTokenManager(); @@ -41,35 +43,35 @@ export class ServerTokenManager implements TokenManager { } private constructor(secrets?: string[]) { - this.keyStore = new JWKS.KeyStore( - secrets?.length - ? secrets.map(secret => JWK.asKey({ kty: 'oct', k: secret })) - : [], - ); + if (secrets?.length) { + this.verificationKeys = new JWKS.KeyStore( + secrets.map(k => JWK.asKey({ kty: 'oct', k })), + ); + this.signingKey = this.verificationKeys.all()[0]; + this.signingAlgorithm = 'HS256'; + } else { + this.verificationKeys = this.signingKey = JWK.None; + } } - async getToken(): Promise<{ token: string }> { - if (this.keyStore.size === 0) { - return { token: '' }; - } - - const jwt = JWT.sign({ sub: 'backstage-server' }, this.keyStore.all()[0], { - algorithm: 'HS256', + async getToken(pluginId: string): Promise<{ token: string }> { + // TODO(mtlewis): should we wrap pluginId in a urn? + const jwt = JWT.sign({ sub: pluginId }, this.signingKey, { + algorithm: this.signingAlgorithm, }); return { token: jwt }; } - validateToken(token: string): void { - if (this.keyStore.size === 0) { - return; - } - + async authenticate(token: string): Promise { + let decodedJwt = {}; try { - JWT.verify(token, this.keyStore); - return; + JWT.verify(token, this.verificationKeys); + decodedJwt = JWT.decode(token); } catch (e) { throw new AuthenticationError('Invalid server token'); } + + return { pluginId: decodedJwt.sub, token }; } } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index ede582de26..fdf54d4712 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -14,12 +14,30 @@ * limitations under the License. */ +/** + * A (pluginId, token) pair. + * + * @public + */ +export type ServerIdentity = { + /** + * The ID of the plugin backend to which this + * identity corresponds. + */ + pluginId: string; + + /** + * The token used to authenticate the plugin backend. + */ + token: string; +}; + /** * Interface for creating and validating tokens. * * @public */ export interface TokenManager { - getToken: () => Promise<{ token: string }>; - validateToken: (token: string) => void; + getToken: (pluginId: string) => Promise<{ token: string }>; + authenticate: (token: string) => Promise; }