diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md new file mode 100644 index 0000000000..2b8407abd2 --- /dev/null +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -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()); + + // ... +} +``` diff --git a/packages/backend/package.json b/packages/backend/package.json index 3fda0ad728..243ebf2ce3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 009b4e13af..68cc170901 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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) diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts index b209d1bac8..5f317aa0c2 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -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 { - this.keys.set(key.kid, { - createdAt: utc(), - key: JSON.stringify(key), - }); - } - - async removeKeys(kids: string[]): Promise { - 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 () => { diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts index df885f9888..970c30dd40 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -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 { + async authenticate(token: string | undefined): Promise { // 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 }); } diff --git a/plugins/auth-backend/src/identity/MemoryKeyStore.ts b/plugins/auth-backend/src/identity/MemoryKeyStore.ts new file mode 100644 index 0000000000..b35002b146 --- /dev/null +++ b/plugins/auth-backend/src/identity/MemoryKeyStore.ts @@ -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 { + this.keys.set(key.kid, { + createdAt: utc(), + key: JSON.stringify(key), + }); + } + + async removeKeys(kids: string[]): Promise { + 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), + })), + }; + } +} diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 8b719799b1..0c7febe616 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -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 { - this.keys.set(key.kid, { - createdAt: utc(), - key: JSON.stringify(key), - }); - } - - async removeKeys(kids: string[]): Promise { - 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 };