From e7e55cdce19529a617e58463fcf5bc3d8a2d5171 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 26 Oct 2021 14:48:51 -0400 Subject: [PATCH 01/56] Add TokenManager interface and basic implementation Signed-off-by: Nataliya Issayeva --- packages/backend-common/src/index.ts | 1 + .../src/tokens/AuthIdentityTokenManager.ts | 31 +++++++++++++++++++ packages/backend-common/src/tokens/index.ts | 17 ++++++++++ packages/backend-common/src/tokens/types.ts | 20 ++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 packages/backend-common/src/tokens/AuthIdentityTokenManager.ts create mode 100644 packages/backend-common/src/tokens/index.ts create mode 100644 packages/backend-common/src/tokens/types.ts diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c214961a2e..e430ddc1a4 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -31,4 +31,5 @@ export * from './paths'; export * from './reading'; export * from './scm'; export * from './service'; +export * from './tokens'; export * from './util'; diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts new file mode 100644 index 0000000000..80b733c449 --- /dev/null +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { TokenManager } from './types'; + +export class AuthIdentityTokenManager implements TokenManager { + private readonly SERVER_TOKEN = 'server-token'; + + async getServerToken(): Promise<{ token: string }> { + return { token: this.SERVER_TOKEN }; + } + + async validateToken(token: string): Promise { + if (token !== this.SERVER_TOKEN) { + throw new Error('Invalid server token'); + } + } +} diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts new file mode 100644 index 0000000000..5b7865a919 --- /dev/null +++ b/packages/backend-common/src/tokens/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +export type { TokenManager } from './types'; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts new file mode 100644 index 0000000000..e7d313e660 --- /dev/null +++ b/packages/backend-common/src/tokens/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +export interface TokenManager { + getServerToken: () => Promise<{ token: string }>; + validateToken: (token: string) => Promise; +} From f96679d0e6770889d3ee71e4efecdca00fcdbdc6 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 26 Oct 2021 15:47:41 -0400 Subject: [PATCH 02/56] Move IdentityClient to backend-common Signed-off-by: Nataliya Issayeva --- packages/backend-common/package.json | 2 ++ .../src/identity/IdentityClient.test.ts | 14 +++++++------- .../src/identity/IdentityClient.ts | 4 ++-- packages/backend-common/src/identity/index.ts | 17 +++++++++++++++++ packages/backend-common/src/index.ts | 1 + plugins/auth-backend/src/identity/index.ts | 2 +- plugins/auth-backend/src/index.ts | 4 ++-- 7 files changed, 32 insertions(+), 12 deletions(-) rename {plugins/auth-backend => packages/backend-common}/src/identity/IdentityClient.test.ts (97%) rename {plugins/auth-backend => packages/backend-common}/src/identity/IdentityClient.ts (97%) create mode 100644 packages/backend-common/src/identity/index.ts diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 9e0187137d..2dbeac6845 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -34,6 +34,7 @@ "@backstage/config-loader": "^0.8.0", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", + "@backstage/plugin-auth-backend": "^0.4.5", "@backstage/types": "^0.1.1", "@google-cloud/storage": "^5.8.0", "@lerna/project": "^4.0.0", @@ -54,6 +55,7 @@ "git-url-parse": "^11.6.0", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "jose": "^1.27.1", "keyv": "^4.0.3", "keyv-memcache": "^1.2.5", "knex": "^0.95.1", diff --git a/plugins/auth-backend/src/identity/IdentityClient.test.ts b/packages/backend-common/src/identity/IdentityClient.test.ts similarity index 97% rename from plugins/auth-backend/src/identity/IdentityClient.test.ts rename to packages/backend-common/src/identity/IdentityClient.test.ts index 5eca6bf8cd..36d737174d 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.test.ts +++ b/packages/backend-common/src/identity/IdentityClient.test.ts @@ -17,14 +17,14 @@ import { JWT, JSONWebKey } from 'jose'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { - getVoidLogger, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; +import { getVoidLogger } from '../logging'; +import { PluginEndpointDiscovery } from '../discovery'; import { IdentityClient } from './IdentityClient'; -import { MemoryKeyStore } from './MemoryKeyStore'; -import { TokenFactory } from './TokenFactory'; -import { KeyStore } from './types'; +import { + KeyStore, + MemoryKeyStore, + TokenFactory, +} from '@backstage/plugin-auth-backend'; const logger = getVoidLogger(); diff --git a/plugins/auth-backend/src/identity/IdentityClient.ts b/packages/backend-common/src/identity/IdentityClient.ts similarity index 97% rename from plugins/auth-backend/src/identity/IdentityClient.ts rename to packages/backend-common/src/identity/IdentityClient.ts index 2af30e4b6a..b3169b02b3 100644 --- a/plugins/auth-backend/src/identity/IdentityClient.ts +++ b/packages/backend-common/src/identity/IdentityClient.ts @@ -16,8 +16,8 @@ import fetch from 'cross-fetch'; import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; -import { BackstageIdentity } from '../providers'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { BackstageIdentity } from '@backstage/plugin-auth-backend'; +import { PluginEndpointDiscovery } from '../discovery'; const CLOCK_MARGIN_S = 10; diff --git a/packages/backend-common/src/identity/index.ts b/packages/backend-common/src/identity/index.ts new file mode 100644 index 0000000000..67692e0ed5 --- /dev/null +++ b/packages/backend-common/src/identity/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +export { IdentityClient } from './IdentityClient'; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index e430ddc1a4..a3c53e6f9f 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -25,6 +25,7 @@ export { loadBackendConfig } from './config'; export * from './database'; export * from './discovery'; export * from './hot'; +export * from './identity'; export * from './logging'; export * from './middleware'; export * from './paths'; diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 73858d7e07..727e47ed6a 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,7 +15,7 @@ */ export { createOidcRouter } from './router'; -export { IdentityClient } from './IdentityClient'; +// export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export { MemoryKeyStore } from './MemoryKeyStore'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index 432da00142..e348da116d 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -21,8 +21,8 @@ */ export * from './service/router'; -export { IdentityClient } from './identity'; -export type { TokenIssuer } from './identity'; +export { MemoryKeyStore, TokenFactory } from './identity'; +export type { KeyStore, TokenIssuer } from './identity'; export * from './providers'; // flow package provides 2 functions From 883b9ad29fce8535d7614fd2b3da7f1bb153533d Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 26 Oct 2021 15:49:48 -0400 Subject: [PATCH 03/56] Use IdentityClient to validate frontend tokens Signed-off-by: Nataliya Issayeva --- .../src/tokens/AuthIdentityTokenManager.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 80b733c449..2c71ba4d10 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -15,9 +15,19 @@ */ import { TokenManager } from './types'; +import { IdentityClient } from '../identity'; +import { PluginEndpointDiscovery } from '../discovery'; export class AuthIdentityTokenManager implements TokenManager { private readonly SERVER_TOKEN = 'server-token'; + private identityClient: IdentityClient; + + constructor(discovery: PluginEndpointDiscovery) { + this.identityClient = new IdentityClient({ + discovery, + issuer: 'auth-identity-token-manager', + }); + } async getServerToken(): Promise<{ token: string }> { return { token: this.SERVER_TOKEN }; @@ -25,7 +35,12 @@ export class AuthIdentityTokenManager implements TokenManager { async validateToken(token: string): Promise { if (token !== this.SERVER_TOKEN) { - throw new Error('Invalid server token'); + try { + await this.identityClient.authenticate(token); + return; + } catch (e) { + throw new Error('Invalid token'); + } } } } From 4f066c3be6a24e4195c97082957c7c7908a66847 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 26 Oct 2021 15:50:40 -0400 Subject: [PATCH 04/56] Inject tokenManager into plugin environment Signed-off-by: Nataliya Issayeva --- packages/backend-common/src/tokens/index.ts | 1 + .../templates/default-app/packages/backend/src/index.ts | 4 +++- .../templates/default-app/packages/backend/src/types.ts | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts index 5b7865a919..107d13ac1d 100644 --- a/packages/backend-common/src/tokens/index.ts +++ b/packages/backend-common/src/tokens/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export { AuthIdentityTokenManager } from './AuthIdentityTokenManager'; export type { TokenManager } from './types'; diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index f2b14b23f9..204fe8ae81 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -17,6 +17,7 @@ import { DatabaseManager, SingleHostDiscovery, UrlReaders, + AuthIdentityTokenManager, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import app from './plugins/app'; @@ -37,12 +38,13 @@ function makeCreateEnv(config: Config) { const cacheManager = CacheManager.fromConfig(config); const databaseManager = DatabaseManager.fromConfig(config); + const tokenManager = new AuthIdentityTokenManager(discovery); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); - return { logger, database, cache, config, reader, discovery }; + return { logger, database, cache, config, reader, discovery, tokenManager }; }; } diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 6c78a2a90c..2aac509609 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -1,6 +1,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { + AuthIdentityTokenManager, PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, @@ -14,4 +15,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; + tokenManager: AuthIdentityTokenManager; }; From 7b4f1ed0b9b82bd965f29509f5a03b05a8e50d78 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 27 Oct 2021 16:49:56 -0400 Subject: [PATCH 05/56] Add TODOs Signed-off-by: Nataliya Issayeva --- packages/backend-common/src/identity/IdentityClient.ts | 3 +++ .../backend-common/src/tokens/AuthIdentityTokenManager.ts | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/identity/IdentityClient.ts b/packages/backend-common/src/identity/IdentityClient.ts index b3169b02b3..c69ff1be64 100644 --- a/packages/backend-common/src/identity/IdentityClient.ts +++ b/packages/backend-common/src/identity/IdentityClient.ts @@ -27,6 +27,8 @@ const CLOCK_MARGIN_S = 10; * * @experimental This is not a stable API yet */ + +// TODO: (b2b-auth) integrate IdentityClient into the TokenManager instead export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; @@ -90,6 +92,7 @@ export class IdentityClient { * Returns the public signing key matching the given jwt token, * or null if no matching key was found */ + // TODO (b2b-auth): switch on type to identify server tokens? private async getKey(rawJwtToken: string): Promise { const { header, payload } = JWT.decode(rawJwtToken, { complete: true, diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 2c71ba4d10..c33c4ecd78 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -19,6 +19,7 @@ import { IdentityClient } from '../identity'; import { PluginEndpointDiscovery } from '../discovery'; export class AuthIdentityTokenManager implements TokenManager { + // TODO: (b2b-auth) replace this in favor of actual server token in config likely private readonly SERVER_TOKEN = 'server-token'; private identityClient: IdentityClient; @@ -33,13 +34,15 @@ export class AuthIdentityTokenManager implements TokenManager { return { token: this.SERVER_TOKEN }; } + // TODO: (b2b-auth) authenticate returns a Backstage Identity + // need to figure out what to return after validating a server token async validateToken(token: string): Promise { if (token !== this.SERVER_TOKEN) { try { await this.identityClient.authenticate(token); return; - } catch (e) { - throw new Error('Invalid token'); + } catch (error) { + throw new Error(`Invalid token, ${error}`); } } } From 657a6a0b8e5301e82703b4f31c6ea6db3bafdaf0 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 27 Oct 2021 16:59:36 -0400 Subject: [PATCH 06/56] Add code to enable authorization Signed-off-by: Nataliya Issayeva --- packages/app/src/App.tsx | 58 ++++++++++++++++++++++++++ packages/backend/package.json | 2 + packages/backend/src/index.ts | 77 ++++++++++++++++++++++++++++++++++- packages/backend/src/types.ts | 2 + 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 401665b62e..64a8163da2 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -86,6 +86,52 @@ import { providers } from './identityProviders'; import * as plugins from './plugins'; import { techDocsPage } from './components/techdocs/TechDocsPage'; +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; + +// Parses supplied JWT token and returns the payload +function parseJwt(token: string): { exp: number } { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`; + }) + .join(''), + ); + + return JSON.parse(jsonPayload); +} + +// Returns milliseconds until the supplied JWT token expires +function msUntilExpiry(token: string): number { + const payload = parseJwt(token); + const remaining = + new Date(payload.exp * 1000).getTime() - new Date().getTime(); + return remaining; +} + +// Calls the specified url regularly using an auth token to set a token cookie +// to authorize regular HTTP requests when loading techdocs +async function setTokenCookie(url: string, getIdToken: () => Promise) { + const token = await getIdToken(); + await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + // Call this function again a few minutes before the token expires + const ms = msUntilExpiry(token) - 4 * 60 * 1000; + setTimeout( + () => { + setTokenCookie(url, getIdToken); + }, + ms > 0 ? ms : 10000, + ); +} const app = createApp({ apis, @@ -96,12 +142,24 @@ const app = createApp({ }, components: { SignInPage: props => { + const discoveryApi = useApi(discoveryApiRef); return ( { + // When logged in, set a token cookie + if (typeof result.getIdToken !== 'undefined') { + setTokenCookie( + await discoveryApi.getBaseUrl('cookie'), + result.getIdToken, + ); + } + // Forward results + props.onResult(result); + }} /> ); }, diff --git a/packages/backend/package.json b/packages/backend/package.json index 4fb692f040..aa2e544386 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,11 +55,13 @@ "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", + "cookie-parser": "^1.4.5", "dockerode": "^3.3.1", "example-app": "^0.2.53", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", + "jose": "^1.27.1", "knex": "^0.95.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f978e84da9..9bf4f7d9cc 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,6 +33,8 @@ import { SingleHostDiscovery, UrlReaders, useHotMemoize, + AuthIdentityTokenManager, + IdentityClient, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; @@ -55,11 +57,16 @@ import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; import { PluginEnvironment } from './types'; +import cookieParser from 'cookie-parser'; +import { Request, Response, NextFunction } from 'express'; +import { JWT } from 'jose'; +import { URL } from 'url'; function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = new AuthIdentityTokenManager(discovery); root.info(`Created UrlReader ${reader}`); @@ -70,10 +77,31 @@ function makeCreateEnv(config: Config) { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); const cache = cacheManager.forPlugin(plugin); - return { logger, cache, database, config, reader, discovery }; + return { logger, cache, database, config, reader, discovery, tokenManager }; }; } +function setTokenCookie( + res: Response, + options: { token: string; secure: boolean; cookieDomain: string }, +) { + try { + const payload = JWT.decode(options.token) as object & { + exp: number; + }; + res.cookie(`token`, options.token, { + expires: new Date(payload?.exp ? payload?.exp * 1000 : 0), + secure: options.secure, + sameSite: 'lax', + domain: options.cookieDomain, + path: '/', + httpOnly: true, + }); + } catch (_err) { + // Ignore + } +} + async function main() { metricsInit(); const logger = getRootLogger(); @@ -112,7 +140,54 @@ async function main() { createEnv('tech-insights'), ); + const discovery = SingleHostDiscovery.fromConfig(config); + const identity = new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + const baseUrl = config.getString('backend.baseUrl'); + const secure = baseUrl.startsWith('https://'); + const cookieDomain = new URL(baseUrl).hostname; + const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const token = + IdentityClient.getBearerToken(req.headers.authorization) || + req.cookies.token; + req.user = await identity.authenticate(token); + if (!req.headers.authorization) { + // Authorization header may be forwarded by plugin requests + req.headers.authorization = `Bearer ${token}`; + } + if (token !== req.cookies.token) { + setTokenCookie(res, { + token, + secure, + cookieDomain, + }); + } + next(); + } catch (error) { + res.status(401).send(`Unauthorized`); + } + }; + const apiRouter = Router(); + apiRouter.use(cookieParser()); + // The auth route must be publically available as it is used during login + apiRouter.use('/auth', await auth(authEnv)); + // Add a simple endpoint to be used when setting a token cookie + apiRouter.use('/cookie', authMiddleware, (_req, res) => { + res.status(200).send(`Coming right up`); + }); + // 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()); apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/code-coverage', await codeCoverage(codeCoverageEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8290e569ef..1089497db7 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,6 +17,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { + AuthIdentityTokenManager, PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, @@ -30,4 +31,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; + tokenManager: AuthIdentityTokenManager; }; From 2a320c21b4aadd1b7739d76da8d783b62b48f585 Mon Sep 17 00:00:00 2001 From: Joe Porpeglia Date: Thu, 28 Oct 2021 15:51:20 -0400 Subject: [PATCH 07/56] Pass auth token from document collators Co-authored-by: Nataliya Issayeva Co-authored-by: Tim Hansen Signed-off-by: Joe Porpeglia --- packages/backend/src/index.ts | 4 +- packages/backend/src/plugins/search.ts | 7 +++- .../src/search/DefaultCatalogCollator.ts | 20 ++++++++-- .../src/search/DefaultTechDocsCollator.ts | 39 ++++++++++++------- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9bf4f7d9cc..81d9c36d3f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -157,7 +157,9 @@ async function main() { const token = IdentityClient.getBearerToken(req.headers.authorization) || req.cookies.token; - req.user = await identity.authenticate(token); + + await authEnv.tokenManager.validateToken(token); + if (!req.headers.authorization) { // Authorization header may be forwarded by plugin requests req.headers.authorization = `Bearer ${token}`; diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 4cf9c10021..9a8db0f0f9 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -59,6 +59,7 @@ export default async function createPlugin({ discovery, config, database, + tokenManager, }: PluginEnvironment) { // Initialize a connection to a search engine. const searchEngine = await createSearchEngine({ config, logger, database }); @@ -68,7 +69,10 @@ export default async function createPlugin({ // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { discovery }), + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + tokenManager, + }), }); indexBuilder.addCollator({ @@ -76,6 +80,7 @@ export default async function createPlugin({ collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, + tokenManager, }), }); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index a28a7645c4..36ba59ad2e 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { IndexableDocument, DocumentCollator } from '@backstage/search-common'; import { Config } from '@backstage/config'; @@ -38,11 +41,13 @@ export class DefaultCatalogCollator implements DocumentCollator { protected filter?: CatalogEntitiesRequest['filter']; protected readonly catalogClient: CatalogApi; public readonly type: string = 'software-catalog'; + protected tokenManager: TokenManager; static fromConfig( _config: Config, options: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; filter?: CatalogEntitiesRequest['filter']; }, ) { @@ -56,8 +61,10 @@ export class DefaultCatalogCollator implements DocumentCollator { locationTemplate, filter, catalogClient, + tokenManager, }: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; locationTemplate?: string; filter?: CatalogEntitiesRequest['filter']; catalogClient?: CatalogApi; @@ -68,6 +75,7 @@ export class DefaultCatalogCollator implements DocumentCollator { this.filter = filter; this.catalogClient = catalogClient || new CatalogClient({ discoveryApi: discovery }); + this.tokenManager = tokenManager; } protected applyArgsToFormat( @@ -82,9 +90,13 @@ export class DefaultCatalogCollator implements DocumentCollator { } async execute() { - const response = await this.catalogClient.getEntities({ - filter: this.filter, - }); + const { token } = await this.tokenManager.getServerToken(); + const response = await this.catalogClient.getEntities( + { + filter: this.filter, + }, + { token }, + ); return response.items.map((entity: Entity): CatalogEntityDocument => { return { title: entity.metadata.title ?? entity.metadata.name, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index 121ef6e46d..094fb8b049 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { DocumentCollator } from '@backstage/search-common'; import fetch from 'cross-fetch'; @@ -34,6 +37,7 @@ interface MkSearchIndexDoc { export type TechDocsCollatorOptions = { discovery: PluginEndpointDiscovery; logger: Logger; + tokenManager: TokenManager; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; @@ -51,6 +55,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { protected locationTemplate: string; private readonly logger: Logger; private readonly catalogClient: CatalogApi; + private readonly tokenManager: TokenManager; private readonly parallelismLimit: number; private readonly legacyPathCasing: boolean; public readonly type: string = 'techdocs'; @@ -63,6 +68,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { locationTemplate, logger, catalogClient, + tokenManager, parallelismLimit = 10, legacyPathCasing = false, }: TechDocsCollatorOptions) { @@ -74,6 +80,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { catalogClient || new CatalogClient({ discoveryApi: discovery }); this.parallelismLimit = parallelismLimit; this.legacyPathCasing = legacyPathCasing; + this.tokenManager = tokenManager; } static fromConfig(config: Config, options: TechDocsCollatorOptions) { @@ -87,19 +94,23 @@ export class DefaultTechDocsCollator implements DocumentCollator { async execute() { const limit = pLimit(this.parallelismLimit); const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs'); - const entities = await this.catalogClient.getEntities({ - fields: [ - 'kind', - 'namespace', - 'metadata.annotations', - 'metadata.name', - 'metadata.title', - 'metadata.namespace', - 'spec.type', - 'spec.lifecycle', - 'relations', - ], - }); + const { token } = await this.tokenManager.getServerToken(); + const entities = await this.catalogClient.getEntities( + { + fields: [ + 'kind', + 'namespace', + 'metadata.annotations', + 'metadata.name', + 'metadata.title', + 'metadata.namespace', + 'spec.type', + 'spec.lifecycle', + 'relations', + ], + }, + { token }, + ); const docPromises = entities.items .filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref']) .map((entity: Entity) => From c4bdd0451579531773e24f5b4124b5d038c080d7 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 29 Oct 2021 15:37:10 -0400 Subject: [PATCH 08/56] Use and validate JWT for servers Signed-off-by: Nataliya Issayeva --- .../src/tokens/AuthIdentityTokenManager.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index c33c4ecd78..da9c62a231 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -14,36 +14,48 @@ * limitations under the License. */ +import { JWK, JWT } from 'jose'; import { TokenManager } from './types'; import { IdentityClient } from '../identity'; import { PluginEndpointDiscovery } from '../discovery'; export class AuthIdentityTokenManager implements TokenManager { - // TODO: (b2b-auth) replace this in favor of actual server token in config likely - private readonly SERVER_TOKEN = 'server-token'; private identityClient: IdentityClient; + private key: JWK.OctKey; - constructor(discovery: PluginEndpointDiscovery) { + constructor(discovery: PluginEndpointDiscovery, secret: string) { this.identityClient = new IdentityClient({ discovery, issuer: 'auth-identity-token-manager', }); + this.key = JWK.asKey(Buffer.from(secret)) as JWK.OctKey; } async getServerToken(): Promise<{ token: string }> { - return { token: this.SERVER_TOKEN }; + const jwt = JWT.sign({ sub: 'backstage-server' }, this.key); + return { token: jwt }; } // TODO: (b2b-auth) authenticate returns a Backstage Identity // need to figure out what to return after validating a server token async validateToken(token: string): Promise { - if (token !== this.SERVER_TOKEN) { - try { - await this.identityClient.authenticate(token); - return; - } catch (error) { - throw new Error(`Invalid token, ${error}`); - } + let maybeUser; + let maybeServer; + try { + maybeUser = await this.identityClient.authenticate(token); + } catch (error) { + // invalid token } + + try { + maybeServer = JWT.verify(token, this.key); + } catch (error) { + // invalid token + } + + if (!maybeUser && !maybeServer) { + throw new Error(`Invalid token`); + } + return; } } From f3d9de436f5df72b6ef0094e5c635bb4bdb0ecdc Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 29 Oct 2021 16:21:51 -0400 Subject: [PATCH 09/56] Make tokenManager constructor private and add static create method Signed-off-by: Nataliya Issayeva --- .../src/identity/IdentityClient.ts | 3 ++- .../src/tokens/AuthIdentityTokenManager.ts | 17 ++++++++++++++--- packages/backend/src/index.ts | 5 ++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/backend-common/src/identity/IdentityClient.ts b/packages/backend-common/src/identity/IdentityClient.ts index c69ff1be64..c2aa913df3 100644 --- a/packages/backend-common/src/identity/IdentityClient.ts +++ b/packages/backend-common/src/identity/IdentityClient.ts @@ -28,7 +28,8 @@ const CLOCK_MARGIN_S = 10; * @experimental This is not a stable API yet */ -// TODO: (b2b-auth) integrate IdentityClient into the TokenManager instead +// TODO: (b2b-auth) move IdentityClient into tokens +// perhaps also create an interface? export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index da9c62a231..72bec24448 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -19,19 +19,30 @@ import { TokenManager } from './types'; import { IdentityClient } from '../identity'; import { PluginEndpointDiscovery } from '../discovery'; +// TODO: (b2b-auth) rename this class export class AuthIdentityTokenManager implements TokenManager { private identityClient: IdentityClient; private key: JWK.OctKey; - constructor(discovery: PluginEndpointDiscovery, secret: string) { - this.identityClient = new IdentityClient({ - discovery, + static create(options: { + discovery: PluginEndpointDiscovery; + secret: string; + }) { + const identityClient = new IdentityClient({ + discovery: options.discovery, issuer: 'auth-identity-token-manager', }); + return new AuthIdentityTokenManager(identityClient, options.secret); + } + + private constructor(identityClient: IdentityClient, secret: string) { + this.identityClient = identityClient; + // TODO: (b2b-auth) how do we get this to be the right JWK type this.key = JWK.asKey(Buffer.from(secret)) as JWK.OctKey; } async getServerToken(): Promise<{ token: string }> { + // TODO: (b2b-auth) figure out how to use HMAC as the alg const jwt = JWT.sign({ sub: 'backstage-server' }, this.key); return { token: jwt }; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 81d9c36d3f..35d6365851 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,7 +66,10 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = new AuthIdentityTokenManager(discovery); + const tokenManager = AuthIdentityTokenManager.create({ + discovery, + secret: 'secret-tehe', + }); root.info(`Created UrlReader ${reader}`); From 1b2bd0ec015d9266fe3ff3d5d7aafea8141b2b1b Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 29 Oct 2021 16:22:27 -0400 Subject: [PATCH 10/56] Add basic tests Signed-off-by: Nataliya Issayeva --- .../tokens/AuthIdentityTokenManager.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts new file mode 100644 index 0000000000..e8df55cb3b --- /dev/null +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { PluginEndpointDiscovery } from '..'; +import { IdentityClient } from '../identity'; +import { AuthIdentityTokenManager } from './AuthIdentityTokenManager'; + +const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return 'url'; + }, + async getExternalBaseUrl() { + return 'url'; + }, +}; + +beforeAll(() => { + jest + .spyOn(IdentityClient.prototype, 'authenticate') + .mockImplementation(async (_token?: string) => { + throw new Error('No'); + }); +}); + +describe('AuthIdentityTokenManager', () => { + it('should validate a valid server token', async () => { + const tokenManager = AuthIdentityTokenManager.create({ + discovery, + secret: 'a-secret-key', + }); + const { token } = await tokenManager.getServerToken(); + await expect(tokenManager.validateToken(token)).resolves.toBeUndefined(); + }); + + it('should reject an invalid server token', async () => { + const tokenManager = AuthIdentityTokenManager.create({ + discovery, + secret: 'a-secret-key', + }); + const differentTokenManager = AuthIdentityTokenManager.create({ + discovery, + secret: 'a-different-key', + }); + const { token } = await tokenManager.getServerToken(); + await expect(differentTokenManager.validateToken(token)).rejects.toThrow( + 'Invalid token', + ); + }); +}); From 2533294ec11b32c2e4e1e73929adef796226ed19 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 29 Oct 2021 16:43:32 -0400 Subject: [PATCH 11/56] Static create and private constructor not really needed Signed-off-by: Nataliya Issayeva --- .../tokens/AuthIdentityTokenManager.test.ts | 18 +++++++++--------- .../src/tokens/AuthIdentityTokenManager.ts | 14 +++----------- packages/backend/src/index.ts | 5 +---- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts index e8df55cb3b..b52acbe300 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts @@ -36,23 +36,23 @@ beforeAll(() => { describe('AuthIdentityTokenManager', () => { it('should validate a valid server token', async () => { - const tokenManager = AuthIdentityTokenManager.create({ + const tokenManager = new AuthIdentityTokenManager( discovery, - secret: 'a-secret-key', - }); + 'a-secret-key', + ); const { token } = await tokenManager.getServerToken(); await expect(tokenManager.validateToken(token)).resolves.toBeUndefined(); }); it('should reject an invalid server token', async () => { - const tokenManager = AuthIdentityTokenManager.create({ + const tokenManager = new AuthIdentityTokenManager( discovery, - secret: 'a-secret-key', - }); - const differentTokenManager = AuthIdentityTokenManager.create({ + 'a-secret-key', + ); + const differentTokenManager = new AuthIdentityTokenManager( discovery, - secret: 'a-different-key', - }); + 'a-different-key', + ); const { token } = await tokenManager.getServerToken(); await expect(differentTokenManager.validateToken(token)).rejects.toThrow( 'Invalid token', diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 72bec24448..42c52b84fa 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -24,19 +24,11 @@ export class AuthIdentityTokenManager implements TokenManager { private identityClient: IdentityClient; private key: JWK.OctKey; - static create(options: { - discovery: PluginEndpointDiscovery; - secret: string; - }) { - const identityClient = new IdentityClient({ - discovery: options.discovery, + constructor(discovery: PluginEndpointDiscovery, secret: string) { + this.identityClient = new IdentityClient({ + discovery: discovery, issuer: 'auth-identity-token-manager', }); - return new AuthIdentityTokenManager(identityClient, options.secret); - } - - private constructor(identityClient: IdentityClient, secret: string) { - this.identityClient = identityClient; // TODO: (b2b-auth) how do we get this to be the right JWK type this.key = JWK.asKey(Buffer.from(secret)) as JWK.OctKey; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 35d6365851..ffcd52a1dd 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,10 +66,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = AuthIdentityTokenManager.create({ - discovery, - secret: 'secret-tehe', - }); + const tokenManager = new AuthIdentityTokenManager(discovery, 'secret-tehe'); root.info(`Created UrlReader ${reader}`); From 75897957984ca807a6b437c60f4705f4044ff63b Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 8 Nov 2021 13:55:01 -0500 Subject: [PATCH 12/56] Use correct JWT type and algorithm Signed-off-by: Nataliya Issayeva --- .../backend-common/src/tokens/AuthIdentityTokenManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 42c52b84fa..9487d4555b 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -29,13 +29,13 @@ export class AuthIdentityTokenManager implements TokenManager { discovery: discovery, issuer: 'auth-identity-token-manager', }); - // TODO: (b2b-auth) how do we get this to be the right JWK type - this.key = JWK.asKey(Buffer.from(secret)) as JWK.OctKey; + this.key = JWK.asKey({ kty: 'oct', k: secret }); } async getServerToken(): Promise<{ token: string }> { - // TODO: (b2b-auth) figure out how to use HMAC as the alg - const jwt = JWT.sign({ sub: 'backstage-server' }, this.key); + const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { + algorithm: 'HS256', + }); return { token: jwt }; } From 8ceffe5f98f32f1bbc2f4ca9996309f314613842 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 8 Nov 2021 16:11:01 -0500 Subject: [PATCH 13/56] Use backend secret from config Signed-off-by: Nataliya Issayeva --- app-config.yaml | 2 ++ .../backend-common/src/tokens/AuthIdentityTokenManager.ts | 5 ++++- packages/backend/src/index.ts | 2 +- .../templates/default-app/packages/backend/src/index.ts | 2 +- plugins/auth-backend/src/identity/index.ts | 1 - 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 8aa3bc569c..fdd97cbb8c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,6 +23,8 @@ app: title: '#backstage' backend: + auth: + secret: ${BACKEND_SECRET} baseUrl: http://localhost:7000 listen: port: 7000 diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 9487d4555b..7aa97925ae 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -15,6 +15,7 @@ */ import { JWK, JWT } from 'jose'; +import { Config } from '@backstage/config'; import { TokenManager } from './types'; import { IdentityClient } from '../identity'; import { PluginEndpointDiscovery } from '../discovery'; @@ -24,11 +25,13 @@ export class AuthIdentityTokenManager implements TokenManager { private identityClient: IdentityClient; private key: JWK.OctKey; - constructor(discovery: PluginEndpointDiscovery, secret: string) { + constructor(discovery: PluginEndpointDiscovery, config: Config) { this.identityClient = new IdentityClient({ discovery: discovery, issuer: 'auth-identity-token-manager', }); + + const secret = config.getConfig('backend.auth').getString('secret'); this.key = JWK.asKey({ kty: 'oct', k: secret }); } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index ffcd52a1dd..57417ce9c6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -66,7 +66,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = new AuthIdentityTokenManager(discovery, 'secret-tehe'); + const tokenManager = new AuthIdentityTokenManager(discovery, config); root.info(`Created UrlReader ${reader}`); diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 204fe8ae81..a85f331a7b 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -38,7 +38,7 @@ function makeCreateEnv(config: Config) { const cacheManager = CacheManager.fromConfig(config); const databaseManager = DatabaseManager.fromConfig(config); - const tokenManager = new AuthIdentityTokenManager(discovery); + const tokenManager = new AuthIdentityTokenManager(discovery, config); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index 727e47ed6a..cb2f369668 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,7 +15,6 @@ */ export { createOidcRouter } from './router'; -// export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export { MemoryKeyStore } from './MemoryKeyStore'; From 8fa1098cd575ea89eab20c413c37077bbf2a6c32 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 8 Nov 2021 16:49:46 -0500 Subject: [PATCH 14/56] Handle optionally set backend auth secret Signed-off-by: Nataliya Issayeva --- .../src/tokens/AuthIdentityTokenManager.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts index 7aa97925ae..f8c3fe061d 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts @@ -23,7 +23,7 @@ import { PluginEndpointDiscovery } from '../discovery'; // TODO: (b2b-auth) rename this class export class AuthIdentityTokenManager implements TokenManager { private identityClient: IdentityClient; - private key: JWK.OctKey; + private key?: JWK.OctKey; constructor(discovery: PluginEndpointDiscovery, config: Config) { this.identityClient = new IdentityClient({ @@ -31,11 +31,17 @@ export class AuthIdentityTokenManager implements TokenManager { issuer: 'auth-identity-token-manager', }); - const secret = config.getConfig('backend.auth').getString('secret'); - this.key = JWK.asKey({ kty: 'oct', k: secret }); + const secret = config.getOptionalString('backend.auth.secret'); + if (secret) { + this.key = JWK.asKey({ kty: 'oct', k: secret }); + } } async getServerToken(): Promise<{ token: string }> { + if (!this.key) { + throw new Error('No server token defined in config'); + } + const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { algorithm: 'HS256', }); @@ -54,6 +60,9 @@ export class AuthIdentityTokenManager implements TokenManager { } try { + if (!this.key) { + throw new Error('No server token defined in config'); + } maybeServer = JWT.verify(token, this.key); } catch (error) { // invalid token From fa6a9b797f00a786fffef6353a68a5abba0c608d Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 8 Nov 2021 17:09:50 -0500 Subject: [PATCH 15/56] Update AuthIdentityTokenManager tests Signed-off-by: Nataliya Issayeva --- packages/backend-common/package.json | 1 + .../tokens/AuthIdentityTokenManager.test.ts | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 2dbeac6845..94f1b39672 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -83,6 +83,7 @@ }, "devDependencies": { "@backstage/cli": "^0.9.0", + "@backstage/core-app-api": "^0.1.21", "@backstage/test-utils": "^0.1.22", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts index b52acbe300..c0e952bae5 100644 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts +++ b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { ConfigReader } from '@backstage/core-app-api'; import { PluginEndpointDiscovery } from '..'; import { IdentityClient } from '../identity'; import { AuthIdentityTokenManager } from './AuthIdentityTokenManager'; @@ -25,6 +26,9 @@ const discovery: PluginEndpointDiscovery = { return 'url'; }, }; +const config = new ConfigReader({ + backend: { auth: { secret: 'a-secret-key' } }, +}); beforeAll(() => { jest @@ -35,23 +39,28 @@ beforeAll(() => { }); describe('AuthIdentityTokenManager', () => { - it('should validate a valid server token', async () => { - const tokenManager = new AuthIdentityTokenManager( - discovery, - 'a-secret-key', + it('should throw in getServerToken if there is no secret in the config', async () => { + const emptyConfig = new ConfigReader({}); + const tokenManager = new AuthIdentityTokenManager(discovery, emptyConfig); + await expect(tokenManager.getServerToken()).rejects.toThrow( + 'No server token defined in config', ); + }); + + it('should validate a valid server token', async () => { + const tokenManager = new AuthIdentityTokenManager(discovery, config); const { token } = await tokenManager.getServerToken(); await expect(tokenManager.validateToken(token)).resolves.toBeUndefined(); }); it('should reject an invalid server token', async () => { - const tokenManager = new AuthIdentityTokenManager( - discovery, - 'a-secret-key', - ); + const differentConfig = new ConfigReader({ + backend: { auth: { secret: 'a-different-key' } }, + }); + const tokenManager = new AuthIdentityTokenManager(discovery, config); const differentTokenManager = new AuthIdentityTokenManager( discovery, - 'a-different-key', + differentConfig, ); const { token } = await tokenManager.getServerToken(); await expect(differentTokenManager.validateToken(token)).rejects.toThrow( From b38d77f4f0f9ebc2cda7a43bc1031549d7458cd3 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 9 Nov 2021 17:57:03 -0500 Subject: [PATCH 16/56] Change AuthIdentityTokenManager to ServerTokenManager Co-authored-by: Joe Porpeglia Co-authored-by: Tim Hansen Signed-off-by: Nataliya Issayeva --- app-config.yaml | 2 +- .../src/identity/IdentityClient.ts | 3 - .../tokens/AuthIdentityTokenManager.test.ts | 70 ----------------- .../src/tokens/AuthIdentityTokenManager.ts | 76 ------------------- .../src/tokens/ServerTokenManager.test.ts | 49 ++++++++++++ .../src/tokens/ServerTokenManager.ts | 45 +++++++++++ packages/backend-common/src/tokens/index.ts | 2 +- packages/backend-common/src/tokens/types.ts | 2 +- packages/backend/src/index.ts | 9 ++- packages/backend/src/types.ts | 4 +- .../default-app/packages/backend/src/index.ts | 4 +- .../default-app/packages/backend/src/types.ts | 4 +- 12 files changed, 109 insertions(+), 161 deletions(-) delete mode 100644 packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts delete mode 100644 packages/backend-common/src/tokens/AuthIdentityTokenManager.ts create mode 100644 packages/backend-common/src/tokens/ServerTokenManager.test.ts create mode 100644 packages/backend-common/src/tokens/ServerTokenManager.ts diff --git a/app-config.yaml b/app-config.yaml index fdd97cbb8c..88cd2c4648 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,7 +23,7 @@ app: title: '#backstage' backend: - auth: + authorization: secret: ${BACKEND_SECRET} baseUrl: http://localhost:7000 listen: diff --git a/packages/backend-common/src/identity/IdentityClient.ts b/packages/backend-common/src/identity/IdentityClient.ts index c2aa913df3..2921b5705c 100644 --- a/packages/backend-common/src/identity/IdentityClient.ts +++ b/packages/backend-common/src/identity/IdentityClient.ts @@ -28,8 +28,6 @@ const CLOCK_MARGIN_S = 10; * @experimental This is not a stable API yet */ -// TODO: (b2b-auth) move IdentityClient into tokens -// perhaps also create an interface? export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; @@ -93,7 +91,6 @@ export class IdentityClient { * Returns the public signing key matching the given jwt token, * or null if no matching key was found */ - // TODO (b2b-auth): switch on type to identify server tokens? private async getKey(rawJwtToken: string): Promise { const { header, payload } = JWT.decode(rawJwtToken, { complete: true, diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts deleted file mode 100644 index c0e952bae5..0000000000 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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 { ConfigReader } from '@backstage/core-app-api'; -import { PluginEndpointDiscovery } from '..'; -import { IdentityClient } from '../identity'; -import { AuthIdentityTokenManager } from './AuthIdentityTokenManager'; - -const discovery: PluginEndpointDiscovery = { - async getBaseUrl() { - return 'url'; - }, - async getExternalBaseUrl() { - return 'url'; - }, -}; -const config = new ConfigReader({ - backend: { auth: { secret: 'a-secret-key' } }, -}); - -beforeAll(() => { - jest - .spyOn(IdentityClient.prototype, 'authenticate') - .mockImplementation(async (_token?: string) => { - throw new Error('No'); - }); -}); - -describe('AuthIdentityTokenManager', () => { - it('should throw in getServerToken if there is no secret in the config', async () => { - const emptyConfig = new ConfigReader({}); - const tokenManager = new AuthIdentityTokenManager(discovery, emptyConfig); - await expect(tokenManager.getServerToken()).rejects.toThrow( - 'No server token defined in config', - ); - }); - - it('should validate a valid server token', async () => { - const tokenManager = new AuthIdentityTokenManager(discovery, config); - const { token } = await tokenManager.getServerToken(); - await expect(tokenManager.validateToken(token)).resolves.toBeUndefined(); - }); - - it('should reject an invalid server token', async () => { - const differentConfig = new ConfigReader({ - backend: { auth: { secret: 'a-different-key' } }, - }); - const tokenManager = new AuthIdentityTokenManager(discovery, config); - const differentTokenManager = new AuthIdentityTokenManager( - discovery, - differentConfig, - ); - const { token } = await tokenManager.getServerToken(); - await expect(differentTokenManager.validateToken(token)).rejects.toThrow( - 'Invalid token', - ); - }); -}); diff --git a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts b/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts deleted file mode 100644 index f8c3fe061d..0000000000 --- a/packages/backend-common/src/tokens/AuthIdentityTokenManager.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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 { JWK, JWT } from 'jose'; -import { Config } from '@backstage/config'; -import { TokenManager } from './types'; -import { IdentityClient } from '../identity'; -import { PluginEndpointDiscovery } from '../discovery'; - -// TODO: (b2b-auth) rename this class -export class AuthIdentityTokenManager implements TokenManager { - private identityClient: IdentityClient; - private key?: JWK.OctKey; - - constructor(discovery: PluginEndpointDiscovery, config: Config) { - this.identityClient = new IdentityClient({ - discovery: discovery, - issuer: 'auth-identity-token-manager', - }); - - const secret = config.getOptionalString('backend.auth.secret'); - if (secret) { - this.key = JWK.asKey({ kty: 'oct', k: secret }); - } - } - - async getServerToken(): Promise<{ token: string }> { - if (!this.key) { - throw new Error('No server token defined in config'); - } - - const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { - algorithm: 'HS256', - }); - return { token: jwt }; - } - - // TODO: (b2b-auth) authenticate returns a Backstage Identity - // need to figure out what to return after validating a server token - async validateToken(token: string): Promise { - let maybeUser; - let maybeServer; - try { - maybeUser = await this.identityClient.authenticate(token); - } catch (error) { - // invalid token - } - - try { - if (!this.key) { - throw new Error('No server token defined in config'); - } - maybeServer = JWT.verify(token, this.key); - } catch (error) { - // invalid token - } - - if (!maybeUser && !maybeServer) { - throw new Error(`Invalid token`); - } - return; - } -} diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts new file mode 100644 index 0000000000..88ef1f27c2 --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/core-app-api'; +import { ServerTokenManager } from './ServerTokenManager'; + +const emptyConfig = new ConfigReader({}); +const configWithSecret = new ConfigReader({ + backend: { authorization: { secret: 'a-secret-key' } }, +}); + +describe('ServerTokenManager', () => { + describe('getServerToken', () => { + it('should always return a token', async () => { + const tokenManager = new ServerTokenManager(configWithSecret); + expect((await tokenManager.getServerToken()).token).toBeDefined(); + + const emptyTokenManager = new ServerTokenManager(emptyConfig); + expect((await emptyTokenManager.getServerToken()).token).toBeDefined(); + }); + }); + + describe('isServerToken', () => { + it('should return true if token is valid', async () => { + const tokenManager = new ServerTokenManager(configWithSecret); + const { token } = await tokenManager.getServerToken(); + const isServerToken = await tokenManager.isServerToken(token); + expect(isServerToken).toBe(true); + }); + + it('should return false if token is invalid', async () => { + const tokenManager = new ServerTokenManager(configWithSecret); + const isServerToken = await tokenManager.isServerToken('random-string'); + expect(isServerToken).toBe(false); + }); + }); +}); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts new file mode 100644 index 0000000000..6737dd4d8b --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { JWK, JWT } from 'jose'; +import { Config } from '@backstage/config'; +import { TokenManager } from './types'; + +export class ServerTokenManager implements TokenManager { + private key: JWK.OctKey; + + constructor(config: Config) { + const secret = + config.getOptionalString('backend.authorization.secret') ?? 'no-secret'; + this.key = JWK.asKey({ kty: 'oct', k: secret }); + } + + async isServerToken(token: string): Promise { + try { + JWT.verify(token, this.key); + return true; + } catch (e) { + return false; + } + } + + async getServerToken(): Promise<{ token: string }> { + const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { + algorithm: 'HS256', + }); + return { token: jwt }; + } +} diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts index 107d13ac1d..43ff12e597 100644 --- a/packages/backend-common/src/tokens/index.ts +++ b/packages/backend-common/src/tokens/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { AuthIdentityTokenManager } from './AuthIdentityTokenManager'; +export { ServerTokenManager } from './ServerTokenManager'; export type { TokenManager } from './types'; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index e7d313e660..278695a448 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -15,6 +15,6 @@ */ export interface TokenManager { + isServerToken: (token: string) => Promise; getServerToken: () => Promise<{ token: string }>; - validateToken: (token: string) => Promise; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 57417ce9c6..171460e026 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,7 +33,7 @@ import { SingleHostDiscovery, UrlReaders, useHotMemoize, - AuthIdentityTokenManager, + ServerTokenManager, IdentityClient, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; @@ -66,7 +66,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = new AuthIdentityTokenManager(discovery, config); + const tokenManager = new ServerTokenManager(config); root.info(`Created UrlReader ${reader}`); @@ -158,7 +158,10 @@ async function main() { IdentityClient.getBearerToken(req.headers.authorization) || req.cookies.token; - await authEnv.tokenManager.validateToken(token); + const isServerToken = await authEnv.tokenManager.isServerToken(token); + if (!isServerToken) { + await identity.authenticate(token); + } if (!req.headers.authorization) { // Authorization header may be forwarded by plugin requests diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 1089497db7..4be9c036b3 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,10 +17,10 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { - AuthIdentityTokenManager, PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, + TokenManager, UrlReader, } from '@backstage/backend-common'; @@ -31,5 +31,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; - tokenManager: AuthIdentityTokenManager; + tokenManager: TokenManager; }; diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index a85f331a7b..87dc390291 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -17,7 +17,7 @@ import { DatabaseManager, SingleHostDiscovery, UrlReaders, - AuthIdentityTokenManager, + ServerTokenManager, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import app from './plugins/app'; @@ -38,7 +38,7 @@ function makeCreateEnv(config: Config) { const cacheManager = CacheManager.fromConfig(config); const databaseManager = DatabaseManager.fromConfig(config); - const tokenManager = new AuthIdentityTokenManager(discovery, config); + const tokenManager = new ServerTokenManager(config); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index 2aac509609..b1e2e0a1df 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -1,10 +1,10 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { - AuthIdentityTokenManager, PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, + TokenManager, UrlReader, } from '@backstage/backend-common'; @@ -15,5 +15,5 @@ export type PluginEnvironment = { config: Config; reader: UrlReader; discovery: PluginEndpointDiscovery; - tokenManager: AuthIdentityTokenManager; + tokenManager: TokenManager; }; From fca908a7d64480645f52999c08da1b22accda802 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 9 Nov 2021 18:02:35 -0500 Subject: [PATCH 17/56] Move IdentityClient back to auth-backend Signed-off-by: Nataliya Issayeva --- packages/backend-common/package.json | 1 - packages/backend-common/src/identity/index.ts | 17 ----------------- packages/backend-common/src/index.ts | 1 - .../src/identity/IdentityClient.test.ts | 14 +++++++------- .../src/identity/IdentityClient.ts | 5 ++--- plugins/auth-backend/src/identity/index.ts | 1 + plugins/auth-backend/src/index.ts | 4 ++-- 7 files changed, 12 insertions(+), 31 deletions(-) delete mode 100644 packages/backend-common/src/identity/index.ts rename {packages/backend-common => plugins/auth-backend}/src/identity/IdentityClient.test.ts (97%) rename {packages/backend-common => plugins/auth-backend}/src/identity/IdentityClient.ts (97%) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 94f1b39672..444a0a273b 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -34,7 +34,6 @@ "@backstage/config-loader": "^0.8.0", "@backstage/errors": "^0.1.4", "@backstage/integration": "^0.6.9", - "@backstage/plugin-auth-backend": "^0.4.5", "@backstage/types": "^0.1.1", "@google-cloud/storage": "^5.8.0", "@lerna/project": "^4.0.0", diff --git a/packages/backend-common/src/identity/index.ts b/packages/backend-common/src/identity/index.ts deleted file mode 100644 index 67692e0ed5..0000000000 --- a/packages/backend-common/src/identity/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * 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. - */ - -export { IdentityClient } from './IdentityClient'; diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index a3c53e6f9f..e430ddc1a4 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -25,7 +25,6 @@ export { loadBackendConfig } from './config'; export * from './database'; export * from './discovery'; export * from './hot'; -export * from './identity'; export * from './logging'; export * from './middleware'; export * from './paths'; diff --git a/packages/backend-common/src/identity/IdentityClient.test.ts b/plugins/auth-backend/src/identity/IdentityClient.test.ts similarity index 97% rename from packages/backend-common/src/identity/IdentityClient.test.ts rename to plugins/auth-backend/src/identity/IdentityClient.test.ts index 36d737174d..5eca6bf8cd 100644 --- a/packages/backend-common/src/identity/IdentityClient.test.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.test.ts @@ -17,14 +17,14 @@ import { JWT, JSONWebKey } from 'jose'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { getVoidLogger } from '../logging'; -import { PluginEndpointDiscovery } from '../discovery'; -import { IdentityClient } from './IdentityClient'; import { - KeyStore, - MemoryKeyStore, - TokenFactory, -} from '@backstage/plugin-auth-backend'; + 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(); diff --git a/packages/backend-common/src/identity/IdentityClient.ts b/plugins/auth-backend/src/identity/IdentityClient.ts similarity index 97% rename from packages/backend-common/src/identity/IdentityClient.ts rename to plugins/auth-backend/src/identity/IdentityClient.ts index 2921b5705c..2af30e4b6a 100644 --- a/packages/backend-common/src/identity/IdentityClient.ts +++ b/plugins/auth-backend/src/identity/IdentityClient.ts @@ -16,8 +16,8 @@ import fetch from 'cross-fetch'; import { JWK, JWT, JWKS, JSONWebKey } from 'jose'; -import { BackstageIdentity } from '@backstage/plugin-auth-backend'; -import { PluginEndpointDiscovery } from '../discovery'; +import { BackstageIdentity } from '../providers'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; const CLOCK_MARGIN_S = 10; @@ -27,7 +27,6 @@ const CLOCK_MARGIN_S = 10; * * @experimental This is not a stable API yet */ - export class IdentityClient { private readonly discovery: PluginEndpointDiscovery; private readonly issuer: string; diff --git a/plugins/auth-backend/src/identity/index.ts b/plugins/auth-backend/src/identity/index.ts index cb2f369668..73858d7e07 100644 --- a/plugins/auth-backend/src/identity/index.ts +++ b/plugins/auth-backend/src/identity/index.ts @@ -15,6 +15,7 @@ */ export { createOidcRouter } from './router'; +export { IdentityClient } from './IdentityClient'; export { TokenFactory } from './TokenFactory'; export { DatabaseKeyStore } from './DatabaseKeyStore'; export { MemoryKeyStore } from './MemoryKeyStore'; diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index e348da116d..432da00142 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -21,8 +21,8 @@ */ export * from './service/router'; -export { MemoryKeyStore, TokenFactory } from './identity'; -export type { KeyStore, TokenIssuer } from './identity'; +export { IdentityClient } from './identity'; +export type { TokenIssuer } from './identity'; export * from './providers'; // flow package provides 2 functions From 9cbb3cf908cfe1565afc4f65acf7cfb6815ed8ce Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 10 Nov 2021 15:14:40 -0500 Subject: [PATCH 18/56] Remove auth middleware Signed-off-by: Nataliya Issayeva --- app-config.yaml | 1 + packages/app/src/App.tsx | 58 -------------------------- packages/backend/package.json | 2 - packages/backend/src/index.ts | 78 ----------------------------------- 4 files changed, 1 insertion(+), 138 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 88cd2c4648..d18b326c2c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,6 +23,7 @@ app: title: '#backstage' backend: + # Used for enabling authorization, secret is shared by all backend plugins authorization: secret: ${BACKEND_SECRET} baseUrl: http://localhost:7000 diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 64a8163da2..401665b62e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -86,52 +86,6 @@ import { providers } from './identityProviders'; import * as plugins from './plugins'; import { techDocsPage } from './components/techdocs/TechDocsPage'; -import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; - -// Parses supplied JWT token and returns the payload -function parseJwt(token: string): { exp: number } { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map(function (c) { - return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`; - }) - .join(''), - ); - - return JSON.parse(jsonPayload); -} - -// Returns milliseconds until the supplied JWT token expires -function msUntilExpiry(token: string): number { - const payload = parseJwt(token); - const remaining = - new Date(payload.exp * 1000).getTime() - new Date().getTime(); - return remaining; -} - -// Calls the specified url regularly using an auth token to set a token cookie -// to authorize regular HTTP requests when loading techdocs -async function setTokenCookie(url: string, getIdToken: () => Promise) { - const token = await getIdToken(); - await fetch(url, { - mode: 'cors', - credentials: 'include', - headers: { - Authorization: `Bearer ${token}`, - }, - }); - // Call this function again a few minutes before the token expires - const ms = msUntilExpiry(token) - 4 * 60 * 1000; - setTimeout( - () => { - setTokenCookie(url, getIdToken); - }, - ms > 0 ? ms : 10000, - ); -} const app = createApp({ apis, @@ -142,24 +96,12 @@ const app = createApp({ }, components: { SignInPage: props => { - const discoveryApi = useApi(discoveryApiRef); return ( { - // When logged in, set a token cookie - if (typeof result.getIdToken !== 'undefined') { - setTokenCookie( - await discoveryApi.getBaseUrl('cookie'), - result.getIdToken, - ); - } - // Forward results - props.onResult(result); - }} /> ); }, diff --git a/packages/backend/package.json b/packages/backend/package.json index aa2e544386..4fb692f040 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -55,13 +55,11 @@ "@gitbeaker/node": "^30.2.0", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^11.0.1", - "cookie-parser": "^1.4.5", "dockerode": "^3.3.1", "example-app": "^0.2.53", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-prom-bundle": "^6.3.6", - "jose": "^1.27.1", "knex": "^0.95.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 171460e026..63285c83fa 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -34,7 +34,6 @@ import { UrlReaders, useHotMemoize, ServerTokenManager, - IdentityClient, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; @@ -57,10 +56,6 @@ import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; import { PluginEnvironment } from './types'; -import cookieParser from 'cookie-parser'; -import { Request, Response, NextFunction } from 'express'; -import { JWT } from 'jose'; -import { URL } from 'url'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -81,27 +76,6 @@ function makeCreateEnv(config: Config) { }; } -function setTokenCookie( - res: Response, - options: { token: string; secure: boolean; cookieDomain: string }, -) { - try { - const payload = JWT.decode(options.token) as object & { - exp: number; - }; - res.cookie(`token`, options.token, { - expires: new Date(payload?.exp ? payload?.exp * 1000 : 0), - secure: options.secure, - sameSite: 'lax', - domain: options.cookieDomain, - path: '/', - httpOnly: true, - }); - } catch (_err) { - // Ignore - } -} - async function main() { metricsInit(); const logger = getRootLogger(); @@ -140,59 +114,7 @@ async function main() { createEnv('tech-insights'), ); - const discovery = SingleHostDiscovery.fromConfig(config); - const identity = new IdentityClient({ - discovery, - issuer: await discovery.getExternalBaseUrl('auth'), - }); - const baseUrl = config.getString('backend.baseUrl'); - const secure = baseUrl.startsWith('https://'); - const cookieDomain = new URL(baseUrl).hostname; - const authMiddleware = async ( - req: Request, - res: Response, - next: NextFunction, - ) => { - try { - const token = - IdentityClient.getBearerToken(req.headers.authorization) || - req.cookies.token; - - const isServerToken = await authEnv.tokenManager.isServerToken(token); - if (!isServerToken) { - await identity.authenticate(token); - } - - if (!req.headers.authorization) { - // Authorization header may be forwarded by plugin requests - req.headers.authorization = `Bearer ${token}`; - } - if (token !== req.cookies.token) { - setTokenCookie(res, { - token, - secure, - cookieDomain, - }); - } - next(); - } catch (error) { - res.status(401).send(`Unauthorized`); - } - }; - const apiRouter = Router(); - apiRouter.use(cookieParser()); - // The auth route must be publically available as it is used during login - apiRouter.use('/auth', await auth(authEnv)); - // Add a simple endpoint to be used when setting a token cookie - apiRouter.use('/cookie', authMiddleware, (_req, res) => { - res.status(200).send(`Coming right up`); - }); - // 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()); apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/code-coverage', await codeCoverage(codeCoverageEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); From 74801b176bae4b34faef9bce97f586a1817239d9 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 10 Nov 2021 16:05:52 -0500 Subject: [PATCH 19/56] Update authenticate api request tutorial doc Signed-off-by: Nataliya Issayeva --- .../tutorials/authenticate-api-requests.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index 9f651ddd1a..d25e4a832a 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -6,6 +6,13 @@ API requests from frontend plugins include an authorization header with a Backst **NOTE**: Enabling this means that Backstage will stop working for guests, as no token is issued for them. +Since the middleware always expects a valid token, API requests from backend plugins will need one as well. Backends have no concept of a Backstage identity so instead they use a token generated using a shared key from the `app-config.yaml`. +You can generate a unique key for your app in a terminal, and set the `BACKEND_SECRET` environment variable to the resulting value. + +```bash +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire). ```typescript @@ -60,7 +67,13 @@ async function main() { const token = IdentityClient.getBearerToken(req.headers.authorization) || req.cookies['token']; - req.user = await identity.authenticate(token); + + // Authenticate all requests originating from backends by default + const isServerToken = await authEnv.tokenManager.isServerToken(token); + if (!isServerToken) { + req.user = await identity.authenticate(token); + } + if (!req.headers.authorization) { // Authorization header may be forwarded by plugin requests req.headers.authorization = `Bearer ${token}`; @@ -261,3 +274,9 @@ export const plugin = createPlugin({ ], }); ``` + +In the (probably unlikely) case that you need to authenticate from a backend plugin, the plugin environment contains a `tokenManager` that will provide a server token to use in the request: + +``` +const { token } = await this.tokenManager.getServerToken(); +``` From c35abaadee02f5dfcd7d75bb2069415c7b801c77 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 10 Nov 2021 16:12:36 -0500 Subject: [PATCH 20/56] Clean up tests a bit Signed-off-by: Nataliya Issayeva --- .../src/tokens/ServerTokenManager.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 88ef1f27c2..246a3199f5 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -23,12 +23,16 @@ const configWithSecret = new ConfigReader({ describe('ServerTokenManager', () => { describe('getServerToken', () => { - it('should always return a token', async () => { + it('should return a token if secret in config exists', async () => { const tokenManager = new ServerTokenManager(configWithSecret); expect((await tokenManager.getServerToken()).token).toBeDefined(); + }); - const emptyTokenManager = new ServerTokenManager(emptyConfig); - expect((await emptyTokenManager.getServerToken()).token).toBeDefined(); + it('should return a token if secret in config does not exist', async () => { + const tokenManagerWithEmptyConfig = new ServerTokenManager(emptyConfig); + expect( + (await tokenManagerWithEmptyConfig.getServerToken()).token, + ).toBeDefined(); }); }); From 83aa387b626b8f40f01743ba5713d6168522adbf Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 12 Nov 2021 15:41:21 -0500 Subject: [PATCH 21/56] Default to noop tokenManager and no backend secret Signed-off-by: Nataliya Issayeva --- app-config.yaml | 5 ++- .../tutorials/authenticate-api-requests.md | 26 +++++++++-- packages/backend-common/config.d.ts | 9 ++++ .../src/tokens/ServerTokenManager.test.ts | 43 ++++++++++++------ .../src/tokens/ServerTokenManager.ts | 44 +++++++++++++------ packages/backend-common/src/tokens/types.ts | 2 +- packages/backend/src/index.ts | 2 +- .../templates/default-app/app-config.yaml.hbs | 4 ++ .../default-app/packages/backend/src/index.ts | 2 +- 9 files changed, 103 insertions(+), 34 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index d18b326c2c..d89961c9f9 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -24,8 +24,9 @@ app: backend: # Used for enabling authorization, secret is shared by all backend plugins - authorization: - secret: ${BACKEND_SECRET} + # See authenticate-api-requests.md in the contrib docs for information on the format + # authorization: + # secret: ${BACKEND_SECRET} baseUrl: http://localhost:7000 listen: port: 7000 diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index d25e4a832a..b58c48f988 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -69,8 +69,10 @@ async function main() { req.cookies['token']; // Authenticate all requests originating from backends by default - const isServerToken = await authEnv.tokenManager.isServerToken(token); - if (!isServerToken) { + const isValidServerToken = await authEnv.tokenManager.validateServerToken( + token, + ); + if (!isValidServerToken) { req.user = await identity.authenticate(token); } @@ -275,7 +277,25 @@ export const plugin = createPlugin({ }); ``` -In the (probably unlikely) case that you need to authenticate from a backend plugin, the plugin environment contains a `tokenManager` that will provide a server token to use in the request: +In the (probably unlikely) case that you need to authenticate from a backend plugin, the plugin environment contains a `tokenManager` that will provide a server token to use in the request. It is a noop `tokenManager` by default -- you'll need to instantiate a new one using the secret from your config instead: + +```diff +// packages/backend/src/index.ts + +function makeCreateEnv(config: Config) { + const root = getRootLogger(); + const reader = UrlReaders.default({ logger: root, config }); + const discovery = SingleHostDiscovery.fromConfig(config); + + root.info(`Created UrlReader ${reader}`); + + const cacheManager = CacheManager.fromConfig(config); + const databaseManager = DatabaseManager.fromConfig(config); +- const tokenManager = ServerTokenManager.noop(); ++ const tokenManager = ServerTokenManager.fromConfig(config); +``` + +With this `tokenManager`, you can then generate a server token for requests: ``` const { token } = await this.tokenManager.getServerToken(); diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 3690069431..865fbb86db 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -20,6 +20,15 @@ export interface Config { }; backend: { + /** Backend configuration for when request authentication is enabled */ + authorization?: { + /** + * Secret shared by all backends for generating tokens + * Format is base64 24-bit key + */ + secret?: string; + }; + baseUrl: string; // defined in core, but repeated here without doc /** Address that the backend should listen to. */ diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 246a3199f5..ddbb62bd42 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -22,32 +22,49 @@ const configWithSecret = new ConfigReader({ }); describe('ServerTokenManager', () => { + it('should throw if secret in config does not exist', () => { + expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError(); + }); + describe('getServerToken', () => { it('should return a token if secret in config exists', async () => { - const tokenManager = new ServerTokenManager(configWithSecret); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); expect((await tokenManager.getServerToken()).token).toBeDefined(); }); - it('should return a token if secret in config does not exist', async () => { - const tokenManagerWithEmptyConfig = new ServerTokenManager(emptyConfig); - expect( - (await tokenManagerWithEmptyConfig.getServerToken()).token, - ).toBeDefined(); + it('should return an empty string if using a noop TokenManager', async () => { + const tokenManager = ServerTokenManager.noop(); + expect((await tokenManager.getServerToken()).token).toBe(''); }); }); - describe('isServerToken', () => { + describe('validateServerToken', () => { it('should return true if token is valid', async () => { - const tokenManager = new ServerTokenManager(configWithSecret); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); const { token } = await tokenManager.getServerToken(); - const isServerToken = await tokenManager.isServerToken(token); - expect(isServerToken).toBe(true); + const isValidServerToken = await tokenManager.validateServerToken(token); + expect(isValidServerToken).toBe(true); }); it('should return false if token is invalid', async () => { - const tokenManager = new ServerTokenManager(configWithSecret); - const isServerToken = await tokenManager.isServerToken('random-string'); - expect(isServerToken).toBe(false); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const isValidServerToken = await tokenManager.validateServerToken( + 'random-string', + ); + expect(isValidServerToken).toBe(false); + }); + + it('should always return true if using noop TokenManager', async () => { + const tokenManager = ServerTokenManager.noop(); + const { token } = await tokenManager.getServerToken(); + const isValidServerToken0 = await tokenManager.validateServerToken(token); + const isValidServerToken1 = await tokenManager.validateServerToken( + 'random-string', + ); + const isValidServerToken2 = await tokenManager.validateServerToken(''); + expect(isValidServerToken0).toBe(true); + expect(isValidServerToken1).toBe(true); + expect(isValidServerToken2).toBe(true); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 6737dd4d8b..8c757f5ff6 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -19,15 +19,40 @@ import { Config } from '@backstage/config'; import { TokenManager } from './types'; export class ServerTokenManager implements TokenManager { - private key: JWK.OctKey; + private key: JWK.OctKey | JWK.NoneKey; - constructor(config: Config) { - const secret = - config.getOptionalString('backend.authorization.secret') ?? 'no-secret'; - this.key = JWK.asKey({ kty: 'oct', k: secret }); + static noop() { + return new ServerTokenManager(); } - async isServerToken(token: string): Promise { + static fromConfig(config: Config) { + const secret = config.getOptionalString('backend.authorization.secret'); + if (!secret) { + throw new Error('No backend auth secret set in app-config'); + } + return new ServerTokenManager(secret); + } + + private constructor(secret: string = '') { + this.key = secret ? JWK.asKey({ kty: 'oct', k: secret }) : JWK.None; + } + + async getServerToken(): Promise<{ token: string }> { + if (this.key === JWK.None) { + return { token: '' }; + } + + const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { + algorithm: 'HS256', + }); + return { token: jwt }; + } + + async validateServerToken(token: string): Promise { + if (this.key === JWK.None) { + return true; + } + try { JWT.verify(token, this.key); return true; @@ -35,11 +60,4 @@ export class ServerTokenManager implements TokenManager { return false; } } - - async getServerToken(): Promise<{ token: string }> { - const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { - algorithm: 'HS256', - }); - return { token: jwt }; - } } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index 278695a448..506e1191ec 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -15,6 +15,6 @@ */ export interface TokenManager { - isServerToken: (token: string) => Promise; getServerToken: () => Promise<{ token: string }>; + validateServerToken: (token: string) => Promise; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 63285c83fa..4426ed7767 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -61,7 +61,7 @@ function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = new ServerTokenManager(config); + const tokenManager = ServerTokenManager.noop(); root.info(`Created UrlReader ${reader}`); diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 9c35c58c33..726d31ea65 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -6,6 +6,10 @@ organization: name: My Company backend: + # Used for enabling authorization, secret is shared by all backend plugins + # See authenticate-api-requests.md in the contrib docs for information on the format + # authorization: + # secret: ${BACKEND_SECRET} baseUrl: http://localhost:7000 listen: port: 7000 diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 87dc390291..3f12122a3f 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -38,7 +38,7 @@ function makeCreateEnv(config: Config) { const cacheManager = CacheManager.fromConfig(config); const databaseManager = DatabaseManager.fromConfig(config); - const tokenManager = new ServerTokenManager(config); + const tokenManager = ServerTokenManager.noop(); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); From f04f38dbcd0bf873a97643d7b7e6e1832937d5d4 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 12 Nov 2021 16:09:08 -0500 Subject: [PATCH 22/56] Fix collator tests Signed-off-by: Nataliya Issayeva --- .../src/search/DefaultCatalogCollator.test.ts | 17 +++++++++++++++-- .../src/search/DefaultTechDocsCollator.test.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index 20e141b921..49235584e1 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + TokenManager, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DefaultCatalogCollator } from './DefaultCatalogCollator'; import { setupServer } from 'msw/node'; @@ -55,6 +58,7 @@ const expectedEntities: Entity[] = [ describe('DefaultCatalogCollator', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultCatalogCollator; beforeAll(() => { @@ -62,7 +66,14 @@ describe('DefaultCatalogCollator', () => { getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), getExternalBaseUrl: jest.fn(), }; - collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi }); + mockTokenManager = { + getServerToken: jest.fn().mockResolvedValue({ token: '' }), + validateServerToken: jest.fn().mockResolvedValue(true), + }; + collator = new DefaultCatalogCollator({ + discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, + }); server.listen(); }); beforeEach(() => { @@ -118,6 +129,7 @@ describe('DefaultCatalogCollator', () => { // Provide an alternate location template. collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, locationTemplate: '/software/:name', }); @@ -131,6 +143,7 @@ describe('DefaultCatalogCollator', () => { // Provide an alternate location template. collator = DefaultCatalogCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, filter: { kind: ['Foo', 'Bar'], }, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index 21be228636..e689cd9f61 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -17,6 +17,7 @@ import { PluginEndpointDiscovery, getVoidLogger, + TokenManager, } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DefaultTechDocsCollator } from './DefaultTechDocsCollator'; @@ -87,6 +88,7 @@ const expectedEntities: Entity[] = [ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultTechDocsCollator; const worker = setupServer(); @@ -96,6 +98,10 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), getExternalBaseUrl: jest.fn(), }; + mockTokenManager = { + getServerToken: jest.fn().mockResolvedValue({ token: '' }), + validateServerToken: jest.fn().mockResolvedValue(true), + }; const mockConfig = new ConfigReader({ techdocs: { legacyUseCaseSensitiveTripletPaths: true, @@ -103,6 +109,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { }); collator = DefaultTechDocsCollator.fromConfig(mockConfig, { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, logger, legacyPathCasing: true, }); @@ -147,6 +154,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { describe('DefaultTechDocsCollator', () => { let mockDiscoveryApi: jest.Mocked; + let mockTokenManager: jest.Mocked; let collator: DefaultTechDocsCollator; const worker = setupServer(); @@ -156,8 +164,13 @@ describe('DefaultTechDocsCollator', () => { getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'), getExternalBaseUrl: jest.fn(), }; + mockTokenManager = { + getServerToken: jest.fn().mockResolvedValue({ token: '' }), + validateServerToken: jest.fn().mockResolvedValue(true), + }; collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, logger, }); @@ -195,6 +208,7 @@ describe('DefaultTechDocsCollator', () => { // Provide an alternate location template. collator = new DefaultTechDocsCollator({ discovery: mockDiscoveryApi, + tokenManager: mockTokenManager, locationTemplate: '/software/:name', logger, }); From 640ba82ca67b0e41270f5672b8a573ed01f673cf Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 12 Nov 2021 16:25:12 -0500 Subject: [PATCH 23/56] Update api reports Signed-off-by: Nataliya Issayeva --- packages/backend-common/api-report.md | 28 ++++++++++++++++++++++++++ plugins/catalog-backend/api-report.md | 6 ++++++ plugins/techdocs-backend/api-report.md | 3 +++ 3 files changed, 37 insertions(+) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e3119e78a8..01d16b587c 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,6 +526,22 @@ export type SearchResponseFile = { content(): Promise; }; +// Warning: (ae-missing-release-tag) "ServerTokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class ServerTokenManager implements TokenManager { + // (undocumented) + static fromConfig(config: Config): ServerTokenManager; + // (undocumented) + getServerToken(): Promise<{ + token: string; + }>; + // (undocumented) + static noop(): ServerTokenManager; + // (undocumented) + validateServerToken(token: string): Promise; +} + // @public (undocumented) export type ServiceBuilder = { loadConfig(config: Config): ServiceBuilder; @@ -583,6 +599,18 @@ export interface StatusCheckHandlerOptions { statusCheck?: StatusCheck; } +// Warning: (ae-missing-release-tag) "TokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface TokenManager { + // (undocumented) + getServerToken: () => Promise<{ + token: string; + }>; + // (undocumented) + validateServerToken: (token: string) => Promise; +} + // @public export type UrlReader = { read(url: string): Promise; diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 6660912dda..abea100aae 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -31,6 +31,7 @@ import { ResourceEntityV1alpha1 } from '@backstage/catalog-model'; import { Router } from 'express'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; +import { TokenManager } from '@backstage/backend-common'; import { UrlReader } from '@backstage/backend-common'; import { Validators } from '@backstage/catalog-model'; @@ -736,8 +737,10 @@ export class DefaultCatalogCollator implements DocumentCollator { locationTemplate, filter, catalogClient, + tokenManager, }: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; locationTemplate?: string; filter?: CatalogEntitiesRequest['filter']; catalogClient?: CatalogApi; @@ -760,12 +763,15 @@ export class DefaultCatalogCollator implements DocumentCollator { _config: Config, options: { discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; filter?: CatalogEntitiesRequest['filter']; }, ): DefaultCatalogCollator; // (undocumented) protected locationTemplate: string; // (undocumented) + protected tokenManager: TokenManager; + // (undocumented) readonly type: string; } diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index a6094c2bdd..f9be757e6a 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -14,6 +14,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PreparerBuilder } from '@backstage/techdocs-common'; import { PublisherBase } from '@backstage/techdocs-common'; import { TechDocsDocument } from '@backstage/techdocs-common'; +import { TokenManager } from '@backstage/backend-common'; // Warning: (ae-forgotten-export) The symbol "RouterOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -31,6 +32,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { locationTemplate, logger, catalogClient, + tokenManager, parallelismLimit, legacyPathCasing, }: TechDocsCollatorOptions); @@ -60,6 +62,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { export type TechDocsCollatorOptions = { discovery: PluginEndpointDiscovery; logger: Logger_2; + tokenManager: TokenManager; locationTemplate?: string; catalogClient?: CatalogApi; parallelismLimit?: number; From 8e77f47119f6ccd5dbd5218efd68fc30b69bc6a1 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Fri, 12 Nov 2021 16:48:09 -0500 Subject: [PATCH 24/56] Add missing tokenManager in default app backend Signed-off-by: Nataliya Issayeva --- .../default-app/packages/backend/src/plugins/search.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index 7fc317d23d..7f5b93dbca 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -11,6 +11,7 @@ export default async function createPlugin({ logger, discovery, config, + tokenManager, }: PluginEnvironment) { // Initialize a connection to a search engine. const searchEngine = new LunrSearchEngine({ logger }); @@ -20,7 +21,7 @@ export default async function createPlugin({ // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { discovery }), + collator: DefaultCatalogCollator.fromConfig(config, { discovery, tokenManager }), }); // The scheduler controls when documents are gathered from collators and sent From 0f5744a5a2576316edd682fc81322164abfcd0c5 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 15 Nov 2021 09:44:44 -0500 Subject: [PATCH 25/56] Mark auth secret as secret Signed-off-by: Nataliya Issayeva --- packages/backend-common/config.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 865fbb86db..53e0e0b838 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -25,6 +25,7 @@ export interface Config { /** * Secret shared by all backends for generating tokens * Format is base64 24-bit key + * @visibility secret */ secret?: string; }; From c149a56f79888ff725d4694416ae2b1e1da65ce5 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 15 Nov 2021 10:03:56 -0500 Subject: [PATCH 26/56] Fix prettier error maybe Signed-off-by: Nataliya Issayeva --- .../default-app/packages/backend/src/plugins/search.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index 7f5b93dbca..4411fbdf33 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -21,7 +21,10 @@ export default async function createPlugin({ // particular collator gathers entities from the software catalog. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { discovery, tokenManager }), + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, + tokenManager, + }), }); // The scheduler controls when documents are gathered from collators and sent From 96bf8661b8b8cae358b8b95802a1768a83b09f6b Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Mon, 15 Nov 2021 11:04:21 -0500 Subject: [PATCH 27/56] Add changeset Signed-off-by: Nataliya Issayeva --- .changeset/odd-flowers-hope.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/odd-flowers-hope.md diff --git a/.changeset/odd-flowers-hope.md b/.changeset/odd-flowers-hope.md new file mode 100644 index 0000000000..d66b59c5ca --- /dev/null +++ b/.changeset/odd-flowers-hope.md @@ -0,0 +1,9 @@ +--- +'example-backend': patch +'@backstage/backend-common': patch +'@backstage/create-app': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-techdocs-backend': patch +--- + +Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests From 98bad1b7d881e177fabf384308aad78970f6ce3c Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Tue, 16 Nov 2021 10:30:59 -0800 Subject: [PATCH 28/56] Update changeset and docs Signed-off-by: Nataliya Issayeva --- .changeset/odd-flowers-hope.md | 86 ++++++++++++++++++++++++- docs/features/search/getting-started.md | 16 ++++- docs/features/search/how-to-guides.md | 3 +- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/.changeset/odd-flowers-hope.md b/.changeset/odd-flowers-hope.md index d66b59c5ca..c76f1b8287 100644 --- a/.changeset/odd-flowers-hope.md +++ b/.changeset/odd-flowers-hope.md @@ -2,8 +2,88 @@ 'example-backend': patch '@backstage/backend-common': patch '@backstage/create-app': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-catalog-backend': minor +'@backstage/plugin-techdocs-backend': minor --- -Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests +Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests. + +**BREAKING** `DefaultCatalogCollator` and `DefaultTechDocsCollator` now require a `tokenManager` to be passed in the `options` argument. + +In your backend, update the `PluginEnvironment` to include a `tokenManager`: + +```diff +// packages/backend/src/types.ts + +... +import { + ... ++ TokenManager, +} from '@backstage/backend-common'; + +export type PluginEnvironment = { + ... ++ tokenManager: TokenManager; +}; +``` + +Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. + +```diff +// packages/backend/src/index.ts + +... +import { + ... ++ ServerTokenManager, +} from '@backstage/backend-common'; +... + +function makeCreateEnv(config: Config) { + ... + // CHOOSE ONE + // TokenManager not requiring a secret ++ const tokenManager = ServerTokenManager.noop(); + // OR TokenManager requiring a secret ++ const tokenManager = ServerTokenManager.fromConfig(config); + + ... + return (plugin: string): PluginEnvironment => { + ... +- return { logger, cache, database, config, reader, discovery }; ++ return { logger, cache, database, config, reader, discovery, tokenManager }; + }; +} +``` + +Finally, pull the `tokenManager` from the search plugin environment and pass it to both collators. + +```diff +// packages/backend/src/plugins/search.ts + +... +export default async function createPlugin({ + ... ++ tokenManager, +}: PluginEnvironment) { + ... + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, ++ tokenManager, + }), + }); + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, ++ tokenManager, + }), + }); + + ... +} +``` diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 27219e529a..7c3717e22e 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -154,13 +154,17 @@ import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; export default async function createPlugin({ logger, discovery, + tokenManager, }: PluginEnvironment) { const searchEngine = new LunrSearchEngine({ logger }); const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); const { scheduler } = await indexBuilder.build(); @@ -285,7 +289,10 @@ const indexBuilder = new IndexBuilder({ logger, searchEngine }); indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); indexBuilder.addCollator({ @@ -303,6 +310,9 @@ its `defaultRefreshIntervalSeconds` value, like this: ```typescript {3} indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: new DefaultCatalogCollator({ discovery }), + collator: new DefaultCatalogCollator({ + discovery, + tokenManager, + }), }); ``` diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index 621d46b51e..4ba44b8ef4 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -2,7 +2,7 @@ id: how-to-guides title: Search "HOW TO" guides sidebar_label: "HOW TO" guides -description: Search "HOW TO" guides +description: Search "HOW TO" guides --- ## How to implement your own Search API @@ -74,6 +74,7 @@ indexBuilder.addCollator({ collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, + tokenManager, }), }); ``` From 6968b52b9d3614ac564a3a96b22dcc39c89b8fcb Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Wed, 17 Nov 2021 08:52:29 -0800 Subject: [PATCH 29/56] Update changeset Signed-off-by: Nataliya Issayeva --- .changeset/odd-flowers-hope.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/odd-flowers-hope.md b/.changeset/odd-flowers-hope.md index c76f1b8287..b07af54680 100644 --- a/.changeset/odd-flowers-hope.md +++ b/.changeset/odd-flowers-hope.md @@ -1,16 +1,15 @@ --- -'example-backend': patch '@backstage/backend-common': patch '@backstage/create-app': patch '@backstage/plugin-catalog-backend': minor '@backstage/plugin-techdocs-backend': minor --- -Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests. +Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests. Incorporate usage of the tokenManager into the backend created using `create-app`. **BREAKING** `DefaultCatalogCollator` and `DefaultTechDocsCollator` now require a `tokenManager` to be passed in the `options` argument. -In your backend, update the `PluginEnvironment` to include a `tokenManager`: +In existing backends, update the `PluginEnvironment` to include a `tokenManager`: ```diff // packages/backend/src/types.ts From 7f0d927d99fd00b18ce449c1cf329d655a699551 Mon Sep 17 00:00:00 2001 From: Nataliya Issayeva Date: Thu, 18 Nov 2021 08:44:28 -0800 Subject: [PATCH 30/56] Move authing API requests tutorial to main docs Signed-off-by: Nataliya Issayeva --- .../tutorials/authenticate-api-requests.md | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) rename {contrib/docs => docs}/tutorials/authenticate-api-requests.md (80%) diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/docs/tutorials/authenticate-api-requests.md similarity index 80% rename from contrib/docs/tutorials/authenticate-api-requests.md rename to docs/tutorials/authenticate-api-requests.md index b58c48f988..54952272da 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/docs/tutorials/authenticate-api-requests.md @@ -1,19 +1,45 @@ +--- +id: authenticate-api-requests +title: Authenticating API Requests +description: Guide for authenticating API requests within Backstage +--- + +> This document has been moved over from the community contributed docs +> directory and will continue to evolve as request authentication/authorization +> becomes a first-class citizen of Backstage. The approach described here is now +> fully supported and functional end-to-end, but will eventually be replaced in +> favor of a built-in framework that will broadly support authentication and +> authorization needs within Backstage. + # 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. +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. +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**: Enabling this means that Backstage will stop working for guests, as no token is issued for them. +**NOTE**: Enabling this means that Backstage will stop working for guests, as no +token is issued for them. -Since the middleware always expects a valid token, API requests from backend plugins will need one as well. Backends have no concept of a Backstage identity so instead they use a token generated using a shared key from the `app-config.yaml`. -You can generate a unique key for your app in a terminal, and set the `BACKEND_SECRET` environment variable to the resulting value. +Since the middleware always expects a valid token, API requests from backend +plugins will need one as well. Backends have no concept of a Backstage identity +so instead they use a token generated using a shared key from the +`app-config.yaml`. You can generate a unique key for your app in a terminal, and +set the `BACKEND_SECRET` environment variable to the resulting value. ```bash node -p 'require("crypto").randomBytes(24).toString("base64")' ``` -As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire). +As techdocs HTML pages load assets without an Authorization header the code +below also sets a token cookie when the user logs in (and when the token is +about to expire). ```typescript // packages/backend/src/index.ts from a create-app deployment @@ -197,9 +223,10 @@ const app = createApp({ // ... ``` -**NOTE**: Most Backstage frontend plugins come with the support for the `IdentityApi`. -In case you already have a dozen of internal ones, you may need to update those too. -Assuming you follow the common plugin structure, the changes to your front-end may look like: +**NOTE**: Most Backstage frontend plugins come with the support for the +`IdentityApi`. In case you already have a dozen of internal ones, you may need +to update those too. Assuming you follow the common plugin structure, the +changes to your front-end may look like: ```diff // plugins/internal-plugin/src/api.ts @@ -277,7 +304,10 @@ export const plugin = createPlugin({ }); ``` -In the (probably unlikely) case that you need to authenticate from a backend plugin, the plugin environment contains a `tokenManager` that will provide a server token to use in the request. It is a noop `tokenManager` by default -- you'll need to instantiate a new one using the secret from your config instead: +In the (probably unlikely) case that you need to authenticate from a backend +plugin, the plugin environment contains a `tokenManager` that will provide a +server token to use in the request. It is a noop `tokenManager` by default -- +you'll need to instantiate a new one using the secret from your config instead: ```diff // packages/backend/src/index.ts From 839e931d34f2b3e64ddeabf28f668fb74f1e1665 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Mon, 22 Nov 2021 18:44:49 +0000 Subject: [PATCH 31/56] create-app: pass tokenManager when constructing TechDocsCollator Signed-off-by: Mike Lewis --- .../default-app/packages/backend/src/plugins/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index f2b35b296a..27a19588c4 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -31,7 +31,7 @@ export default async function createPlugin({ // collator gathers entities from techdocs. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger }), + collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, tokenManager }), }); // The scheduler controls when documents are gathered from collators and sent From 03962638a03b08f8f8943afc0ef0fc73b2691d5e Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 10:49:40 +0000 Subject: [PATCH 32/56] create-app: fix formatting in search.ts Signed-off-by: Mike Lewis --- .../default-app/packages/backend/src/plugins/search.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index 27a19588c4..f23b0c7bcf 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -31,7 +31,11 @@ export default async function createPlugin({ // collator gathers entities from techdocs. indexBuilder.addCollator({ defaultRefreshIntervalSeconds: 600, - collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger, tokenManager }), + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, + tokenManager, + }), }); // The scheduler controls when documents are gathered from collators and sent From ef9dfd34402f8a563c10b595e14fa524f3c66cac Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 13:54:04 +0000 Subject: [PATCH 33/56] backend-common: leave ConfigReader to throw errors when config is missing in ServerTokenManager Signed-off-by: Mike Lewis --- packages/backend-common/src/tokens/ServerTokenManager.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 8c757f5ff6..fa7e6fac2d 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -26,10 +26,8 @@ export class ServerTokenManager implements TokenManager { } static fromConfig(config: Config) { - const secret = config.getOptionalString('backend.authorization.secret'); - if (!secret) { - throw new Error('No backend auth secret set in app-config'); - } + const secret = config.getString('backend.authorization.secret'); + return new ServerTokenManager(secret); } From db1befcf3985668b2ec3f5edcd03c575b327cd5a Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 13:56:07 +0000 Subject: [PATCH 34/56] backend-common: make secret required if authorization config block is provided Signed-off-by: Mike Lewis --- packages/backend-common/config.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 53e0e0b838..ceb9706ca9 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -27,7 +27,7 @@ export interface Config { * Format is base64 24-bit key * @visibility secret */ - secret?: string; + secret: string; }; baseUrl: string; // defined in core, but repeated here without doc From 42b01ee7250448788aec816e0afd14eaccb0d7ce Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 13:57:38 +0000 Subject: [PATCH 35/56] backend-common: import ConfigReader from @backstage/config Signed-off-by: Mike Lewis --- packages/backend-common/package.json | 1 - packages/backend-common/src/tokens/ServerTokenManager.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 444a0a273b..ceb0b91594 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -82,7 +82,6 @@ }, "devDependencies": { "@backstage/cli": "^0.9.0", - "@backstage/core-app-api": "^0.1.21", "@backstage/test-utils": "^0.1.22", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index ddbb62bd42..6fe920939b 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ConfigReader } from '@backstage/core-app-api'; +import { ConfigReader } from '@backstage/config'; import { ServerTokenManager } from './ServerTokenManager'; const emptyConfig = new ConfigReader({}); From 8cb1c4d17c5b79cdb9e4ce664d300fc4d32fb931 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 14:41:33 +0000 Subject: [PATCH 36/56] backend-common: rename config block from authorization to auth Signed-off-by: Mike Lewis --- app-config.yaml | 4 ++-- packages/backend-common/config.d.ts | 2 +- packages/backend-common/src/tokens/ServerTokenManager.test.ts | 2 +- packages/backend-common/src/tokens/ServerTokenManager.ts | 2 +- packages/create-app/templates/default-app/app-config.yaml.hbs | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 032ce0b4e9..08dd2837e4 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -23,9 +23,9 @@ app: title: '#backstage' backend: - # Used for enabling authorization, secret is shared by all backend plugins + # Used for enabling authentication, secret is shared by all backend plugins # See authenticate-api-requests.md in the contrib docs for information on the format - # authorization: + # auth: # secret: ${BACKEND_SECRET} baseUrl: http://localhost:7007 listen: diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index ceb9706ca9..66b54d1ebb 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -21,7 +21,7 @@ export interface Config { backend: { /** Backend configuration for when request authentication is enabled */ - authorization?: { + auth?: { /** * Secret shared by all backends for generating tokens * Format is base64 24-bit key diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 6fe920939b..d027833cb1 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -18,7 +18,7 @@ import { ServerTokenManager } from './ServerTokenManager'; const emptyConfig = new ConfigReader({}); const configWithSecret = new ConfigReader({ - backend: { authorization: { secret: 'a-secret-key' } }, + backend: { auth: { secret: 'a-secret-key' } }, }); describe('ServerTokenManager', () => { diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index fa7e6fac2d..f7fccd30b2 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -26,7 +26,7 @@ export class ServerTokenManager implements TokenManager { } static fromConfig(config: Config) { - const secret = config.getString('backend.authorization.secret'); + const secret = config.getString('backend.auth.secret'); return new ServerTokenManager(secret); } diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index cbe1ef07d4..44bae0ff26 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -6,9 +6,9 @@ organization: name: My Company backend: - # Used for enabling authorization, secret is shared by all backend plugins + # Used for enabling authentication, secret is shared by all backend plugins # See authenticate-api-requests.md in the contrib docs for information on the format - # authorization: + # auth: # secret: ${BACKEND_SECRET} baseUrl: http://localhost:7007 listen: From f38dff408535dcc010fcc89e02312c343035b04f Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 14:58:48 +0000 Subject: [PATCH 37/56] backend-common: tests validating interop of multiple token managers Signed-off-by: Mike Lewis --- .../src/tokens/ServerTokenManager.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index d027833cb1..6d06c874ea 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -54,6 +54,28 @@ describe('ServerTokenManager', () => { expect(isValidServerToken).toBe(false); }); + it('should return true for server tokens created by a different instance using the same secret', async () => { + const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); + + const { token } = await tokenManager1.getServerToken(); + + expect(await tokenManager2.validateServerToken(token)).toBe(true); + }); + + it('should return false for server tokens created by a different instance using a different secret', async () => { + const tokenManager1 = ServerTokenManager.fromConfig( + new ConfigReader({ backend: { auth: { secret: 'a1b2c3' } } }), + ); + const tokenManager2 = ServerTokenManager.fromConfig( + new ConfigReader({ backend: { auth: { secret: 'd4e5f6' } } }), + ); + + const { token } = await tokenManager1.getServerToken(); + + expect(await tokenManager2.validateServerToken(token)).toBe(false); + }); + it('should always return true if using noop TokenManager', async () => { const tokenManager = ServerTokenManager.noop(); const { token } = await tokenManager.getServerToken(); From 8a001f8202f2249a645a7f980b46d653dd075035 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 17:16:57 +0000 Subject: [PATCH 38/56] backend-common: TokenManager throws for invalid tokens rather than returning boolean Signed-off-by: Mike Lewis --- docs/tutorials/authenticate-api-requests.md | 5 +- packages/backend-common/api-report.md | 4 +- .../src/tokens/ServerTokenManager.test.ts | 57 +++++++++++-------- .../src/tokens/ServerTokenManager.ts | 9 +-- packages/backend-common/src/tokens/types.ts | 2 +- .../src/search/DefaultCatalogCollator.test.ts | 2 +- .../search/DefaultTechDocsCollator.test.ts | 4 +- 7 files changed, 47 insertions(+), 36 deletions(-) diff --git a/docs/tutorials/authenticate-api-requests.md b/docs/tutorials/authenticate-api-requests.md index 54952272da..ea7ae72a27 100644 --- a/docs/tutorials/authenticate-api-requests.md +++ b/docs/tutorials/authenticate-api-requests.md @@ -95,9 +95,8 @@ async function main() { req.cookies['token']; // Authenticate all requests originating from backends by default - const isValidServerToken = await authEnv.tokenManager.validateServerToken( - token, - ); + const isValidServerToken = + authEnv.tokenManager.validateServerToken(token); if (!isValidServerToken) { req.user = await identity.authenticate(token); } diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 01d16b587c..6cd1364285 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -539,7 +539,7 @@ export class ServerTokenManager implements TokenManager { // (undocumented) static noop(): ServerTokenManager; // (undocumented) - validateServerToken(token: string): Promise; + validateServerToken(token: string): void; } // @public (undocumented) @@ -608,7 +608,7 @@ export interface TokenManager { token: string; }>; // (undocumented) - validateServerToken: (token: string) => Promise; + validateServerToken: (token: string) => void; } // @public diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 6d06c874ea..82936d8f82 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -39,31 +39,29 @@ describe('ServerTokenManager', () => { }); describe('validateServerToken', () => { - it('should return true if token is valid', async () => { + it('should not throw if token is valid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); const { token } = await tokenManager.getServerToken(); - const isValidServerToken = await tokenManager.validateServerToken(token); - expect(isValidServerToken).toBe(true); + expect(() => tokenManager.validateServerToken(token)).not.toThrow(); }); - it('should return false if token is invalid', async () => { + it('should throw if token is invalid', () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const isValidServerToken = await tokenManager.validateServerToken( - 'random-string', - ); - expect(isValidServerToken).toBe(false); + expect(() => + tokenManager.validateServerToken('random-string'), + ).toThrowError(/invalid server token/i); }); - it('should return true for server tokens created by a different instance using the same secret', async () => { + it('should validate server tokens created by a different instance using the same secret', async () => { const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); const { token } = await tokenManager1.getServerToken(); - expect(await tokenManager2.validateServerToken(token)).toBe(true); + expect(() => tokenManager2.validateServerToken(token)).not.toThrow(); }); - it('should return false for server tokens created by a different instance using a different secret', async () => { + it('should throw for server tokens created using a different secret', async () => { const tokenManager1 = ServerTokenManager.fromConfig( new ConfigReader({ backend: { auth: { secret: 'a1b2c3' } } }), ); @@ -73,20 +71,33 @@ describe('ServerTokenManager', () => { const { token } = await tokenManager1.getServerToken(); - expect(await tokenManager2.validateServerToken(token)).toBe(false); + expect(() => tokenManager2.validateServerToken(token)).toThrowError( + /invalid server token/i, + ); + }); + }); + + describe('ServerTokenManager.noop', () => { + let noopTokenManager: ServerTokenManager; + + beforeEach(() => { + noopTokenManager = ServerTokenManager.noop(); }); - it('should always return true if using noop TokenManager', async () => { - const tokenManager = ServerTokenManager.noop(); - const { token } = await tokenManager.getServerToken(); - const isValidServerToken0 = await tokenManager.validateServerToken(token); - const isValidServerToken1 = await tokenManager.validateServerToken( - 'random-string', - ); - const isValidServerToken2 = await tokenManager.validateServerToken(''); - expect(isValidServerToken0).toBe(true); - expect(isValidServerToken1).toBe(true); - expect(isValidServerToken2).toBe(true); + it('should accept tokens it generates', async () => { + const { token } = await noopTokenManager.getServerToken(); + + expect(() => noopTokenManager.validateServerToken(token)).not.toThrow(); + }); + + it('should accept arbitrary strings', async () => { + expect(() => + noopTokenManager.validateServerToken('random-string'), + ).not.toThrow(); + }); + + it('should accept empty strings', async () => { + expect(() => noopTokenManager.validateServerToken('')).not.toThrow(); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index f7fccd30b2..6354162d38 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -16,6 +16,7 @@ import { JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; +import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; export class ServerTokenManager implements TokenManager { @@ -46,16 +47,16 @@ export class ServerTokenManager implements TokenManager { return { token: jwt }; } - async validateServerToken(token: string): Promise { + validateServerToken(token: string): void { if (this.key === JWK.None) { - return true; + return; } try { JWT.verify(token, this.key); - return true; + return; } catch (e) { - return false; + throw new AuthenticationError('Invalid server token'); } } } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index 506e1191ec..65f3a749a4 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -16,5 +16,5 @@ export interface TokenManager { getServerToken: () => Promise<{ token: string }>; - validateServerToken: (token: string) => Promise; + validateServerToken: (token: string) => void; } diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index 9f42c7de83..ba8cb64f02 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -68,7 +68,7 @@ describe('DefaultCatalogCollator', () => { }; mockTokenManager = { getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn().mockResolvedValue(true), + validateServerToken: jest.fn(), }; collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index e689cd9f61..ad6f5c3520 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -100,7 +100,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { }; mockTokenManager = { getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn().mockResolvedValue(true), + validateServerToken: jest.fn(), }; const mockConfig = new ConfigReader({ techdocs: { @@ -166,7 +166,7 @@ describe('DefaultTechDocsCollator', () => { }; mockTokenManager = { getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn().mockResolvedValue(true), + validateServerToken: jest.fn(), }; collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, From 905dd952acaa5821a5bd016b71fad04fe10c23f3 Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 17:49:02 +0000 Subject: [PATCH 39/56] chore: split up backend-to-backend auth changeset to reduce changelog noise Signed-off-by: Mike Lewis --- .changeset/dry-pianos-brush.md | 51 +++++++++++++++++++ .changeset/green-toes-search.md | 28 +++++++++++ .changeset/lemon-moons-stare.md | 5 ++ .changeset/odd-flowers-hope.md | 88 --------------------------------- .changeset/young-bikes-argue.md | 27 ++++++++++ 5 files changed, 111 insertions(+), 88 deletions(-) create mode 100644 .changeset/dry-pianos-brush.md create mode 100644 .changeset/green-toes-search.md create mode 100644 .changeset/lemon-moons-stare.md delete mode 100644 .changeset/odd-flowers-hope.md create mode 100644 .changeset/young-bikes-argue.md diff --git a/.changeset/dry-pianos-brush.md b/.changeset/dry-pianos-brush.md new file mode 100644 index 0000000000..c749962a83 --- /dev/null +++ b/.changeset/dry-pianos-brush.md @@ -0,0 +1,51 @@ +--- +'@backstage/create-app': patch +--- + +Incorporate usage of the tokenManager into the backend created using `create-app`. + +In existing backends, update the `PluginEnvironment` to include a `tokenManager`: + +```diff +// packages/backend/src/types.ts + +... +import { + ... ++ TokenManager, +} from '@backstage/backend-common'; + +export type PluginEnvironment = { + ... ++ tokenManager: TokenManager; +}; +``` + +Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. + +```diff +// packages/backend/src/index.ts + +... +import { + ... ++ ServerTokenManager, +} from '@backstage/backend-common'; +... + +function makeCreateEnv(config: Config) { + ... + // CHOOSE ONE + // TokenManager not requiring a secret ++ const tokenManager = ServerTokenManager.noop(); + // OR TokenManager requiring a secret ++ const tokenManager = ServerTokenManager.fromConfig(config); + + ... + return (plugin: string): PluginEnvironment => { + ... +- return { logger, cache, database, config, reader, discovery }; ++ return { logger, cache, database, config, reader, discovery, tokenManager }; + }; +} +``` diff --git a/.changeset/green-toes-search.md b/.changeset/green-toes-search.md new file mode 100644 index 0000000000..2bdba7e31a --- /dev/null +++ b/.changeset/green-toes-search.md @@ -0,0 +1,28 @@ +--- +'@backstage/plugin-techdocs-backend': minor +--- + +**BREAKING** `DefaultTechDocsCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: + +```diff +// packages/backend/src/plugins/search.ts + +... +export default async function createPlugin({ + ... ++ tokenManager, +}: PluginEnvironment) { + ... + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultTechDocsCollator.fromConfig(config, { + discovery, + logger, ++ tokenManager, + }), + }); + + ... +} +``` diff --git a/.changeset/lemon-moons-stare.md b/.changeset/lemon-moons-stare.md new file mode 100644 index 0000000000..4b7818d403 --- /dev/null +++ b/.changeset/lemon-moons-stare.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Create a `TokenManager` interface and `ServerTokenManager` implementation to generate and validate server tokens for authenticated backend-to-backend API requests. diff --git a/.changeset/odd-flowers-hope.md b/.changeset/odd-flowers-hope.md deleted file mode 100644 index b07af54680..0000000000 --- a/.changeset/odd-flowers-hope.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/create-app': patch -'@backstage/plugin-catalog-backend': minor -'@backstage/plugin-techdocs-backend': minor ---- - -Create a TokenManager interface and ServerTokenManager implementation to generate and validate server tokens for authenticated backend-to-backend API requests. Incorporate usage of the tokenManager into the backend created using `create-app`. - -**BREAKING** `DefaultCatalogCollator` and `DefaultTechDocsCollator` now require a `tokenManager` to be passed in the `options` argument. - -In existing backends, update the `PluginEnvironment` to include a `tokenManager`: - -```diff -// packages/backend/src/types.ts - -... -import { - ... -+ TokenManager, -} from '@backstage/backend-common'; - -export type PluginEnvironment = { - ... -+ tokenManager: TokenManager; -}; -``` - -Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens. - -```diff -// packages/backend/src/index.ts - -... -import { - ... -+ ServerTokenManager, -} from '@backstage/backend-common'; -... - -function makeCreateEnv(config: Config) { - ... - // CHOOSE ONE - // TokenManager not requiring a secret -+ const tokenManager = ServerTokenManager.noop(); - // OR TokenManager requiring a secret -+ const tokenManager = ServerTokenManager.fromConfig(config); - - ... - return (plugin: string): PluginEnvironment => { - ... -- return { logger, cache, database, config, reader, discovery }; -+ return { logger, cache, database, config, reader, discovery, tokenManager }; - }; -} -``` - -Finally, pull the `tokenManager` from the search plugin environment and pass it to both collators. - -```diff -// packages/backend/src/plugins/search.ts - -... -export default async function createPlugin({ - ... -+ tokenManager, -}: PluginEnvironment) { - ... - indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: DefaultCatalogCollator.fromConfig(config, { - discovery, -+ tokenManager, - }), - }); - - indexBuilder.addCollator({ - defaultRefreshIntervalSeconds: 600, - collator: DefaultTechDocsCollator.fromConfig(config, { - discovery, - logger, -+ tokenManager, - }), - }); - - ... -} -``` diff --git a/.changeset/young-bikes-argue.md b/.changeset/young-bikes-argue.md new file mode 100644 index 0000000000..a9f09cd12b --- /dev/null +++ b/.changeset/young-bikes-argue.md @@ -0,0 +1,27 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +**BREAKING** `DefaultCatalogCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`: + +```diff +// packages/backend/src/plugins/search.ts + +... +export default async function createPlugin({ + ... ++ tokenManager, +}: PluginEnvironment) { + ... + + indexBuilder.addCollator({ + defaultRefreshIntervalSeconds: 600, + collator: DefaultCatalogCollator.fromConfig(config, { + discovery, ++ tokenManager, + }), + }); + + ... +} +``` From 4b6de2f87272423d5814655df2f248bbf13a597e Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 18:12:28 +0000 Subject: [PATCH 40/56] backend-common: remove 'Server' from TokenManager method names Signed-off-by: Mike Lewis --- docs/tutorials/authenticate-api-requests.md | 5 ++- packages/backend-common/api-report.md | 8 ++--- .../src/tokens/ServerTokenManager.test.ts | 34 +++++++++---------- .../src/tokens/ServerTokenManager.ts | 4 +-- packages/backend-common/src/tokens/types.ts | 4 +-- .../src/search/DefaultCatalogCollator.test.ts | 4 +-- .../src/search/DefaultCatalogCollator.ts | 2 +- .../search/DefaultTechDocsCollator.test.ts | 8 ++--- .../src/search/DefaultTechDocsCollator.ts | 2 +- 9 files changed, 35 insertions(+), 36 deletions(-) diff --git a/docs/tutorials/authenticate-api-requests.md b/docs/tutorials/authenticate-api-requests.md index ea7ae72a27..33d7d2cb31 100644 --- a/docs/tutorials/authenticate-api-requests.md +++ b/docs/tutorials/authenticate-api-requests.md @@ -95,8 +95,7 @@ async function main() { req.cookies['token']; // Authenticate all requests originating from backends by default - const isValidServerToken = - authEnv.tokenManager.validateServerToken(token); + const isValidServerToken = authEnv.tokenManager.validateToken(token); if (!isValidServerToken) { req.user = await identity.authenticate(token); } @@ -327,5 +326,5 @@ function makeCreateEnv(config: Config) { With this `tokenManager`, you can then generate a server token for requests: ``` -const { token } = await this.tokenManager.getServerToken(); +const { token } = await this.tokenManager.getToken(); ``` diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 6cd1364285..4a89f9f8a7 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -533,13 +533,13 @@ export class ServerTokenManager implements TokenManager { // (undocumented) static fromConfig(config: Config): ServerTokenManager; // (undocumented) - getServerToken(): Promise<{ + getToken(): Promise<{ token: string; }>; // (undocumented) static noop(): ServerTokenManager; // (undocumented) - validateServerToken(token: string): void; + validateToken(token: string): void; } // @public (undocumented) @@ -604,11 +604,11 @@ export interface StatusCheckHandlerOptions { // @public (undocumented) export interface TokenManager { // (undocumented) - getServerToken: () => Promise<{ + getToken: () => Promise<{ token: string; }>; // (undocumented) - validateServerToken: (token: string) => void; + validateToken: (token: string) => void; } // @public diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 82936d8f82..20e18e9080 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -26,39 +26,39 @@ describe('ServerTokenManager', () => { expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError(); }); - describe('getServerToken', () => { + describe('getToken', () => { it('should return a token if secret in config exists', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect((await tokenManager.getServerToken()).token).toBeDefined(); + expect((await tokenManager.getToken()).token).toBeDefined(); }); it('should return an empty string if using a noop TokenManager', async () => { const tokenManager = ServerTokenManager.noop(); - expect((await tokenManager.getServerToken()).token).toBe(''); + expect((await tokenManager.getToken()).token).toBe(''); }); }); - describe('validateServerToken', () => { + describe('validateToken', () => { it('should not throw if token is valid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager.getServerToken(); - expect(() => tokenManager.validateServerToken(token)).not.toThrow(); + const { token } = await tokenManager.getToken(); + expect(() => tokenManager.validateToken(token)).not.toThrow(); }); it('should throw if token is invalid', () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect(() => - tokenManager.validateServerToken('random-string'), - ).toThrowError(/invalid server token/i); + expect(() => tokenManager.validateToken('random-string')).toThrowError( + /invalid server token/i, + ); }); it('should validate server tokens created by a different instance using the same secret', async () => { const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager1.getServerToken(); + const { token } = await tokenManager1.getToken(); - expect(() => tokenManager2.validateServerToken(token)).not.toThrow(); + expect(() => tokenManager2.validateToken(token)).not.toThrow(); }); it('should throw for server tokens created using a different secret', async () => { @@ -69,9 +69,9 @@ describe('ServerTokenManager', () => { new ConfigReader({ backend: { auth: { secret: 'd4e5f6' } } }), ); - const { token } = await tokenManager1.getServerToken(); + const { token } = await tokenManager1.getToken(); - expect(() => tokenManager2.validateServerToken(token)).toThrowError( + expect(() => tokenManager2.validateToken(token)).toThrowError( /invalid server token/i, ); }); @@ -85,19 +85,19 @@ describe('ServerTokenManager', () => { }); it('should accept tokens it generates', async () => { - const { token } = await noopTokenManager.getServerToken(); + const { token } = await noopTokenManager.getToken(); - expect(() => noopTokenManager.validateServerToken(token)).not.toThrow(); + expect(() => noopTokenManager.validateToken(token)).not.toThrow(); }); it('should accept arbitrary strings', async () => { expect(() => - noopTokenManager.validateServerToken('random-string'), + noopTokenManager.validateToken('random-string'), ).not.toThrow(); }); it('should accept empty strings', async () => { - expect(() => noopTokenManager.validateServerToken('')).not.toThrow(); + expect(() => noopTokenManager.validateToken('')).not.toThrow(); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 6354162d38..5ce2262c88 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -36,7 +36,7 @@ export class ServerTokenManager implements TokenManager { this.key = secret ? JWK.asKey({ kty: 'oct', k: secret }) : JWK.None; } - async getServerToken(): Promise<{ token: string }> { + async getToken(): Promise<{ token: string }> { if (this.key === JWK.None) { return { token: '' }; } @@ -47,7 +47,7 @@ export class ServerTokenManager implements TokenManager { return { token: jwt }; } - validateServerToken(token: string): void { + validateToken(token: string): void { if (this.key === JWK.None) { return; } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index 65f3a749a4..39b650b3a3 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -15,6 +15,6 @@ */ export interface TokenManager { - getServerToken: () => Promise<{ token: string }>; - validateServerToken: (token: string) => void; + getToken: () => Promise<{ token: string }>; + validateToken: (token: string) => void; } diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index ba8cb64f02..1415341795 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -67,8 +67,8 @@ describe('DefaultCatalogCollator', () => { getExternalBaseUrl: jest.fn(), }; mockTokenManager = { - getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn(), + getToken: jest.fn().mockResolvedValue({ token: '' }), + validateToken: jest.fn(), }; collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi, diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 6bef785ac8..4c6342c6c6 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -108,7 +108,7 @@ export class DefaultCatalogCollator implements DocumentCollator { } async execute() { - const { token } = await this.tokenManager.getServerToken(); + const { token } = await this.tokenManager.getToken(); const response = await this.catalogClient.getEntities( { filter: this.filter, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index ad6f5c3520..416b040d91 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -99,8 +99,8 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { getExternalBaseUrl: jest.fn(), }; mockTokenManager = { - getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn(), + getToken: jest.fn().mockResolvedValue({ token: '' }), + validateToken: jest.fn(), }; const mockConfig = new ConfigReader({ techdocs: { @@ -165,8 +165,8 @@ describe('DefaultTechDocsCollator', () => { getExternalBaseUrl: jest.fn(), }; mockTokenManager = { - getServerToken: jest.fn().mockResolvedValue({ token: '' }), - validateServerToken: jest.fn(), + getToken: jest.fn().mockResolvedValue({ token: '' }), + validateToken: jest.fn(), }; collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index 094fb8b049..f7da18c38a 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -94,7 +94,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { async execute() { const limit = pLimit(this.parallelismLimit); const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs'); - const { token } = await this.tokenManager.getServerToken(); + const { token } = await this.tokenManager.getToken(); const entities = await this.catalogClient.getEntities( { fields: [ From 40ea710c145293f0e049d4c0a893ce6e2f26d49c Mon Sep 17 00:00:00 2001 From: Mike Lewis Date: Tue, 23 Nov 2021 18:22:53 +0000 Subject: [PATCH 41/56] backend-common: add api doc comments Signed-off-by: Mike Lewis --- packages/backend-common/api-report.md | 8 ++------ packages/backend-common/src/tokens/ServerTokenManager.ts | 6 ++++++ packages/backend-common/src/tokens/types.ts | 5 +++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 4a89f9f8a7..33d41a5929 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,9 +526,7 @@ export type SearchResponseFile = { content(): Promise; }; -// Warning: (ae-missing-release-tag) "ServerTokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export class ServerTokenManager implements TokenManager { // (undocumented) static fromConfig(config: Config): ServerTokenManager; @@ -599,9 +597,7 @@ export interface StatusCheckHandlerOptions { statusCheck?: StatusCheck; } -// Warning: (ae-missing-release-tag) "TokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface TokenManager { // (undocumented) getToken: () => Promise<{ diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 5ce2262c88..e4afc2997e 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -19,6 +19,12 @@ import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; +/** + * Creates and validates tokens for use during backend-to-backend + * authentication. + * + * @public + */ export class ServerTokenManager implements TokenManager { private key: JWK.OctKey | JWK.NoneKey; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index 39b650b3a3..ede582de26 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -14,6 +14,11 @@ * limitations under the License. */ +/** + * Interface for creating and validating tokens. + * + * @public + */ export interface TokenManager { getToken: () => Promise<{ token: string }>; validateToken: (token: string) => void; From 422de86abe612d81367b0d8c0246d9cd8446ecb7 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 12:20:28 +0000 Subject: [PATCH 42/56] docs: split up backend-to-backend and api request authentication docs Signed-off-by: MT Lewis --- .../tutorials/authenticate-api-requests.md | 83 ++----------------- docs/tutorials/backend-to-backend-auth.md | 71 ++++++++++++++++ 2 files changed, 79 insertions(+), 75 deletions(-) rename {docs => contrib/docs}/tutorials/authenticate-api-requests.md (69%) create mode 100644 docs/tutorials/backend-to-backend-auth.md diff --git a/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md similarity index 69% rename from docs/tutorials/authenticate-api-requests.md rename to contrib/docs/tutorials/authenticate-api-requests.md index 33d7d2cb31..9f651ddd1a 100644 --- a/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -1,45 +1,12 @@ ---- -id: authenticate-api-requests -title: Authenticating API Requests -description: Guide for authenticating API requests within Backstage ---- - -> This document has been moved over from the community contributed docs -> directory and will continue to evolve as request authentication/authorization -> becomes a first-class citizen of Backstage. The approach described here is now -> fully supported and functional end-to-end, but will eventually be replaced in -> favor of a built-in framework that will broadly support authentication and -> authorization needs within Backstage. - # 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. +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. +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**: Enabling this means that Backstage will stop working for guests, as no -token is issued for them. +**NOTE**: Enabling this means that Backstage will stop working for guests, as no token is issued for them. -Since the middleware always expects a valid token, API requests from backend -plugins will need one as well. Backends have no concept of a Backstage identity -so instead they use a token generated using a shared key from the -`app-config.yaml`. You can generate a unique key for your app in a terminal, and -set the `BACKEND_SECRET` environment variable to the resulting value. - -```bash -node -p 'require("crypto").randomBytes(24).toString("base64")' -``` - -As techdocs HTML pages load assets without an Authorization header the code -below also sets a token cookie when the user logs in (and when the token is -about to expire). +As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire). ```typescript // packages/backend/src/index.ts from a create-app deployment @@ -93,13 +60,7 @@ async function main() { const token = IdentityClient.getBearerToken(req.headers.authorization) || req.cookies['token']; - - // Authenticate all requests originating from backends by default - const isValidServerToken = authEnv.tokenManager.validateToken(token); - if (!isValidServerToken) { - req.user = await identity.authenticate(token); - } - + req.user = await identity.authenticate(token); if (!req.headers.authorization) { // Authorization header may be forwarded by plugin requests req.headers.authorization = `Bearer ${token}`; @@ -221,10 +182,9 @@ const app = createApp({ // ... ``` -**NOTE**: Most Backstage frontend plugins come with the support for the -`IdentityApi`. In case you already have a dozen of internal ones, you may need -to update those too. Assuming you follow the common plugin structure, the -changes to your front-end may look like: +**NOTE**: Most Backstage frontend plugins come with the support for the `IdentityApi`. +In case you already have a dozen of internal ones, you may need to update those too. +Assuming you follow the common plugin structure, the changes to your front-end may look like: ```diff // plugins/internal-plugin/src/api.ts @@ -301,30 +261,3 @@ export const plugin = createPlugin({ ], }); ``` - -In the (probably unlikely) case that you need to authenticate from a backend -plugin, the plugin environment contains a `tokenManager` that will provide a -server token to use in the request. It is a noop `tokenManager` by default -- -you'll need to instantiate a new one using the secret from your config instead: - -```diff -// packages/backend/src/index.ts - -function makeCreateEnv(config: Config) { - const root = getRootLogger(); - const reader = UrlReaders.default({ logger: root, config }); - const discovery = SingleHostDiscovery.fromConfig(config); - - root.info(`Created UrlReader ${reader}`); - - const cacheManager = CacheManager.fromConfig(config); - const databaseManager = DatabaseManager.fromConfig(config); -- const tokenManager = ServerTokenManager.noop(); -+ const tokenManager = ServerTokenManager.fromConfig(config); -``` - -With this `tokenManager`, you can then generate a server token for requests: - -``` -const { token } = await this.tokenManager.getToken(); -``` diff --git a/docs/tutorials/backend-to-backend-auth.md b/docs/tutorials/backend-to-backend-auth.md new file mode 100644 index 0000000000..b49a9fe3b5 --- /dev/null +++ b/docs/tutorials/backend-to-backend-auth.md @@ -0,0 +1,71 @@ +--- +id: backend-to-backend-auth +title: Backend-to-Backend Authentication +description: + Guide for authenticating API requests between Backstage plugin backends +--- + +This tutorial describes the steps needed to handle _backend-to-backend +authentication_, which allows plugin backends to determine whether a given +request originates from a legitimate Backstage backend by verifying a token +signed with a shared secret. This system has limited use for now, but will be +needed to support the upcoming framework for permissions and authorization (see +[the PRFC on the topic](https://github.com/backstage/backstage/pull/7761) for +more details). + +Backends have no concept of a Backstage identity, so instead they use a token +generated using a shared key stored in config. You can generate a unique key for +your app in a terminal, and set the `BACKEND_SECRET` environment variable to the +resulting value. + +```bash +node -p 'require("crypto").randomBytes(24).toString("base64")' +``` + +Requests originating from a backend plugin can be authenticated by decorating +them with a backend token. Backend tokens can be generated using a +`TokenManager`, which can be passed to plugin backends via the +`PluginEnvironment`. The `TokenManager` provided in new Backstage instances +generated by `create-app` is a stub, which returns empty tokens and accepts any +input string as valid. To enable backend-to-backend authentication, you'll need +to instantiate a new one using the secret from your config instead: + +```diff +// packages/backend/src/index.ts + +function makeCreateEnv(config: Config) { + const root = getRootLogger(); + const reader = UrlReaders.default({ logger: root, config }); + const discovery = SingleHostDiscovery.fromConfig(config); + + root.info(`Created UrlReader ${reader}`); + + const cacheManager = CacheManager.fromConfig(config); + const databaseManager = DatabaseManager.fromConfig(config); +- const tokenManager = ServerTokenManager.noop(); ++ const tokenManager = ServerTokenManager.fromConfig(config); +``` + +With this `tokenManager`, you can then generate a server token for requests: + +```typescript +const { token } = await this.tokenManager.getToken(); + +const response = await fetch(pluginBackendApiUrl, { + method: 'GET', + headers: { + ...headers, + Authorization: `Bearer ${token}`, + }, +}); +``` + +You can use the same `tokenManager` to validate tokens supplied on incoming +requests: + +```typescript +const isValidServerToken = await tokenManager.validateToken(token); +if (!isValidServerToken) { + throw new UnauthorizedError(); +} +``` From c8445162356bef7008115722a474c88b1be84c98 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 12:22:52 +0000 Subject: [PATCH 43/56] docs: fix reference to backend-to-backend auth tutorial docs Signed-off-by: MT Lewis --- app-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-config.yaml b/app-config.yaml index 08dd2837e4..d3dad59c2f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -24,7 +24,7 @@ app: backend: # Used for enabling authentication, secret is shared by all backend plugins - # See authenticate-api-requests.md in the contrib docs for information on the format + # See backend-to-backend-auth.md in the docs for information on the format # auth: # secret: ${BACKEND_SECRET} baseUrl: http://localhost:7007 From 51608d2021fccc7e305e5ffcc26e2268f6e2d9e3 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 18:16:37 +0000 Subject: [PATCH 44/56] backend-common: bits->bytes in config schema comment Signed-off-by: MT Lewis --- packages/backend-common/config.d.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 66b54d1ebb..3ba3d83ebc 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -23,8 +23,9 @@ export interface Config { /** Backend configuration for when request authentication is enabled */ auth?: { /** - * Secret shared by all backends for generating tokens - * Format is base64 24-bit key + * Secret shared by all backends for generating tokens. Should be + * a base64 string, recommended length is 24 bytes. + * * @visibility secret */ secret: string; From 189f7f8a722ad94b466d494912aafd3a0418c04b Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 18:42:07 +0000 Subject: [PATCH 45/56] backend-common: allow for multiple signing keys in ServerTokenManager Signed-off-by: MT Lewis --- app-config.yaml | 3 +- packages/backend-common/config.d.ts | 17 +++++---- .../src/tokens/ServerTokenManager.test.ts | 38 +++++++++++++++++-- .../src/tokens/ServerTokenManager.ts | 29 ++++++++------ 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index d3dad59c2f..913a72dbab 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -26,7 +26,8 @@ backend: # Used for enabling authentication, secret is shared by all backend plugins # See backend-to-backend-auth.md in the docs for information on the format # auth: - # secret: ${BACKEND_SECRET} + # keys: + # - secret: ${BACKEND_SECRET} baseUrl: http://localhost:7007 listen: port: 7007 diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 3ba3d83ebc..bdc7740c13 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -22,13 +22,16 @@ export interface Config { backend: { /** Backend configuration for when request authentication is enabled */ auth?: { - /** - * Secret shared by all backends for generating tokens. Should be - * a base64 string, recommended length is 24 bytes. - * - * @visibility secret - */ - secret: string; + /** Keys shared by all backends for signing and validating backend tokens. */ + keys: { + /** + * Secret for generating tokens. Should be a base64 string, recommended + * length is 24 bytes. + * + * @visibility secret + */ + secret: string; + }[]; }; baseUrl: string; // defined in core, but repeated here without doc diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 20e18e9080..c00f708179 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -18,7 +18,7 @@ import { ServerTokenManager } from './ServerTokenManager'; const emptyConfig = new ConfigReader({}); const configWithSecret = new ConfigReader({ - backend: { auth: { secret: 'a-secret-key' } }, + backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, }); describe('ServerTokenManager', () => { @@ -61,12 +61,42 @@ describe('ServerTokenManager', () => { expect(() => tokenManager2.validateToken(token)).not.toThrow(); }); - it('should throw for server tokens created using a different secret', async () => { + it('should validate server tokens created using any of the secrets', async () => { const tokenManager1 = ServerTokenManager.fromConfig( - new ConfigReader({ backend: { auth: { secret: 'a1b2c3' } } }), + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), ); const tokenManager2 = ServerTokenManager.fromConfig( - new ConfigReader({ backend: { auth: { secret: 'd4e5f6' } } }), + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, + }), + ); + const tokenManager3 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] }, + }, + }), + ); + + const { token: token1 } = await tokenManager1.getToken(); + expect(() => tokenManager3.validateToken(token1)).not.toThrow(); + + const { token: token2 } = await tokenManager2.getToken(); + expect(() => tokenManager3.validateToken(token2)).not.toThrow(); + }); + + it('should throw for server tokens created using a different secret', async () => { + const tokenManager1 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + const tokenManager2 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, + }), ); const { token } = await tokenManager1.getToken(); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index e4afc2997e..ea79497b42 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { JWK, JWT } from 'jose'; +import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; @@ -26,40 +26,47 @@ import { TokenManager } from './types'; * @public */ export class ServerTokenManager implements TokenManager { - private key: JWK.OctKey | JWK.NoneKey; + private readonly keyStore: JWKS.KeyStore; static noop() { return new ServerTokenManager(); } static fromConfig(config: Config) { - const secret = config.getString('backend.auth.secret'); - - return new ServerTokenManager(secret); + return new ServerTokenManager( + config + .getConfigArray('backend.auth.keys') + .map(key => key.getString('secret')), + ); } - private constructor(secret: string = '') { - this.key = secret ? JWK.asKey({ kty: 'oct', k: secret }) : JWK.None; + private constructor(secrets?: string[]) { + this.keyStore = new JWKS.KeyStore( + secrets?.length + ? secrets.map(secret => JWK.asKey({ kty: 'oct', k: secret })) + : [], + ); } async getToken(): Promise<{ token: string }> { - if (this.key === JWK.None) { + if (this.keyStore.size === 0) { return { token: '' }; } - const jwt = JWT.sign({ sub: 'backstage-server' }, this.key, { + const jwt = JWT.sign({ sub: 'backstage-server' }, this.keyStore.all()[0], { algorithm: 'HS256', }); + return { token: jwt }; } validateToken(token: string): void { - if (this.key === JWK.None) { + if (this.keyStore.size === 0) { return; } try { - JWT.verify(token, this.key); + JWT.verify(token, this.keyStore); return; } catch (e) { throw new AuthenticationError('Invalid server token'); From 4e6b2319a23e37546208777b60dfc20cee6131b8 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Thu, 25 Nov 2021 18:00:33 +0000 Subject: [PATCH 46/56] backend-common: include self-reported pluginId in backend token Signed-off-by: MT Lewis --- .../src/tokens/ServerTokenManager.test.ts | 98 ++++++++++++++----- .../src/tokens/ServerTokenManager.ts | 44 +++++---- packages/backend-common/src/tokens/types.ts | 22 ++++- 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index c00f708179..361d98121e 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -29,36 +29,46 @@ describe('ServerTokenManager', () => { describe('getToken', () => { it('should return a token if secret in config exists', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect((await tokenManager.getToken()).token).toBeDefined(); + expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); }); - it('should return an empty string if using a noop TokenManager', async () => { + it('should return a token string if using a noop TokenManager', async () => { const tokenManager = ServerTokenManager.noop(); - expect((await tokenManager.getToken()).token).toBe(''); + expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); }); }); - describe('validateToken', () => { + describe('authenticate', () => { it('should not throw if token is valid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager.getToken(); - expect(() => tokenManager.validateToken(token)).not.toThrow(); + const { token } = await tokenManager.getToken('test-plugin'); + await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); }); - it('should throw if token is invalid', () => { + it('should allow retrieving the pluginId from valid tokens', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect(() => tokenManager.validateToken('random-string')).toThrowError( - /invalid server token/i, - ); + const { token } = await tokenManager.getToken('test-plugin-123'); + + await expect(tokenManager.authenticate(token)).resolves.toEqual({ + pluginId: 'test-plugin-123', + token, + }); + }); + + it('should throw if token is invalid', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + tokenManager.authenticate('random-string'), + ).rejects.toThrowError(/invalid server token/i); }); it('should validate server tokens created by a different instance using the same secret', async () => { const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager1.getToken(); + const { token } = await tokenManager1.getToken('test-plugin'); - expect(() => tokenManager2.validateToken(token)).not.toThrow(); + await expect(tokenManager2.authenticate(token)).resolves.not.toThrow(); }); it('should validate server tokens created using any of the secrets', async () => { @@ -80,11 +90,11 @@ describe('ServerTokenManager', () => { }), ); - const { token: token1 } = await tokenManager1.getToken(); - expect(() => tokenManager3.validateToken(token1)).not.toThrow(); + const { token: token1 } = await tokenManager1.getToken('test-plugin'); + await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow(); - const { token: token2 } = await tokenManager2.getToken(); - expect(() => tokenManager3.validateToken(token2)).not.toThrow(); + const { token: token2 } = await tokenManager2.getToken('test-plugin'); + await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow(); }); it('should throw for server tokens created using a different secret', async () => { @@ -99,9 +109,24 @@ describe('ServerTokenManager', () => { }), ); - const { token } = await tokenManager1.getToken(); + const { token } = await tokenManager1.getToken('test-plugin'); - expect(() => tokenManager2.validateToken(token)).toThrowError( + await expect(tokenManager2.authenticate(token)).rejects.toThrowError( + /invalid server token/i, + ); + }); + + it('should throw for server tokens created using a noop TokenManager', async () => { + const noopTokenManager = ServerTokenManager.noop(); + const tokenManager = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + ); + + const { token } = await noopTokenManager.getToken('test-plugin'); + + await expect(tokenManager.authenticate(token)).rejects.toThrowError( /invalid server token/i, ); }); @@ -115,19 +140,40 @@ describe('ServerTokenManager', () => { }); it('should accept tokens it generates', async () => { - const { token } = await noopTokenManager.getToken(); + const { token } = await noopTokenManager.getToken('test-plugin'); - expect(() => noopTokenManager.validateToken(token)).not.toThrow(); + await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow(); }); - it('should accept arbitrary strings', async () => { - expect(() => - noopTokenManager.validateToken('random-string'), - ).not.toThrow(); + it('should accept tokens generated by other noop token managers', async () => { + const noopTokenManager2 = ServerTokenManager.noop(); + await expect( + noopTokenManager.authenticate( + ( + await noopTokenManager2.getToken('test-plugin') + ).token, + ), + ).resolves.not.toThrow(); }); - it('should accept empty strings', async () => { - expect(() => noopTokenManager.validateToken('')).not.toThrow(); + it('should not accept signed tokens', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + await expect( + noopTokenManager.authenticate( + ( + await tokenManager.getToken('test-plugin') + ).token, + ), + ).rejects.toThrowError(/invalid server token/i); + }); + + it('should allow retrieving the pluginId from valid tokens', async () => { + const { token } = await noopTokenManager.getToken('test-plugin-123'); + + await expect(noopTokenManager.authenticate(token)).resolves.toEqual({ + pluginId: 'test-plugin-123', + token, + }); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index ea79497b42..01ebf55f15 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -17,7 +17,7 @@ import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; -import { TokenManager } from './types'; +import { ServerIdentity, TokenManager } from './types'; /** * Creates and validates tokens for use during backend-to-backend @@ -26,7 +26,9 @@ import { TokenManager } from './types'; * @public */ export class ServerTokenManager implements TokenManager { - private readonly keyStore: JWKS.KeyStore; + private readonly verificationKeys: JWKS.KeyStore | JWK.NoneKey; + private readonly signingKey: JWK.Key | JWK.NoneKey; + private readonly signingAlgorithm: string | undefined; static noop() { return new ServerTokenManager(); @@ -41,35 +43,35 @@ export class ServerTokenManager implements TokenManager { } private constructor(secrets?: string[]) { - this.keyStore = new JWKS.KeyStore( - secrets?.length - ? secrets.map(secret => JWK.asKey({ kty: 'oct', k: secret })) - : [], - ); + if (secrets?.length) { + this.verificationKeys = new JWKS.KeyStore( + secrets.map(k => JWK.asKey({ kty: 'oct', k })), + ); + this.signingKey = this.verificationKeys.all()[0]; + this.signingAlgorithm = 'HS256'; + } else { + this.verificationKeys = this.signingKey = JWK.None; + } } - async getToken(): Promise<{ token: string }> { - if (this.keyStore.size === 0) { - return { token: '' }; - } - - const jwt = JWT.sign({ sub: 'backstage-server' }, this.keyStore.all()[0], { - algorithm: 'HS256', + async getToken(pluginId: string): Promise<{ token: string }> { + // TODO(mtlewis): should we wrap pluginId in a urn? + const jwt = JWT.sign({ sub: pluginId }, this.signingKey, { + algorithm: this.signingAlgorithm, }); return { token: jwt }; } - validateToken(token: string): void { - if (this.keyStore.size === 0) { - return; - } - + async authenticate(token: string): Promise { + let decodedJwt = {}; try { - JWT.verify(token, this.keyStore); - return; + JWT.verify(token, this.verificationKeys); + decodedJwt = JWT.decode(token); } catch (e) { throw new AuthenticationError('Invalid server token'); } + + return { pluginId: decodedJwt.sub, token }; } } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index ede582de26..fdf54d4712 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -14,12 +14,30 @@ * limitations under the License. */ +/** + * A (pluginId, token) pair. + * + * @public + */ +export type ServerIdentity = { + /** + * The ID of the plugin backend to which this + * identity corresponds. + */ + pluginId: string; + + /** + * The token used to authenticate the plugin backend. + */ + token: string; +}; + /** * Interface for creating and validating tokens. * * @public */ export interface TokenManager { - getToken: () => Promise<{ token: string }>; - validateToken: (token: string) => void; + getToken: (pluginId: string) => Promise<{ token: string }>; + authenticate: (token: string) => Promise; } From 5510cee0397028d2ccaad5e58674a5634afb00c0 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 10:21:11 +0000 Subject: [PATCH 47/56] backend-common: stop accepting self-reported pluginId in ServerTokenManager Until we have a solution for assigning plugin/backend identities in a way that's not open to impersonation, we can't build on top of this information, so it's better to leave it out for now. Signed-off-by: MT Lewis --- .../src/tokens/ServerTokenManager.test.ts | 45 +++++-------------- .../src/tokens/ServerTokenManager.ts | 13 ++---- packages/backend-common/src/tokens/types.ts | 22 +-------- 3 files changed, 17 insertions(+), 63 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 361d98121e..925c770a0d 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -29,32 +29,22 @@ describe('ServerTokenManager', () => { describe('getToken', () => { it('should return a token if secret in config exists', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); + expect((await tokenManager.getToken()).token).toBeDefined(); }); it('should return a token string if using a noop TokenManager', async () => { const tokenManager = ServerTokenManager.noop(); - expect((await tokenManager.getToken('test-plugin')).token).toBeDefined(); + expect((await tokenManager.getToken()).token).toBeDefined(); }); }); describe('authenticate', () => { it('should not throw if token is valid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager.getToken('test-plugin'); + const { token } = await tokenManager.getToken(); await expect(tokenManager.authenticate(token)).resolves.not.toThrow(); }); - it('should allow retrieving the pluginId from valid tokens', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager.getToken('test-plugin-123'); - - await expect(tokenManager.authenticate(token)).resolves.toEqual({ - pluginId: 'test-plugin-123', - token, - }); - }); - it('should throw if token is invalid', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); await expect( @@ -66,7 +56,7 @@ describe('ServerTokenManager', () => { const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret); const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret); - const { token } = await tokenManager1.getToken('test-plugin'); + const { token } = await tokenManager1.getToken(); await expect(tokenManager2.authenticate(token)).resolves.not.toThrow(); }); @@ -90,10 +80,10 @@ describe('ServerTokenManager', () => { }), ); - const { token: token1 } = await tokenManager1.getToken('test-plugin'); + const { token: token1 } = await tokenManager1.getToken(); await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow(); - const { token: token2 } = await tokenManager2.getToken('test-plugin'); + const { token: token2 } = await tokenManager2.getToken(); await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow(); }); @@ -109,7 +99,7 @@ describe('ServerTokenManager', () => { }), ); - const { token } = await tokenManager1.getToken('test-plugin'); + const { token } = await tokenManager1.getToken(); await expect(tokenManager2.authenticate(token)).rejects.toThrowError( /invalid server token/i, @@ -124,7 +114,7 @@ describe('ServerTokenManager', () => { }), ); - const { token } = await noopTokenManager.getToken('test-plugin'); + const { token } = await noopTokenManager.getToken(); await expect(tokenManager.authenticate(token)).rejects.toThrowError( /invalid server token/i, @@ -140,7 +130,7 @@ describe('ServerTokenManager', () => { }); it('should accept tokens it generates', async () => { - const { token } = await noopTokenManager.getToken('test-plugin'); + const { token } = await noopTokenManager.getToken(); await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow(); }); @@ -150,7 +140,7 @@ describe('ServerTokenManager', () => { await expect( noopTokenManager.authenticate( ( - await noopTokenManager2.getToken('test-plugin') + await noopTokenManager2.getToken() ).token, ), ).resolves.not.toThrow(); @@ -159,21 +149,8 @@ describe('ServerTokenManager', () => { it('should not accept signed tokens', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); await expect( - noopTokenManager.authenticate( - ( - await tokenManager.getToken('test-plugin') - ).token, - ), + noopTokenManager.authenticate((await tokenManager.getToken()).token), ).rejects.toThrowError(/invalid server token/i); }); - - it('should allow retrieving the pluginId from valid tokens', async () => { - const { token } = await noopTokenManager.getToken('test-plugin-123'); - - await expect(noopTokenManager.authenticate(token)).resolves.toEqual({ - pluginId: 'test-plugin-123', - token, - }); - }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 01ebf55f15..7796b675b8 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -17,7 +17,7 @@ import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; -import { ServerIdentity, TokenManager } from './types'; +import { TokenManager } from './types'; /** * Creates and validates tokens for use during backend-to-backend @@ -54,24 +54,19 @@ export class ServerTokenManager implements TokenManager { } } - async getToken(pluginId: string): Promise<{ token: string }> { - // TODO(mtlewis): should we wrap pluginId in a urn? - const jwt = JWT.sign({ sub: pluginId }, this.signingKey, { + async getToken(): Promise<{ token: string }> { + const jwt = JWT.sign({ sub: 'backstage-server' }, this.signingKey, { algorithm: this.signingAlgorithm, }); return { token: jwt }; } - async authenticate(token: string): Promise { - let decodedJwt = {}; + async authenticate(token: string): Promise { try { JWT.verify(token, this.verificationKeys); - decodedJwt = JWT.decode(token); } catch (e) { throw new AuthenticationError('Invalid server token'); } - - return { pluginId: decodedJwt.sub, token }; } } diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts index fdf54d4712..1fea018db9 100644 --- a/packages/backend-common/src/tokens/types.ts +++ b/packages/backend-common/src/tokens/types.ts @@ -14,30 +14,12 @@ * limitations under the License. */ -/** - * A (pluginId, token) pair. - * - * @public - */ -export type ServerIdentity = { - /** - * The ID of the plugin backend to which this - * identity corresponds. - */ - pluginId: string; - - /** - * The token used to authenticate the plugin backend. - */ - token: string; -}; - /** * Interface for creating and validating tokens. * * @public */ export interface TokenManager { - getToken: (pluginId: string) => Promise<{ token: string }>; - authenticate: (token: string) => Promise; + getToken: () => Promise<{ token: string }>; + authenticate: (token: string) => Promise; } From 10d0109d2e897c35b3664801c39e4ea8599da3ba Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:01:11 +0000 Subject: [PATCH 48/56] backend-common: extract NoopTokenManager from ServerTokenManager Signed-off-by: MT Lewis --- .../src/tokens/ServerTokenManager.test.ts | 39 +++++++++++++++++-- .../src/tokens/ServerTokenManager.ts | 32 +++++++++------ 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index 925c770a0d..fe724a0a4b 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { ConfigReader } from '@backstage/config'; +import { TokenManager } from './types'; import { ServerTokenManager } from './ServerTokenManager'; const emptyConfig = new ConfigReader({}); @@ -122,8 +123,40 @@ describe('ServerTokenManager', () => { }); }); + describe('ServerTokenManager.fromConfig', () => { + it('should throw if backend auth configuration is missing', () => { + expect(() => + ServerTokenManager.fromConfig(new ConfigReader({})), + ).toThrow(); + }); + + it('should throw if no keys are included in the configuration', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [] } }, + }), + ), + ).toThrow(); + }); + + it('should throw if any key is missing a secret property', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { + keys: [{ secret: '123' }, {}, { secret: '789' }], + }, + }, + }), + ), + ).toThrow(); + }); + }); + describe('ServerTokenManager.noop', () => { - let noopTokenManager: ServerTokenManager; + let noopTokenManager: TokenManager; beforeEach(() => { noopTokenManager = ServerTokenManager.noop(); @@ -146,11 +179,11 @@ describe('ServerTokenManager', () => { ).resolves.not.toThrow(); }); - it('should not accept signed tokens', async () => { + it('should accept signed tokens', async () => { const tokenManager = ServerTokenManager.fromConfig(configWithSecret); await expect( noopTokenManager.authenticate((await tokenManager.getToken()).token), - ).rejects.toThrowError(/invalid server token/i); + ).resolves.not.toThrow(); }); }); }); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 7796b675b8..edd781671b 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -19,6 +19,14 @@ import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; +class NoopTokenManager implements TokenManager { + async getToken() { + return { token: '' }; + } + + async authenticate() {} +} + /** * Creates and validates tokens for use during backend-to-backend * authentication. @@ -26,12 +34,11 @@ import { TokenManager } from './types'; * @public */ export class ServerTokenManager implements TokenManager { - private readonly verificationKeys: JWKS.KeyStore | JWK.NoneKey; - private readonly signingKey: JWK.Key | JWK.NoneKey; - private readonly signingAlgorithm: string | undefined; + private readonly verificationKeys: JWKS.KeyStore; + private readonly signingKey: JWK.Key; static noop() { - return new ServerTokenManager(); + return new NoopTokenManager(); } static fromConfig(config: Config) { @@ -43,20 +50,21 @@ export class ServerTokenManager implements TokenManager { } private constructor(secrets?: string[]) { - if (secrets?.length) { - this.verificationKeys = new JWKS.KeyStore( - secrets.map(k => JWK.asKey({ kty: 'oct', k })), + if (!secrets?.length) { + throw new Error( + 'No secrets provided when constructing ServerTokenManager', ); - this.signingKey = this.verificationKeys.all()[0]; - this.signingAlgorithm = 'HS256'; - } else { - this.verificationKeys = this.signingKey = JWK.None; } + + this.verificationKeys = new JWKS.KeyStore( + secrets.map(k => JWK.asKey({ kty: 'oct', k })), + ); + this.signingKey = this.verificationKeys.all()[0]; } async getToken(): Promise<{ token: string }> { const jwt = JWT.sign({ sub: 'backstage-server' }, this.signingKey, { - algorithm: this.signingAlgorithm, + algorithm: 'HS256', }); return { token: jwt }; From e5b4256e70530e0700f0ab217d26ee6053882af0 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:11:31 +0000 Subject: [PATCH 49/56] catalog-backend: update TokenManager mock in collator test suite Signed-off-by: MT Lewis --- .../catalog-backend/src/search/DefaultCatalogCollator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index 1415341795..1360ca2647 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -68,7 +68,7 @@ describe('DefaultCatalogCollator', () => { }; mockTokenManager = { getToken: jest.fn().mockResolvedValue({ token: '' }), - validateToken: jest.fn(), + authenticate: jest.fn(), }; collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi, From 87f1b59ff94e9f8187dfd81dab0568bfa94e2ade Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:12:01 +0000 Subject: [PATCH 50/56] techdocs-backend: update TokenManager mock in collator test suite Signed-off-by: MT Lewis --- .../src/search/DefaultTechDocsCollator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index 416b040d91..5f0bed55dd 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -100,7 +100,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => { }; mockTokenManager = { getToken: jest.fn().mockResolvedValue({ token: '' }), - validateToken: jest.fn(), + authenticate: jest.fn(), }; const mockConfig = new ConfigReader({ techdocs: { @@ -166,7 +166,7 @@ describe('DefaultTechDocsCollator', () => { }; mockTokenManager = { getToken: jest.fn().mockResolvedValue({ token: '' }), - validateToken: jest.fn(), + authenticate: jest.fn(), }; collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), { discovery: mockDiscoveryApi, From 54eaeddaf1c3215f3681fe6f802a19c34d1ccf5b Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:12:43 +0000 Subject: [PATCH 51/56] backend-common: move NoopTokenManager into ServerTokenManager namespace Signed-off-by: MT Lewis --- .../src/tokens/ServerTokenManager.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index edd781671b..659cb2c264 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -19,14 +19,6 @@ import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; -class NoopTokenManager implements TokenManager { - async getToken() { - return { token: '' }; - } - - async authenticate() {} -} - /** * Creates and validates tokens for use during backend-to-backend * authentication. @@ -38,7 +30,7 @@ export class ServerTokenManager implements TokenManager { private readonly signingKey: JWK.Key; static noop() { - return new NoopTokenManager(); + return new ServerTokenManager.NoopTokenManager(); } static fromConfig(config: Config) { @@ -78,3 +70,13 @@ export class ServerTokenManager implements TokenManager { } } } + +export namespace ServerTokenManager { + export class NoopTokenManager implements TokenManager { + async getToken() { + return { token: '' }; + } + + async authenticate() {} + } +} From 73bff921fdae1b44d5a02b99250c59983ca2752e Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:06:35 +0000 Subject: [PATCH 52/56] backend-common: update api-report Signed-off-by: MT Lewis --- packages/backend-common/api-report.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 33d41a5929..78a73eb42b 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,8 +526,12 @@ export type SearchResponseFile = { content(): Promise; }; +// Warning: (ae-missing-release-tag) "ServerTokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// // @public export class ServerTokenManager implements TokenManager { + // (undocumented) + authenticate(token: string): Promise; // (undocumented) static fromConfig(config: Config): ServerTokenManager; // (undocumented) @@ -535,9 +539,20 @@ export class ServerTokenManager implements TokenManager { token: string; }>; // (undocumented) - static noop(): ServerTokenManager; + static noop(): ServerTokenManager.NoopTokenManager; +} + +// @public (undocumented) +export namespace ServerTokenManager { // (undocumented) - validateToken(token: string): void; + export class NoopTokenManager implements TokenManager { + // (undocumented) + authenticate(): Promise; + // (undocumented) + getToken(): Promise<{ + token: string; + }>; + } } // @public (undocumented) @@ -599,12 +614,12 @@ export interface StatusCheckHandlerOptions { // @public export interface TokenManager { + // (undocumented) + authenticate: (token: string) => Promise; // (undocumented) getToken: () => Promise<{ token: string; }>; - // (undocumented) - validateToken: (token: string) => void; } // @public From 1cad4e54ca58d5e2aeaf4229eb7f8b4cb04e49be Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:24:48 +0000 Subject: [PATCH 53/56] backend-common: additional api-report comments and tags Signed-off-by: MT Lewis --- packages/backend-common/api-report.md | 3 --- packages/backend-common/src/tokens/ServerTokenManager.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 78a73eb42b..6efc504fc5 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,8 +526,6 @@ export type SearchResponseFile = { content(): Promise; }; -// Warning: (ae-missing-release-tag) "ServerTokenManager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class ServerTokenManager implements TokenManager { // (undocumented) @@ -544,7 +542,6 @@ export class ServerTokenManager implements TokenManager { // @public (undocumented) export namespace ServerTokenManager { - // (undocumented) export class NoopTokenManager implements TokenManager { // (undocumented) authenticate(): Promise; diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 659cb2c264..2eeb7b5506 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -71,7 +71,16 @@ export class ServerTokenManager implements TokenManager { } } +/** + * @public + */ export namespace ServerTokenManager { + /** + * Stub token manager which returns empty tokens and always + * authenticates successfully. + * + * @public + */ export class NoopTokenManager implements TokenManager { async getToken() { return { token: '' }; From e65d8b51745db2361530edfbbc6c2c745a8c7f0a Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:31:10 +0000 Subject: [PATCH 54/56] docs: update backend-to-backend auth docs to reflect new TokenManager api Signed-off-by: MT Lewis --- docs/tutorials/backend-to-backend-auth.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/tutorials/backend-to-backend-auth.md b/docs/tutorials/backend-to-backend-auth.md index b49a9fe3b5..3cb72a4827 100644 --- a/docs/tutorials/backend-to-backend-auth.md +++ b/docs/tutorials/backend-to-backend-auth.md @@ -60,12 +60,9 @@ const response = await fetch(pluginBackendApiUrl, { }); ``` -You can use the same `tokenManager` to validate tokens supplied on incoming +You can use the same `tokenManager` to authenticate tokens supplied on incoming requests: ```typescript -const isValidServerToken = await tokenManager.validateToken(token); -if (!isValidServerToken) { - throw new UnauthorizedError(); -} +await tokenManager.authenticate(token); // throws if token is invalid ``` From f5c43945eb2b0251d7c18139cc740e1956c7b53c Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 12:35:09 +0000 Subject: [PATCH 55/56] create-app: update backend-to-backend auth comment in app-config template Signed-off-by: MT Lewis --- .../create-app/templates/default-app/app-config.yaml.hbs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 44bae0ff26..4b495ed957 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -7,9 +7,10 @@ organization: backend: # Used for enabling authentication, secret is shared by all backend plugins - # See authenticate-api-requests.md in the contrib docs for information on the format + # See backend-to-backend-auth.md in the docs for information on the format # auth: - # secret: ${BACKEND_SECRET} + # keys: + # - secret: ${BACKEND_SECRET} baseUrl: http://localhost:7007 listen: port: 7007 From a8eeaa13b434297b4e87442c76fedf08cab05b39 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 26 Nov 2021 13:38:32 +0000 Subject: [PATCH 56/56] backend-common: refactor away namespace usage in ServerTokenManager Signed-off-by: MT Lewis --- packages/backend-common/api-report.md | 14 +-------- .../src/tokens/ServerTokenManager.ts | 31 ++++++------------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 6efc504fc5..9c66c0573e 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -537,19 +537,7 @@ export class ServerTokenManager implements TokenManager { token: string; }>; // (undocumented) - static noop(): ServerTokenManager.NoopTokenManager; -} - -// @public (undocumented) -export namespace ServerTokenManager { - export class NoopTokenManager implements TokenManager { - // (undocumented) - authenticate(): Promise; - // (undocumented) - getToken(): Promise<{ - token: string; - }>; - } + static noop(): TokenManager; } // @public (undocumented) diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts index 2eeb7b5506..35a07509b6 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -19,6 +19,14 @@ import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; +class NoopTokenManager implements TokenManager { + async getToken() { + return { token: '' }; + } + + async authenticate() {} +} + /** * Creates and validates tokens for use during backend-to-backend * authentication. @@ -29,8 +37,8 @@ export class ServerTokenManager implements TokenManager { private readonly verificationKeys: JWKS.KeyStore; private readonly signingKey: JWK.Key; - static noop() { - return new ServerTokenManager.NoopTokenManager(); + static noop(): TokenManager { + return new NoopTokenManager(); } static fromConfig(config: Config) { @@ -70,22 +78,3 @@ export class ServerTokenManager implements TokenManager { } } } - -/** - * @public - */ -export namespace ServerTokenManager { - /** - * Stub token manager which returns empty tokens and always - * authenticates successfully. - * - * @public - */ - export class NoopTokenManager implements TokenManager { - async getToken() { - return { token: '' }; - } - - async authenticate() {} - } -}