Refactored as per PR comments

This commit is contained in:
Erik Larsson
2021-01-20 01:06:34 +01:00
parent acaa350fe5
commit b798e8b9e8
7 changed files with 139 additions and 118 deletions
@@ -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());
// ...
}
```
-1
View File
@@ -47,7 +47,6 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"knex": "^0.21.6",
"passport": "^0.4.1",
"pg": "^8.3.0",
"pg-connection-string": "^2.3.0",
"sqlite3": "^5.0.0",
+8 -28
View File
@@ -22,7 +22,6 @@
* Happy hacking!
*/
import { Request, Response, NextFunction } from 'express';
import Router from 'express-promise-router';
import {
createServiceBuilder,
@@ -35,7 +34,6 @@ import {
useHotMemoize,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { IdentityClient } from '@backstage/plugin-auth-backend';
import healthcheck from './plugins/healthcheck';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
@@ -82,34 +80,16 @@ async function main() {
const graphqlEnv = useHotMemoize(module, () => createEnv('graphql'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
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 {
req.user = await identity.authenticate(req.headers.authorization);
next();
} catch (error) {
res.status(401).send(`Unauthorized`);
}
};
const apiRouter = Router();
apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv));
apiRouter.use('/rollbar', authMiddleware, await rollbar(rollbarEnv));
apiRouter.use('/scaffolder', authMiddleware, await scaffolder(scaffolderEnv));
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/rollbar', await rollbar(rollbarEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', authMiddleware, await kubernetes(kubernetesEnv));
apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv));
apiRouter.use('/graphql', authMiddleware, await graphql(graphqlEnv));
apiRouter.use(authMiddleware, notFoundHandler());
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/graphql', await graphql(graphqlEnv));
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
.loadConfig(config)
@@ -15,7 +15,6 @@
*/
import { JWT, JSONWebKey } from 'jose';
import { utc } from 'moment';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
@@ -23,40 +22,12 @@ import {
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { IdentityClient } from './IdentityClient';
import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { KeyStore } from './types';
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 };
@@ -124,7 +95,7 @@ describe('IdentityClient', () => {
it('should accept fresh token', async () => {
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const response = await client.authenticate(`Bearer ${token}`);
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
});
@@ -139,7 +110,7 @@ describe('IdentityClient', () => {
const token = await hackerFactory.issueToken({
claims: { sub: 'foo' },
});
await client.authenticate(`Bearer ${token}`);
await client.authenticate(token);
}).rejects.toThrow();
});
@@ -153,7 +124,7 @@ describe('IdentityClient', () => {
claims: { sub: 'foo' },
});
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
await client.authenticate(`Bearer ${token}`);
await client.authenticate(token);
}).rejects.toThrow();
});
@@ -168,7 +139,7 @@ describe('IdentityClient', () => {
const token = await hackerFactory.issueToken({
claims: { sub: 'foo' },
});
await client.authenticate(`Bearer ${token}`);
await client.authenticate(token);
}).rejects.toThrow();
});
@@ -180,14 +151,14 @@ describe('IdentityClient', () => {
const token1 = await factory.issueToken({ claims: { sub: 'foo1' } });
try {
// This throws as token has already expired
await client.authenticate(`Bearer ${token1}`);
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(`Bearer ${token}`);
const response = await client.authenticate(token);
expect(response).toEqual({ id: 'foo', idToken: token });
});
@@ -207,25 +178,25 @@ describe('IdentityClient', () => {
}),
);
const fakeToken = `${header}.${payload}.`;
return await client.authenticate(`Bearer ${fakeToken}`);
return await client.authenticate(fakeToken);
}).rejects.toThrow();
});
});
describe('getBearerToken', () => {
it('should return null on undefined input', async () => {
it('should return undefined on undefined input', async () => {
const token = IdentityClient.getBearerToken(undefined);
expect(token).toBeNull();
expect(token).toBeUndefined();
});
it('should return null on malformed input', async () => {
it('should return undefined on malformed input', async () => {
const token = IdentityClient.getBearerToken('malformed');
expect(token).toBeNull();
expect(token).toBeUndefined();
});
it('should return null on unexpected scheme', async () => {
it('should return undefined on unexpected scheme', async () => {
const token = IdentityClient.getBearerToken('Basic token');
expect(token).toBeNull();
expect(token).toBeUndefined();
});
it('should return Bearer token', async () => {
@@ -19,9 +19,13 @@ 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;
@@ -38,17 +42,13 @@ export class IdentityClient {
/**
* Verifies the given backstage identity token
* (provided as a Bearer token in the authorization header).
* Returns a BackstageIdentity (user) matching the token.
* The method throws an error if verification fails.
*/
async authenticate(
authorizationHeader: string | undefined,
): Promise<BackstageIdentity> {
async authenticate(token: string | undefined): Promise<BackstageIdentity> {
// Extract token from header
const token = IdentityClient.getBearerToken(authorizationHeader);
if (!token) {
throw new Error('No bearer token found in authorization header');
throw new Error('No token specified');
}
// Get signing key matching token
const key = await this.getKey(token);
@@ -78,12 +78,12 @@ export class IdentityClient {
*/
static getBearerToken(
authorizationHeader: string | undefined,
): string | null {
): string | undefined {
if (typeof authorizationHeader !== 'string') {
return null;
return undefined;
}
const matches = authorizationHeader.match(/Bearer\s+(\S+)/i);
return matches && matches[1];
return matches?.[1];
}
/**
@@ -97,14 +97,16 @@ export class IdentityClient {
header: { kid: string };
payload: { iat: number };
};
// Refresh public keys if needed
if (
!this.keyStore.get({ kid: header.kid }) &&
payload?.iat &&
payload.iat > this.keyStoreUpdated
) {
// 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 });
}
@@ -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 };