diff --git a/.changeset/empty-bugs-deliver.md b/.changeset/empty-bugs-deliver.md new file mode 100644 index 0000000000..5b26f4eaca --- /dev/null +++ b/.changeset/empty-bugs-deliver.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Export `DefaultHttpAuthService` to allow for custom token extraction logic. diff --git a/docs/backend-system/core-services/http-auth.md b/docs/backend-system/core-services/http-auth.md index 13938c921d..f3ce96fb25 100644 --- a/docs/backend-system/core-services/http-auth.md +++ b/docs/backend-system/core-services/http-auth.md @@ -102,6 +102,45 @@ or [contribute](https://github.com/backstage/backstage/blob/master/CONTRIBUTING. ::: +### Custom token extraction logic + +In some cases, you might want to customize how tokens are extracted from incoming requests. It might be that you want to extract tokens from a different location in the request. To support this you can supply your own slightly modified httpAuth service. The `DefaultHttpAuthService` class is exported from `@backstage/backend-defaults/httpAuth` and it's static `create` method can be used to pass in a custom `getTokenFromRequest` extraction function. + +```ts +import { DefaultHttpAuthService } from '@backstage/backend-defaults/httpAuth'; +import { + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; + +export const customizedAuthServiceFactory = createServiceFactory({ + service: coreServices.httpAuth, + deps: { + auth: coreServices.auth, + discovery: coreServices.discovery, + plugin: coreServices.pluginMetadata, + }, + async factory({ auth, discovery, plugin }) { + return DefaultHttpAuthService.create({ + auth, + discovery, + pluginId: plugin.getId(), + getTokenFromRequest: req => { + let token: string | undefined; + const header = req.headers.some_random_header; + if (typeof header === 'string') { + const parts = header.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + token = parts[1]; + } + } + return { token }; + }, + }); + }, +}); +``` + This service has no configuration options, but it abides by the policies you have set up using [the `httpRouter` service](./http-router.md) for your routes, if any. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index cbd2847cab..01fd915be1 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -201,6 +201,7 @@ "aws-sdk-client-mock": "^4.0.0", "http-errors": "^2.0.0", "msw": "^1.0.0", + "node-mocks-http": "^1.0.0", "supertest": "^7.0.0", "wait-for-expect": "^3.0.2" }, diff --git a/packages/backend-defaults/report-httpAuth.api.md b/packages/backend-defaults/report-httpAuth.api.md index 6c08a6f4d0..b3843beb3d 100644 --- a/packages/backend-defaults/report-httpAuth.api.md +++ b/packages/backend-defaults/report-httpAuth.api.md @@ -3,9 +3,51 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { BackstagePrincipalTypes } from '@backstage/backend-plugin-api'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import { HttpAuthService } from '@backstage/backend-plugin-api'; +import { Request as Request_2 } from 'express'; +import { Response as Response_2 } from 'express'; import { ServiceFactory } from '@backstage/backend-plugin-api'; +// @public +export class DefaultHttpAuthService implements HttpAuthService { + // (undocumented) + static create(options: DefaultHttpAuthServiceOptions): DefaultHttpAuthService; + // (undocumented) + credentials( + req: Request_2, + options?: { + allow?: Array; + allowLimitedAccess?: boolean; + }, + ): Promise>; + // (undocumented) + issueUserCookie( + res: Response_2, + options?: { + credentials?: BackstageCredentials; + }, + ): Promise<{ + expiresAt: Date; + }>; +} + +// @public +export interface DefaultHttpAuthServiceOptions { + // (undocumented) + auth: AuthService; + // (undocumented) + discovery: DiscoveryService; + getTokenFromRequest?: (req: Request_2) => { + token?: string; + }; + // (undocumented) + pluginId: string; +} + // @public export const httpAuthServiceFactory: ServiceFactory< HttpAuthService, diff --git a/packages/backend-defaults/src/entrypoints/httpAuth/DefaultHttpAuthService.test.ts b/packages/backend-defaults/src/entrypoints/httpAuth/DefaultHttpAuthService.test.ts new file mode 100644 index 0000000000..e314564b59 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/httpAuth/DefaultHttpAuthService.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { DefaultHttpAuthService } from './httpAuthServiceFactory'; +import { mockServices } from '@backstage/backend-test-utils'; +import { createRequest } from 'node-mocks-http'; + +describe('DefaultHttpAuthService', () => { + it('should extract token from custom header', async () => { + const auth = mockServices.auth.mock(); + const httpAuthService = DefaultHttpAuthService.create({ + discovery: mockServices.discovery(), + auth, + pluginId: 'test', + getTokenFromRequest: req => { + let token: string | undefined; + const header = req.headers.test; + if (typeof header === 'string') { + token = header; + } + return { token }; + }, + }); + await httpAuthService.credentials( + createRequest({ headers: { test: 'mock-user-token' } }), + ); + expect(auth.authenticate).toHaveBeenCalledWith('mock-user-token'); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts b/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts index 76550873f6..4bc6b3b1fc 100644 --- a/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts @@ -33,17 +33,14 @@ const FIVE_MINUTES_MS = 5 * 60 * 1000; const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; function getTokenFromRequest(req: Request) { - // TODO: support multiple auth headers (iterate rawHeaders) + let token: string | undefined; const authHeader = req.headers.authorization; if (typeof authHeader === 'string') { const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); - const token = matches?.[1]; - if (token) { - return token; - } + token = matches?.[1]; } - return undefined; + return { token }; } function getCookieFromRequest(req: Request) { @@ -71,23 +68,55 @@ type RequestWithCredentials = Request & { [limitedCredentialsSymbol]?: Promise; }; -class DefaultHttpAuthService implements HttpAuthService { +/** + * @public + * Options for creating a DefaultHttpAuthService. + */ +export interface DefaultHttpAuthServiceOptions { + auth: AuthService; + discovery: DiscoveryService; + pluginId: string; + /** + * Optionally override logic for extracting the token from the request. + */ + getTokenFromRequest?: (req: Request) => { token?: string }; +} + +/** + * @public + * DefaultHttpAuthService is the default implementation of the HttpAuthService + */ +export class DefaultHttpAuthService implements HttpAuthService { readonly #auth: AuthService; readonly #discovery: DiscoveryService; readonly #pluginId: string; + readonly #getToken: (req: Request) => { token?: string }; - constructor( + private constructor( auth: AuthService, discovery: DiscoveryService, pluginId: string, + getToken?: (req: Request) => { token?: string }, ) { this.#auth = auth; this.#discovery = discovery; this.#pluginId = pluginId; + this.#getToken = getToken ?? getTokenFromRequest; + } + + static create( + options: DefaultHttpAuthServiceOptions, + ): DefaultHttpAuthService { + return new DefaultHttpAuthService( + options.auth, + options.discovery, + options.pluginId, + options.getTokenFromRequest, + ); } async #extractCredentialsFromRequest(req: Request) { - const token = getTokenFromRequest(req); + const { token } = this.#getToken(req); if (!token) { return await this.#auth.getNoneCredentials(); } @@ -96,7 +125,7 @@ class DefaultHttpAuthService implements HttpAuthService { } async #extractLimitedCredentialsFromRequest(req: Request) { - const token = getTokenFromRequest(req); + const { token } = this.#getToken(req); if (token) { return await this.#auth.authenticate(token, { allowLimitedAccess: true, @@ -289,6 +318,10 @@ export const httpAuthServiceFactory = createServiceFactory({ plugin: coreServices.pluginMetadata, }, async factory({ auth, discovery, plugin }) { - return new DefaultHttpAuthService(auth, discovery, plugin.getId()); + return DefaultHttpAuthService.create({ + auth, + discovery, + pluginId: plugin.getId(), + }); }, }); diff --git a/packages/backend-defaults/src/entrypoints/httpAuth/index.ts b/packages/backend-defaults/src/entrypoints/httpAuth/index.ts index edd7e53026..9afe06ae0e 100644 --- a/packages/backend-defaults/src/entrypoints/httpAuth/index.ts +++ b/packages/backend-defaults/src/entrypoints/httpAuth/index.ts @@ -14,4 +14,8 @@ * limitations under the License. */ -export { httpAuthServiceFactory } from './httpAuthServiceFactory'; +export { + httpAuthServiceFactory, + DefaultHttpAuthService, +} from './httpAuthServiceFactory'; +export type { DefaultHttpAuthServiceOptions } from './httpAuthServiceFactory'; diff --git a/yarn.lock b/yarn.lock index 3f43ff15b8..39e53cfd4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3643,6 +3643,7 @@ __metadata: mysql2: ^3.0.0 node-fetch: ^2.7.0 node-forge: ^1.3.1 + node-mocks-http: ^1.0.0 p-limit: ^3.1.0 path-to-regexp: ^8.0.0 pg: ^8.11.3