Refactored as per PR comments
This commit is contained in:
@@ -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());
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user