Merge pull request #27741 from backstage/jhaals/httpauth

backend-defaults: export DefaultHttpAuthService
This commit is contained in:
Johan Haals
2024-11-25 09:40:12 +01:00
committed by GitHub
8 changed files with 179 additions and 12 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Export `DefaultHttpAuthService` to allow for custom token extraction logic.
@@ -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.
+1
View File
@@ -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"
},
@@ -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<TAllowed extends keyof BackstagePrincipalTypes = 'unknown'>(
req: Request_2,
options?: {
allow?: Array<TAllowed>;
allowLimitedAccess?: boolean;
},
): Promise<BackstageCredentials<BackstagePrincipalTypes[TAllowed]>>;
// (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,
@@ -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');
});
});
@@ -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<BackstageCredentials>;
};
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(),
});
},
});
@@ -14,4 +14,8 @@
* limitations under the License.
*/
export { httpAuthServiceFactory } from './httpAuthServiceFactory';
export {
httpAuthServiceFactory,
DefaultHttpAuthService,
} from './httpAuthServiceFactory';
export type { DefaultHttpAuthServiceOptions } from './httpAuthServiceFactory';
+1
View File
@@ -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