backend-{plugin,app}-api: add HttpAuthService + initial implementation
Co-authored-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: Carl-Erik Bergström <cbergstrom@spotify.com> Co-authored-by: blam <ben@blam.sh> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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<string>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const httpAuthServiceFactory: () => ServiceFactory<
|
||||
HttpAuthService,
|
||||
'plugin'
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface HttpRouterFactoryOptions {
|
||||
getPath?(pluginId: string): string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+176
@@ -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<TAllowed extends keyof BackstageCredentialTypes>(
|
||||
req: RequestWithCredentials,
|
||||
options: {
|
||||
allow: TAllowed[];
|
||||
},
|
||||
): Promise<BackstageCredentialTypes[TAllowed]> {
|
||||
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<Record<string, string>> {
|
||||
return {
|
||||
Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async issueUserCookie(res: Response): Promise<void> {
|
||||
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());
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
+12
-2
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<RootConfigService, 'root'>;
|
||||
const database: ServiceRef<DatabaseService, 'plugin'>;
|
||||
const discovery: ServiceRef<DiscoveryService, 'plugin'>;
|
||||
const httpAuth: ServiceRef<HttpAuthService, 'plugin'>;
|
||||
const httpRouter: ServiceRef<HttpRouterService, 'plugin'>;
|
||||
const lifecycle: ServiceRef<LifecycleService, 'plugin'>;
|
||||
const logger: ServiceRef<LoggerService, 'plugin'>;
|
||||
@@ -252,6 +269,25 @@ export interface ExtensionPointConfig {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface HttpAuthService {
|
||||
// (undocumented)
|
||||
createHttpPluginRouterMiddleware(): Handler;
|
||||
// (undocumented)
|
||||
credentials<TAllowed extends keyof BackstageCredentialTypes>(
|
||||
req: Request_2,
|
||||
options: {
|
||||
allow: Array<TAllowed>;
|
||||
},
|
||||
): Promise<BackstageCredentialTypes[TAllowed]>;
|
||||
// (undocumented)
|
||||
issueUserCookie(res: Response_2): Promise<void>;
|
||||
// (undocumented)
|
||||
requestHeaders(options?: {
|
||||
forward?: BackstageCredentials;
|
||||
}): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface HttpRouterService {
|
||||
// (undocumented)
|
||||
|
||||
@@ -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<TAllowed extends keyof BackstageCredentialTypes>(
|
||||
req: Request,
|
||||
options: {
|
||||
allow: Array<TAllowed>;
|
||||
},
|
||||
): Promise<BackstageCredentialTypes[TAllowed]>;
|
||||
|
||||
// TODO: Keep an eye on this, might not be needed
|
||||
requestHeaders(options?: {
|
||||
forward?: BackstageCredentials;
|
||||
}): Promise<Record<string, string>>;
|
||||
|
||||
issueUserCookie(res: Response): Promise<void>;
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user