diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 6836ec40a0..e4a3d3d6de 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -19,6 +19,7 @@ import { Format } from 'logform'; import { Handler } from 'express'; import { HelmetOptions } from 'helmet'; import * as http from 'http'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { HttpRouterService } from '@backstage/backend-plugin-api'; import { HumanDuration } from '@backstage/types'; import { IdentityService } from '@backstage/backend-plugin-api'; @@ -148,6 +149,12 @@ export class HostDiscovery implements DiscoveryService { getExternalBaseUrl(pluginId: string): Promise; } +// @public (undocumented) +export const httpAuthServiceFactory: () => ServiceFactory< + HttpAuthService, + 'plugin' +>; + // @public (undocumented) export interface HttpRouterFactoryOptions { getPath?(pluginId: string): string; diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index e899e5a075..52378fef37 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -60,6 +60,7 @@ "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", + "cookie": "^0.6.0", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts new file mode 100644 index 0000000000..e2b60e31b2 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2024 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 { + AuthService, + BackstageCredentialTypes, + BackstageCredentials, + BackstageUnauthorizedCredentials, + DiscoveryService, + HttpAuthService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { parse as parseCookie } from 'cookie'; +import { Handler, Request, Response } from 'express'; +import { decodeJwt } from 'jose'; +import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; + +const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; + +function getTokenFromRequest(req: Request) { + // TODO: support multiple auth headers (iterate rawHeaders) + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string') { + const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); + const token = matches?.[1]; + if (token) { + return { token, isCookie: false }; + } + } + + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const cookies = parseCookie(cookieHeader); + const token = cookies[BACKSTAGE_AUTH_COOKIE]; + if (token) { + return { token, isCookie: true }; + } + } + + return { token: undefined, isCookie: false }; +} + +const credentialsSymbol = Symbol('backstage-credentials'); +// TODO: This is temporary and should be removed once we have proper cookie handling in place +const isCookieSymbol = Symbol('backstage-is-cookie-credentials'); + +type RequestWithCredentials = Request & { + [credentialsSymbol]?: BackstageCredentials | BackstageUnauthorizedCredentials; + [isCookieSymbol]?: boolean; +}; + +function createUnauthorizedCredentials(): BackstageUnauthorizedCredentials { + return { + $$type: '@backstage/BackstageCredentials', + type: 'unauthorized', + }; +} + +class DefaultHttpAuthService implements HttpAuthService { + constructor( + private readonly auth: AuthService, + private readonly discovery: DiscoveryService, + private readonly pluginId: string, + ) {} + + createHttpPluginRouterMiddleware(): Handler { + return async (req: RequestWithCredentials, _res, next) => { + try { + const { token, isCookie } = getTokenFromRequest(req); + // TODO: Is this where we match against configured rules? + if (!token) { + req[credentialsSymbol] = createUnauthorizedCredentials(); + } else { + req[credentialsSymbol] = await this.auth.authenticate(token); + req[isCookieSymbol] = isCookie; + } + next(); + } catch (e) { + next(e); + } + }; + } + + async credentials( + req: RequestWithCredentials, + options: { + allow: TAllowed[]; + }, + ): Promise { + const credentials = req[credentialsSymbol]; + if (!credentials) { + throw new Error('Internal error, no credentials found on request'); + } + + if (credentials.type === 'user' && req[isCookieSymbol]) { + if (options.allow.includes('user-cookie' as TAllowed)) { + return credentials as BackstageCredentialTypes[TAllowed]; + } + throw new NotAllowedError( + `This endpoint does not allow 'user-cookie' credentials`, + ); + } + + if (!options.allow.includes(credentials.type as TAllowed)) { + throw new NotAllowedError( + `This endpoint does not allow '${credentials.type}' credentials`, + ); + } + + return credentials as BackstageCredentialTypes[TAllowed]; + } + + async requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise> { + return { + Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, + }; + } + + async issueUserCookie(res: Response): Promise { + const credentials = await this.credentials(res.req, { allow: ['user'] }); + + // https://backstage.spotify.net/api/catalog + const externalBaseUrlStr = await this.discovery.getExternalBaseUrl( + this.pluginId, + ); + const externalBaseUrl = new URL(externalBaseUrlStr); + + const { token } = toInternalBackstageCredentials(credentials); + + // TODO: Proper refresh and expiration handling + const expires = decodeJwt(token).exp!; + + // TODO: refresh this thing + res.cookie(BACKSTAGE_AUTH_COOKIE, token, { + domain: externalBaseUrl.hostname, + httpOnly: true, + expires: new Date(expires * 1000), + path: externalBaseUrl.pathname, + priority: 'high', + sameSite: 'lax', // TBD + }); + throw new Error('Method not implemented.'); + } +} + +/** @public */ +export const httpAuthServiceFactory = createServiceFactory({ + service: coreServices.httpAuth, + deps: { + config: coreServices.rootConfig, + logger: coreServices.rootLogger, + discovery: coreServices.discovery, + auth: coreServices.auth, + plugin: coreServices.pluginMetadata, + }, + async factory({ auth, discovery, plugin }) { + return new DefaultHttpAuthService(auth, discovery, plugin.getId()); + }, +}); diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/index.ts b/packages/backend-app-api/src/services/implementations/httpAuth/index.ts new file mode 100644 index 0000000000..edd7e53026 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpAuth/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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 { httpAuthServiceFactory } from './httpAuthServiceFactory'; diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index 2ecdeffd77..710accbadb 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -17,10 +17,12 @@ import { coreServices, createServiceFactory, + HttpRouterServiceAuthPolicy, } from '@backstage/backend-plugin-api'; import { Handler } from 'express'; import PromiseRouter from 'express-promise-router'; import { createLifecycleMiddleware } from './createLifecycleMiddleware'; +import { createCredentialsBarrier } from './createCredentialsBarrier'; /** * @public @@ -40,20 +42,28 @@ export const httpRouterServiceFactory = createServiceFactory( plugin: coreServices.pluginMetadata, lifecycle: coreServices.lifecycle, rootHttpRouter: coreServices.rootHttpRouter, + httpAuth: coreServices.httpAuth, }, - async factory({ plugin, rootHttpRouter, lifecycle }) { + async factory({ httpAuth, plugin, rootHttpRouter, lifecycle }) { const getPath = options?.getPath ?? (id => `/api/${id}`); const path = getPath(plugin.getId()); const router = PromiseRouter(); rootHttpRouter.use(path, router); + const credentialsBarrier = createCredentialsBarrier({ httpAuth }); + router.use(createLifecycleMiddleware({ lifecycle })); + router.use(httpAuth.createHttpPluginRouterMiddleware()); + router.use(credentialsBarrier.middleware); return { - use(handler: Handler) { + use(handler: Handler): void { router.use(handler); }, + addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { + credentialsBarrier.addAuthPolicy(policy); + }, }; }, }), diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 9d42dbfef7..e038a13224 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -19,6 +19,7 @@ export * from './cache'; export * from './config'; export * from './database'; export * from './discovery'; +export * from './httpAuth'; export * from './httpRouter'; export * from './identity'; export * from './lifecycle'; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 52f52afaa9..68dff04e48 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -14,6 +14,8 @@ import { Knex } from 'knex'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { Readable } from 'stream'; +import { Request as Request_2 } from 'express'; +import { Response as Response_2 } from 'express'; // @public (undocumented) export interface AuthService { @@ -91,6 +93,14 @@ export type BackstageCredentials = | BackstageUserCredentials | BackstageServiceCredentials; +// @public (undocumented) +export type BackstageCredentialTypes = { + user: BackstageUserCredentials; + 'user-cookie': BackstageUserCredentials; + service: BackstageServiceCredentials; + unauthorized: BackstageUnauthorizedCredentials; +}; + // @public (undocumented) export type BackstageServiceCredentials = { $$type: '@backstage/BackstageCredentials'; @@ -98,6 +108,12 @@ export type BackstageServiceCredentials = { subject: string; }; +// @public (undocumented) +export type BackstageUnauthorizedCredentials = { + $$type: '@backstage/BackstageCredentials'; + type: 'unauthorized'; +}; + // @public (undocumented) export type BackstageUserCredentials = { $$type: '@backstage/BackstageCredentials'; @@ -134,6 +150,7 @@ export namespace coreServices { const rootConfig: ServiceRef; const database: ServiceRef; const discovery: ServiceRef; + const httpAuth: ServiceRef; const httpRouter: ServiceRef; const lifecycle: ServiceRef; const logger: ServiceRef; @@ -252,6 +269,25 @@ export interface ExtensionPointConfig { id: string; } +// @public (undocumented) +export interface HttpAuthService { + // (undocumented) + createHttpPluginRouterMiddleware(): Handler; + // (undocumented) + credentials( + req: Request_2, + options: { + allow: Array; + }, + ): Promise; + // (undocumented) + issueUserCookie(res: Response_2): Promise; + // (undocumented) + requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise>; +} + // @public (undocumented) export interface HttpRouterService { // (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts new file mode 100644 index 0000000000..817ca8691b --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2024 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 { Request, Response, Handler } from 'express'; +import { + BackstageUserCredentials, + BackstageServiceCredentials, + BackstageCredentials, +} from './AuthService'; + +/** @public */ +export type BackstageUnauthorizedCredentials = { + $$type: '@backstage/BackstageCredentials'; + + type: 'unauthorized'; +}; + +/** @public */ +export type BackstageCredentialTypes = { + user: BackstageUserCredentials; + 'user-cookie': BackstageUserCredentials; + service: BackstageServiceCredentials; + unauthorized: BackstageUnauthorizedCredentials; +}; + +/** @public */ +export interface HttpAuthService { + createHttpPluginRouterMiddleware(): Handler; + + credentials( + req: Request, + options: { + allow: Array; + }, + ): Promise; + + // TODO: Keep an eye on this, might not be needed + requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise>; + + issueUserCookie(res: Response): Promise; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 561a587e5e..c9cadd84c8 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -67,6 +67,15 @@ export namespace coreServices { import('./DiscoveryService').DiscoveryService >({ id: 'core.discovery' }); + /** + * The service reference for the plugin scoped {@link HttpAuthService}. + * + * @public + */ + export const httpAuth = createServiceRef< + import('./HttpAuthService').HttpAuthService + >({ id: 'core.httpAuth' }); + /** * The service reference for the plugin scoped {@link HttpRouterService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 8a53399be6..843269c3ee 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -30,6 +30,11 @@ export type { RootConfigService } from './RootConfigService'; export type { DatabaseService } from './DatabaseService'; export type { DiscoveryService } from './DiscoveryService'; export type { HttpRouterService } from './HttpRouterService'; +export type { + HttpAuthService, + BackstageCredentialTypes, + BackstageUnauthorizedCredentials, +} from './HttpAuthService'; export type { LifecycleService, LifecycleServiceStartupHook, diff --git a/yarn.lock b/yarn.lock index 555b683cd0..202aa50d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3244,6 +3244,7 @@ __metadata: "@types/node-forge": ^1.3.0 "@types/stoppable": ^1.1.0 compression: ^1.7.4 + cookie: ^0.6.0 cors: ^2.8.5 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -23958,7 +23959,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.6.0, cookie@npm:~0.6.0": +"cookie@npm:0.6.0, cookie@npm:^0.6.0, cookie@npm:~0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" checksum: f56a7d32a07db5458e79c726b77e3c2eff655c36792f2b6c58d351fb5f61531e5b1ab7f46987150136e366c65213cbe31729e02a3eaed630c3bf7334635fb410