backend-common: include self-reported pluginId in backend token

Signed-off-by: MT Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
MT Lewis
2021-11-25 18:00:33 +00:00
parent 189f7f8a72
commit 4e6b2319a2
3 changed files with 115 additions and 49 deletions
@@ -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,
});
});
});
});
@@ -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<ServerIdentity> {
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 };
}
}
+20 -2
View File
@@ -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<ServerIdentity | undefined>;
}