diff --git a/.changeset/clean-plums-crash.md b/.changeset/clean-plums-crash.md new file mode 100644 index 0000000000..77cd94f097 --- /dev/null +++ b/.changeset/clean-plums-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Forward authorization header on backend request if present diff --git a/.changeset/old-coats-suffer.md b/.changeset/old-coats-suffer.md new file mode 100644 index 0000000000..5938c60b1e --- /dev/null +++ b/.changeset/old-coats-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Add authorization header on techdocs api requests. Breaking change as clients now needs the Identity API. diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index 2b8407abd2..4f4547a49e 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -11,11 +11,35 @@ Caveat: as of writing this, Backstage does not refresh the identity token so eve ```typescript // packages/backend/src/index.ts from a create-app deployment +import cookieParser from 'cookie-parser'; import { Request, Response, NextFunction } from 'express'; +import { JWT } from 'jose'; +import { URL } from 'url'; import { IdentityClient } from '@backstage/plugin-auth-backend'; // ... +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() { // ... @@ -24,14 +48,30 @@ async function main() { 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); + 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`); @@ -39,6 +79,7 @@ async function main() { }; 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)); // Only authenticated requests are allowed to the routes below diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index ef032e9f8b..d300400c70 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -82,9 +82,13 @@ export async function createRouter({ const { kind, namespace, name } = req.params; try { + const token = getBearerToken(req.headers.authorization); const entity = (await ( await fetch( `${catalogUrl}/entities/by-name/${kind}/${namespace}/${name}`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ) ).json()) as Entity; @@ -109,7 +113,10 @@ export async function createRouter({ const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); - const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`); + const token = getBearerToken(req.headers.authorization); + const catalogRes = await fetch(`${catalogUrl}/entities/by-name/${triple}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!catalogRes.ok) { const catalogResText = await catalogRes.text(); res.status(catalogRes.status); @@ -202,3 +209,7 @@ export async function createRouter({ return router; } + +function getBearerToken(header?: string): string | undefined { + return header?.match(/(?:Bearer)\s+(\S+)/i)?.[1]; +} diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 7d0679f6f6..5fa494a973 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DiscoveryApi } from '@backstage/core'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; @@ -21,16 +21,20 @@ import { TechDocsStorage } from '../src/api'; export class TechDocsDevStorageApi implements TechDocsStorage { public configApi: Config; public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; constructor({ configApi, discoveryApi, + identityApi, }: { configApi: Config; discoveryApi: DiscoveryApi; + identityApi: IdentityApi; }) { this.configApi = configApi; this.discoveryApi = discoveryApi; + this.identityApi = identityApi; } async getApiOrigin() { @@ -45,9 +49,13 @@ export class TechDocsDevStorageApi implements TechDocsStorage { const apiOrigin = await this.getApiOrigin(); const url = `${apiOrigin}/${name}/${path}`; + const token = await this.identityApi.getIdToken(); const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ); if (request.status === 404) { diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 4cf74c9622..3eefa0a814 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { configApiRef, discoveryApiRef } from '@backstage/core'; +import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; import { techdocsPlugin } from '../src/plugin'; import { TechDocsDevStorageApi } from './api'; @@ -23,11 +23,16 @@ import { techdocsStorageApiRef } from '../src'; createDevApp() .registerApi({ api: techdocsStorageApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsDevStorageApi({ configApi, discoveryApi, + identityApi, }), }) .registerPlugin(techdocsPlugin) diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 7f77be44e2..6de94b63bb 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsMetadata } from './types'; @@ -51,16 +51,20 @@ export interface TechDocs { export class TechDocsApi implements TechDocs { public configApi: Config; public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; constructor({ configApi, discoveryApi, + identityApi, }: { configApi: Config; discoveryApi: DiscoveryApi; + identityApi: IdentityApi; }) { this.configApi = configApi; this.discoveryApi = discoveryApi; + this.identityApi = identityApi; } async getApiOrigin() { @@ -84,8 +88,11 @@ export class TechDocsApi implements TechDocs { const apiOrigin = await this.getApiOrigin(); const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); - const request = await fetch(`${requestUrl}`); + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); const res = await request.json(); return res; @@ -104,8 +111,11 @@ export class TechDocsApi implements TechDocs { const apiOrigin = await this.getApiOrigin(); const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); - const request = await fetch(`${requestUrl}`); + const request = await fetch(`${requestUrl}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); const res = await request.json(); return res; @@ -120,16 +130,20 @@ export class TechDocsApi implements TechDocs { export class TechDocsStorageApi implements TechDocsStorage { public configApi: Config; public discoveryApi: DiscoveryApi; + public identityApi: IdentityApi; constructor({ configApi, discoveryApi, + identityApi, }: { configApi: Config; discoveryApi: DiscoveryApi; + identityApi: IdentityApi; }) { this.configApi = configApi; this.discoveryApi = discoveryApi; + this.identityApi = identityApi; } async getApiOrigin() { @@ -152,9 +166,13 @@ export class TechDocsStorageApi implements TechDocsStorage { const apiOrigin = await this.getApiOrigin(); const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; + const token = await this.identityApi.getIdToken(); const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, + { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, ); let errorMessage = ''; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 0b4063c237..a945ca1494 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -35,6 +35,7 @@ import { createApiFactory, configApiRef, discoveryApiRef, + identityApiRef, createRoutableExtension, } from '@backstage/core'; import { @@ -64,20 +65,30 @@ export const techdocsPlugin = createPlugin({ apis: [ createApiFactory({ api: techdocsStorageApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsStorageApi({ configApi, discoveryApi, + identityApi, }), }), createApiFactory({ api: techdocsApiRef, - deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, - factory: ({ configApi, discoveryApi }) => + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + }, + factory: ({ configApi, discoveryApi, identityApi }) => new TechDocsApi({ configApi, discoveryApi, + identityApi, }), }), ],