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; };