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