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/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 73f90fd153..d57692de90 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..4aa492bac3 100644 --- a/packages/backend-defaults/report-httpAuth.api.md +++ b/packages/backend-defaults/report-httpAuth.api.md @@ -3,9 +3,50 @@ > 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; + // (undocumented) + extractTokenFromRequest?: (req: Request_2) => string | undefined; + // (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..bbcd77795e --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/httpAuth/DefaultHttpAuthService.test.ts @@ -0,0 +1,46 @@ +/* + * 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', + extractTokenFromRequest: req => { + const authHeader = req.headers.test; + if (typeof authHeader === 'string') { + const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); + const token = matches?.[1]; + if (token) { + return token; + } + } + + return undefined; + }, + }); + await httpAuthService.credentials( + createRequest({ headers: { test: 'Bearer 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..4259a604c4 100644 --- a/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/httpAuth/httpAuthServiceFactory.ts @@ -33,7 +33,6 @@ const FIVE_MINUTES_MS = 5 * 60 * 1000; 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); @@ -71,23 +70,54 @@ 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 the default token extraction logic + extractTokenFromRequest?: (req: Request) => string | undefined; +} + +/** + * @public + * DefaultHttpAuthService is the default implementation of the HttpAuthService + */ +export class DefaultHttpAuthService implements HttpAuthService { readonly #auth: AuthService; readonly #discovery: DiscoveryService; readonly #pluginId: string; + readonly #extractTokenFromRequest: (req: Request) => string | undefined; - constructor( + private constructor( auth: AuthService, discovery: DiscoveryService, pluginId: string, + extractTokenFromRequest?: (req: Request) => string | undefined, ) { this.#auth = auth; this.#discovery = discovery; this.#pluginId = pluginId; + this.#extractTokenFromRequest = + extractTokenFromRequest ?? getTokenFromRequest; + } + + static create( + options: DefaultHttpAuthServiceOptions, + ): DefaultHttpAuthService { + return new DefaultHttpAuthService( + options.auth, + options.discovery, + options.pluginId, + options.extractTokenFromRequest, + ); } async #extractCredentialsFromRequest(req: Request) { - const token = getTokenFromRequest(req); + const token = this.#extractTokenFromRequest(req); if (!token) { return await this.#auth.getNoneCredentials(); } @@ -96,7 +126,7 @@ class DefaultHttpAuthService implements HttpAuthService { } async #extractLimitedCredentialsFromRequest(req: Request) { - const token = getTokenFromRequest(req); + const token = this.#extractTokenFromRequest(req); if (token) { return await this.#auth.authenticate(token, { allowLimitedAccess: true, @@ -289,6 +319,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 738da2d156..18ad296c19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3645,6 +3645,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