diff --git a/.changeset/big-yaks-film.md b/.changeset/big-yaks-film.md
new file mode 100644
index 0000000000..432a876e97
--- /dev/null
+++ b/.changeset/big-yaks-film.md
@@ -0,0 +1,7 @@
+---
+'@backstage/backend-test-utils': patch
+---
+
+Added support for the new [`auth`](https://backstage.io/docs/backend-system/core-services/auth/) and [`httpAuth`](https://backstage.io/docs/backend-system/core-services/http-auth) services that were created as part of [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution). These services will be present by default in test apps, and you can access mocked versions of their features under `mockServices.auth` and `mockServices.httpAuth` if you want to inspect or replace their behaviors.
+
+There is also a new `mockCredentials` that you can use for acquiring mocks of the various types of credentials that are used in the new system.
diff --git a/.changeset/eleven-cows-learn.md b/.changeset/eleven-cows-learn.md
new file mode 100644
index 0000000000..3af2c6dba1
--- /dev/null
+++ b/.changeset/eleven-cows-learn.md
@@ -0,0 +1,9 @@
+---
+'@backstage/backend-plugin-api': patch
+---
+
+Added the new [`auth`](https://backstage.io/docs/backend-system/core-services/auth/), [`httpAuth`](https://backstage.io/docs/backend-system/core-services/http-auth), and [`userInfo`](https://backstage.io/docs/backend-system/core-services/user-info) services that were created as part of [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution) to the `coreServices`.
+
+At the same time, the [`httpRouter`](https://backstage.io/docs/backend-system/core-services/http-router) service gained a new `addAuthPolicy` method that lets your plugin declare exemptions to the default auth policy - for example if you want to allow unauthenticated or cookie-based access to some subset of your feature routes.
+
+If you have migrated to the new backend system, please see the [Auth Service Migration tutorial](https://backstage.io/docs/tutorials/auth-service-migration) for more information on how to move toward using these services.
diff --git a/.changeset/perfect-taxis-give.md b/.changeset/perfect-taxis-give.md
new file mode 100644
index 0000000000..540834c5c5
--- /dev/null
+++ b/.changeset/perfect-taxis-give.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-defaults': patch
+---
+
+Added support for the new auth services, which are now installed by default. See the [migration guide](https://backstage.io/docs/tutorials/auth-service-migration) for details.
diff --git a/.changeset/smart-owls-tease.md b/.changeset/smart-owls-tease.md
new file mode 100644
index 0000000000..8d59586bf8
--- /dev/null
+++ b/.changeset/smart-owls-tease.md
@@ -0,0 +1,7 @@
+---
+'@backstage/backend-app-api': minor
+---
+
+**BREAKING**: For users that have migrated to the new backend system, incoming requests will now be rejected if they are not properly authenticated (e.g. with a Backstage bearer token or a backend token). Please see the [Auth Service Migration tutorial](https://backstage.io/docs/tutorials/auth-service-migration) for more information on how to circumvent this behavior in the short term and how to properly leverage it in the longer term.
+
+Added service factories for the new [`auth`](https://backstage.io/docs/backend-system/core-services/auth/), [`httpAuth`](https://backstage.io/docs/backend-system/core-services/http-auth), and [`userInfo`](https://backstage.io/docs/backend-system/core-services/user-info) services that were created as part of [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution).
diff --git a/.changeset/thirty-shirts-allow.md b/.changeset/thirty-shirts-allow.md
new file mode 100644
index 0000000000..b84a3a621e
--- /dev/null
+++ b/.changeset/thirty-shirts-allow.md
@@ -0,0 +1,7 @@
+---
+'@backstage/backend-common': patch
+---
+
+Added a `createLegacyAuthAdapters` function that can be used as a compatibility adapter for backend plugins who want to start using the new [`auth`](https://backstage.io/docs/backend-system/core-services/auth/) and [`httpAuth`](https://backstage.io/docs/backend-system/core-services/http-auth) services that were created as part of [BEP-0003](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution).
+
+See the [Auth Service Migration tutorial](https://backstage.io/docs/tutorials/auth-service-migration) for more information on the usage of this adapter.
diff --git a/app-config.yaml b/app-config.yaml
index c53a6475f4..9b059da216 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -32,6 +32,12 @@ backend:
# auth:
# keys:
# - secret: ${BACKEND_SECRET}
+
+ auth:
+ # TODO: once plugins have been migrated we can remove this, but right now it
+ # is require for the backend-next to work in this repo
+ dangerouslyDisableDefaultAuthPolicy: true
+
baseUrl: http://localhost:7007
listen:
port: 7007
diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md
index f41a38de7b..52780c6729 100644
--- a/packages/backend-app-api/api-report.md
+++ b/packages/backend-app-api/api-report.md
@@ -6,6 +6,7 @@
///
import type { AppConfig } from '@backstage/config';
+import { AuthService } from '@backstage/backend-plugin-api';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { CacheClient } from '@backstage/backend-common';
import { Config } from '@backstage/config';
@@ -18,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';
@@ -40,6 +42,10 @@ import { ServiceFactoryOrFunction } from '@backstage/backend-plugin-api';
import { TokenManagerService } from '@backstage/backend-plugin-api';
import { transport } from 'winston';
import { UrlReader } from '@backstage/backend-common';
+import { UserInfoService } from '@backstage/backend-plugin-api';
+
+// @public (undocumented)
+export const authServiceFactory: () => ServiceFactory;
// @public (undocumented)
export interface Backend {
@@ -144,6 +150,12 @@ export class HostDiscovery implements DiscoveryService {
getExternalBaseUrl(pluginId: string): Promise;
}
+// @public (undocumented)
+export const httpAuthServiceFactory: () => ServiceFactory<
+ HttpAuthService,
+ 'plugin'
+>;
+
// @public (undocumented)
export interface HttpRouterFactoryOptions {
getPath?(pluginId: string): string;
@@ -322,6 +334,12 @@ export const tokenManagerServiceFactory: () => ServiceFactory<
// @public (undocumented)
export const urlReaderServiceFactory: () => ServiceFactory;
+// @public (undocumented)
+export const userInfoServiceFactory: () => ServiceFactory<
+ UserInfoService,
+ 'plugin'
+>;
+
// @public
export class WinstonLogger implements RootLoggerService {
// (undocumented)
diff --git a/packages/backend-app-api/config.d.ts b/packages/backend-app-api/config.d.ts
index 86b63b527a..137615a152 100644
--- a/packages/backend-app-api/config.d.ts
+++ b/packages/backend-app-api/config.d.ts
@@ -15,6 +15,26 @@
*/
export interface Config {
+ backend?: {
+ auth?: {
+ /**
+ * This disables the otherwise default auth policy, which requires all
+ * requests to be authenticated with either user or service credentials.
+ *
+ * Disabling this check means that the backend will no longer block
+ * unauthenticated requests, but instead allow them to pass through to
+ * plugins.
+ *
+ * If permissions are enabled, unauthenticated requests will be treated
+ * exactly as such, leaving it to the permission policy to determine what
+ * permissions should be allowed for an unauthenticated identity. Note
+ * that this will also apply to service-to-service calls between plugins
+ * unless you configure credentials for service calls.
+ */
+ dangerouslyDisableDefaultAuthPolicy?: boolean;
+ };
+ };
+
/** Discovery options. */
discovery?: {
/**
diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json
index be4d15fc11..ae46e13083 100644
--- a/packages/backend-app-api/package.json
+++ b/packages/backend-app-api/package.json
@@ -60,17 +60,20 @@
"@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",
"fs-extra": "^11.2.0",
"helmet": "^6.0.0",
+ "jose": "^5.0.0",
"lodash": "^4.17.21",
"logform": "^2.3.2",
"minimatch": "^9.0.0",
"minimist": "^1.2.5",
"morgan": "^1.10.0",
"node-forge": "^1.3.1",
+ "path-to-regexp": "^6.2.1",
"selfsigned": "^2.0.0",
"stoppable": "^1.1.0",
"winston": "^3.2.1",
diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts
new file mode 100644
index 0000000000..f24264930a
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts
@@ -0,0 +1,128 @@
+/*
+ * 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 {
+ ServiceFactoryTester,
+ mockServices,
+} from '@backstage/backend-test-utils';
+import {
+ InternalBackstageCredentials,
+ authServiceFactory,
+} from './authServiceFactory';
+import { decodeJwt } from 'jose';
+import { discoveryServiceFactory } from '../discovery';
+import {
+ BackstageServicePrincipal,
+ BackstageUserPrincipal,
+} from '@backstage/backend-plugin-api';
+
+// TODO: Ship discovery mock service in the service factory tester
+const mockDeps = [
+ discoveryServiceFactory(),
+ mockServices.rootConfig.factory({
+ data: {
+ backend: {
+ baseUrl: 'http://localhost',
+ auth: { keys: [{ secret: 'abc' }] },
+ },
+ },
+ }),
+];
+
+describe('authServiceFactory', () => {
+ it('should authenticate issued tokens', async () => {
+ const tester = ServiceFactoryTester.from(authServiceFactory, {
+ dependencies: mockDeps,
+ });
+
+ const searchAuth = await tester.get('search');
+ const catalogAuth = await tester.get('catalog');
+
+ const { token: searchToken } = await searchAuth.getPluginRequestToken({
+ onBehalfOf: await searchAuth.getOwnServiceCredentials(),
+ targetPluginId: 'catalog',
+ });
+
+ await expect(searchAuth.authenticate(searchToken)).resolves.toEqual(
+ expect.objectContaining({
+ principal: {
+ type: 'service',
+ subject: 'external:backstage-plugin',
+ },
+ }),
+ );
+ await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual(
+ expect.objectContaining({
+ principal: {
+ type: 'service',
+ subject: 'external:backstage-plugin',
+ },
+ }),
+ );
+ });
+
+ it('should forward user tokens', async () => {
+ const tester = ServiceFactoryTester.from(authServiceFactory, {
+ dependencies: mockDeps,
+ });
+
+ const catalogAuth = await tester.get('catalog');
+
+ await expect(
+ catalogAuth.getPluginRequestToken({
+ onBehalfOf: {
+ $$type: '@backstage/BackstageCredentials',
+ version: 'v1',
+ authMethod: 'token',
+ token: 'alice-token',
+ principal: {
+ type: 'user',
+ userEntityRef: 'user:default/alice',
+ },
+ } as InternalBackstageCredentials,
+ targetPluginId: 'catalog',
+ }),
+ ).resolves.toEqual({ token: 'alice-token' });
+ });
+
+ it('should not forward service tokens', async () => {
+ const tester = ServiceFactoryTester.from(authServiceFactory, {
+ dependencies: mockDeps,
+ });
+
+ const catalogAuth = await tester.get('catalog');
+
+ const { token } = await catalogAuth.getPluginRequestToken({
+ onBehalfOf: {
+ $$type: '@backstage/BackstageCredentials',
+ version: 'v1',
+ authMethod: 'token',
+ token: 'some-upstream-service-token',
+ principal: {
+ type: 'service',
+ subject: 'external:upstream-service',
+ },
+ } as InternalBackstageCredentials,
+ targetPluginId: 'catalog',
+ });
+
+ expect(decodeJwt(token)).toEqual(
+ expect.objectContaining({
+ sub: 'backstage-server',
+ }),
+ );
+ });
+});
diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts
new file mode 100644
index 0000000000..72d5635f61
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts
@@ -0,0 +1,227 @@
+/*
+ * 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 { ServerTokenManager, TokenManager } from '@backstage/backend-common';
+import {
+ AuthService,
+ BackstageCredentials,
+ BackstagePrincipalTypes,
+ BackstageServicePrincipal,
+ BackstageNonePrincipal,
+ BackstageUserPrincipal,
+ IdentityService,
+ coreServices,
+ createServiceFactory,
+} from '@backstage/backend-plugin-api';
+import { AuthenticationError } from '@backstage/errors';
+import {
+ DefaultIdentityClient,
+ IdentityApiGetIdentityRequest,
+} from '@backstage/plugin-auth-node';
+import { decodeJwt } from 'jose';
+
+/** @internal */
+export type InternalBackstageCredentials =
+ BackstageCredentials & {
+ version: string;
+ token?: string;
+ authMethod: 'token' | 'cookie' | 'none';
+ };
+
+export function createCredentialsWithServicePrincipal(
+ sub: string,
+): InternalBackstageCredentials {
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ version: 'v1',
+ principal: {
+ type: 'service',
+ subject: sub,
+ },
+ authMethod: 'token',
+ };
+}
+
+export function createCredentialsWithUserPrincipal(
+ sub: string,
+ token: string,
+ authMethod: 'token' | 'cookie' = 'token',
+): InternalBackstageCredentials {
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ version: 'v1',
+ token,
+ principal: {
+ type: 'user',
+ userEntityRef: sub,
+ },
+ authMethod,
+ };
+}
+
+export function createCredentialsWithNonePrincipal(): InternalBackstageCredentials {
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ version: 'v1',
+ principal: {
+ type: 'none',
+ },
+ authMethod: 'none',
+ };
+}
+
+export function toInternalBackstageCredentials(
+ credentials: BackstageCredentials,
+): InternalBackstageCredentials<
+ BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal
+> {
+ if (credentials.$$type !== '@backstage/BackstageCredentials') {
+ throw new Error('Invalid credential type');
+ }
+
+ const internalCredentials = credentials as InternalBackstageCredentials<
+ BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal
+ >;
+
+ if (internalCredentials.version !== 'v1') {
+ throw new Error(
+ `Invalid credential version ${internalCredentials.version}`,
+ );
+ }
+
+ return internalCredentials;
+}
+
+/** @internal */
+class DefaultAuthService implements AuthService {
+ constructor(
+ private readonly tokenManager: TokenManager,
+ private readonly identity: IdentityService,
+ private readonly pluginId: string,
+ private readonly disableDefaultAuthPolicy: boolean,
+ ) {}
+
+ async authenticate(token: string): Promise {
+ const { sub, aud } = decodeJwt(token);
+
+ // Legacy service-to-service token
+ if (sub === 'backstage-server' && !aud) {
+ await this.tokenManager.authenticate(token);
+ return createCredentialsWithServicePrincipal('external:backstage-plugin');
+ }
+
+ // User Backstage token
+ const identity = await this.identity.getIdentity({
+ request: {
+ headers: { authorization: `Bearer ${token}` },
+ },
+ } as IdentityApiGetIdentityRequest);
+
+ if (!identity) {
+ throw new AuthenticationError('Invalid user token');
+ }
+
+ return createCredentialsWithUserPrincipal(
+ identity.identity.userEntityRef,
+ token,
+ );
+ }
+
+ isPrincipal(
+ credentials: BackstageCredentials,
+ type: TType,
+ ): credentials is BackstageCredentials {
+ const principal = credentials.principal as
+ | BackstageUserPrincipal
+ | BackstageServicePrincipal;
+
+ if (type === 'unknown') {
+ return true;
+ }
+
+ if (principal.type !== type) {
+ return false;
+ }
+
+ return true;
+ }
+
+ async getOwnServiceCredentials(): Promise<
+ BackstageCredentials
+ > {
+ return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
+ }
+
+ async getPluginRequestToken(options: {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ }): Promise<{ token: string }> {
+ const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
+ const { type } = internalForward.principal;
+
+ // Since disabling the default policy means we'll be allowing
+ // unauthenticated requests through, we might have unauthenticated
+ // credentials from service calls that reach this point. If that's the case,
+ // we'll want to keep "forwarding" the unauthenticated credentials, which we
+ // do by returning an empty token.
+ if (type === 'none' && this.disableDefaultAuthPolicy) {
+ return { token: '' };
+ }
+
+ switch (type) {
+ // TODO: Check whether the principal is ourselves
+ case 'service':
+ return this.tokenManager.getToken();
+ case 'user':
+ if (!internalForward.token) {
+ throw new Error('User credentials is unexpectedly missing token');
+ }
+ return { token: internalForward.token };
+ default:
+ throw new AuthenticationError(
+ `Refused to issue service token for credential type '${type}'`,
+ );
+ }
+ }
+}
+
+/** @public */
+export const authServiceFactory = createServiceFactory({
+ service: coreServices.auth,
+ deps: {
+ config: coreServices.rootConfig,
+ logger: coreServices.rootLogger,
+ discovery: coreServices.discovery,
+ plugin: coreServices.pluginMetadata,
+ },
+ createRootContext({ config, logger }) {
+ return ServerTokenManager.fromConfig(config, { logger });
+ },
+ async factory({ discovery, config, plugin }, tokenManager) {
+ const identity = DefaultIdentityClient.create({ discovery });
+ const disableDefaultAuthPolicy = Boolean(
+ config.getOptionalBoolean(
+ 'backend.auth.dangerouslyDisableDefaultAuthPolicy',
+ ),
+ );
+ return new DefaultAuthService(
+ tokenManager,
+ identity,
+ plugin.getId(),
+ disableDefaultAuthPolicy,
+ );
+ },
+});
diff --git a/packages/backend-app-api/src/services/implementations/auth/index.ts b/packages/backend-app-api/src/services/implementations/auth/index.ts
new file mode 100644
index 0000000000..1b55d46a83
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/auth/index.ts
@@ -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 { authServiceFactory } from './authServiceFactory';
diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts
new file mode 100644
index 0000000000..1bd9d3cf2a
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts
@@ -0,0 +1,180 @@
+/*
+ * 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,
+ BackstageCredentials,
+ BackstagePrincipalTypes,
+ DiscoveryService,
+ HttpAuthService,
+ coreServices,
+ createServiceFactory,
+} from '@backstage/backend-plugin-api';
+import { AuthenticationError, NotAllowedError } from '@backstage/errors';
+import { parse as parseCookie } from 'cookie';
+import { Request, Response } from 'express';
+import { decodeJwt } from 'jose';
+import {
+ createCredentialsWithNonePrincipal,
+ 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');
+
+type RequestWithCredentials = Request & {
+ [credentialsSymbol]?: Promise;
+};
+
+class DefaultHttpAuthService implements HttpAuthService {
+ constructor(
+ private readonly auth: AuthService,
+ private readonly discovery: DiscoveryService,
+ private readonly pluginId: string,
+ ) {}
+
+ async #extractCredentialsFromRequest(req: Request) {
+ const { token, isCookie } = getTokenFromRequest(req);
+ if (!token) {
+ return createCredentialsWithNonePrincipal();
+ }
+
+ const credentials = toInternalBackstageCredentials(
+ await this.auth.authenticate(token),
+ );
+ if (isCookie) {
+ if (credentials.principal.type !== 'user') {
+ throw new AuthenticationError(
+ 'Refusing to authenticate non-user principal with cookie auth',
+ );
+ }
+ credentials.authMethod = 'cookie';
+ }
+
+ return credentials;
+ }
+
+ async #getCredentials(req: RequestWithCredentials) {
+ return (req[credentialsSymbol] ??=
+ this.#extractCredentialsFromRequest(req));
+ }
+
+ async credentials(
+ req: Request,
+ options?: {
+ allow?: Array;
+ allowedAuthMethods?: Array<'token' | 'cookie'>;
+ },
+ ): Promise> {
+ const credentials = toInternalBackstageCredentials(
+ await this.#getCredentials(req),
+ );
+
+ const allowedPrincipalTypes = options?.allow;
+ const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
+ options?.allowedAuthMethods ?? ['token'];
+
+ if (
+ credentials.authMethod !== 'none' &&
+ !allowedAuthMethods.includes(credentials.authMethod)
+ ) {
+ throw new NotAllowedError(
+ `This endpoint does not allow the '${credentials.authMethod}' auth method`,
+ );
+ }
+
+ if (
+ allowedPrincipalTypes &&
+ !allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
+ ) {
+ if (credentials.authMethod === 'none') {
+ throw new AuthenticationError();
+ }
+ throw new NotAllowedError(
+ `This endpoint does not allow '${credentials.principal.type}' credentials`,
+ );
+ }
+
+ return credentials as any;
+ }
+
+ async issueUserCookie(res: Response): Promise {
+ const credentials = await this.credentials(res.req, { allow: ['user'] });
+
+ // https://backstage.example.com/api/catalog
+ const externalBaseUrlStr = await this.discovery.getExternalBaseUrl(
+ this.pluginId,
+ );
+ const externalBaseUrl = new URL(externalBaseUrlStr);
+
+ const { token } = toInternalBackstageCredentials(credentials);
+ if (!token) {
+ throw new Error('User credentials is unexpectedly missing token');
+ }
+
+ // 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: {
+ auth: coreServices.auth,
+ discovery: coreServices.discovery,
+ plugin: coreServices.pluginMetadata,
+ },
+ async factory({ auth, discovery, plugin }) {
+ return new DefaultHttpAuthService(auth, discovery, plugin.getId());
+ },
+});
diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/index.ts b/packages/backend-app-api/src/services/implementations/httpAuth/index.ts
new file mode 100644
index 0000000000..edd7e53026
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/httpAuth/index.ts
@@ -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';
diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts
new file mode 100644
index 0000000000..801c87d430
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts
@@ -0,0 +1,95 @@
+/*
+ * 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 {
+ HttpAuthService,
+ HttpRouterServiceAuthPolicy,
+ RootConfigService,
+} from '@backstage/backend-plugin-api';
+import { RequestHandler } from 'express';
+import { pathToRegexp } from 'path-to-regexp';
+
+export function createPathPolicyPredicate(policyPath: string) {
+ if (policyPath === '/' || policyPath === '*') {
+ return () => true;
+ }
+
+ const pathRegex = pathToRegexp(policyPath, undefined, {
+ end: false,
+ });
+
+ return (path: string): boolean => {
+ return pathRegex.test(path);
+ };
+}
+
+export function createCredentialsBarrier(options: {
+ httpAuth: HttpAuthService;
+ config: RootConfigService;
+}): {
+ middleware: RequestHandler;
+ addAuthPolicy: (policy: HttpRouterServiceAuthPolicy) => void;
+} {
+ const { httpAuth, config } = options;
+
+ const disableDefaultAuthPolicy = config.getOptionalBoolean(
+ 'backend.auth.dangerouslyDisableDefaultAuthPolicy',
+ );
+
+ if (disableDefaultAuthPolicy) {
+ return {
+ middleware: (_req, _res, next) => next(),
+ addAuthPolicy: () => {},
+ };
+ }
+
+ const unauthenticatedPredicates = new Array<(path: string) => boolean>();
+ const cookiePredicates = new Array<(path: string) => boolean>();
+
+ const middleware: RequestHandler = async (req, _, next) => {
+ const allowsUnauthenticated = unauthenticatedPredicates.some(predicate =>
+ predicate(req.path),
+ );
+
+ if (allowsUnauthenticated) {
+ next();
+ return;
+ }
+
+ const allowsCookie = cookiePredicates.some(predicate =>
+ predicate(req.path),
+ );
+
+ await httpAuth.credentials(req, {
+ allow: ['user', 'service'],
+ allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'],
+ });
+
+ next();
+ };
+
+ const addAuthPolicy = (policy: HttpRouterServiceAuthPolicy) => {
+ if (policy.allow === 'unauthenticated') {
+ unauthenticatedPredicates.push(createPathPolicyPredicate(policy.path));
+ } else if (policy.allow === 'user-cookie') {
+ cookiePredicates.push(createPathPolicyPredicate(policy.path));
+ } else {
+ throw new Error('Invalid auth policy');
+ }
+ };
+
+ return { middleware, addAuthPolicy };
+}
diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts
index a6c586d806..58c074f985 100644
--- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts
+++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts
@@ -16,9 +16,17 @@
import {
ServiceFactoryTester,
+ mockCredentials,
mockServices,
+ startTestBackend,
} from '@backstage/backend-test-utils';
import { httpRouterServiceFactory } from './httpRouterServiceFactory';
+import request from 'supertest';
+import {
+ coreServices,
+ createBackendPlugin,
+} from '@backstage/backend-plugin-api';
+import Router from 'express-promise-router';
describe('httpRouterFactory', () => {
it('should register plugin paths', async () => {
@@ -69,4 +77,190 @@ describe('httpRouterFactory', () => {
expect.any(Function),
);
});
+
+ describe('auth services', () => {
+ const pluginSubject = createBackendPlugin({
+ pluginId: 'test',
+ register(reg) {
+ reg.registerInit({
+ deps: {
+ httpRouter: coreServices.httpRouter,
+ auth: coreServices.auth,
+ httpAuth: coreServices.httpAuth,
+ },
+ async init({ httpRouter, auth, httpAuth }) {
+ const router = Router();
+ httpRouter.use(router);
+ httpRouter.addAuthPolicy({
+ path: '/public',
+ allow: 'unauthenticated',
+ });
+ httpRouter.addAuthPolicy({
+ path: '/cookie',
+ allow: 'user-cookie',
+ });
+
+ router.get('/public', (_req, res) => {
+ res.json({ ok: true });
+ });
+ router.get('/cookie', (_req, res) => {
+ res.json({ ok: true });
+ });
+ router.get('/protected/no-checks', (_req, res) => {
+ res.json({ ok: true });
+ });
+ router.get('/protected/only-users', async (req, res) => {
+ await httpAuth.credentials(req, { allow: ['user'] });
+ res.json({ ok: true });
+ });
+ router.get('/protected/only-services', async (req, res) => {
+ await httpAuth.credentials(req, { allow: ['service'] });
+ res.json({ ok: true });
+ });
+ router.get('/protected/credentials', async (req, res) => {
+ res.json({
+ credentials: await httpAuth.credentials(req),
+ });
+ });
+ router.get('/protected/service-token', async (req, res) => {
+ res.json(
+ await auth.getPluginRequestToken({
+ onBehalfOf: await httpAuth.credentials(req),
+ targetPluginId: 'test',
+ }),
+ );
+ });
+ },
+ });
+ },
+ });
+
+ const defaultServices = [
+ httpRouterServiceFactory(),
+ mockServices.httpAuth.factory({
+ defaultCredentials: mockCredentials.none(),
+ }),
+ ];
+
+ it('should block unauthenticated requests by default', async () => {
+ const { server } = await startTestBackend({
+ features: [pluginSubject, ...defaultServices],
+ });
+
+ await expect(
+ request(server).get('/api/test/public'),
+ ).resolves.toMatchObject({
+ status: 200,
+ });
+
+ // TODO: cookie
+
+ await expect(
+ request(server).get('/api/test/protected/no-checks'),
+ ).resolves.toMatchObject({
+ status: 401,
+ });
+ });
+
+ it('should not block unauthenticated requests if default policy is disabled', async () => {
+ const { server } = await startTestBackend({
+ features: [
+ pluginSubject,
+ ...defaultServices,
+ mockServices.rootConfig.factory({
+ data: {
+ backend: { auth: { dangerouslyDisableDefaultAuthPolicy: true } },
+ },
+ }),
+ ],
+ });
+
+ await expect(
+ request(server).get('/api/test/public'),
+ ).resolves.toMatchObject({
+ status: 200,
+ });
+
+ // TODO: cookie
+
+ await expect(
+ request(server).get('/api/test/protected/no-checks'),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: { ok: true },
+ });
+
+ await expect(
+ request(server).get('/api/test/protected/only-users'),
+ ).resolves.toMatchObject({
+ status: 401,
+ });
+
+ await expect(
+ request(server).get('/api/test/protected/only-services'),
+ ).resolves.toMatchObject({
+ status: 401,
+ });
+
+ await expect(
+ request(server).get('/api/test/protected/credentials'),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: { credentials: mockCredentials.none() },
+ });
+
+ await expect(
+ request(server)
+ .get('/api/test/protected/credentials')
+ .set('authorization', mockCredentials.user.header()),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: { credentials: mockCredentials.user() },
+ });
+
+ await expect(
+ request(server)
+ .get('/api/test/protected/credentials')
+ .set('authorization', mockCredentials.service.header()),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: { credentials: mockCredentials.service() },
+ });
+
+ await expect(
+ request(server).get('/api/test/protected/service-token'),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: { token: '' },
+ });
+
+ await expect(
+ request(server)
+ .get('/api/test/protected/service-token')
+ .set('authorization', mockCredentials.user.header()),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: {
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.user(),
+ targetPluginId: 'test',
+ }),
+ },
+ });
+
+ await expect(
+ request(server)
+ .get('/api/test/protected/service-token')
+ .set('authorization', mockCredentials.service.header()),
+ ).resolves.toMatchObject({
+ status: 200,
+ body: {
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.service(),
+ targetPluginId: 'test',
+ }),
+ },
+ });
+ });
+ });
});
diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts
index 2ecdeffd77..e72d091b96 100644
--- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts
+++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts
@@ -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
@@ -38,22 +40,30 @@ export const httpRouterServiceFactory = createServiceFactory(
service: coreServices.httpRouter,
deps: {
plugin: coreServices.pluginMetadata,
+ config: coreServices.rootConfig,
lifecycle: coreServices.lifecycle,
rootHttpRouter: coreServices.rootHttpRouter,
+ httpAuth: coreServices.httpAuth,
},
- async factory({ plugin, rootHttpRouter, lifecycle }) {
+ async factory({ httpAuth, config, 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, config });
+
router.use(createLifecycleMiddleware({ lifecycle }));
+ router.use(credentialsBarrier.middleware);
return {
- use(handler: Handler) {
+ use(handler: Handler): void {
router.use(handler);
},
+ addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void {
+ credentialsBarrier.addAuthPolicy(policy);
+ },
};
},
}),
diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts
index 57042951d9..a1114ab3e3 100644
--- a/packages/backend-app-api/src/services/implementations/index.ts
+++ b/packages/backend-app-api/src/services/implementations/index.ts
@@ -14,10 +14,12 @@
* limitations under the License.
*/
+export * from './auth';
export * from './cache';
export * from './config';
export * from './database';
export * from './discovery';
+export * from './httpAuth';
export * from './httpRouter';
export * from './identity';
export * from './lifecycle';
@@ -29,3 +31,4 @@ export * from './rootLogger';
export * from './scheduler';
export * from './tokenManager';
export * from './urlReader';
+export * from './userInfo';
diff --git a/packages/backend-app-api/src/services/implementations/userInfo/index.ts b/packages/backend-app-api/src/services/implementations/userInfo/index.ts
new file mode 100644
index 0000000000..13b42f053c
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/userInfo/index.ts
@@ -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 { userInfoServiceFactory } from './userInfoServiceFactory';
diff --git a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts
new file mode 100644
index 0000000000..a74b8b7002
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts
@@ -0,0 +1,61 @@
+/*
+ * 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 {
+ UserInfoService,
+ BackstageUserInfo,
+ coreServices,
+ createServiceFactory,
+ BackstageCredentials,
+} from '@backstage/backend-plugin-api';
+import { toInternalBackstageCredentials } from '../auth/authServiceFactory';
+import { decodeJwt } from 'jose';
+
+// TODO: The intention is for this to eventually be replaced by a call to the auth-backend
+export class DefaultUserInfoService implements UserInfoService {
+ async getUserInfo(
+ credentials: BackstageCredentials,
+ ): Promise {
+ const internalCredentials = toInternalBackstageCredentials(credentials);
+ if (internalCredentials.principal.type !== 'user') {
+ throw new Error('Only user credentials are supported');
+ }
+ if (!internalCredentials.token) {
+ throw new Error('User credentials is unexpectedly missing token');
+ }
+ const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = decodeJwt(
+ internalCredentials.token,
+ );
+
+ if (typeof userEntityRef !== 'string') {
+ throw new Error('User entity ref must be a string');
+ }
+ if (!Array.isArray(ownershipEntityRefs)) {
+ throw new Error('Ownership entity refs must be an array');
+ }
+
+ return { userEntityRef, ownershipEntityRefs };
+ }
+}
+
+/** @public */
+export const userInfoServiceFactory = createServiceFactory({
+ service: coreServices.userInfo,
+ deps: {},
+ async factory() {
+ return new DefaultUserInfoService();
+ },
+});
diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md
index 6c9b63ae11..1940a79b78 100644
--- a/packages/backend-common/api-report.md
+++ b/packages/backend-common/api-report.md
@@ -8,6 +8,7 @@
import { AppConfig } from '@backstage/config';
import { AuthCallback } from 'isomorphic-git';
+import { AuthService } from '@backstage/backend-plugin-api';
import { AwsCredentialsManager } from '@backstage/integration-aws-node';
import { AwsS3Integration } from '@backstage/integration';
import { AzureDevOpsCredentialsProvider } from '@backstage/integration';
@@ -30,6 +31,7 @@ import { GithubCredentialsProvider } from '@backstage/integration';
import { GithubIntegration } from '@backstage/integration';
import { GitLabIntegration } from '@backstage/integration';
import { HostDiscovery as HostDiscovery_2 } from '@backstage/backend-app-api';
+import { HttpAuthService } from '@backstage/backend-plugin-api';
import { IdentityService } from '@backstage/backend-plugin-api';
import { isChildPath } from '@backstage/cli-common';
import { Knex } from 'knex';
@@ -232,6 +234,37 @@ export function createDatabaseClient(
},
): knexFactory.Knex;
+// @public
+export function createLegacyAuthAdapters<
+ TOptions extends {
+ auth?: AuthService;
+ httpAuth?: HttpAuthService;
+ identity?: IdentityService;
+ tokenManager?: TokenManager;
+ discovery: PluginEndpointDiscovery;
+ },
+ TAdapters = TOptions extends {
+ auth?: AuthService;
+ }
+ ? TOptions extends {
+ httpAuth?: HttpAuthService;
+ }
+ ? {
+ auth: AuthService;
+ httpAuth: HttpAuthService;
+ }
+ : {
+ auth: AuthService;
+ }
+ : TOptions extends {
+ httpAuth?: HttpAuthService;
+ }
+ ? {
+ httpAuth: HttpAuthService;
+ }
+ : 'error: at least one of auth and/or httpAuth must be provided',
+>(options: TOptions): TAdapters;
+
// @public
export function createRootLogger(
options?: winston.LoggerOptions,
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index 0dfeee7980..2fe5945750 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -63,6 +63,7 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-aws-node": "workspace:^",
+ "@backstage/plugin-auth-node": "workspace:^",
"@backstage/types": "workspace:^",
"@google-cloud/storage": "^7.0.0",
"@keyv/memcache": "^1.3.5",
diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts
new file mode 100644
index 0000000000..7e1f1be858
--- /dev/null
+++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { mockServices } from '@backstage/backend-test-utils';
+import { createLegacyAuthAdapters } from './createLegacyAuthAdapters';
+
+describe('createLegacyAuthAdapters', () => {
+ it('should pass through auth if only auth is provided', () => {
+ const auth = {};
+ const ret = createLegacyAuthAdapters({
+ auth: auth as any,
+ tokenManager: mockServices.tokenManager(),
+ discovery: {} as any,
+ identity: mockServices.identity(),
+ });
+
+ expect(ret.auth).toBe(auth);
+ });
+
+ it('should pass through httpAuth if only httpAuth is provided', () => {
+ const httpAuth = {};
+ const ret = createLegacyAuthAdapters({
+ httpAuth: httpAuth as any,
+ tokenManager: mockServices.tokenManager(),
+ discovery: {} as any,
+ identity: mockServices.identity(),
+ });
+
+ expect(ret.httpAuth).toBe(httpAuth);
+ });
+
+ it('should pass through both auth and httpAuth if both are provided', () => {
+ const auth = {};
+ const httpAuth = {};
+ const ret = createLegacyAuthAdapters({
+ auth: auth as any,
+ httpAuth: httpAuth as any,
+ tokenManager: mockServices.tokenManager(),
+ discovery: {} as any,
+ identity: mockServices.identity(),
+ });
+
+ expect(ret.auth).toBe(auth);
+ expect(ret.httpAuth).toBe(httpAuth);
+ });
+
+ it('should adapt both auth and httpAuth if neither are provided', () => {
+ const ret = createLegacyAuthAdapters({
+ auth: undefined,
+ httpAuth: undefined,
+ tokenManager: mockServices.tokenManager(),
+ discovery: {} as any,
+ identity: mockServices.identity(),
+ });
+
+ expect(ret).toEqual({
+ auth: expect.any(Object),
+ httpAuth: expect.any(Object),
+ });
+ });
+});
diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts
new file mode 100644
index 0000000000..98d21ba559
--- /dev/null
+++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts
@@ -0,0 +1,261 @@
+/*
+ * 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,
+ BackstageCredentials,
+ BackstagePrincipalTypes,
+ BackstageServicePrincipal,
+ BackstageUserPrincipal,
+ HttpAuthService,
+ IdentityService,
+ TokenManagerService,
+} from '@backstage/backend-plugin-api';
+import { ServerTokenManager, TokenManager } from '../tokens';
+import { AuthenticationError, NotAllowedError } from '@backstage/errors';
+import type { Request, Response } from 'express';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import {
+ createCredentialsWithServicePrincipal,
+ createCredentialsWithUserPrincipal,
+ createCredentialsWithNonePrincipal,
+ toInternalBackstageCredentials,
+} from '../../../backend-app-api/src/services/implementations/auth/authServiceFactory';
+// TODO is this circular thingy a problem? Test in e2e
+import {
+ type IdentityApiGetIdentityRequest,
+ DefaultIdentityClient,
+} from '@backstage/plugin-auth-node';
+import { decodeJwt } from 'jose';
+import { PluginEndpointDiscovery } from '../discovery';
+
+class AuthCompat implements AuthService {
+ constructor(
+ private readonly identity: IdentityService,
+ private readonly tokenManager: TokenManagerService,
+ ) {}
+
+ isPrincipal(
+ credentials: BackstageCredentials,
+ type: TType,
+ ): credentials is BackstageCredentials {
+ const principal = credentials.principal as
+ | BackstageUserPrincipal
+ | BackstageServicePrincipal;
+
+ if (principal.type !== type) {
+ return false;
+ }
+
+ return true;
+ }
+
+ async getOwnServiceCredentials(): Promise<
+ BackstageCredentials
+ > {
+ return createCredentialsWithServicePrincipal('external:backstage-plugin');
+ }
+
+ async authenticate(token: string): Promise {
+ const { aud } = decodeJwt(token);
+
+ if (aud === 'backstage') {
+ // User Backstage token
+ const identity = await this.identity.getIdentity({
+ request: {
+ headers: { authorization: `Bearer ${token}` },
+ },
+ } as IdentityApiGetIdentityRequest);
+
+ if (!identity) {
+ throw new AuthenticationError('Invalid user token');
+ }
+
+ return createCredentialsWithUserPrincipal(
+ identity.identity.userEntityRef,
+ token,
+ );
+ }
+
+ await this.tokenManager.authenticate(token);
+
+ return createCredentialsWithServicePrincipal('external:backstage-plugin');
+ }
+
+ async getPluginRequestToken(options: {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ }): Promise<{ token: string }> {
+ const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
+ const { type } = internalForward.principal;
+
+ switch (type) {
+ // TODO: Check whether the principal is ourselves
+ case 'service':
+ return this.tokenManager.getToken();
+ case 'user':
+ if (!internalForward.token) {
+ throw new Error('User credentials is unexpectedly missing token');
+ }
+ return { token: internalForward.token };
+ // NOTE: this is not the behavior of this service in the new backend system, it only applies
+ // here since we'll need to accept and forward requests without authentication.
+ case 'none':
+ return { token: '' };
+ default:
+ throw new AuthenticationError(
+ `Refused to issue service token for credential type '${type}'`,
+ );
+ }
+ }
+}
+
+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;
+ }
+ }
+
+ return undefined;
+}
+
+const credentialsSymbol = Symbol('backstage-credentials');
+
+type RequestWithCredentials = Request & {
+ [credentialsSymbol]?: Promise;
+};
+
+class HttpAuthCompat implements HttpAuthService {
+ constructor(private readonly auth: AuthService) {}
+
+ async #extractCredentialsFromRequest(req: Request) {
+ const token = getTokenFromRequest(req);
+ if (!token) {
+ return createCredentialsWithNonePrincipal();
+ }
+
+ const credentials = toInternalBackstageCredentials(
+ await this.auth.authenticate(token),
+ );
+
+ return credentials;
+ }
+
+ async #getCredentials(req: /* */ RequestWithCredentials) {
+ return (req[credentialsSymbol] ??=
+ this.#extractCredentialsFromRequest(req));
+ }
+
+ async credentials(
+ req: Request,
+ options?: {
+ allow?: Array;
+ allowedAuthMethods?: Array<'token' | 'cookie'>;
+ },
+ ): Promise> {
+ const credentials = toInternalBackstageCredentials(
+ await this.#getCredentials(req),
+ );
+
+ const allowedPrincipalTypes = options?.allow;
+ const allowedAuthMethods: Array<'token' | 'cookie' | 'none'> =
+ options?.allowedAuthMethods ?? ['token'];
+
+ if (
+ credentials.authMethod !== 'none' &&
+ !allowedAuthMethods.includes(credentials.authMethod)
+ ) {
+ throw new NotAllowedError(
+ `This endpoint does not allow the '${credentials.authMethod}' auth method`,
+ );
+ }
+
+ if (
+ allowedPrincipalTypes &&
+ !allowedPrincipalTypes.includes(credentials.principal.type as TAllowed)
+ ) {
+ throw new NotAllowedError(
+ `This endpoint does not allow '${credentials.principal.type}' credentials`,
+ );
+ }
+
+ return credentials as any;
+ }
+
+ async issueUserCookie(_res: Response): Promise {}
+}
+
+/**
+ * An adapter that ensures presence of the auth and/or httpAuth services.
+ * @public
+ */
+export function createLegacyAuthAdapters<
+ TOptions extends {
+ auth?: AuthService;
+ httpAuth?: HttpAuthService;
+ identity?: IdentityService;
+ tokenManager?: TokenManager;
+ discovery: PluginEndpointDiscovery;
+ },
+ TAdapters = TOptions extends {
+ auth?: AuthService;
+ }
+ ? TOptions extends { httpAuth?: HttpAuthService }
+ ? { auth: AuthService; httpAuth: HttpAuthService }
+ : { auth: AuthService }
+ : TOptions extends { httpAuth?: HttpAuthService }
+ ? { httpAuth: HttpAuthService }
+ : 'error: at least one of auth and/or httpAuth must be provided',
+>(options: TOptions): TAdapters {
+ const { auth, httpAuth, discovery } = options;
+
+ if (auth && httpAuth) {
+ return {
+ auth,
+ httpAuth,
+ } as TAdapters;
+ }
+
+ if (auth) {
+ return {
+ auth,
+ } as TAdapters;
+ }
+
+ if (httpAuth) {
+ return {
+ httpAuth,
+ } as TAdapters;
+ }
+
+ const identity =
+ options.identity ?? DefaultIdentityClient.create({ discovery });
+ const tokenManager = options.tokenManager ?? ServerTokenManager.noop();
+
+ const authImpl = new AuthCompat(identity, tokenManager);
+
+ const httpAuthImpl = new HttpAuthCompat(authImpl);
+
+ return {
+ auth: authImpl,
+ httpAuth: httpAuthImpl,
+ } as TAdapters;
+}
diff --git a/packages/backend-common/src/auth/index.ts b/packages/backend-common/src/auth/index.ts
new file mode 100644
index 0000000000..c92ecdce25
--- /dev/null
+++ b/packages/backend-common/src/auth/index.ts
@@ -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 { createLegacyAuthAdapters } from './createLegacyAuthAdapters';
diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts
index afb12115b2..4b1314b7d2 100644
--- a/packages/backend-common/src/index.ts
+++ b/packages/backend-common/src/index.ts
@@ -22,6 +22,7 @@
export { legacyPlugin, makeLegacyPlugin } from './legacy';
export type { LegacyCreateRouter } from './legacy';
+export * from './auth';
export * from './cache';
export { loadBackendConfig } from './config';
export * from './database';
diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts
index c07d1cc38a..5495665dd8 100644
--- a/packages/backend-defaults/src/CreateBackend.ts
+++ b/packages/backend-defaults/src/CreateBackend.ts
@@ -32,13 +32,18 @@ import {
tokenManagerServiceFactory,
urlReaderServiceFactory,
identityServiceFactory,
+ authServiceFactory,
+ httpAuthServiceFactory,
+ userInfoServiceFactory,
} from '@backstage/backend-app-api';
export const defaultServiceFactories = [
+ authServiceFactory(),
cacheServiceFactory(),
rootConfigServiceFactory(),
databaseServiceFactory(),
discoveryServiceFactory(),
+ httpAuthServiceFactory(),
httpRouterServiceFactory(),
identityServiceFactory(),
lifecycleServiceFactory(),
@@ -49,6 +54,7 @@ export const defaultServiceFactories = [
rootLoggerServiceFactory(),
schedulerServiceFactory(),
tokenManagerServiceFactory(),
+ userInfoServiceFactory(),
urlReaderServiceFactory(),
];
diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json
index 98ae4c6ac4..74016539b0 100644
--- a/packages/backend-next/package.json
+++ b/packages/backend-next/package.json
@@ -28,8 +28,11 @@
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
+ "@backstage/catalog-model": "workspace:^",
"@backstage/plugin-adr-backend": "workspace:^",
"@backstage/plugin-app-backend": "workspace:^",
+ "@backstage/plugin-auth-backend": "workspace:^",
+ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-azure-devops-backend": "workspace:^",
"@backstage/plugin-badges-backend": "workspace:^",
diff --git a/packages/backend-next/src/authModuleGithubProvider.ts b/packages/backend-next/src/authModuleGithubProvider.ts
new file mode 100644
index 0000000000..9245600d2c
--- /dev/null
+++ b/packages/backend-next/src/authModuleGithubProvider.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 { createBackendModule } from '@backstage/backend-plugin-api';
+import {
+ DEFAULT_NAMESPACE,
+ stringifyEntityRef,
+} from '@backstage/catalog-model';
+import { githubAuthenticator } from '@backstage/plugin-auth-backend-module-github-provider';
+import {
+ authProvidersExtensionPoint,
+ createOAuthProviderFactory,
+} from '@backstage/plugin-auth-node';
+
+export default createBackendModule({
+ pluginId: 'auth',
+ moduleId: 'githubProvider',
+ register(reg) {
+ reg.registerInit({
+ deps: { providers: authProvidersExtensionPoint },
+ async init({ providers }) {
+ providers.registerProvider({
+ providerId: 'github',
+ factory: createOAuthProviderFactory({
+ authenticator: githubAuthenticator,
+ async signInResolver({ result: { fullProfile } }, ctx) {
+ const userId = fullProfile.username;
+ if (!userId) {
+ throw new Error(
+ `GitHub user profile does not contain a username`,
+ );
+ }
+
+ const userEntityRef = stringifyEntityRef({
+ kind: 'User',
+ name: userId,
+ namespace: DEFAULT_NAMESPACE,
+ });
+
+ return ctx.issueToken({
+ claims: {
+ sub: userEntityRef,
+ ent: [userEntityRef],
+ },
+ });
+ },
+ }),
+ });
+ },
+ });
+ },
+});
diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts
index 9c61b98ec4..e4dd127208 100644
--- a/packages/backend-next/src/index.ts
+++ b/packages/backend-next/src/index.ts
@@ -18,6 +18,9 @@ import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
+backend.add(import('@backstage/plugin-auth-backend'));
+backend.add(import('./authModuleGithubProvider'));
+
backend.add(import('@backstage/plugin-adr-backend'));
backend.add(import('@backstage/plugin-app-backend/alpha'));
backend.add(import('@backstage/plugin-azure-devops-backend'));
diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md
index 3b3d28cc27..d676de2fce 100644
--- a/packages/backend-plugin-api/api-report.md
+++ b/packages/backend-plugin-api/api-report.md
@@ -14,6 +14,30 @@ 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 {
+ // (undocumented)
+ authenticate(token: string): Promise;
+ // (undocumented)
+ getOwnServiceCredentials(): Promise<
+ BackstageCredentials
+ >;
+ // (undocumented)
+ getPluginRequestToken(options: {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ }): Promise<{
+ token: string;
+ }>;
+ // (undocumented)
+ isPrincipal(
+ credentials: BackstageCredentials,
+ type: TType,
+ ): credentials is BackstageCredentials;
+}
// @public (undocumented)
export interface BackendFeature {
@@ -76,6 +100,45 @@ export interface BackendPluginRegistrationPoints {
}): void;
}
+// @public (undocumented)
+export type BackstageCredentials = {
+ $$type: '@backstage/BackstageCredentials';
+ principal: TPrincipal;
+};
+
+// @public (undocumented)
+export type BackstageNonePrincipal = {
+ type: 'none';
+};
+
+// @public (undocumented)
+export type BackstagePrincipalTypes = {
+ user: BackstageUserPrincipal;
+ service: BackstageServicePrincipal;
+ none: BackstageNonePrincipal;
+ unknown: unknown;
+};
+
+// @public (undocumented)
+export type BackstageServicePrincipal = {
+ type: 'service';
+ subject: string;
+};
+
+// @public (undocumented)
+export interface BackstageUserInfo {
+ // (undocumented)
+ ownershipEntityRefs: string[];
+ // (undocumented)
+ userEntityRef: string;
+}
+
+// @public (undocumented)
+export type BackstageUserPrincipal = {
+ type: 'user';
+ userEntityRef: string;
+};
+
// @public
export interface CacheService {
delete(key: string): Promise;
@@ -100,10 +163,13 @@ export type CacheServiceSetOptions = {
// @public
export namespace coreServices {
+ const auth: ServiceRef;
+ const userInfo: ServiceRef;
const cache: ServiceRef;
const rootConfig: ServiceRef;
const database: ServiceRef;
const discovery: ServiceRef;
+ const httpAuth: ServiceRef;
const httpRouter: ServiceRef;
const lifecycle: ServiceRef;
const logger: ServiceRef;
@@ -222,12 +288,36 @@ export interface ExtensionPointConfig {
id: string;
}
+// @public (undocumented)
+export interface HttpAuthService {
+ // (undocumented)
+ credentials(
+ req: Request_2,
+ options?: {
+ allow?: Array;
+ allowedAuthMethods?: Array<'token' | 'cookie'>;
+ },
+ ): Promise>;
+ // (undocumented)
+ issueUserCookie(res: Response_2): Promise;
+}
+
// @public (undocumented)
export interface HttpRouterService {
+ // (undocumented)
+ addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void;
// (undocumented)
use(handler: Handler): void;
}
+// @public (undocumented)
+export interface HttpRouterServiceAuthPolicy {
+ // (undocumented)
+ allow: 'unauthenticated' | 'user-cookie';
+ // (undocumented)
+ path: string;
+}
+
// @public (undocumented)
export interface IdentityService extends IdentityApi {}
@@ -458,4 +548,10 @@ export interface UrlReaderService {
readUrl(url: string, options?: ReadUrlOptions): Promise;
search(url: string, options?: SearchOptions): Promise;
}
+
+// @public (undocumented)
+export interface UserInfoService {
+ // (undocumented)
+ getUserInfo(credentials: BackstageCredentials): Promise;
+}
```
diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts
new file mode 100644
index 0000000000..eab0a7fac7
--- /dev/null
+++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2022 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.
+ */
+
+/**
+ * @public
+ */
+export type BackstageUserPrincipal = {
+ type: 'user';
+
+ userEntityRef: string;
+};
+
+/**
+ * @public
+ */
+export type BackstageNonePrincipal = {
+ type: 'none';
+};
+
+/**
+ * @public
+ */
+export type BackstageServicePrincipal = {
+ type: 'service';
+
+ // Exact format TBD, possibly 'plugin:' or 'external:'
+ subject: string;
+};
+
+/**
+ * @public
+ */
+export type BackstageCredentials = {
+ $$type: '@backstage/BackstageCredentials';
+
+ principal: TPrincipal;
+};
+
+/**
+ * @public
+ */
+export type BackstagePrincipalTypes = {
+ user: BackstageUserPrincipal;
+ service: BackstageServicePrincipal;
+ none: BackstageNonePrincipal;
+ unknown: unknown;
+};
+
+/**
+ * @public
+ */
+export interface AuthService {
+ authenticate(token: string): Promise;
+
+ isPrincipal(
+ credentials: BackstageCredentials,
+ type: TType,
+ ): credentials is BackstageCredentials;
+
+ getOwnServiceCredentials(): Promise<
+ BackstageCredentials
+ >;
+
+ getPluginRequestToken(options: {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ }): Promise<{ token: string }>;
+}
diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts
new file mode 100644
index 0000000000..44696bd777
--- /dev/null
+++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts
@@ -0,0 +1,31 @@
+/*
+ * 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 } from 'express';
+import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService';
+
+/** @public */
+export interface HttpAuthService {
+ credentials(
+ req: Request,
+ options?: {
+ allow?: Array;
+ allowedAuthMethods?: Array<'token' | 'cookie'>;
+ },
+ ): Promise>;
+
+ issueUserCookie(res: Response): Promise;
+}
diff --git a/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts b/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts
index 3c45ef1da1..695b337754 100644
--- a/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts
+++ b/packages/backend-plugin-api/src/services/definitions/HttpRouterService.ts
@@ -16,9 +16,17 @@
import { Handler } from 'express';
+/** @public */
+export interface HttpRouterServiceAuthPolicy {
+ path: string;
+ allow: 'unauthenticated' | 'user-cookie';
+}
+
/**
* @public
*/
export interface HttpRouterService {
use(handler: Handler): void;
+
+ addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void;
}
diff --git a/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts
new file mode 100644
index 0000000000..90f5306a58
--- /dev/null
+++ b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 { BackstageCredentials } from './AuthService';
+
+/** @public */
+export interface BackstageUserInfo {
+ userEntityRef: string;
+ ownershipEntityRefs: string[];
+}
+
+/** @public */
+export interface UserInfoService {
+ getUserInfo(credentials: BackstageCredentials): Promise;
+}
diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts
index b42795d052..c760afc7b4 100644
--- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts
+++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts
@@ -22,6 +22,26 @@ import { createServiceRef } from '../system';
* @public
*/
export namespace coreServices {
+ /**
+ * The service reference for the plugin scoped {@link AuthService}.
+ *
+ * @public
+ */
+ export const auth = createServiceRef({
+ id: 'core.auth',
+ });
+
+ /**
+ * The service reference for the plugin scoped {@link UserInfoService}.
+ *
+ * @public
+ */
+ export const userInfo = createServiceRef<
+ import('./UserInfoService').UserInfoService
+ >({
+ id: 'core.userInfo',
+ });
+
/**
* The service reference for the plugin scoped {@link CacheService}.
*
@@ -58,6 +78,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}.
*
diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts
index 98be8211db..5955c4cbb1 100644
--- a/packages/backend-plugin-api/src/services/definitions/index.ts
+++ b/packages/backend-plugin-api/src/services/definitions/index.ts
@@ -15,6 +15,14 @@
*/
export { coreServices } from './coreServices';
+export type {
+ AuthService,
+ BackstageCredentials,
+ BackstageUserPrincipal,
+ BackstageServicePrincipal,
+ BackstagePrincipalTypes,
+ BackstageNonePrincipal,
+} from './AuthService';
export type {
CacheService,
CacheServiceOptions,
@@ -23,7 +31,11 @@ export type {
export type { RootConfigService } from './RootConfigService';
export type { DatabaseService } from './DatabaseService';
export type { DiscoveryService } from './DiscoveryService';
-export type { HttpRouterService } from './HttpRouterService';
+export type {
+ HttpRouterService,
+ HttpRouterServiceAuthPolicy,
+} from './HttpRouterService';
+export type { HttpAuthService } from './HttpAuthService';
export type {
LifecycleService,
LifecycleServiceStartupHook,
@@ -51,4 +63,5 @@ export type {
SearchResponseFile,
UrlReaderService,
} from './UrlReaderService';
+export type { BackstageUserInfo, UserInfoService } from './UserInfoService';
export type { IdentityService } from './IdentityService';
diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md
index 204e5dc84a..a9ebb402c4 100644
--- a/packages/backend-test-utils/api-report.md
+++ b/packages/backend-test-utils/api-report.md
@@ -6,12 +6,19 @@
///
///
+import { AuthService } from '@backstage/backend-plugin-api';
import { Backend } from '@backstage/backend-app-api';
import { BackendFeature } from '@backstage/backend-plugin-api';
+import { BackstageCredentials } from '@backstage/backend-plugin-api';
+import { BackstageNonePrincipal } from '@backstage/backend-plugin-api';
+import { BackstageServicePrincipal } from '@backstage/backend-plugin-api';
+import { BackstageUserPrincipal } from '@backstage/backend-plugin-api';
import { CacheService } from '@backstage/backend-plugin-api';
import { DatabaseService } from '@backstage/backend-plugin-api';
+import { DiscoveryService } from '@backstage/backend-plugin-api';
import { ExtendedHttpServer } from '@backstage/backend-app-api';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
+import { HttpAuthService } from '@backstage/backend-plugin-api';
import { HttpRouterFactoryOptions } from '@backstage/backend-app-api';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { IdentityService } from '@backstage/backend-plugin-api';
@@ -39,6 +46,40 @@ export function createMockDirectory(
// @public (undocumented)
export function isDockerDisabledForTests(): boolean;
+// @public (undocumented)
+export namespace mockCredentials {
+ export function none(): BackstageCredentials;
+ export namespace none {
+ export function header(): string;
+ }
+ export function service(
+ subject?: string,
+ ): BackstageCredentials;
+ export namespace service {
+ export function header(options?: TokenOptions): string;
+ // (undocumented)
+ export function invalidHeader(): string;
+ // (undocumented)
+ export function invalidToken(): string;
+ export function token(options?: TokenOptions): string;
+ export type TokenOptions = {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ };
+ }
+ export function user(
+ userEntityRef?: string,
+ ): BackstageCredentials;
+ export namespace user {
+ export function header(userEntityRef?: string): string;
+ // (undocumented)
+ export function invalidHeader(): string;
+ // (undocumented)
+ export function invalidToken(): string;
+ export function token(userEntityRef?: string): string;
+ }
+}
+
// @public
export interface MockDirectory {
addContent(root: MockDirectoryContent): void;
@@ -86,6 +127,20 @@ export interface MockDirectoryOptions {
// @public (undocumented)
export namespace mockServices {
+ // (undocumented)
+ export function auth(options?: {
+ pluginId?: string;
+ disableDefaultAuthPolicy?: boolean;
+ }): AuthService;
+ // (undocumented)
+ export namespace auth {
+ const // (undocumented)
+ factory: () => ServiceFactory;
+ const // (undocumented)
+ mock: (
+ partialImpl?: Partial | undefined,
+ ) => ServiceMock;
+ }
// (undocumented)
export namespace cache {
const // (undocumented)
@@ -105,6 +160,35 @@ export namespace mockServices {
) => ServiceMock;
}
// (undocumented)
+ export function discovery(): DiscoveryService;
+ // (undocumented)
+ export namespace discovery {
+ const // (undocumented)
+ factory: () => ServiceFactory;
+ const // (undocumented)
+ mock: (
+ partialImpl?: Partial | undefined,
+ ) => ServiceMock;
+ }
+ export function httpAuth(options?: {
+ pluginId?: string;
+ defaultCredentials?: BackstageCredentials;
+ }): HttpAuthService;
+ // (undocumented)
+ export namespace httpAuth {
+ const factory: (
+ options?:
+ | {
+ defaultCredentials?: BackstageCredentials | undefined;
+ }
+ | undefined,
+ ) => ServiceFactory;
+ const // (undocumented)
+ mock: (
+ partialImpl?: Partial | undefined,
+ ) => ServiceMock;
+ }
+ // (undocumented)
export namespace httpRouter {
const // (undocumented)
factory: (
diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts
new file mode 100644
index 0000000000..dfb54b9762
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts
@@ -0,0 +1,214 @@
+/*
+ * 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 { MockAuthService } from './MockAuthService';
+import {
+ DEFAULT_MOCK_SERVICE_SUBJECT,
+ DEFAULT_MOCK_USER_ENTITY_REF,
+ MOCK_SERVICE_TOKEN_PREFIX,
+ MOCK_USER_TOKEN_PREFIX,
+ mockCredentials,
+} from './mockCredentials';
+
+describe('MockAuthService', () => {
+ const auth = new MockAuthService({
+ pluginId: 'test',
+ disableDefaultAuthPolicy: false,
+ });
+
+ it('should reject invalid tokens', async () => {
+ await expect(auth.authenticate('')).rejects.toThrow('Token is empty');
+ await expect(auth.authenticate('not-a-mock-token')).rejects.toThrow(
+ "Unknown mock token 'not-a-mock-token'",
+ );
+ await expect(auth.authenticate(MOCK_USER_TOKEN_PREFIX)).rejects.toThrow(
+ 'Unexpected end of JSON input',
+ );
+ await expect(
+ auth.authenticate(`${MOCK_USER_TOKEN_PREFIX}{"invalid":json}`),
+ ).rejects.toThrow('Unexpected token');
+ await expect(auth.authenticate(MOCK_SERVICE_TOKEN_PREFIX)).rejects.toThrow(
+ 'Unexpected end of JSON input',
+ );
+ await expect(
+ auth.authenticate(`${MOCK_SERVICE_TOKEN_PREFIX}{"invalid":json}`),
+ ).rejects.toThrow('Unexpected token');
+ });
+
+ it('should authenticate mock user tokens', async () => {
+ await expect(
+ auth.authenticate(mockCredentials.user.token()),
+ ).resolves.toEqual(mockCredentials.user());
+
+ await expect(
+ auth.authenticate(mockCredentials.user.token()),
+ ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF));
+
+ await expect(
+ auth.authenticate(mockCredentials.user.token('user:default/other')),
+ ).resolves.toEqual(mockCredentials.user('user:default/other'));
+
+ await expect(
+ auth.authenticate(mockCredentials.user.invalidToken()),
+ ).rejects.toThrow('User token is invalid');
+ });
+
+ it('should authenticate mock service tokens', async () => {
+ await expect(
+ auth.authenticate(mockCredentials.service.token()),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(
+ auth.authenticate(mockCredentials.service.token()),
+ ).resolves.toEqual(mockCredentials.service(DEFAULT_MOCK_SERVICE_SUBJECT));
+
+ await expect(
+ auth.authenticate(mockCredentials.service.token()),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(
+ auth.authenticate(
+ mockCredentials.service.token({
+ onBehalfOf: mockCredentials.service('plugin:catalog'),
+ targetPluginId: 'test',
+ }),
+ ),
+ ).resolves.toEqual(mockCredentials.service('plugin:catalog'));
+
+ await expect(
+ auth.authenticate(
+ mockCredentials.service.token({
+ onBehalfOf: await auth.getOwnServiceCredentials(),
+ targetPluginId: 'test',
+ }),
+ ),
+ ).resolves.toEqual(mockCredentials.service('plugin:test'));
+
+ await expect(
+ auth.authenticate(
+ mockCredentials.service.token({
+ onBehalfOf: await auth.getOwnServiceCredentials(),
+ targetPluginId: 'other',
+ }),
+ ),
+ ).rejects.toThrow(
+ "Invalid mock token target plugin ID, got 'other' but expected 'test'",
+ );
+
+ await expect(
+ auth.authenticate(mockCredentials.service.invalidToken()),
+ ).rejects.toThrow('Service token is invalid');
+ });
+
+ it('should return own service credentials', async () => {
+ await expect(auth.getOwnServiceCredentials()).resolves.toEqual(
+ mockCredentials.service('plugin:test'),
+ );
+ });
+
+ it('should check principal types', () => {
+ const none = mockCredentials.none();
+ const user = mockCredentials.user();
+ const service = mockCredentials.service();
+
+ expect(auth.isPrincipal(none, 'unknown')).toBe(true);
+ expect(auth.isPrincipal(user, 'unknown')).toBe(true);
+ expect(auth.isPrincipal(service, 'unknown')).toBe(true);
+
+ expect(auth.isPrincipal(none, 'none')).toBe(true);
+ expect(auth.isPrincipal(user, 'none')).toBe(false);
+ expect(auth.isPrincipal(service, 'none')).toBe(false);
+
+ expect(auth.isPrincipal(none, 'user')).toBe(false);
+ expect(auth.isPrincipal(user, 'user')).toBe(true);
+ expect(auth.isPrincipal(service, 'user')).toBe(false);
+
+ expect(auth.isPrincipal(none, 'service')).toBe(false);
+ expect(auth.isPrincipal(user, 'service')).toBe(false);
+ expect(auth.isPrincipal(service, 'service')).toBe(true);
+ });
+
+ it('should issue plugin request tokens', async () => {
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: mockCredentials.user(),
+ targetPluginId: 'test',
+ }),
+ ).resolves.toEqual({
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.user(),
+ targetPluginId: 'test',
+ }),
+ });
+
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: mockCredentials.user('user:default/other'),
+ targetPluginId: 'test',
+ }),
+ ).resolves.toEqual({
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.user('user:default/other'),
+ targetPluginId: 'test',
+ }),
+ });
+
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: mockCredentials.service(),
+ targetPluginId: 'test',
+ }),
+ ).resolves.toEqual({
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.service(),
+ targetPluginId: 'test',
+ }),
+ });
+
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: mockCredentials.service('external:other'),
+ targetPluginId: 'test',
+ }),
+ ).resolves.toEqual({
+ token: mockCredentials.service.token({
+ onBehalfOf: mockCredentials.service('external:other'),
+ targetPluginId: 'test',
+ }),
+ });
+
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: await auth.getOwnServiceCredentials(),
+ targetPluginId: 'other',
+ }),
+ ).resolves.toEqual({
+ token: mockCredentials.service.token({
+ onBehalfOf: await mockCredentials.service('plugin:test'),
+ targetPluginId: 'other',
+ }),
+ });
+
+ await expect(
+ auth.getPluginRequestToken({
+ onBehalfOf: await mockCredentials.none(),
+ targetPluginId: 'other',
+ }),
+ ).rejects.toThrow(
+ `Refused to issue service token for credential type 'none'`,
+ );
+ });
+});
diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts
new file mode 100644
index 0000000000..6a18711798
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts
@@ -0,0 +1,147 @@
+/*
+ * 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 {
+ BackstageCredentials,
+ BackstageServicePrincipal,
+ BackstagePrincipalTypes,
+ BackstageUserPrincipal,
+ BackstageNonePrincipal,
+ AuthService,
+} from '@backstage/backend-plugin-api';
+import { AuthenticationError } from '@backstage/errors';
+import {
+ mockCredentials,
+ MOCK_USER_TOKEN,
+ MOCK_USER_TOKEN_PREFIX,
+ MOCK_SERVICE_TOKEN,
+ MOCK_SERVICE_TOKEN_PREFIX,
+ MOCK_INVALID_USER_TOKEN,
+ MOCK_INVALID_SERVICE_TOKEN,
+ UserTokenPayload,
+ ServiceTokenPayload,
+} from './mockCredentials';
+
+/** @internal */
+export class MockAuthService implements AuthService {
+ readonly pluginId: string;
+ readonly disableDefaultAuthPolicy: boolean;
+
+ constructor(options: {
+ pluginId: string;
+ disableDefaultAuthPolicy: boolean;
+ }) {
+ this.pluginId = options.pluginId;
+ this.disableDefaultAuthPolicy = options.disableDefaultAuthPolicy;
+ }
+
+ async authenticate(token: string): Promise {
+ switch (token) {
+ case MOCK_USER_TOKEN:
+ return mockCredentials.user();
+ case MOCK_SERVICE_TOKEN:
+ return mockCredentials.service();
+ case MOCK_INVALID_USER_TOKEN:
+ throw new AuthenticationError('User token is invalid');
+ case MOCK_INVALID_SERVICE_TOKEN:
+ throw new AuthenticationError('Service token is invalid');
+ case '':
+ throw new AuthenticationError('Token is empty');
+ default:
+ break;
+ }
+
+ if (token.startsWith(MOCK_USER_TOKEN_PREFIX)) {
+ const { sub: userEntityRef }: UserTokenPayload = JSON.parse(
+ token.slice(MOCK_USER_TOKEN_PREFIX.length),
+ );
+
+ return mockCredentials.user(userEntityRef);
+ }
+
+ if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) {
+ const { sub, target, obo }: ServiceTokenPayload = JSON.parse(
+ token.slice(MOCK_SERVICE_TOKEN_PREFIX.length),
+ );
+
+ if (target && target !== this.pluginId) {
+ throw new AuthenticationError(
+ `Invalid mock token target plugin ID, got '${target}' but expected '${this.pluginId}'`,
+ );
+ }
+ if (obo) {
+ return mockCredentials.user(obo);
+ }
+
+ return mockCredentials.service(sub);
+ }
+
+ throw new AuthenticationError(`Unknown mock token '${token}'`);
+ }
+
+ async getOwnServiceCredentials(): Promise<
+ BackstageCredentials
+ > {
+ return mockCredentials.service(`plugin:${this.pluginId}`);
+ }
+
+ isPrincipal(
+ credentials: BackstageCredentials,
+ type: TType,
+ ): credentials is BackstageCredentials {
+ const principal = credentials.principal as
+ | BackstageUserPrincipal
+ | BackstageServicePrincipal
+ | BackstageNonePrincipal;
+
+ if (type === 'unknown') {
+ return true;
+ }
+
+ if (principal.type !== type) {
+ return false;
+ }
+
+ return true;
+ }
+
+ async getPluginRequestToken(options: {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ }): Promise<{ token: string }> {
+ const principal = options.onBehalfOf.principal as
+ | BackstageUserPrincipal
+ | BackstageServicePrincipal
+ | BackstageNonePrincipal;
+
+ if (principal.type === 'none' && this.disableDefaultAuthPolicy) {
+ return { token: '' };
+ }
+
+ if (principal.type !== 'user' && principal.type !== 'service') {
+ throw new AuthenticationError(
+ `Refused to issue service token for credential type '${principal.type}'`,
+ );
+ }
+
+ return {
+ token: mockCredentials.service.token({
+ onBehalfOf: options.onBehalfOf,
+ targetPluginId: options.targetPluginId,
+ }),
+ };
+ }
+}
diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts
new file mode 100644
index 0000000000..922b3daaf4
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts
@@ -0,0 +1,169 @@
+/*
+ * 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 } from 'express';
+import { MockHttpAuthService } from './MockHttpAuthService';
+import { mockCredentials } from './mockCredentials';
+import { AuthenticationError } from '@backstage/errors';
+
+describe('MockHttpAuthService', () => {
+ const httpAuth = new MockHttpAuthService('test', mockCredentials.none());
+
+ function makeAuthReq(header?: string) {
+ return { headers: { authorization: header } } as Request;
+ }
+
+ it('should authenticate unauthenticated requests', async () => {
+ await expect(httpAuth.credentials(makeAuthReq())).resolves.toEqual(
+ mockCredentials.none(),
+ );
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(), { allow: ['none'] }),
+ ).resolves.toEqual(mockCredentials.none());
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(), { allow: ['user'] }),
+ ).rejects.toThrow(AuthenticationError);
+
+ await expect(httpAuth.credentials(makeAuthReq())).resolves.toEqual(
+ mockCredentials.none(),
+ );
+ });
+
+ it('should authenticate user requests', async () => {
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.user.header())),
+ ).resolves.toEqual(mockCredentials.user());
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), {
+ allow: ['user'],
+ }),
+ ).resolves.toEqual(mockCredentials.user());
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), {
+ allow: ['none', 'service'],
+ }),
+ ).rejects.toThrow("This endpoint does not allow 'user' credentials");
+
+ await expect(
+ httpAuth.credentials(
+ makeAuthReq(mockCredentials.user.header('user:default/other')),
+ ),
+ ).resolves.toEqual(mockCredentials.user('user:default/other'));
+ });
+
+ it('should authenticate service requests', async () => {
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.service.header())),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.service.header()), {
+ allow: ['none', 'service'],
+ }),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(
+ httpAuth.credentials(
+ makeAuthReq(
+ mockCredentials.service.header({
+ onBehalfOf: mockCredentials.service('plugin:other'),
+ targetPluginId: 'test',
+ }),
+ ),
+ ),
+ ).resolves.toEqual(mockCredentials.service('plugin:other'));
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.service.header()), {
+ allow: ['user'],
+ }),
+ ).rejects.toThrow("This endpoint does not allow 'service' credentials");
+ });
+
+ it('should default to different credential types', async () => {
+ const noneAuth = new MockHttpAuthService('test', mockCredentials.none());
+ const userAuth = new MockHttpAuthService('test', mockCredentials.user());
+ const serviceAuth = new MockHttpAuthService(
+ 'test',
+ mockCredentials.service(),
+ );
+
+ await expect(noneAuth.credentials(makeAuthReq())).resolves.toEqual(
+ mockCredentials.none(),
+ );
+ await expect(
+ noneAuth.credentials(makeAuthReq(mockCredentials.none.header())),
+ ).resolves.toEqual(mockCredentials.none());
+ await expect(
+ noneAuth.credentials(makeAuthReq(mockCredentials.user.header())),
+ ).resolves.toEqual(mockCredentials.user());
+ await expect(
+ noneAuth.credentials(makeAuthReq(mockCredentials.service.header())),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(userAuth.credentials(makeAuthReq())).resolves.toEqual(
+ mockCredentials.user(),
+ );
+ await expect(
+ userAuth.credentials(makeAuthReq(mockCredentials.none.header())),
+ ).resolves.toEqual(mockCredentials.none());
+ await expect(
+ userAuth.credentials(makeAuthReq(mockCredentials.user.header())),
+ ).resolves.toEqual(mockCredentials.user());
+ await expect(
+ userAuth.credentials(makeAuthReq(mockCredentials.service.header())),
+ ).resolves.toEqual(mockCredentials.service());
+
+ await expect(serviceAuth.credentials(makeAuthReq())).resolves.toEqual(
+ mockCredentials.service(),
+ );
+ await expect(
+ serviceAuth.credentials(makeAuthReq(mockCredentials.none.header())),
+ ).resolves.toEqual(mockCredentials.none());
+ await expect(
+ serviceAuth.credentials(makeAuthReq(mockCredentials.user.header())),
+ ).resolves.toEqual(mockCredentials.user());
+ await expect(
+ serviceAuth.credentials(makeAuthReq(mockCredentials.service.header())),
+ ).resolves.toEqual(mockCredentials.service());
+ });
+
+ it('should reject invalid credentials', async () => {
+ await expect(
+ httpAuth.credentials(makeAuthReq('Bearer bad')),
+ ).rejects.toThrow("Unknown mock token 'bad'");
+
+ await expect(
+ httpAuth.credentials(makeAuthReq(mockCredentials.user.invalidHeader())),
+ ).rejects.toThrow('User token is invalid');
+
+ await expect(
+ httpAuth.credentials(
+ makeAuthReq(mockCredentials.service.invalidHeader()),
+ ),
+ ).rejects.toThrow('Service token is invalid');
+ });
+
+ it('does not implement .issueUserCookie', async () => {
+ await expect(httpAuth.issueUserCookie({} as any)).rejects.toThrow(
+ 'Not implemented',
+ );
+ });
+});
diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts
new file mode 100644
index 0000000000..9b133f996b
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts
@@ -0,0 +1,110 @@
+/*
+ * 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,
+ BackstageCredentials,
+ BackstagePrincipalTypes,
+ HttpAuthService,
+} from '@backstage/backend-plugin-api';
+import { Request, Response } from 'express';
+import { MockAuthService } from './MockAuthService';
+import {
+ AuthenticationError,
+ NotAllowedError,
+ NotImplementedError,
+} from '@backstage/errors';
+import { mockCredentials } from './mockCredentials';
+
+// TODO: support mock cookie auth?
+export class MockHttpAuthService implements HttpAuthService {
+ #auth: AuthService;
+ #defaultCredentials: BackstageCredentials;
+
+ constructor(pluginId: string, defaultCredentials: BackstageCredentials) {
+ this.#auth = new MockAuthService({
+ pluginId,
+ disableDefaultAuthPolicy: false,
+ });
+ this.#defaultCredentials = defaultCredentials;
+ }
+
+ async #getCredentials(req: Request) {
+ const header = req.headers.authorization;
+
+ if (header === mockCredentials.none.header()) {
+ return mockCredentials.none();
+ }
+
+ const token =
+ typeof header === 'string'
+ ? header.match(/^Bearer[ ]+(\S+)$/i)?.[1]
+ : undefined;
+
+ if (!token) {
+ return this.#defaultCredentials;
+ }
+
+ return await this.#auth.authenticate(token);
+ }
+
+ async credentials(
+ req: Request,
+ options?: {
+ allow?: Array;
+ allowedAuthMethods?: Array<'token' | 'cookie'>;
+ },
+ ): Promise> {
+ const credentials = await this.#getCredentials(req);
+
+ const allowedPrincipalTypes = options?.allow;
+ if (!allowedPrincipalTypes) {
+ return credentials as any;
+ }
+
+ if (this.#auth.isPrincipal(credentials, 'none')) {
+ if (allowedPrincipalTypes.includes('none' as TAllowed)) {
+ return credentials as any;
+ }
+
+ throw new AuthenticationError();
+ } else if (this.#auth.isPrincipal(credentials, 'user')) {
+ if (allowedPrincipalTypes.includes('user' as TAllowed)) {
+ return credentials as any;
+ }
+
+ throw new NotAllowedError(
+ `This endpoint does not allow 'user' credentials`,
+ );
+ } else if (this.#auth.isPrincipal(credentials, 'service')) {
+ if (allowedPrincipalTypes.includes('service' as TAllowed)) {
+ return credentials as any;
+ }
+
+ throw new NotAllowedError(
+ `This endpoint does not allow 'service' credentials`,
+ );
+ }
+
+ throw new NotAllowedError(
+ 'Unknown principal type, this should never happen',
+ );
+ }
+
+ async issueUserCookie(_res: Response): Promise {
+ throw new NotImplementedError('Not implemented');
+ }
+}
diff --git a/packages/backend-test-utils/src/next/services/index.ts b/packages/backend-test-utils/src/next/services/index.ts
index 97a22567fd..7c3949e207 100644
--- a/packages/backend-test-utils/src/next/services/index.ts
+++ b/packages/backend-test-utils/src/next/services/index.ts
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { mockServices, type ServiceMock } from './mockServices';
+export { mockCredentials } from './mockCredentials';
diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts
new file mode 100644
index 0000000000..67cda92be8
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts
@@ -0,0 +1,135 @@
+/*
+ * 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 { mockCredentials } from './mockCredentials';
+
+describe('mockCredentials', () => {
+ it('creates a mocked credentials object for a none principal', () => {
+ expect(mockCredentials.none()).toEqual({
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'none' },
+ });
+ });
+
+ it('creates a mocked credentials object for a user principal', () => {
+ expect(mockCredentials.user()).toEqual({
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'user', userEntityRef: 'user:default/mock' },
+ });
+
+ expect(mockCredentials.user('user:default/other')).toEqual({
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'user', userEntityRef: 'user:default/other' },
+ });
+ });
+
+ it('creates a mocked credentials object for a service principal', () => {
+ expect(mockCredentials.service()).toEqual({
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'service', subject: 'external:test-service' },
+ });
+
+ expect(mockCredentials.service('plugin:other')).toEqual({
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'service', subject: 'plugin:other' },
+ });
+ });
+
+ it('creates unauthenticated tokens and headers', () => {
+ expect(mockCredentials.none.header()).toBe('Bearer mock-none-token');
+ });
+
+ it('creates user tokens and headers', () => {
+ expect(mockCredentials.user.token()).toBe('mock-user-token');
+ expect(mockCredentials.user.token('user:default/other')).toBe(
+ 'mock-user-token:{"sub":"user:default/other"}',
+ );
+ expect(mockCredentials.user.invalidToken()).toBe('mock-invalid-user-token');
+
+ expect(mockCredentials.user.header()).toBe('Bearer mock-user-token');
+ expect(mockCredentials.user.header('user:default/other')).toBe(
+ 'Bearer mock-user-token:{"sub":"user:default/other"}',
+ );
+ expect(mockCredentials.user.invalidHeader()).toBe(
+ 'Bearer mock-invalid-user-token',
+ );
+ });
+
+ it('creates service tokens and headers', () => {
+ expect(mockCredentials.service.token()).toBe('mock-service-token');
+ expect(
+ mockCredentials.service.token({
+ onBehalfOf: mockCredentials.service('external:other'),
+ targetPluginId: 'other',
+ }),
+ ).toBe('mock-service-token:{"sub":"external:other","target":"other"}');
+ expect(
+ mockCredentials.service.token({
+ onBehalfOf: mockCredentials.user('user:default/other'),
+ targetPluginId: 'other',
+ }),
+ ).toBe('mock-service-token:{"obo":"user:default/other","target":"other"}');
+ expect(mockCredentials.service.invalidToken()).toBe(
+ 'mock-invalid-service-token',
+ );
+
+ expect(mockCredentials.service.header()).toBe('Bearer mock-service-token');
+ expect(
+ mockCredentials.service.header({
+ onBehalfOf: mockCredentials.service('external:other'),
+ targetPluginId: 'other',
+ }),
+ ).toBe(
+ 'Bearer mock-service-token:{"sub":"external:other","target":"other"}',
+ );
+ expect(
+ mockCredentials.service.header({
+ onBehalfOf: mockCredentials.user('user:default/other'),
+ targetPluginId: 'other',
+ }),
+ ).toBe(
+ 'Bearer mock-service-token:{"obo":"user:default/other","target":"other"}',
+ );
+ expect(mockCredentials.service.invalidHeader()).toBe(
+ 'Bearer mock-invalid-service-token',
+ );
+ });
+
+ it('should throw on invalid user entity refs', () => {
+ expect(() => mockCredentials.user('wrong')).toThrow(
+ "Invalid user entity reference 'wrong', expected :/",
+ );
+ expect(() => mockCredentials.user('wr:ong')).toThrow(
+ "Invalid user entity reference 'wr:ong', expected :/",
+ );
+ expect(() => mockCredentials.user('wr/ong')).toThrow(
+ "Invalid user entity reference 'wr/ong', expected :/",
+ );
+ expect(() => mockCredentials.user('wr/o:ng')).toThrow(
+ "Invalid user entity reference 'wr/o:ng', expected :/",
+ );
+ expect(() => mockCredentials.user('wr:/ong')).toThrow(
+ "Invalid user entity reference 'wr:/ong', expected :/",
+ );
+
+ expect(() => mockCredentials.user.token('wrong')).toThrow(
+ "Invalid user entity reference 'wrong', expected :/",
+ );
+ expect(() => mockCredentials.user.header('wrong')).toThrow(
+ "Invalid user entity reference 'wrong', expected :/",
+ );
+ });
+});
diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts
new file mode 100644
index 0000000000..8dac9c1a4b
--- /dev/null
+++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts
@@ -0,0 +1,216 @@
+/*
+ * 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 {
+ BackstageCredentials,
+ BackstageNonePrincipal,
+ BackstageServicePrincipal,
+ BackstageUserPrincipal,
+} from '@backstage/backend-plugin-api';
+
+export const DEFAULT_MOCK_USER_ENTITY_REF = 'user:default/mock';
+export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service';
+
+export const MOCK_NONE_TOKEN = 'mock-none-token';
+export const MOCK_USER_TOKEN = 'mock-user-token';
+export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:';
+export const MOCK_INVALID_USER_TOKEN = 'mock-invalid-user-token';
+export const MOCK_SERVICE_TOKEN = 'mock-service-token';
+export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:';
+export const MOCK_INVALID_SERVICE_TOKEN = 'mock-invalid-service-token';
+
+function validateUserEntityRef(ref: string) {
+ if (!ref.match(/^.+:.+\/.+$/)) {
+ throw new TypeError(
+ `Invalid user entity reference '${ref}', expected :/`,
+ );
+ }
+}
+
+/**
+ * The payload that can be encoded into a mock user token.
+ * @internal
+ */
+export type UserTokenPayload = {
+ sub?: string;
+};
+
+/**
+ * The payload that can be encoded into a mock service token.
+ * @internal
+ */
+export type ServiceTokenPayload = {
+ sub?: string; // service subject
+ obo?: string; // user entity reference
+ target?: string; // target plugin id
+};
+
+/**
+ * @public
+ */
+export namespace mockCredentials {
+ /**
+ * Creates a mocked credentials object for a unauthenticated principal.
+ */
+ export function none(): BackstageCredentials {
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'none' },
+ };
+ }
+
+ /**
+ * Utilities related to none credentials.
+ */
+ export namespace none {
+ /**
+ * Returns an authorization header that translates to unauthenticated
+ * credentials.
+ *
+ * This is useful when one wants to explicitly test unauthenticated requests
+ * while still using the default behavior of the mock HttpAuthService where
+ * it defaults to user credentials.
+ */
+ export function header(): string {
+ // NOTE: there is no .token() version of this because only the
+ // HttpAuthService should know about and consume this token
+ return `Bearer ${MOCK_NONE_TOKEN}`;
+ }
+ }
+
+ /**
+ * Creates a mocked credentials object for a user principal.
+ *
+ * The default user entity reference is 'user:default/mock'.
+ */
+ export function user(
+ userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF,
+ ): BackstageCredentials {
+ validateUserEntityRef(userEntityRef);
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'user', userEntityRef },
+ };
+ }
+
+ /**
+ * Utilities related to user credentials.
+ */
+ export namespace user {
+ /**
+ * Creates a mocked user token. If a payload is provided it will be encoded
+ * into the token and forwarded to the credentials object when authenticated
+ * by the mock auth service.
+ */
+ export function token(userEntityRef?: string): string {
+ if (userEntityRef) {
+ validateUserEntityRef(userEntityRef);
+ return `${MOCK_USER_TOKEN_PREFIX}${JSON.stringify({
+ sub: userEntityRef,
+ } satisfies UserTokenPayload)}`;
+ }
+ return MOCK_USER_TOKEN;
+ }
+
+ /**
+ * Returns an authorization header with a mocked user token. If a payload is
+ * provided it will be encoded into the token and forwarded to the
+ * credentials object when authenticated by the mock auth service.
+ */
+ export function header(userEntityRef?: string): string {
+ return `Bearer ${token(userEntityRef)}`;
+ }
+
+ export function invalidToken(): string {
+ return MOCK_INVALID_USER_TOKEN;
+ }
+
+ export function invalidHeader(): string {
+ return `Bearer ${invalidToken()}`;
+ }
+ }
+
+ /**
+ * Creates a mocked credentials object for a service principal.
+ *
+ * The default subject is 'external:test-service'.
+ */
+ export function service(
+ subject: string = DEFAULT_MOCK_SERVICE_SUBJECT,
+ ): BackstageCredentials {
+ return {
+ $$type: '@backstage/BackstageCredentials',
+ principal: { type: 'service', subject },
+ };
+ }
+
+ /**
+ * Utilities related to service credentials.
+ */
+ export namespace service {
+ /**
+ * Options for the creation of mock service tokens.
+ */
+ export type TokenOptions = {
+ onBehalfOf: BackstageCredentials;
+ targetPluginId: string;
+ };
+
+ /**
+ * Creates a mocked service token. The provided options will be encoded into
+ * the token and forwarded to the credentials object when authenticated by
+ * the mock auth service.
+ */
+ export function token(options?: TokenOptions): string {
+ if (options) {
+ const { targetPluginId, onBehalfOf } = options; // for fixed ordering
+
+ const oboPrincipal = onBehalfOf?.principal as
+ | BackstageServicePrincipal
+ | BackstageUserPrincipal
+ | BackstageNonePrincipal;
+ const obo =
+ oboPrincipal.type === 'user' ? oboPrincipal.userEntityRef : undefined;
+ const subject =
+ oboPrincipal.type === 'service' ? oboPrincipal.subject : undefined;
+
+ return `${MOCK_SERVICE_TOKEN_PREFIX}${JSON.stringify({
+ sub: subject,
+ obo,
+ target: targetPluginId,
+ } satisfies ServiceTokenPayload)}`;
+ }
+ return MOCK_SERVICE_TOKEN;
+ }
+
+ /**
+ * Returns an authorization header with a mocked service token. The provided
+ * options will be encoded into the token and forwarded to the credentials
+ * object when authenticated by the mock auth service.
+ */
+ export function header(options?: TokenOptions): string {
+ return `Bearer ${token(options)}`;
+ }
+
+ export function invalidToken(): string {
+ return MOCK_INVALID_SERVICE_TOKEN;
+ }
+
+ export function invalidHeader(): string {
+ return `Bearer ${invalidToken()}`;
+ }
+ }
+}
diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts
index bd684f7560..f72f229434 100644
--- a/packages/backend-test-utils/src/next/services/mockServices.ts
+++ b/packages/backend-test-utils/src/next/services/mockServices.ts
@@ -23,6 +23,10 @@ import {
ServiceFactory,
ServiceRef,
TokenManagerService,
+ AuthService,
+ DiscoveryService,
+ HttpAuthService,
+ BackstageCredentials,
} from '@backstage/backend-plugin-api';
import {
cacheServiceFactory,
@@ -35,11 +39,16 @@ import {
rootLifecycleServiceFactory,
schedulerServiceFactory,
urlReaderServiceFactory,
+ discoveryServiceFactory,
+ HostDiscovery,
} from '@backstage/backend-app-api';
import { ConfigReader } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { MockIdentityService } from './MockIdentityService';
import { MockRootLoggerService } from './MockRootLoggerService';
+import { MockAuthService } from './MockAuthService';
+import { MockHttpAuthService } from './MockHttpAuthService';
+import { mockCredentials } from './mockCredentials';
/** @internal */
function simpleFactory<
@@ -160,6 +169,109 @@ export namespace mockServices {
}));
}
+ export function auth(options?: {
+ pluginId?: string;
+ disableDefaultAuthPolicy?: boolean;
+ }): AuthService {
+ return new MockAuthService({
+ pluginId: options?.pluginId ?? 'test',
+ disableDefaultAuthPolicy: Boolean(options?.disableDefaultAuthPolicy),
+ });
+ }
+ export namespace auth {
+ export const factory = createServiceFactory({
+ service: coreServices.auth,
+ deps: {
+ plugin: coreServices.pluginMetadata,
+ config: coreServices.rootConfig,
+ },
+ factory({ plugin, config }) {
+ const disableDefaultAuthPolicy = Boolean(
+ config.getOptionalBoolean(
+ 'backend.auth.dangerouslyDisableDefaultAuthPolicy',
+ ),
+ );
+ return new MockAuthService({
+ pluginId: plugin.getId(),
+ disableDefaultAuthPolicy,
+ });
+ },
+ });
+ export const mock = simpleMock(coreServices.auth, () => ({
+ authenticate: jest.fn(),
+ getOwnServiceCredentials: jest.fn(),
+ isPrincipal: jest.fn() as any,
+ getPluginRequestToken: jest.fn(),
+ }));
+ }
+
+ export function discovery(): DiscoveryService {
+ return HostDiscovery.fromConfig(
+ new ConfigReader({
+ backend: {
+ // Invalid port to make sure that requests are always mocked
+ baseUrl: 'http://localhost:0',
+ listen: { port: 0 },
+ },
+ }),
+ );
+ }
+ export namespace discovery {
+ export const factory = discoveryServiceFactory;
+ export const mock = simpleMock(coreServices.discovery, () => ({
+ getBaseUrl: jest.fn(),
+ getExternalBaseUrl: jest.fn(),
+ }));
+ }
+
+ /**
+ * Creates a mock implementation of the `HttpAuthService`.
+ *
+ * By default all requests without credentials are treated as requests from
+ * the default mock user principal. This behavior can be configured with the
+ * `defaultCredentials` option.
+ */
+ export function httpAuth(options?: {
+ pluginId?: string;
+ /**
+ * The default credentials to use if there are no credentials present in the
+ * incoming request.
+ *
+ * By default all requests without credentials are treated as authenticated
+ * as the default mock user as returned from `mockCredentials.user()`.
+ */
+ defaultCredentials?: BackstageCredentials;
+ }): HttpAuthService {
+ return new MockHttpAuthService(
+ options?.pluginId ?? 'test',
+ options?.defaultCredentials ?? mockCredentials.user(),
+ );
+ }
+ export namespace httpAuth {
+ /**
+ * Creates a mock service factory for the `HttpAuthService`.
+ *
+ * By default all requests without credentials are treated as requests from
+ * the default mock user principal. This behavior can be configured with the
+ * `defaultCredentials` option.
+ */
+ export const factory = createServiceFactory(
+ (options?: { defaultCredentials?: BackstageCredentials }) => ({
+ service: coreServices.httpAuth,
+ deps: { plugin: coreServices.pluginMetadata },
+ factory: ({ plugin }) =>
+ new MockHttpAuthService(
+ plugin.getId(),
+ options?.defaultCredentials ?? mockCredentials.user(),
+ ),
+ }),
+ );
+ export const mock = simpleMock(coreServices.httpAuth, () => ({
+ credentials: jest.fn(),
+ issueUserCookie: jest.fn(),
+ }));
+ }
+
// TODO(Rugvip): Not all core services have implementations available here yet.
// some may need a bit more refactoring for it to be simpler to
// re-implement functioning mock versions here.
@@ -182,6 +294,7 @@ export namespace mockServices {
export const factory = httpRouterServiceFactory;
export const mock = simpleMock(coreServices.httpRouter, () => ({
use: jest.fn(),
+ addAuthPolicy: jest.fn(),
}));
}
export namespace rootHttpRouter {
diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts
index 968237f11d..a4b74b5ffa 100644
--- a/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts
+++ b/packages/backend-test-utils/src/next/wiring/TestBackend.test.ts
@@ -26,6 +26,7 @@ import { Router } from 'express';
import request from 'supertest';
import { startTestBackend } from './TestBackend';
+import { mockCredentials } from '../services';
// This bit makes sure that test backends are cleaned up properly
let globalTestBackendHasBeenStopped = false;
@@ -199,9 +200,11 @@ describe('TestBackend', () => {
scheduler: coreServices.scheduler,
tokenManager: coreServices.tokenManager,
urlReader: coreServices.urlReader,
+ auth: coreServices.auth,
+ httpAuth: coreServices.httpAuth,
},
async init(deps) {
- expect(Object.keys(deps)).toHaveLength(15);
+ expect(Object.keys(deps)).toHaveLength(17);
expect(Object.values(deps)).not.toContain(undefined);
},
});
@@ -232,7 +235,9 @@ describe('TestBackend', () => {
const { server } = await startTestBackend({ features: [testPlugin()] });
- const res = await request(server).get('/api/test/ping-me');
+ const res = await request(server)
+ .get('/api/test/ping-me')
+ .set('authorization', mockCredentials.user.header());
expect(res.status).toEqual(200);
expect(res.body).toEqual({ message: 'pong' });
});
diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts
index 14e8920390..72a2ed8edb 100644
--- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts
+++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts
@@ -66,9 +66,11 @@ export interface TestBackend extends Backend {
}
export const defaultServiceFactories = [
+ mockServices.auth.factory(),
mockServices.cache.factory(),
mockServices.rootConfig.factory(),
mockServices.database.factory(),
+ mockServices.httpAuth.factory(),
mockServices.httpRouter.factory(),
mockServices.identity.factory(),
mockServices.lifecycle.factory(),
diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts
index 6d69606316..d3dcc83bb0 100644
--- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts
+++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts
@@ -102,6 +102,12 @@ describe('resourcesRoutes', () => {
},
],
},
+ backend: {
+ auth: {
+ // TODO: Remove once migrated to support new auth services
+ dangerouslyDisableDefaultAuthPolicy: true,
+ },
+ },
},
}),
import('@backstage/plugin-kubernetes-backend/alpha'),
diff --git a/scripts/verify-links.js b/scripts/verify-links.js
index f685af191b..94ffc497cd 100755
--- a/scripts/verify-links.js
+++ b/scripts/verify-links.js
@@ -78,6 +78,9 @@ async function verifyUrl(basePath, absUrl, docPages) {
}
if (basePath.startsWith('.changeset/')) {
+ if (absUrl.match(/^https?:\/\//)) {
+ return undefined;
+ }
return { url, basePath, problem: 'out-of-changeset' };
}
diff --git a/yarn.lock b/yarn.lock
index e291690997..922409280e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3244,18 +3244,21 @@ __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
fs-extra: ^11.2.0
helmet: ^6.0.0
http-errors: ^2.0.0
+ jose: ^5.0.0
lodash: ^4.17.21
logform: ^2.3.2
minimatch: ^9.0.0
minimist: ^1.2.5
morgan: ^1.10.0
node-forge: ^1.3.1
+ path-to-regexp: ^6.2.1
selfsigned: ^2.0.0
stoppable: ^1.1.0
supertest: ^6.1.3
@@ -3284,6 +3287,7 @@ __metadata:
"@backstage/errors": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/integration-aws-node": "workspace:^"
+ "@backstage/plugin-auth-node": "workspace:^"
"@backstage/types": "workspace:^"
"@google-cloud/storage": ^7.0.0
"@keyv/memcache": ^1.3.5
@@ -23972,7 +23976,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
@@ -27373,9 +27377,12 @@ __metadata:
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
+ "@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-adr-backend": "workspace:^"
"@backstage/plugin-app-backend": "workspace:^"
+ "@backstage/plugin-auth-backend": "workspace:^"
+ "@backstage/plugin-auth-backend-module-github-provider": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-azure-devops-backend": "workspace:^"
"@backstage/plugin-badges-backend": "workspace:^"
@@ -37528,10 +37535,10 @@ __metadata:
languageName: node
linkType: hard
-"path-to-regexp@npm:^6.2.0":
- version: 6.2.0
- resolution: "path-to-regexp@npm:6.2.0"
- checksum: a6aca74d2d6e2e7594d812f653cf85e9cb5054d3a8d80f099722a44ef6ad22639b02078e5ea83d11db16321c3e4359e3f1ab0274fa78dad0754a6e53f630b0fc
+"path-to-regexp@npm:^6.2.0, path-to-regexp@npm:^6.2.1":
+ version: 6.2.1
+ resolution: "path-to-regexp@npm:6.2.1"
+ checksum: f0227af8284ea13300f4293ba111e3635142f976d4197f14d5ad1f124aebd9118783dd2e5f1fe16f7273743cc3dbeddfb7493f237bb27c10fdae07020cc9b698
languageName: node
linkType: hard