Merge pull request #3916 from erikxiv/feat/api-auth-backend

Optional identity token authorization of api requests
This commit is contained in:
Patrik Oldsberg
2021-01-21 13:11:05 +01:00
committed by GitHub
8 changed files with 501 additions and 31 deletions
@@ -0,0 +1,248 @@
/*
* 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 { JWT, JSONWebKey } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
getVoidLogger,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { IdentityClient } from './IdentityClient';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { KeyStore } from './types';
const logger = getVoidLogger();
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: TokenFactory;
let keyStore: KeyStore;
const keyDurationSeconds = 5;
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
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(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(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 TokenFactory({
issuer: mockBaseUrl,
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
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', 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(fakeToken);
}).rejects.toThrow();
});
});
describe('getBearerToken', () => {
it('should return undefined on undefined input', async () => {
const token = IdentityClient.getBearerToken(undefined);
expect(token).toBeUndefined();
});
it('should return undefined on malformed input', async () => {
const token = IdentityClient.getBearerToken('malformed');
expect(token).toBeUndefined();
});
it('should return undefined on unexpected scheme', async () => {
const token = IdentityClient.getBearerToken('Basic token');
expect(token).toBeUndefined();
});
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: 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.listPublicKeys();
expect(response).toEqual(defaultServiceResponse);
});
});
});
@@ -0,0 +1,146 @@
/*
* 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 fetch from 'cross-fetch';
import { JWK, JWT, JWKS, JSONWebKey } from 'jose';
import { BackstageIdentity } from '../providers';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
const CLOCK_MARGIN_S = 10;
/**
* A identity client to interact with auth-backend
* and authenticate backstage identity tokens
*
* @experimental This is not a stable API yet
*/
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<BackstageIdentity> {
// Extract token from header
if (!token) {
throw new Error('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
if (!key) {
throw new Error('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 };
// Verified, return the matching user as BackstageIdentity
// TODO: Settle internal user format/properties
const user: BackstageIdentity = {
id: decoded.sub,
idToken: token,
};
return user;
}
/**
* Parses the given authorization header and returns
* the bearer token, or null if no bearer token is given
*/
static getBearerToken(
authorizationHeader: string | undefined,
): string | undefined {
if (typeof authorizationHeader !== 'string') {
return undefined;
}
const matches = authorizationHeader.match(/Bearer\s+(\S+)/i);
return matches?.[1];
}
/**
* 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
*/
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;
}
}
@@ -0,0 +1,47 @@
/*
* 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 { KeyStore, AnyJWK, StoredKey } from './types';
export class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
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),
})),
};
}
}
@@ -14,43 +14,13 @@
* limitations under the License.
*/
import { utc } from 'moment';
import { MemoryKeyStore } from './MemoryKeyStore';
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<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
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 };
@@ -15,6 +15,7 @@
*/
export { createOidcRouter } from './router';
export { IdentityClient } from './IdentityClient';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
+1
View File
@@ -15,6 +15,7 @@
*/
export * from './service/router';
export { IdentityClient } from './identity';
export * from './providers';
// flow package provides 2 functions