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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Optional identity token authorization of api requests
@@ -0,0 +1,52 @@
# Authenticate API requests
The Backstage backend APIs are by default available without authentication. To avoid evil-doers from accessing or modifying data, one might use a network protection mechanism such as a firewall or an authenticating reverse proxy. For Backstage instances that are available on the Internet one can instead use the experimental IdentityClient as outlined below.
API requests from frontend plugins include an authorization header with a Backstage identity token acquired when the user logs in. By adding a middleware that verifies said token to be valid and signed by Backstage, non-authenticated requests can be blocked with a 401 Unauthorized response.
Note that this means Backstage will stop working for guests, as no token is issued for them.
Caveat: as of writing this, Backstage does not refresh the identity token so eventually users will get a 401 response on API calls (not on loading the web page as only the API calls are authenticated) and have to logout/login again to get a new token.
```typescript
// packages/backend/src/index.ts from a create-app deployment
import { Request, Response, NextFunction } from 'express';
import { IdentityClient } from '@backstage/plugin-auth-backend';
// ...
async function main() {
// ...
const discovery = SingleHostDiscovery.fromConfig(config);
const identity = new IdentityClient({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
});
const authMiddleware = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const token = IdentityClient.getBearerToken(req.headers.authorization);
req.user = await identity.authenticate(token);
next();
} catch (error) {
res.status(401).send(`Unauthorized`);
}
};
const apiRouter = Router();
// The auth route must be publically available as it is used during login
apiRouter.use('/auth', await auth(authEnv));
// Only authenticated requests are allowed to the routes below
apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv));
apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv));
apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv));
apiRouter.use(authMiddleware, notFoundHandler());
// ...
}
```
@@ -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