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:
Patrik Oldsberg
2024-02-09 13:02:13 +01:00
parent c5a7cf4a4f
commit 9e45e2af6d
11 changed files with 322 additions and 3 deletions
+7
View File
@@ -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;
+1
View File
@@ -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",
@@ -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';
@@ -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';
+36
View File
@@ -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,
+2 -1
View File
@@ -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