moved over IdentityClient as well
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { JSONWebKey, JWK, JWS, JWT } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IdentityClient } from './IdentityClient';
|
||||
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const key = await JWK.generate('EC', 'P-256', {
|
||||
use: 'sig',
|
||||
kid: uuid(),
|
||||
alg: 'ES256',
|
||||
});
|
||||
this.keys.push(key.toJWK(false) as unknown as AnyJWK);
|
||||
|
||||
const iss = this.options.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
|
||||
alg: key.alg,
|
||||
kid: key.kid,
|
||||
});
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
};
|
||||
|
||||
describe('IdentityClient', () => {
|
||||
let client: IdentityClient;
|
||||
let factory: FakeTokenFactory;
|
||||
const keyDurationSeconds = 5;
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||
afterAll(() => server.close());
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
beforeEach(() => {
|
||||
client = new IdentityClient({ discovery, issuer: mockBaseUrl });
|
||||
factory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
});
|
||||
|
||||
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 as any).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(token);
|
||||
expect(response).toEqual({
|
||||
id: 'foo',
|
||||
token: token,
|
||||
identity: {
|
||||
ownershipEntityRefs: [],
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on incorrect issuer', async () => {
|
||||
const hackerFactory = new FakeTokenFactory({
|
||||
issuer: 'hacker',
|
||||
keyDurationSeconds,
|
||||
});
|
||||
return expect(async () => {
|
||||
const token = await hackerFactory.issueToken({
|
||||
claims: { sub: 'foo' },
|
||||
});
|
||||
await client.authenticate(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(token);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw on incorrect signing key', async () => {
|
||||
const hackerFactory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
return expect(async () => {
|
||||
const token = await hackerFactory.issueToken({
|
||||
claims: { sub: 'foo' },
|
||||
});
|
||||
await client.authenticate(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(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(token);
|
||||
expect(response).toEqual({
|
||||
id: 'foo',
|
||||
token: token,
|
||||
identity: {
|
||||
ownershipEntityRefs: [],
|
||||
type: 'user',
|
||||
userEntityRef: 'foo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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(fakeToken);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPublicKeys', () => {
|
||||
const defaultServiceResponse: {
|
||||
keys: JSONWebKey[];
|
||||
} = {
|
||||
keys: [
|
||||
{
|
||||
crv: 'P-256',
|
||||
x: 'JWy80Goa-8C3oaeDLnk0ANVPPMfI9T3u_T5T7W2b_ls',
|
||||
y: 'Ge6jAhCDW1PFBfme2RA5ZsXN0cESiCwW29LMRPX5wkw',
|
||||
kty: 'EC',
|
||||
kid: 'kid-a',
|
||||
alg: 'ES256',
|
||||
use: 'sig',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, (_, res, ctx) => {
|
||||
return res(ctx.json(defaultServiceResponse));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use the correct endpoint', async () => {
|
||||
const response = await (client as any).listPublicKeys();
|
||||
expect(response).toEqual(defaultServiceResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2020 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 { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { JSONWebKey, JWK, JWKS, JWT } from 'jose';
|
||||
import fetch from 'node-fetch';
|
||||
import { BackstageIdentityResponse } from './types';
|
||||
|
||||
const CLOCK_MARGIN_S = 10;
|
||||
|
||||
/**
|
||||
* An identity client to interact with auth-backend and authenticate Backstage
|
||||
* tokens
|
||||
*
|
||||
* @experimental This is not a stable API yet
|
||||
* @public
|
||||
*/
|
||||
export class IdentityClient {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
private readonly issuer: string;
|
||||
private keyStore: JWKS.KeyStore;
|
||||
private keyStoreUpdated: number;
|
||||
|
||||
constructor(options: { discovery: PluginEndpointDiscovery; issuer: string }) {
|
||||
this.discovery = options.discovery;
|
||||
this.issuer = options.issuer;
|
||||
this.keyStore = new JWKS.KeyStore();
|
||||
this.keyStoreUpdated = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the given backstage identity token
|
||||
* Returns a BackstageIdentity (user) matching the token.
|
||||
* The method throws an error if verification fails.
|
||||
*/
|
||||
async authenticate(
|
||||
token: string | undefined,
|
||||
): Promise<BackstageIdentityResponse> {
|
||||
// Extract token from header
|
||||
if (!token) {
|
||||
throw new AuthenticationError('No token specified');
|
||||
}
|
||||
// Get signing key matching token
|
||||
const key = await this.getKey(token);
|
||||
if (!key) {
|
||||
throw new AuthenticationError('No signing key matching token found');
|
||||
}
|
||||
// Verify token claims and signature
|
||||
// Note: Claims must match those set by TokenFactory when issuing tokens
|
||||
// Note: verify throws if verification fails
|
||||
const decoded = JWT.IdToken.verify(token, key, {
|
||||
algorithms: ['ES256'],
|
||||
audience: 'backstage',
|
||||
issuer: this.issuer,
|
||||
}) as { sub: string; ent: string[] };
|
||||
// Verified, return the matching user as BackstageIdentity
|
||||
// TODO: Settle internal user format/properties
|
||||
if (!decoded.sub) {
|
||||
throw new AuthenticationError('No user sub found in token');
|
||||
}
|
||||
|
||||
const user: BackstageIdentityResponse = {
|
||||
id: decoded.sub,
|
||||
token,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: decoded.sub,
|
||||
ownershipEntityRefs: decoded.ent ?? [],
|
||||
},
|
||||
};
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public signing key matching the given jwt token,
|
||||
* or null if no matching key was found
|
||||
*/
|
||||
private async getKey(rawJwtToken: string): Promise<JWK.Key | null> {
|
||||
const { header, payload } = JWT.decode(rawJwtToken, {
|
||||
complete: true,
|
||||
}) as {
|
||||
header: { kid: string };
|
||||
payload: { iat: number };
|
||||
};
|
||||
|
||||
// Refresh public keys if needed
|
||||
// Add a small margin in case clocks are out of sync
|
||||
const keyStoreHasKey = !!this.keyStore.get({ kid: header.kid });
|
||||
const issuedAfterLastRefresh =
|
||||
payload?.iat && payload.iat > this.keyStoreUpdated - CLOCK_MARGIN_S;
|
||||
if (!keyStoreHasKey && issuedAfterLastRefresh) {
|
||||
await this.refreshKeyStore();
|
||||
}
|
||||
|
||||
return this.keyStore.get({ kid: header.kid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists public part of keys used to sign Backstage Identity tokens
|
||||
*/
|
||||
private async listPublicKeys(): Promise<{
|
||||
keys: JSONWebKey[];
|
||||
}> {
|
||||
const url = `${await this.discovery.getBaseUrl(
|
||||
'auth',
|
||||
)}/.well-known/jwks.json`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const publicKeys: { keys: JSONWebKey[] } = await response.json();
|
||||
|
||||
return publicKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches public keys and caches them locally
|
||||
*/
|
||||
private async refreshKeyStore(): Promise<void> {
|
||||
const now = Date.now() / 1000;
|
||||
const publicKeys = await this.listPublicKeys();
|
||||
this.keyStore = JWKS.asKeyStore({
|
||||
keys: publicKeys.keys.map(key => key as JSONWebKey),
|
||||
});
|
||||
this.keyStoreUpdated = now;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
|
||||
export { IdentityClient } from './IdentityClient';
|
||||
export type {
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
|
||||
Reference in New Issue
Block a user