From c5a7cf4a4f635560a8eaea529ec4f9608b705a80 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Feb 2024 15:01:19 +0100 Subject: [PATCH 01/42] backend-{app,plugin}-api: add initial AuthService interface + implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/api-report.md | 4 + packages/backend-app-api/package.json | 1 + .../auth/authServiceFactory.test.ts | 110 ++++++++++++ .../auth/authServiceFactory.ts | 157 ++++++++++++++++++ .../services/implementations/auth/index.ts | 17 ++ .../src/services/implementations/index.ts | 1 + packages/backend-plugin-api/api-report.md | 30 ++++ .../src/services/definitions/AuthService.ts | 54 ++++++ .../src/services/definitions/coreServices.ts | 9 + .../src/services/definitions/index.ts | 6 + yarn.lock | 1 + 11 files changed, 390 insertions(+) create mode 100644 packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts create mode 100644 packages/backend-app-api/src/services/implementations/auth/index.ts create mode 100644 packages/backend-plugin-api/src/services/definitions/AuthService.ts diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index f41a38de7b..6836ec40a0 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'; @@ -41,6 +42,9 @@ import { TokenManagerService } from '@backstage/backend-plugin-api'; import { transport } from 'winston'; import { UrlReader } from '@backstage/backend-common'; +// @public (undocumented) +export const authServiceFactory: () => ServiceFactory; + // @public (undocumented) export interface Backend { // (undocumented) diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 66393853db..e899e5a075 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -65,6 +65,7 @@ "express-promise-router": "^4.1.0", "fs-extra": "^11.2.0", "helmet": "^6.0.0", + "jose": "^4.6.0", "lodash": "^4.17.21", "logform": "^2.3.2", "minimatch": "^5.0.0", 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..9dc64212de --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.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 { + ServiceFactoryTester, + mockServices, +} from '@backstage/backend-test-utils'; +import { + InternalBackstageServiceCredentials, + InternalBackstageUserCredentials, + authServiceFactory, +} from './authServiceFactory'; +import { decodeJwt } from 'jose'; +import { discoveryServiceFactory } from '../discovery'; + +// 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.issueServiceToken(); + + await expect(searchAuth.authenticate(searchToken)).resolves.toEqual( + expect.objectContaining({ + type: 'service', + subject: 'external:backstage-plugin', + }), + ); + await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual( + expect.objectContaining({ + 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.issueServiceToken({ + forward: { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + type: 'user', + userEntityRef: 'user:default/alice', + token: 'alice-token', + } as InternalBackstageUserCredentials, + }), + ).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.issueServiceToken({ + forward: { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + type: 'service', + subject: 'external:backstage-plugin', + token: 'some-upstream-service-token', + } as InternalBackstageServiceCredentials, + }); + + 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..a21829b228 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -0,0 +1,157 @@ +/* + * 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, + BackstageServiceCredentials, + BackstageUserCredentials, + 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 InternalBackstageServiceCredentials = + BackstageServiceCredentials & { + version: string; + token: string; + }; + +function createServiceCredentials( + sub: string, + token: string, +): BackstageServiceCredentials { + return { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token, + type: 'service', + subject: sub, + } as InternalBackstageServiceCredentials; +} + +/** @internal */ +export type InternalBackstageUserCredentials = BackstageUserCredentials & { + version: string; + token: string; +}; + +function createUserCredentials( + sub: string, + token: string, +): BackstageUserCredentials { + return { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token, + type: 'user', + userEntityRef: sub, + } as InternalBackstageUserCredentials; +} + +export function toInternalBackstageCredentials( + credentials: BackstageCredentials, +): InternalBackstageServiceCredentials | InternalBackstageUserCredentials { + if (credentials.$$type !== '@backstage/BackstageCredentials') { + throw new Error('Invalid credential type'); + } + const internalCredentials = credentials as + | InternalBackstageServiceCredentials + | InternalBackstageUserCredentials; + 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, + ) {} + + 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 createServiceCredentials('external:backstage-plugin', token); + } + + // User Backstage token + const identity = await this.identity.getIdentity({ + request: { + headers: { authorization: `Bearer ${token}` }, + }, + } as IdentityApiGetIdentityRequest); + + if (!identity) { + throw new AuthenticationError('No identity found'); + } + + return createUserCredentials(identity.identity.userEntityRef, token); + } + + async issueServiceToken(options?: { + forward?: BackstageCredentials; + }): Promise<{ token: string }> { + const internalForward = + options?.forward && toInternalBackstageCredentials(options.forward); + + if (internalForward) { + const { type } = internalForward; + if (type === 'user') { + return { token: internalForward.token }; + } else if (type !== 'service') { + throw new AuthenticationError( + `Refused to issue service token for credential type '${type}'`, + ); + } + } + + const { token } = await this.tokenManager.getToken(); + return { token }; + } +} + +/** @public */ +export const authServiceFactory = createServiceFactory({ + service: coreServices.auth, + deps: { + config: coreServices.rootConfig, + logger: coreServices.rootLogger, + discovery: coreServices.discovery, + }, + createRootContext({ config, logger }) { + return ServerTokenManager.fromConfig(config, { logger }); + }, + async factory({ discovery }, tokenManager) { + const identity = DefaultIdentityClient.create({ discovery }); + return new DefaultAuthService(tokenManager, identity); + }, +}); 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/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 57042951d9..9d42dbfef7 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './auth'; export * from './cache'; export * from './config'; export * from './database'; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 3b3d28cc27..52f52afaa9 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -15,6 +15,16 @@ import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { Readable } from 'stream'; +// @public (undocumented) +export interface AuthService { + // (undocumented) + authenticate(token: string): Promise; + // (undocumented) + issueServiceToken(options?: { forward?: BackstageCredentials }): Promise<{ + token: string; + }>; +} + // @public (undocumented) export interface BackendFeature { // (undocumented) @@ -76,6 +86,25 @@ export interface BackendPluginRegistrationPoints { }): void; } +// @public (undocumented) +export type BackstageCredentials = + | BackstageUserCredentials + | BackstageServiceCredentials; + +// @public (undocumented) +export type BackstageServiceCredentials = { + $$type: '@backstage/BackstageCredentials'; + type: 'service'; + subject: string; +}; + +// @public (undocumented) +export type BackstageUserCredentials = { + $$type: '@backstage/BackstageCredentials'; + type: 'user'; + userEntityRef: string; +}; + // @public export interface CacheService { delete(key: string): Promise; @@ -100,6 +129,7 @@ export type CacheServiceSetOptions = { // @public export namespace coreServices { + const auth: ServiceRef; const cache: ServiceRef; const rootConfig: ServiceRef; const database: ServiceRef; 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..d4ab8456a8 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -0,0 +1,54 @@ +/* + * 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 BackstageUserCredentials = { + $$type: '@backstage/BackstageCredentials'; + + type: 'user'; + + userEntityRef: string; +}; + +/** + * @public + */ +export type BackstageServiceCredentials = { + $$type: '@backstage/BackstageCredentials'; + + type: 'service'; + + subject: string; +}; + +/** + * @public + */ +export type BackstageCredentials = + | BackstageUserCredentials + | BackstageServiceCredentials; + +/** + * @public + */ +export interface AuthService { + authenticate(token: string): Promise; + issueServiceToken(options?: { + forward?: BackstageCredentials; + }): Promise<{ token: string }>; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index b42795d052..561a587e5e 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -22,6 +22,15 @@ import { createServiceRef } from '../system'; * @public */ export namespace coreServices { + /** + * The service reference for the plugin scoped {@link IdentityService}. + * + * @public + */ + export const auth = createServiceRef({ + id: 'core.auth', + }); + /** * The service reference for the plugin scoped {@link CacheService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 98be8211db..8a53399be6 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -15,6 +15,12 @@ */ export { coreServices } from './coreServices'; +export type { + AuthService, + BackstageCredentials, + BackstageServiceCredentials, + BackstageUserCredentials, +} from './AuthService'; export type { CacheService, CacheServiceOptions, diff --git a/yarn.lock b/yarn.lock index ef624e232e..555b683cd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3250,6 +3250,7 @@ __metadata: fs-extra: ^11.2.0 helmet: ^6.0.0 http-errors: ^2.0.0 + jose: ^4.6.0 lodash: ^4.17.21 logform: ^2.3.2 minimatch: ^5.0.0 From 9e45e2af6da0c2e4401f689f6cf20afc9f4569fe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Feb 2024 13:02:13 +0100 Subject: [PATCH 02/42] backend-{plugin,app}-api: add HttpAuthService + initial implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/api-report.md | 7 + packages/backend-app-api/package.json | 1 + .../httpAuth/httpAuthServiceFactory.ts | 176 ++++++++++++++++++ .../implementations/httpAuth/index.ts | 17 ++ .../httpRouter/httpRouterServiceFactory.ts | 14 +- .../src/services/implementations/index.ts | 1 + packages/backend-plugin-api/api-report.md | 36 ++++ .../services/definitions/HttpAuthService.ts | 56 ++++++ .../src/services/definitions/coreServices.ts | 9 + .../src/services/definitions/index.ts | 5 + yarn.lock | 3 +- 11 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts create mode 100644 packages/backend-app-api/src/services/implementations/httpAuth/index.ts create mode 100644 packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index 6836ec40a0..e4a3d3d6de 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -19,6 +19,7 @@ import { Format } from 'logform'; import { Handler } from 'express'; import { HelmetOptions } from 'helmet'; import * as http from 'http'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { HttpRouterService } from '@backstage/backend-plugin-api'; import { HumanDuration } from '@backstage/types'; import { IdentityService } from '@backstage/backend-plugin-api'; @@ -148,6 +149,12 @@ export class HostDiscovery implements DiscoveryService { getExternalBaseUrl(pluginId: string): Promise; } +// @public (undocumented) +export const httpAuthServiceFactory: () => ServiceFactory< + HttpAuthService, + 'plugin' +>; + // @public (undocumented) export interface HttpRouterFactoryOptions { getPath?(pluginId: string): string; diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index e899e5a075..52378fef37 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -60,6 +60,7 @@ "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", + "cookie": "^0.6.0", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", 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..e2b60e31b2 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthService, + BackstageCredentialTypes, + BackstageCredentials, + BackstageUnauthorizedCredentials, + DiscoveryService, + HttpAuthService, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { parse as parseCookie } from 'cookie'; +import { Handler, Request, Response } from 'express'; +import { decodeJwt } from 'jose'; +import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; + +const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; + +function getTokenFromRequest(req: Request) { + // TODO: support multiple auth headers (iterate rawHeaders) + const authHeader = req.headers.authorization; + if (typeof authHeader === 'string') { + const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i); + const token = matches?.[1]; + if (token) { + return { token, isCookie: false }; + } + } + + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const cookies = parseCookie(cookieHeader); + const token = cookies[BACKSTAGE_AUTH_COOKIE]; + if (token) { + return { token, isCookie: true }; + } + } + + return { token: undefined, isCookie: false }; +} + +const credentialsSymbol = Symbol('backstage-credentials'); +// TODO: This is temporary and should be removed once we have proper cookie handling in place +const isCookieSymbol = Symbol('backstage-is-cookie-credentials'); + +type RequestWithCredentials = Request & { + [credentialsSymbol]?: BackstageCredentials | BackstageUnauthorizedCredentials; + [isCookieSymbol]?: boolean; +}; + +function createUnauthorizedCredentials(): BackstageUnauthorizedCredentials { + return { + $$type: '@backstage/BackstageCredentials', + type: 'unauthorized', + }; +} + +class DefaultHttpAuthService implements HttpAuthService { + constructor( + private readonly auth: AuthService, + private readonly discovery: DiscoveryService, + private readonly pluginId: string, + ) {} + + createHttpPluginRouterMiddleware(): Handler { + return async (req: RequestWithCredentials, _res, next) => { + try { + const { token, isCookie } = getTokenFromRequest(req); + // TODO: Is this where we match against configured rules? + if (!token) { + req[credentialsSymbol] = createUnauthorizedCredentials(); + } else { + req[credentialsSymbol] = await this.auth.authenticate(token); + req[isCookieSymbol] = isCookie; + } + next(); + } catch (e) { + next(e); + } + }; + } + + async credentials( + req: RequestWithCredentials, + options: { + allow: TAllowed[]; + }, + ): Promise { + const credentials = req[credentialsSymbol]; + if (!credentials) { + throw new Error('Internal error, no credentials found on request'); + } + + if (credentials.type === 'user' && req[isCookieSymbol]) { + if (options.allow.includes('user-cookie' as TAllowed)) { + return credentials as BackstageCredentialTypes[TAllowed]; + } + throw new NotAllowedError( + `This endpoint does not allow 'user-cookie' credentials`, + ); + } + + if (!options.allow.includes(credentials.type as TAllowed)) { + throw new NotAllowedError( + `This endpoint does not allow '${credentials.type}' credentials`, + ); + } + + return credentials as BackstageCredentialTypes[TAllowed]; + } + + async requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise> { + return { + Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, + }; + } + + async issueUserCookie(res: Response): Promise { + const credentials = await this.credentials(res.req, { allow: ['user'] }); + + // https://backstage.spotify.net/api/catalog + const externalBaseUrlStr = await this.discovery.getExternalBaseUrl( + this.pluginId, + ); + const externalBaseUrl = new URL(externalBaseUrlStr); + + const { token } = toInternalBackstageCredentials(credentials); + + // TODO: Proper refresh and expiration handling + const expires = decodeJwt(token).exp!; + + // TODO: refresh this thing + res.cookie(BACKSTAGE_AUTH_COOKIE, token, { + domain: externalBaseUrl.hostname, + httpOnly: true, + expires: new Date(expires * 1000), + path: externalBaseUrl.pathname, + priority: 'high', + sameSite: 'lax', // TBD + }); + throw new Error('Method not implemented.'); + } +} + +/** @public */ +export const httpAuthServiceFactory = createServiceFactory({ + service: coreServices.httpAuth, + deps: { + config: coreServices.rootConfig, + logger: coreServices.rootLogger, + discovery: coreServices.discovery, + auth: coreServices.auth, + plugin: coreServices.pluginMetadata, + }, + async factory({ auth, discovery, plugin }) { + return new DefaultHttpAuthService(auth, discovery, plugin.getId()); + }, +}); 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/httpRouterServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts index 2ecdeffd77..710accbadb 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 @@ -40,20 +42,28 @@ export const httpRouterServiceFactory = createServiceFactory( plugin: coreServices.pluginMetadata, lifecycle: coreServices.lifecycle, rootHttpRouter: coreServices.rootHttpRouter, + httpAuth: coreServices.httpAuth, }, - async factory({ plugin, rootHttpRouter, lifecycle }) { + async factory({ httpAuth, plugin, rootHttpRouter, lifecycle }) { const getPath = options?.getPath ?? (id => `/api/${id}`); const path = getPath(plugin.getId()); const router = PromiseRouter(); rootHttpRouter.use(path, router); + const credentialsBarrier = createCredentialsBarrier({ httpAuth }); + router.use(createLifecycleMiddleware({ lifecycle })); + router.use(httpAuth.createHttpPluginRouterMiddleware()); + router.use(credentialsBarrier.middleware); return { - use(handler: Handler) { + use(handler: Handler): void { router.use(handler); }, + addAuthPolicy(policy: HttpRouterServiceAuthPolicy): void { + credentialsBarrier.addAuthPolicy(policy); + }, }; }, }), diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index 9d42dbfef7..e038a13224 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -19,6 +19,7 @@ export * from './cache'; export * from './config'; export * from './database'; export * from './discovery'; +export * from './httpAuth'; export * from './httpRouter'; export * from './identity'; export * from './lifecycle'; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 52f52afaa9..68dff04e48 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -14,6 +14,8 @@ import { Knex } from 'knex'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { Readable } from 'stream'; +import { Request as Request_2 } from 'express'; +import { Response as Response_2 } from 'express'; // @public (undocumented) export interface AuthService { @@ -91,6 +93,14 @@ export type BackstageCredentials = | BackstageUserCredentials | BackstageServiceCredentials; +// @public (undocumented) +export type BackstageCredentialTypes = { + user: BackstageUserCredentials; + 'user-cookie': BackstageUserCredentials; + service: BackstageServiceCredentials; + unauthorized: BackstageUnauthorizedCredentials; +}; + // @public (undocumented) export type BackstageServiceCredentials = { $$type: '@backstage/BackstageCredentials'; @@ -98,6 +108,12 @@ export type BackstageServiceCredentials = { subject: string; }; +// @public (undocumented) +export type BackstageUnauthorizedCredentials = { + $$type: '@backstage/BackstageCredentials'; + type: 'unauthorized'; +}; + // @public (undocumented) export type BackstageUserCredentials = { $$type: '@backstage/BackstageCredentials'; @@ -134,6 +150,7 @@ export namespace coreServices { const rootConfig: ServiceRef; const database: ServiceRef; const discovery: ServiceRef; + const httpAuth: ServiceRef; const httpRouter: ServiceRef; const lifecycle: ServiceRef; const logger: ServiceRef; @@ -252,6 +269,25 @@ export interface ExtensionPointConfig { id: string; } +// @public (undocumented) +export interface HttpAuthService { + // (undocumented) + createHttpPluginRouterMiddleware(): Handler; + // (undocumented) + credentials( + req: Request_2, + options: { + allow: Array; + }, + ): Promise; + // (undocumented) + issueUserCookie(res: Response_2): Promise; + // (undocumented) + requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise>; +} + // @public (undocumented) export interface HttpRouterService { // (undocumented) 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..817ca8691b --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Request, Response, Handler } from 'express'; +import { + BackstageUserCredentials, + BackstageServiceCredentials, + BackstageCredentials, +} from './AuthService'; + +/** @public */ +export type BackstageUnauthorizedCredentials = { + $$type: '@backstage/BackstageCredentials'; + + type: 'unauthorized'; +}; + +/** @public */ +export type BackstageCredentialTypes = { + user: BackstageUserCredentials; + 'user-cookie': BackstageUserCredentials; + service: BackstageServiceCredentials; + unauthorized: BackstageUnauthorizedCredentials; +}; + +/** @public */ +export interface HttpAuthService { + createHttpPluginRouterMiddleware(): Handler; + + credentials( + req: Request, + options: { + allow: Array; + }, + ): Promise; + + // TODO: Keep an eye on this, might not be needed + requestHeaders(options?: { + forward?: BackstageCredentials; + }): Promise>; + + issueUserCookie(res: Response): Promise; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 561a587e5e..c9cadd84c8 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -67,6 +67,15 @@ export namespace coreServices { import('./DiscoveryService').DiscoveryService >({ id: 'core.discovery' }); + /** + * The service reference for the plugin scoped {@link HttpAuthService}. + * + * @public + */ + export const httpAuth = createServiceRef< + import('./HttpAuthService').HttpAuthService + >({ id: 'core.httpAuth' }); + /** * The service reference for the plugin scoped {@link HttpRouterService}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 8a53399be6..843269c3ee 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -30,6 +30,11 @@ export type { RootConfigService } from './RootConfigService'; export type { DatabaseService } from './DatabaseService'; export type { DiscoveryService } from './DiscoveryService'; export type { HttpRouterService } from './HttpRouterService'; +export type { + HttpAuthService, + BackstageCredentialTypes, + BackstageUnauthorizedCredentials, +} from './HttpAuthService'; export type { LifecycleService, LifecycleServiceStartupHook, diff --git a/yarn.lock b/yarn.lock index 555b683cd0..202aa50d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3244,6 +3244,7 @@ __metadata: "@types/node-forge": ^1.3.0 "@types/stoppable": ^1.1.0 compression: ^1.7.4 + cookie: ^0.6.0 cors: ^2.8.5 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -23958,7 +23959,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.6.0, cookie@npm:~0.6.0": +"cookie@npm:0.6.0, cookie@npm:^0.6.0, cookie@npm:~0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" checksum: f56a7d32a07db5458e79c726b77e3c2eff655c36792f2b6c58d351fb5f61531e5b1ab7f46987150136e366c65213cbe31729e02a3eaed630c3bf7334635fb410 From 746701e094f341b47732a9f8ddbacd5dba3dd70e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Feb 2024 13:03:06 +0100 Subject: [PATCH 03/42] backend-{plugin,app}-api: add UserInfoService + initial implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/api-report.md | 7 +++ .../src/services/implementations/index.ts | 1 + .../implementations/userInfo/index.ts | 17 ++++++ .../userInfo/userInfoServiceFactory.ts | 58 +++++++++++++++++++ packages/backend-plugin-api/api-report.md | 17 ++++++ .../services/definitions/UserInfoService.ts | 30 ++++++++++ .../src/services/definitions/coreServices.ts | 11 ++++ .../src/services/definitions/index.ts | 1 + 8 files changed, 142 insertions(+) create mode 100644 packages/backend-app-api/src/services/implementations/userInfo/index.ts create mode 100644 packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts create mode 100644 packages/backend-plugin-api/src/services/definitions/UserInfoService.ts diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md index e4a3d3d6de..52780c6729 100644 --- a/packages/backend-app-api/api-report.md +++ b/packages/backend-app-api/api-report.md @@ -42,6 +42,7 @@ 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; @@ -333,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/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts index e038a13224..a1114ab3e3 100644 --- a/packages/backend-app-api/src/services/implementations/index.ts +++ b/packages/backend-app-api/src/services/implementations/index.ts @@ -31,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..a177f2442b --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts @@ -0,0 +1,58 @@ +/* + * 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 { + BackstageUserCredentials, + UserInfoService, + BackstageUserInfo, + coreServices, + createServiceFactory, +} 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: BackstageUserCredentials, + ): Promise { + const internalCredentials = toInternalBackstageCredentials(credentials); + if (internalCredentials.type !== 'user') { + throw new Error('Only user credentials are supported'); + } + 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-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 68dff04e48..d49eefd8f2 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -121,6 +121,14 @@ export type BackstageUserCredentials = { userEntityRef: string; }; +// @public (undocumented) +export interface BackstageUserInfo { + // (undocumented) + ownershipEntityRefs: string[]; + // (undocumented) + userEntityRef: string; +} + // @public export interface CacheService { delete(key: string): Promise; @@ -146,6 +154,7 @@ export type CacheServiceSetOptions = { // @public export namespace coreServices { const auth: ServiceRef; + const userInfo: ServiceRef; const cache: ServiceRef; const rootConfig: ServiceRef; const database: ServiceRef; @@ -524,4 +533,12 @@ export interface UrlReaderService { readUrl(url: string, options?: ReadUrlOptions): Promise; search(url: string, options?: SearchOptions): Promise; } + +// @public (undocumented) +export interface UserInfoService { + // (undocumented) + getUserInfo( + credentials: BackstageUserCredentials, + ): Promise; +} ``` 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..b789116ffb --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts @@ -0,0 +1,30 @@ +/* + * 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 { BackstageUserCredentials } from './AuthService'; + +/** @public */ +export interface BackstageUserInfo { + userEntityRef: string; + ownershipEntityRefs: string[]; +} + +/** @public */ +export interface UserInfoService { + getUserInfo( + credentials: BackstageUserCredentials, + ): Promise; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index c9cadd84c8..a7c9d09445 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -31,6 +31,17 @@ export namespace coreServices { 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}. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 843269c3ee..294bed1eb1 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -62,4 +62,5 @@ export type { SearchResponseFile, UrlReaderService, } from './UrlReaderService'; +export type { BackstageUserInfo, UserInfoService } from './UserInfoService'; export type { IdentityService } from './IdentityService'; From 344a82b4e5d52afba86a661f7133b035b4c8bd23 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Feb 2024 13:04:48 +0100 Subject: [PATCH 04/42] backend-{plugin,app}-api: added addAuthPolicy to HttpRouterService + implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/package.json | 1 + .../httpRouter/createCredentialsBarrier.ts | 89 +++++++++++++++++++ packages/backend-plugin-api/api-report.md | 10 +++ .../services/definitions/HttpRouterService.ts | 8 ++ .../src/services/definitions/index.ts | 5 +- .../src/next/services/mockServices.ts | 1 + yarn.lock | 9 +- 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 52378fef37..d5985d1172 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -73,6 +73,7 @@ "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/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts new file mode 100644 index 0000000000..c502dc25e5 --- /dev/null +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -0,0 +1,89 @@ +/* + * 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, +} 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; +}): { + middleware: RequestHandler; + addAuthPolicy: (policy: HttpRouterServiceAuthPolicy) => void; +} { + const { httpAuth } = options; + + 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), + ); + + if (allowsCookie) { + // don't we need a user-cookie allow type here? + await httpAuth.credentials(req, { + allow: ['user-cookie', 'user', 'service'], + }); + next(); + return; + } + + await httpAuth.credentials(req, { + allow: ['user', 'service'], + }); + 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)); + } + + throw new Error('Invalid auth policy'); + }; + + return { middleware, addAuthPolicy }; +} diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index d49eefd8f2..302a3d37d4 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -299,10 +299,20 @@ export interface HttpAuthService { // @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 {} 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/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 294bed1eb1..420c722a4d 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -29,7 +29,10 @@ 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, BackstageCredentialTypes, diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index bd684f7560..78f59b53c4 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -182,6 +182,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/yarn.lock b/yarn.lock index 202aa50d88..6d841a09db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3258,6 +3258,7 @@ __metadata: 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 @@ -37508,10 +37509,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 From 6a685868ffd6dc11dd10c46bd554e3c34caa8386 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Feb 2024 11:16:25 +0100 Subject: [PATCH 05/42] backend-plugin-api: refactor to remove createHttpPluginRouterMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../httpAuth/httpAuthServiceFactory.ts | 45 ++++++++----------- .../httpRouter/httpRouterServiceFactory.ts | 1 - .../services/definitions/HttpAuthService.ts | 4 +- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index e2b60e31b2..02b9975a47 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -26,7 +26,7 @@ import { } from '@backstage/backend-plugin-api'; import { NotAllowedError } from '@backstage/errors'; import { parse as parseCookie } from 'cookie'; -import { Handler, Request, Response } from 'express'; +import { Request, Response } from 'express'; import { decodeJwt } from 'jose'; import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; @@ -56,12 +56,13 @@ function getTokenFromRequest(req: Request) { } const credentialsSymbol = Symbol('backstage-credentials'); -// TODO: This is temporary and should be removed once we have proper cookie handling in place -const isCookieSymbol = Symbol('backstage-is-cookie-credentials'); type RequestWithCredentials = Request & { - [credentialsSymbol]?: BackstageCredentials | BackstageUnauthorizedCredentials; - [isCookieSymbol]?: boolean; + [credentialsSymbol]?: Promise<{ + credentials: BackstageCredentials | BackstageUnauthorizedCredentials; + // TODO: This is temporary and should be removed once we have proper cookie handling in place + isCookie: boolean; + }>; }; function createUnauthorizedCredentials(): BackstageUnauthorizedCredentials { @@ -78,22 +79,17 @@ class DefaultHttpAuthService implements HttpAuthService { private readonly pluginId: string, ) {} - createHttpPluginRouterMiddleware(): Handler { - return async (req: RequestWithCredentials, _res, next) => { - try { - const { token, isCookie } = getTokenFromRequest(req); - // TODO: Is this where we match against configured rules? - if (!token) { - req[credentialsSymbol] = createUnauthorizedCredentials(); - } else { - req[credentialsSymbol] = await this.auth.authenticate(token); - req[isCookieSymbol] = isCookie; - } - next(); - } catch (e) { - next(e); - } - }; + async #getCredentials(req: Request) { + const { token, isCookie } = getTokenFromRequest(req); + // TODO: Is this where we match against configured rules? + if (!token) { + return { credentials: createUnauthorizedCredentials(), isCookie }; + } + return { credentials: await this.auth.authenticate(token), isCookie }; + } + + async #getCredentialsCached(req: /* */ RequestWithCredentials) { + return (req[credentialsSymbol] ??= this.#getCredentials(req)); } async credentials( @@ -102,12 +98,9 @@ class DefaultHttpAuthService implements HttpAuthService { allow: TAllowed[]; }, ): Promise { - const credentials = req[credentialsSymbol]; - if (!credentials) { - throw new Error('Internal error, no credentials found on request'); - } + const { credentials, isCookie } = await this.#getCredentialsCached(req); - if (credentials.type === 'user' && req[isCookieSymbol]) { + if (credentials.type === 'user' && isCookie) { if (options.allow.includes('user-cookie' as TAllowed)) { return credentials as BackstageCredentialTypes[TAllowed]; } 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 710accbadb..6c89834f2b 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -54,7 +54,6 @@ export const httpRouterServiceFactory = createServiceFactory( const credentialsBarrier = createCredentialsBarrier({ httpAuth }); router.use(createLifecycleMiddleware({ lifecycle })); - router.use(httpAuth.createHttpPluginRouterMiddleware()); router.use(credentialsBarrier.middleware); return { diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 817ca8691b..d5c5cb9613 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Request, Response, Handler } from 'express'; +import { Request, Response } from 'express'; import { BackstageUserCredentials, BackstageServiceCredentials, @@ -38,8 +38,6 @@ export type BackstageCredentialTypes = { /** @public */ export interface HttpAuthService { - createHttpPluginRouterMiddleware(): Handler; - credentials( req: Request, options: { From a19ef483f14a8930c130c493d10788ab1004d4c4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 10 Feb 2024 09:53:37 +0100 Subject: [PATCH 06/42] backend-{plugin,app}-api: refactored auth APIs to use principals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.test.ts | 43 ++++--- .../auth/authServiceFactory.ts | 104 ++++++++++----- .../httpAuth/httpAuthServiceFactory.ts | 121 +++++++++++------- .../userInfo/userInfoServiceFactory.ts | 9 +- packages/backend-plugin-api/api-report.md | 84 +++++++----- .../src/services/definitions/AuthService.ts | 51 ++++++-- .../services/definitions/HttpAuthService.ts | 39 +++--- .../services/definitions/UserInfoService.ts | 6 +- .../src/services/definitions/index.ts | 9 +- 9 files changed, 296 insertions(+), 170 deletions(-) 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 index 9dc64212de..64c2bed2a9 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -19,12 +19,15 @@ import { mockServices, } from '@backstage/backend-test-utils'; import { - InternalBackstageServiceCredentials, - InternalBackstageUserCredentials, + 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 = [ @@ -48,18 +51,22 @@ describe('authServiceFactory', () => { const searchAuth = await tester.get('search'); const catalogAuth = await tester.get('catalog'); - const { token: searchToken } = await searchAuth.issueServiceToken(); + const { token: searchToken } = await searchAuth.issueToken({}); await expect(searchAuth.authenticate(searchToken)).resolves.toEqual( expect.objectContaining({ - type: 'service', - subject: 'external:backstage-plugin', + principal: { + type: 'service', + subject: 'external:backstage-plugin', + }, }), ); await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual( expect.objectContaining({ - type: 'service', - subject: 'external:backstage-plugin', + principal: { + type: 'service', + subject: 'external:backstage-plugin', + }, }), ); }); @@ -72,14 +79,17 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); await expect( - catalogAuth.issueServiceToken({ + catalogAuth.issueToken({ forward: { $$type: '@backstage/BackstageCredentials', version: 'v1', - type: 'user', - userEntityRef: 'user:default/alice', + authMethod: 'token', token: 'alice-token', - } as InternalBackstageUserCredentials, + principal: { + type: 'user', + userEntityRef: 'user:default/alice', + }, + } as InternalBackstageCredentials, }), ).resolves.toEqual({ token: 'alice-token' }); }); @@ -91,14 +101,17 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); - const { token } = await catalogAuth.issueServiceToken({ + const { token } = await catalogAuth.issueToken({ forward: { $$type: '@backstage/BackstageCredentials', version: 'v1', - type: 'service', - subject: 'external:backstage-plugin', + authMethod: 'token', token: 'some-upstream-service-token', - } as InternalBackstageServiceCredentials, + principal: { + type: 'service', + subject: 'external:upstream-service', + }, + } as InternalBackstageCredentials, }); expect(decodeJwt(token)).toEqual( diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index a21829b228..5190b90e7e 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -18,8 +18,10 @@ import { ServerTokenManager, TokenManager } from '@backstage/backend-common'; import { AuthService, BackstageCredentials, - BackstageServiceCredentials, - BackstageUserCredentials, + BackstagePrincipalTypes, + BackstageServicePrincipal, + BackstageNonePrincipal, + BackstageUserPrincipal, IdentityService, coreServices, createServiceFactory, @@ -32,58 +34,76 @@ import { import { decodeJwt } from 'jose'; /** @internal */ -export type InternalBackstageServiceCredentials = - BackstageServiceCredentials & { +export type InternalBackstageCredentials = + BackstageCredentials & { version: string; - token: string; + token?: string; + authMethod: 'token' | 'cookie' | 'none'; }; -function createServiceCredentials( +export function createCredentialsWithServicePrincipal( sub: string, token: string, -): BackstageServiceCredentials { +): InternalBackstageCredentials { return { $$type: '@backstage/BackstageCredentials', version: 'v1', token, - type: 'service', - subject: sub, - } as InternalBackstageServiceCredentials; + principal: { + type: 'service', + subject: sub, + }, + authMethod: 'token', + }; } -/** @internal */ -export type InternalBackstageUserCredentials = BackstageUserCredentials & { - version: string; - token: string; -}; - -function createUserCredentials( +export function createCredentialsWithUserPrincipal( sub: string, token: string, -): BackstageUserCredentials { + authMethod: 'token' | 'cookie' = 'token', +): InternalBackstageCredentials { return { $$type: '@backstage/BackstageCredentials', version: 'v1', token, - type: 'user', - userEntityRef: sub, - } as InternalBackstageUserCredentials; + 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, -): InternalBackstageServiceCredentials | InternalBackstageUserCredentials { +): InternalBackstageCredentials< + BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal +> { if (credentials.$$type !== '@backstage/BackstageCredentials') { throw new Error('Invalid credential type'); } - const internalCredentials = credentials as - | InternalBackstageServiceCredentials - | InternalBackstageUserCredentials; + + const internalCredentials = credentials as InternalBackstageCredentials< + BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal + >; + if (internalCredentials.version !== 'v1') { throw new Error( `Invalid credential version ${internalCredentials.version}`, ); } + return internalCredentials; } @@ -100,7 +120,10 @@ class DefaultAuthService implements AuthService { // Legacy service-to-service token if (sub === 'backstage-server' && !aud) { await this.tokenManager.authenticate(token); - return createServiceCredentials('external:backstage-plugin', token); + return createCredentialsWithServicePrincipal( + 'external:backstage-plugin', + token, + ); } // User Backstage token @@ -111,21 +134,42 @@ class DefaultAuthService implements AuthService { } as IdentityApiGetIdentityRequest); if (!identity) { - throw new AuthenticationError('No identity found'); + throw new AuthenticationError('Invalid user token'); } - return createUserCredentials(identity.identity.userEntityRef, token); + return createCredentialsWithUserPrincipal( + identity.identity.userEntityRef, + token, + ); } - async issueServiceToken(options?: { + isPrincipal( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials { + const principal = credentials.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal; + + if (principal.type !== type) { + return false; + } + + return true; + } + + async issueToken(options?: { forward?: BackstageCredentials; }): Promise<{ token: string }> { const internalForward = options?.forward && toInternalBackstageCredentials(options.forward); if (internalForward) { - const { type } = internalForward; + const { type } = internalForward.principal; if (type === 'user') { + if (!internalForward.token) { + throw new Error('User credentials is unexpectedly missing token'); + } return { token: internalForward.token }; } else if (type !== 'service') { throw new AuthenticationError( diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 02b9975a47..c7525acebb 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -16,19 +16,21 @@ import { AuthService, - BackstageCredentialTypes, BackstageCredentials, - BackstageUnauthorizedCredentials, + BackstageHttpAccessToPrincipalTypesMapping, DiscoveryService, HttpAuthService, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { NotAllowedError } from '@backstage/errors'; +import { AuthenticationError, NotAllowedError } from '@backstage/errors'; import { parse as parseCookie } from 'cookie'; import { Request, Response } from 'express'; import { decodeJwt } from 'jose'; -import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; +import { + createCredentialsWithNonePrincipal, + toInternalBackstageCredentials, +} from '../auth/authServiceFactory'; const BACKSTAGE_AUTH_COOKIE = 'backstage-auth'; @@ -58,20 +60,9 @@ function getTokenFromRequest(req: Request) { const credentialsSymbol = Symbol('backstage-credentials'); type RequestWithCredentials = Request & { - [credentialsSymbol]?: Promise<{ - credentials: BackstageCredentials | BackstageUnauthorizedCredentials; - // TODO: This is temporary and should be removed once we have proper cookie handling in place - isCookie: boolean; - }>; + [credentialsSymbol]?: Promise; }; -function createUnauthorizedCredentials(): BackstageUnauthorizedCredentials { - return { - $$type: '@backstage/BackstageCredentials', - type: 'unauthorized', - }; -} - class DefaultHttpAuthService implements HttpAuthService { constructor( private readonly auth: AuthService, @@ -79,63 +70,94 @@ class DefaultHttpAuthService implements HttpAuthService { private readonly pluginId: string, ) {} - async #getCredentials(req: Request) { + async #extractCredentialsFromRequest(req: Request) { const { token, isCookie } = getTokenFromRequest(req); - // TODO: Is this where we match against configured rules? if (!token) { - return { credentials: createUnauthorizedCredentials(), isCookie }; + return createCredentialsWithNonePrincipal(); } - return { credentials: await this.auth.authenticate(token), isCookie }; - } - async #getCredentialsCached(req: /* */ RequestWithCredentials) { - return (req[credentialsSymbol] ??= this.#getCredentials(req)); - } - - async credentials( - req: RequestWithCredentials, - options: { - allow: TAllowed[]; - }, - ): Promise { - const { credentials, isCookie } = await this.#getCredentialsCached(req); - - if (credentials.type === 'user' && isCookie) { - if (options.allow.includes('user-cookie' as TAllowed)) { - return credentials as BackstageCredentialTypes[TAllowed]; + 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', + ); } - throw new NotAllowedError( - `This endpoint does not allow 'user-cookie' credentials`, - ); + credentials.authMethod = 'cookie'; } - if (!options.allow.includes(credentials.type as TAllowed)) { - throw new NotAllowedError( - `This endpoint does not allow '${credentials.type}' credentials`, - ); - } - - return credentials as BackstageCredentialTypes[TAllowed]; + return credentials; } - async requestHeaders(options?: { + async #getCredentials(req: /* */ RequestWithCredentials) { + return (req[credentialsSymbol] ??= + this.#extractCredentialsFromRequest(req)); + } + + async credentials< + TAllowed extends + | keyof BackstageHttpAccessToPrincipalTypesMapping + | undefined = undefined, + >( + req: Request, + options?: { + allow: Array; + }, + ): Promise< + BackstageCredentials< + TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping + ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] + : unknown + > + > { + const credentials = toInternalBackstageCredentials( + await this.#getCredentials(req), + ); + + // TODO break out into more readable function as we always + // want to check cookie regardless if options are set or not + // Probably asserts function that ensures authMethod = 'token' after cookie check + if (credentials.authMethod === 'cookie') { + if (options && !options.allow.includes('user-cookie' as TAllowed)) { + throw new NotAllowedError( + `This endpoint does not allow 'user-cookie' credentials`, + ); + } + } else if ( + options && + !options.allow.includes(credentials.principal.type as TAllowed) + ) { + throw new NotAllowedError( + `This endpoint does not allow '${credentials.principal.type}' credentials`, + ); + } + + return credentials as any; + } + + async requestHeaders(options: { forward?: BackstageCredentials; }): Promise> { return { - Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, + Authorization: `Bearer ${await this.auth.issueToken(options)}`, }; } async issueUserCookie(res: Response): Promise { const credentials = await this.credentials(res.req, { allow: ['user'] }); - // https://backstage.spotify.net/api/catalog + // 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!; @@ -149,6 +171,7 @@ class DefaultHttpAuthService implements HttpAuthService { priority: 'high', sameSite: 'lax', // TBD }); + throw new Error('Method not implemented.'); } } diff --git a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts index a177f2442b..a74b8b7002 100644 --- a/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/userInfo/userInfoServiceFactory.ts @@ -15,11 +15,11 @@ */ import { - BackstageUserCredentials, UserInfoService, BackstageUserInfo, coreServices, createServiceFactory, + BackstageCredentials, } from '@backstage/backend-plugin-api'; import { toInternalBackstageCredentials } from '../auth/authServiceFactory'; import { decodeJwt } from 'jose'; @@ -27,12 +27,15 @@ 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: BackstageUserCredentials, + credentials: BackstageCredentials, ): Promise { const internalCredentials = toInternalBackstageCredentials(credentials); - if (internalCredentials.type !== 'user') { + 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, ); diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 302a3d37d4..81579fb0e9 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -22,7 +22,12 @@ export interface AuthService { // (undocumented) authenticate(token: string): Promise; // (undocumented) - issueServiceToken(options?: { forward?: BackstageCredentials }): Promise<{ + isPrincipal( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials; + // (undocumented) + issueToken(options: { forward?: BackstageCredentials }): Promise<{ token: string; }>; } @@ -89,36 +94,35 @@ export interface BackendPluginRegistrationPoints { } // @public (undocumented) -export type BackstageCredentials = - | BackstageUserCredentials - | BackstageServiceCredentials; - -// @public (undocumented) -export type BackstageCredentialTypes = { - user: BackstageUserCredentials; - 'user-cookie': BackstageUserCredentials; - service: BackstageServiceCredentials; - unauthorized: BackstageUnauthorizedCredentials; +export type BackstageCredentials = { + $$type: '@backstage/BackstageCredentials'; + principal: TPrincipal; }; // @public (undocumented) -export type BackstageServiceCredentials = { - $$type: '@backstage/BackstageCredentials'; +export type BackstageHttpAccessToPrincipalTypesMapping = { + user: BackstageUserPrincipal; + 'user-cookie': BackstageUserPrincipal; + service: BackstageServicePrincipal; + unauthenticated: BackstageNonePrincipal; +}; + +// @public (undocumented) +export type BackstageNonePrincipal = { + type: 'none'; +}; + +// @public (undocumented) +export type BackstagePrincipalTypes = { + user: BackstageUserPrincipal; + service: BackstageServicePrincipal; +}; + +// @public (undocumented) +export type BackstageServicePrincipal = { type: 'service'; subject: string; -}; - -// @public (undocumented) -export type BackstageUnauthorizedCredentials = { - $$type: '@backstage/BackstageCredentials'; - type: 'unauthorized'; -}; - -// @public (undocumented) -export type BackstageUserCredentials = { - $$type: '@backstage/BackstageCredentials'; - type: 'user'; - userEntityRef: string; + permissions?: string[]; }; // @public (undocumented) @@ -129,6 +133,12 @@ export interface BackstageUserInfo { userEntityRef: string; } +// @public (undocumented) +export type BackstageUserPrincipal = { + type: 'user'; + userEntityRef: string; +}; + // @public export interface CacheService { delete(key: string): Promise; @@ -281,14 +291,22 @@ export interface ExtensionPointConfig { // @public (undocumented) export interface HttpAuthService { // (undocumented) - createHttpPluginRouterMiddleware(): Handler; - // (undocumented) - credentials( + credentials< + TAllowed extends + | keyof BackstageHttpAccessToPrincipalTypesMapping + | undefined = undefined, + >( req: Request_2, - options: { + options?: { allow: Array; }, - ): Promise; + ): Promise< + BackstageCredentials< + TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping + ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] + : unknown + > + >; // (undocumented) issueUserCookie(res: Response_2): Promise; // (undocumented) @@ -547,8 +565,6 @@ export interface UrlReaderService { // @public (undocumented) export interface UserInfoService { // (undocumented) - getUserInfo( - credentials: BackstageUserCredentials, - ): Promise; + 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 index d4ab8456a8..8a946e5c91 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -17,9 +17,7 @@ /** * @public */ -export type BackstageUserCredentials = { - $$type: '@backstage/BackstageCredentials'; - +export type BackstageUserPrincipal = { type: 'user'; userEntityRef: string; @@ -28,27 +26,54 @@ export type BackstageUserCredentials = { /** * @public */ -export type BackstageServiceCredentials = { - $$type: '@backstage/BackstageCredentials'; - - type: 'service'; - - subject: string; +export type BackstageNonePrincipal = { + type: 'none'; }; /** * @public */ -export type BackstageCredentials = - | BackstageUserCredentials - | BackstageServiceCredentials; +export type BackstageServicePrincipal = { + type: 'service'; + + // Exact format TBD, possibly 'plugin:' or 'external:' + subject: string; + + // Not implemented in the first iteration, but this is how we might extend this in the future + permissions?: string[]; +}; + +/** + * @public + */ +export type BackstageCredentials = { + $$type: '@backstage/BackstageCredentials'; + + principal: TPrincipal; +}; + +/** + * @public + */ +export type BackstagePrincipalTypes = { + user: BackstageUserPrincipal; + service: BackstageServicePrincipal; +}; /** * @public */ export interface AuthService { authenticate(token: string): Promise; - issueServiceToken(options?: { + + isPrincipal( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials; + + // TODO: should the caller provide the target plugin ID? + // TODO: how can we make it very difficult to forget to forward credentials + issueToken(options: { forward?: BackstageCredentials; }): 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 index d5c5cb9613..f84234932b 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -16,36 +16,39 @@ import { Request, Response } from 'express'; import { - BackstageUserCredentials, - BackstageServiceCredentials, BackstageCredentials, + BackstageServicePrincipal, + BackstageNonePrincipal, + BackstageUserPrincipal, } from './AuthService'; /** @public */ -export type BackstageUnauthorizedCredentials = { - $$type: '@backstage/BackstageCredentials'; - - type: 'unauthorized'; -}; - -/** @public */ -export type BackstageCredentialTypes = { - user: BackstageUserCredentials; - 'user-cookie': BackstageUserCredentials; - service: BackstageServiceCredentials; - unauthorized: BackstageUnauthorizedCredentials; +export type BackstageHttpAccessToPrincipalTypesMapping = { + user: BackstageUserPrincipal; + 'user-cookie': BackstageUserPrincipal; + service: BackstageServicePrincipal; + unauthenticated: BackstageNonePrincipal; }; /** @public */ export interface HttpAuthService { - credentials( + credentials< + TAllowed extends + | keyof BackstageHttpAccessToPrincipalTypesMapping + | undefined = undefined, + >( req: Request, - options: { + options?: { allow: Array; }, - ): Promise; + ): Promise< + BackstageCredentials< + TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping + ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] + : unknown + > + >; - // TODO: Keep an eye on this, might not be needed requestHeaders(options?: { forward?: BackstageCredentials; }): Promise>; diff --git a/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts index b789116ffb..90f5306a58 100644 --- a/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts +++ b/packages/backend-plugin-api/src/services/definitions/UserInfoService.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BackstageUserCredentials } from './AuthService'; +import { BackstageCredentials } from './AuthService'; /** @public */ export interface BackstageUserInfo { @@ -24,7 +24,5 @@ export interface BackstageUserInfo { /** @public */ export interface UserInfoService { - getUserInfo( - credentials: BackstageUserCredentials, - ): Promise; + getUserInfo(credentials: BackstageCredentials): Promise; } diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 420c722a4d..810f1754b7 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -18,8 +18,10 @@ export { coreServices } from './coreServices'; export type { AuthService, BackstageCredentials, - BackstageServiceCredentials, - BackstageUserCredentials, + BackstageUserPrincipal, + BackstageServicePrincipal, + BackstagePrincipalTypes, + BackstageNonePrincipal, } from './AuthService'; export type { CacheService, @@ -35,8 +37,7 @@ export type { } from './HttpRouterService'; export type { HttpAuthService, - BackstageCredentialTypes, - BackstageUnauthorizedCredentials, + BackstageHttpAccessToPrincipalTypesMapping, } from './HttpAuthService'; export type { LifecycleService, From 9ae4b9b6c2c2683889bf3fbaf9c77224e380677b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 10 Feb 2024 11:41:17 +0100 Subject: [PATCH 07/42] backend-plugin-api: simplify auth.credentials() types Signed-off-by: Patrik Oldsberg --- .../httpAuth/httpAuthServiceFactory.ts | 9 ++------- packages/backend-plugin-api/api-report.md | 11 +++-------- .../src/services/definitions/HttpAuthService.ts | 14 ++++---------- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index c7525acebb..79c74ffa9f 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -98,19 +98,14 @@ class DefaultHttpAuthService implements HttpAuthService { async credentials< TAllowed extends - | keyof BackstageHttpAccessToPrincipalTypesMapping - | undefined = undefined, + | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', >( req: Request, options?: { allow: Array; }, ): Promise< - BackstageCredentials< - TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping - ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] - : unknown - > + BackstageCredentials > { const credentials = toInternalBackstageCredentials( await this.#getCredentials(req), diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 81579fb0e9..a3e5930375 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -105,6 +105,7 @@ export type BackstageHttpAccessToPrincipalTypesMapping = { 'user-cookie': BackstageUserPrincipal; service: BackstageServicePrincipal; unauthenticated: BackstageNonePrincipal; + unknown: unknown; }; // @public (undocumented) @@ -292,20 +293,14 @@ export interface ExtensionPointConfig { export interface HttpAuthService { // (undocumented) credentials< - TAllowed extends - | keyof BackstageHttpAccessToPrincipalTypesMapping - | undefined = undefined, + TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', >( req: Request_2, options?: { allow: Array; }, ): Promise< - BackstageCredentials< - TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping - ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] - : unknown - > + BackstageCredentials >; // (undocumented) issueUserCookie(res: Response_2): Promise; diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index f84234932b..949ca1af0a 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -28,25 +28,19 @@ export type BackstageHttpAccessToPrincipalTypesMapping = { 'user-cookie': BackstageUserPrincipal; service: BackstageServicePrincipal; unauthenticated: BackstageNonePrincipal; + unknown: unknown; }; /** @public */ export interface HttpAuthService { credentials< TAllowed extends - | keyof BackstageHttpAccessToPrincipalTypesMapping - | undefined = undefined, + | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', >( req: Request, - options?: { - allow: Array; - }, + options?: { allow: Array }, ): Promise< - BackstageCredentials< - TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping - ? BackstageHttpAccessToPrincipalTypesMapping[TAllowed] - : unknown - > + BackstageCredentials >; requestHeaders(options?: { From c599fe1f7ba752d481809fffe7f82baffd91c615 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 10 Feb 2024 12:04:11 +0100 Subject: [PATCH 08/42] backend-plugin-api: auth refactor to issueServiceToken with required credentials + getOwnCredentials Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.test.ts | 8 ++-- .../auth/authServiceFactory.ts | 42 +++++++++---------- .../httpAuth/httpAuthServiceFactory.ts | 4 +- packages/backend-plugin-api/api-report.md | 8 ++-- .../src/services/definitions/AuthService.ts | 7 ++-- .../services/definitions/HttpAuthService.ts | 4 +- 6 files changed, 39 insertions(+), 34 deletions(-) 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 index 64c2bed2a9..16f05ef9d0 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -51,7 +51,9 @@ describe('authServiceFactory', () => { const searchAuth = await tester.get('search'); const catalogAuth = await tester.get('catalog'); - const { token: searchToken } = await searchAuth.issueToken({}); + const { token: searchToken } = await searchAuth.issueServiceToken({ + forward: await searchAuth.getOwnCredentials(), + }); await expect(searchAuth.authenticate(searchToken)).resolves.toEqual( expect.objectContaining({ @@ -79,7 +81,7 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); await expect( - catalogAuth.issueToken({ + catalogAuth.issueServiceToken({ forward: { $$type: '@backstage/BackstageCredentials', version: 'v1', @@ -101,7 +103,7 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); - const { token } = await catalogAuth.issueToken({ + const { token } = await catalogAuth.issueServiceToken({ forward: { $$type: '@backstage/BackstageCredentials', version: 'v1', diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 5190b90e7e..c2b4236f45 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -43,12 +43,10 @@ export type InternalBackstageCredentials = export function createCredentialsWithServicePrincipal( sub: string, - token: string, ): InternalBackstageCredentials { return { $$type: '@backstage/BackstageCredentials', version: 'v1', - token, principal: { type: 'service', subject: sub, @@ -112,6 +110,7 @@ class DefaultAuthService implements AuthService { constructor( private readonly tokenManager: TokenManager, private readonly identity: IdentityService, + private readonly pluginId: string, ) {} async authenticate(token: string): Promise { @@ -120,10 +119,7 @@ class DefaultAuthService implements AuthService { // Legacy service-to-service token if (sub === 'backstage-server' && !aud) { await this.tokenManager.authenticate(token); - return createCredentialsWithServicePrincipal( - 'external:backstage-plugin', - token, - ); + return createCredentialsWithServicePrincipal('external:backstage-plugin'); } // User Backstage token @@ -158,28 +154,31 @@ class DefaultAuthService implements AuthService { return true; } - async issueToken(options?: { - forward?: BackstageCredentials; - }): Promise<{ token: string }> { - const internalForward = - options?.forward && toInternalBackstageCredentials(options.forward); + async getOwnCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`); + } - if (internalForward) { - const { type } = internalForward.principal; - if (type === 'user') { + async issueServiceToken(options: { + forward: BackstageCredentials; + }): Promise<{ token: string }> { + const internalForward = toInternalBackstageCredentials(options.forward); + const { type } = internalForward.principal; + + switch (type) { + case 'service': + return this.tokenManager.getToken(); + case 'user': if (!internalForward.token) { throw new Error('User credentials is unexpectedly missing token'); } return { token: internalForward.token }; - } else if (type !== 'service') { + default: throw new AuthenticationError( `Refused to issue service token for credential type '${type}'`, ); - } } - - const { token } = await this.tokenManager.getToken(); - return { token }; } } @@ -190,12 +189,13 @@ export const authServiceFactory = createServiceFactory({ config: coreServices.rootConfig, logger: coreServices.rootLogger, discovery: coreServices.discovery, + plugin: coreServices.pluginMetadata, }, createRootContext({ config, logger }) { return ServerTokenManager.fromConfig(config, { logger }); }, - async factory({ discovery }, tokenManager) { + async factory({ discovery, plugin }, tokenManager) { const identity = DefaultIdentityClient.create({ discovery }); - return new DefaultAuthService(tokenManager, identity); + return new DefaultAuthService(tokenManager, identity, plugin.getId()); }, }); diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 79c74ffa9f..d23c35fb43 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -133,10 +133,10 @@ class DefaultHttpAuthService implements HttpAuthService { } async requestHeaders(options: { - forward?: BackstageCredentials; + forward: BackstageCredentials; }): Promise> { return { - Authorization: `Bearer ${await this.auth.issueToken(options)}`, + Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, }; } diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index a3e5930375..23e9172bee 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -22,12 +22,14 @@ export interface AuthService { // (undocumented) authenticate(token: string): Promise; // (undocumented) + getOwnCredentials(): Promise>; + // (undocumented) isPrincipal( credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; // (undocumented) - issueToken(options: { forward?: BackstageCredentials }): Promise<{ + issueServiceToken(options: { forward: BackstageCredentials }): Promise<{ token: string; }>; } @@ -305,8 +307,8 @@ export interface HttpAuthService { // (undocumented) issueUserCookie(res: Response_2): Promise; // (undocumented) - requestHeaders(options?: { - forward?: BackstageCredentials; + requestHeaders(options: { + forward: BackstageCredentials; }): Promise>; } diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 8a946e5c91..8eeb40a5ca 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -71,9 +71,10 @@ export interface AuthService { type: TType, ): credentials is BackstageCredentials; + getOwnCredentials(): Promise>; + // TODO: should the caller provide the target plugin ID? - // TODO: how can we make it very difficult to forget to forward credentials - issueToken(options: { - forward?: BackstageCredentials; + issueServiceToken(options: { + forward: BackstageCredentials; }): 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 index 949ca1af0a..937bb1ee82 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -43,8 +43,8 @@ export interface HttpAuthService { BackstageCredentials >; - requestHeaders(options?: { - forward?: BackstageCredentials; + requestHeaders(options: { + forward: BackstageCredentials; }): Promise>; issueUserCookie(res: Response): Promise; From 99dadac0af4370a73ce90de1cedad4430f9d90df Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 10 Feb 2024 12:04:39 +0100 Subject: [PATCH 09/42] backend-plugin-api: remove WIP permissions from service principal Signed-off-by: Patrik Oldsberg --- packages/backend-plugin-api/api-report.md | 1 - .../backend-plugin-api/src/services/definitions/AuthService.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 23e9172bee..a798f1b1f7 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -125,7 +125,6 @@ export type BackstagePrincipalTypes = { export type BackstageServicePrincipal = { type: 'service'; subject: string; - permissions?: string[]; }; // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 8eeb40a5ca..a35d5a5160 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -38,9 +38,6 @@ export type BackstageServicePrincipal = { // Exact format TBD, possibly 'plugin:' or 'external:' subject: string; - - // Not implemented in the first iteration, but this is how we might extend this in the future - permissions?: string[]; }; /** From 6b19a73abc5c0320ac483dcfe3aa4b9bf40faaa2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 12 Feb 2024 16:28:16 +0100 Subject: [PATCH 10/42] backend-{plugin,app}-api: refactored HttpAuth to separate allowing principals from auth method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../httpAuth/httpAuthServiceFactory.ts | 46 +++++++++---------- .../httpRouter/createCredentialsBarrier.ts | 11 +---- packages/backend-plugin-api/api-report.md | 22 +++------ .../src/services/definitions/AuthService.ts | 2 + .../services/definitions/HttpAuthService.ts | 30 +++--------- .../src/services/definitions/index.ts | 5 +- 6 files changed, 40 insertions(+), 76 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index d23c35fb43..1723e3b76c 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -17,7 +17,7 @@ import { AuthService, BackstageCredentials, - BackstageHttpAccessToPrincipalTypesMapping, + BackstagePrincipalTypes, DiscoveryService, HttpAuthService, coreServices, @@ -96,33 +96,33 @@ class DefaultHttpAuthService implements HttpAuthService { this.#extractCredentialsFromRequest(req)); } - async credentials< - TAllowed extends - | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', - >( + async credentials( req: Request, options?: { - allow: Array; + allow?: Array; + allowedAuthMethods?: Array<'token' | 'cookie'>; }, - ): Promise< - BackstageCredentials - > { + ): Promise> { const credentials = toInternalBackstageCredentials( await this.#getCredentials(req), ); - // TODO break out into more readable function as we always - // want to check cookie regardless if options are set or not - // Probably asserts function that ensures authMethod = 'token' after cookie check - if (credentials.authMethod === 'cookie') { - if (options && !options.allow.includes('user-cookie' as TAllowed)) { - throw new NotAllowedError( - `This endpoint does not allow 'user-cookie' credentials`, - ); - } - } else if ( - options && - !options.allow.includes(credentials.principal.type as TAllowed) + 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`, @@ -175,10 +175,8 @@ class DefaultHttpAuthService implements HttpAuthService { export const httpAuthServiceFactory = createServiceFactory({ service: coreServices.httpAuth, deps: { - config: coreServices.rootConfig, - logger: coreServices.rootLogger, - discovery: coreServices.discovery, auth: coreServices.auth, + discovery: coreServices.discovery, plugin: coreServices.pluginMetadata, }, async factory({ auth, discovery, plugin }) { diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts index c502dc25e5..6cdff9d32c 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -60,18 +60,11 @@ export function createCredentialsBarrier(options: { predicate(req.path), ); - if (allowsCookie) { - // don't we need a user-cookie allow type here? - await httpAuth.credentials(req, { - allow: ['user-cookie', 'user', 'service'], - }); - next(); - return; - } - await httpAuth.credentials(req, { allow: ['user', 'service'], + allowedAuthMethods: allowsCookie ? ['token', 'cookie'] : ['token'], }); + next(); }; diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index a798f1b1f7..27dfeb13b6 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -101,15 +101,6 @@ export type BackstageCredentials = { principal: TPrincipal; }; -// @public (undocumented) -export type BackstageHttpAccessToPrincipalTypesMapping = { - user: BackstageUserPrincipal; - 'user-cookie': BackstageUserPrincipal; - service: BackstageServicePrincipal; - unauthenticated: BackstageNonePrincipal; - unknown: unknown; -}; - // @public (undocumented) export type BackstageNonePrincipal = { type: 'none'; @@ -119,6 +110,8 @@ export type BackstageNonePrincipal = { export type BackstagePrincipalTypes = { user: BackstageUserPrincipal; service: BackstageServicePrincipal; + unauthenticated: BackstageNonePrincipal; + unknown: unknown; }; // @public (undocumented) @@ -293,16 +286,13 @@ export interface ExtensionPointConfig { // @public (undocumented) export interface HttpAuthService { // (undocumented) - credentials< - TAllowed extends keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', - >( + credentials( req: Request_2, options?: { - allow: Array; + allow?: Array; + allowedAuthMethods?: Array<'token' | 'cookie'>; }, - ): Promise< - BackstageCredentials - >; + ): Promise>; // (undocumented) issueUserCookie(res: Response_2): Promise; // (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index a35d5a5160..4566d4a3fa 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -55,6 +55,8 @@ export type BackstageCredentials = { export type BackstagePrincipalTypes = { user: BackstageUserPrincipal; service: BackstageServicePrincipal; + unauthenticated: BackstageNonePrincipal; + unknown: unknown; }; /** diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index 937bb1ee82..cc8aace8ba 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -15,33 +15,17 @@ */ import { Request, Response } from 'express'; -import { - BackstageCredentials, - BackstageServicePrincipal, - BackstageNonePrincipal, - BackstageUserPrincipal, -} from './AuthService'; - -/** @public */ -export type BackstageHttpAccessToPrincipalTypesMapping = { - user: BackstageUserPrincipal; - 'user-cookie': BackstageUserPrincipal; - service: BackstageServicePrincipal; - unauthenticated: BackstageNonePrincipal; - unknown: unknown; -}; +import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService'; /** @public */ export interface HttpAuthService { - credentials< - TAllowed extends - | keyof BackstageHttpAccessToPrincipalTypesMapping = 'unknown', - >( + credentials( req: Request, - options?: { allow: Array }, - ): Promise< - BackstageCredentials - >; + options?: { + allow?: Array; + allowedAuthMethods?: Array<'token' | 'cookie'>; + }, + ): Promise>; requestHeaders(options: { forward: BackstageCredentials; diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 810f1754b7..5955c4cbb1 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -35,10 +35,7 @@ export type { HttpRouterService, HttpRouterServiceAuthPolicy, } from './HttpRouterService'; -export type { - HttpAuthService, - BackstageHttpAccessToPrincipalTypesMapping, -} from './HttpAuthService'; +export type { HttpAuthService } from './HttpAuthService'; export type { LifecycleService, LifecycleServiceStartupHook, From 3444a2c7c96123e43719f5205fbcdc2139d07f0d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 12 Feb 2024 16:33:53 +0100 Subject: [PATCH 11/42] backend-common: add createLegacyAuthAdapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-common/api-report.md | 14 + packages/backend-common/package.json | 1 + .../src/auth/createLegacyAuthAdapters.ts | 249 ++++++++++++++++++ packages/backend-common/src/auth/index.ts | 17 ++ packages/backend-common/src/index.ts | 1 + 5 files changed, 282 insertions(+) create mode 100644 packages/backend-common/src/auth/createLegacyAuthAdapters.ts create mode 100644 packages/backend-common/src/auth/index.ts diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 6c9b63ae11..8740910449 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,18 @@ export function createDatabaseClient( }, ): knexFactory.Knex; +// @public (undocumented) +export function createLegacyAuthAdapters(options: { + auth?: AuthService; + httpAuth?: HttpAuthService; + identity?: IdentityService; + tokenManager?: TokenManager; + discovery: PluginEndpointDiscovery; +}): { + auth: AuthService; + httpAuth: HttpAuthService; +}; + // @public export function createRootLogger( options?: winston.LoggerOptions, diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index d202b19040..bfe8fd09fc 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.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts new file mode 100644 index 0000000000..f0c259ad08 --- /dev/null +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -0,0 +1,249 @@ +/* + * 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 getOwnCredentials(): Promise< + BackstageCredentials + > { + return createCredentialsWithServicePrincipal('external:backstage-plugin'); + } + + 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, + ); + } + + async issueServiceToken(options: { + forward: BackstageCredentials; + }): Promise<{ token: string }> { + const internalForward = toInternalBackstageCredentials(options.forward); + 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 requestHeaders(options: { + forward: BackstageCredentials; + }): Promise> { + return { + Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, + }; + } + + async issueUserCookie(_res: Response): Promise {} +} + +/** @public */ +export function createLegacyAuthAdapters(options: { + auth?: AuthService; + httpAuth?: HttpAuthService; + identity?: IdentityService; + tokenManager?: TokenManager; + discovery: PluginEndpointDiscovery; +}): { + auth: AuthService; + httpAuth: HttpAuthService; +} { + const { auth, httpAuth, discovery } = options; + + if (auth || httpAuth) { + // TODO: Is this sensible? Could be that a lot of plugins only want one of them and in + // that case overloads might be better, so that it's possible to only provide one of them. + if (!(auth && httpAuth)) { + throw new Error('Both auth and httpAuth must be provided'); + } + return { + auth, + httpAuth, + }; + } + + 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, + }; +} 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'; From 54fa4997ee5488b42fe84d2144c2b1412b4c06b6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Feb 2024 00:13:36 +0100 Subject: [PATCH 12/42] backend-common: update createLegacyAuthAdapters to work with a single service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-common/api-report.md | 39 +++++++--- .../src/auth/createLegacyAuthAdapters.test.ts | 73 +++++++++++++++++++ .../src/auth/createLegacyAuthAdapters.ts | 57 ++++++++++----- 3 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 packages/backend-common/src/auth/createLegacyAuthAdapters.test.ts diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 8740910449..cd4d5ab309 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -235,16 +235,35 @@ export function createDatabaseClient( ): knexFactory.Knex; // @public (undocumented) -export function createLegacyAuthAdapters(options: { - auth?: AuthService; - httpAuth?: HttpAuthService; - identity?: IdentityService; - tokenManager?: TokenManager; - discovery: PluginEndpointDiscovery; -}): { - auth: AuthService; - httpAuth: HttpAuthService; -}; +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( 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 index f0c259ad08..932c8c421a 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -210,29 +210,47 @@ class HttpAuthCompat implements HttpAuthService { async issueUserCookie(_res: Response): Promise {} } -/** @public */ -export function createLegacyAuthAdapters(options: { - auth?: AuthService; - httpAuth?: HttpAuthService; - identity?: IdentityService; - tokenManager?: TokenManager; - discovery: PluginEndpointDiscovery; -}): { - auth: AuthService; - httpAuth: HttpAuthService; -} { +/** + * 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) { - // TODO: Is this sensible? Could be that a lot of plugins only want one of them and in - // that case overloads might be better, so that it's possible to only provide one of them. - if (!(auth && httpAuth)) { - throw new Error('Both auth and httpAuth must be provided'); - } + if (auth && httpAuth) { return { auth, httpAuth, - }; + } as TAdapters; + } + + if (auth) { + return { + auth, + } as TAdapters; + } + + if (httpAuth) { + return { + httpAuth, + } as TAdapters; } const identity = @@ -240,10 +258,11 @@ export function createLegacyAuthAdapters(options: { const tokenManager = options.tokenManager ?? ServerTokenManager.noop(); const authImpl = new AuthCompat(identity, tokenManager); + const httpAuthImpl = new HttpAuthCompat(authImpl); return { auth: authImpl, httpAuth: httpAuthImpl, - }; + } as TAdapters; } From abaaf4970a9997f5f2f7d2cf7d33edeec22ec59b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Feb 2024 00:38:58 +0100 Subject: [PATCH 13/42] backend-test-utils: add mocks for AuthService and HttpAuthService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 22 +++++ .../src/next/services/MockAuthService.ts | 90 +++++++++++++++++++ .../src/next/services/mockServices.ts | 28 ++++++ .../src/next/wiring/TestBackend.ts | 2 + 4 files changed, 142 insertions(+) create mode 100644 packages/backend-test-utils/src/next/services/MockAuthService.ts diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 204e5dc84a..a29a782af0 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -6,12 +6,14 @@ /// /// +import { AuthService } from '@backstage/backend-plugin-api'; import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { CacheService } from '@backstage/backend-plugin-api'; import { DatabaseService } 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'; @@ -86,6 +88,17 @@ export interface MockDirectoryOptions { // @public (undocumented) export namespace mockServices { + // (undocumented) + export function auth(options?: { pluginId?: string }): AuthService; + // (undocumented) + export namespace auth { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } // (undocumented) export namespace cache { const // (undocumented) @@ -105,6 +118,15 @@ export namespace mockServices { ) => ServiceMock; } // (undocumented) + export namespace httpAuth { + const // (undocumented) + factory: () => 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.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts new file mode 100644 index 0000000000..9af9be4818 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -0,0 +1,90 @@ +/* + * 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'; + +/** @internal */ +export class MockAuthService implements AuthService { + constructor(private readonly pluginId: string) {} + + async authenticate(token: string): Promise { + if (token === 'mock-user-token') { + return { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef: 'user:default/mock' }, + }; + } else if (token === 'mock-service-token') { + return { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'service', subject: 'external:test-service' }, + }; + } + + throw new AuthenticationError('Invalid token'); + } + + async getOwnCredentials(): Promise< + BackstageCredentials + > { + return { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'service', subject: `plugin:${this.pluginId}` }, + }; + } + + isPrincipal( + credentials: BackstageCredentials, + type: TType, + ): credentials is BackstageCredentials { + const principal = credentials.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal + | BackstageNonePrincipal; + if (principal.type !== type) { + return false; + } + + return true; + } + + async issueServiceToken(options: { + forward: BackstageCredentials; + }): Promise<{ token: string }> { + const principal = options.forward.principal as + | BackstageUserPrincipal + | BackstageServicePrincipal + | BackstageNonePrincipal; + + switch (principal.type) { + case 'user': + return { token: 'mock-user-token' }; + case 'service': + return { token: 'mock-service-token' }; + default: + throw new AuthenticationError( + `Refused to issue service token for credential type '${principal.type}'`, + ); + } + } +} diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 78f59b53c4..58a6f7c421 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -23,6 +23,7 @@ import { ServiceFactory, ServiceRef, TokenManagerService, + AuthService, } from '@backstage/backend-plugin-api'; import { cacheServiceFactory, @@ -35,11 +36,13 @@ import { rootLifecycleServiceFactory, schedulerServiceFactory, urlReaderServiceFactory, + httpAuthServiceFactory, } 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'; /** @internal */ function simpleFactory< @@ -160,6 +163,23 @@ export namespace mockServices { })); } + export function auth(options?: { pluginId?: string }): AuthService { + return new MockAuthService(options?.pluginId ?? 'test'); + } + export namespace auth { + export const factory = createServiceFactory({ + service: coreServices.auth, + deps: { plugin: coreServices.pluginMetadata }, + factory: ({ plugin }) => new MockAuthService(plugin.getId()), + }); + export const mock = simpleMock(coreServices.auth, () => ({ + authenticate: jest.fn(), + getOwnCredentials: jest.fn(), + isPrincipal: jest.fn() as any, + issueServiceToken: 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. @@ -185,6 +205,14 @@ export namespace mockServices { addAuthPolicy: jest.fn(), })); } + export namespace httpAuth { + export const factory = httpAuthServiceFactory; + export const mock = simpleMock(coreServices.httpAuth, () => ({ + credentials: jest.fn(), + issueUserCookie: jest.fn(), + requestHeaders: jest.fn(), + })); + } export namespace rootHttpRouter { export const factory = rootHttpRouterServiceFactory; export const mock = simpleMock(coreServices.rootHttpRouter, () => ({ 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(), From bfc63fdf378a496bfdca71f820c7fe0360e10842 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Feb 2024 00:39:54 +0100 Subject: [PATCH 14/42] backend-app-api: add TODO for authServiceFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Carl-Erik Bergström Co-authored-by: blam Signed-off-by: Patrik Oldsberg --- .../src/services/implementations/auth/authServiceFactory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index c2b4236f45..7e9ad3077f 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -167,6 +167,7 @@ class DefaultAuthService implements AuthService { const { type } = internalForward.principal; switch (type) { + // TODO: Check whether the principal is ourselves case 'service': return this.tokenManager.getToken(); case 'user': From 05b7e918612ec41a151f2c5d4e91915d066c429b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Feb 2024 12:32:38 +0100 Subject: [PATCH 15/42] backend-test-utils: add httpAuth instance mock + discovery mock Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 14 ++++++ .../src/next/services/mockServices.ts | 49 ++++++++++++++++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index a29a782af0..cff8a8fda9 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -11,6 +11,7 @@ import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } 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'; @@ -118,6 +119,19 @@ export namespace mockServices { ) => ServiceMock; } // (undocumented) + export function discovery(): DiscoveryService; + // (undocumented) + export namespace discovery { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) + export function httpAuth(options?: { pluginId?: string }): HttpAuthService; + // (undocumented) export namespace httpAuth { const // (undocumented) factory: () => ServiceFactory; diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 58a6f7c421..ba43aff008 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -24,6 +24,8 @@ import { ServiceRef, TokenManagerService, AuthService, + DiscoveryService, + HttpAuthService, } from '@backstage/backend-plugin-api'; import { cacheServiceFactory, @@ -37,12 +39,16 @@ import { schedulerServiceFactory, urlReaderServiceFactory, httpAuthServiceFactory, + 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'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { DefaultHttpAuthService } from '../../../../backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory'; /** @internal */ function simpleFactory< @@ -180,6 +186,41 @@ export namespace mockServices { })); } + 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(), + })); + } + + export function httpAuth(options?: { pluginId?: string }): HttpAuthService { + return new DefaultHttpAuthService( + auth(), + discovery(), + options?.pluginId ?? 'test', + ); + } + export namespace httpAuth { + export const factory = httpAuthServiceFactory; + export const mock = simpleMock(coreServices.httpAuth, () => ({ + credentials: jest.fn(), + issueUserCookie: jest.fn(), + requestHeaders: 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. @@ -205,14 +246,6 @@ export namespace mockServices { addAuthPolicy: jest.fn(), })); } - export namespace httpAuth { - export const factory = httpAuthServiceFactory; - export const mock = simpleMock(coreServices.httpAuth, () => ({ - credentials: jest.fn(), - issueUserCookie: jest.fn(), - requestHeaders: jest.fn(), - })); - } export namespace rootHttpRouter { export const factory = rootHttpRouterServiceFactory; export const mock = simpleMock(coreServices.rootHttpRouter, () => ({ From 413a9e7de86f513e6c5495a6fa5e8c5b4a6d8793 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 14:45:08 +0100 Subject: [PATCH 16/42] backend-*-api: updates auth APIs for BEP changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- .../auth/authServiceFactory.test.ts | 15 +++-- .../auth/authServiceFactory.ts | 9 +-- .../httpAuth/httpAuthServiceFactory.ts | 10 +--- packages/backend-common/api-report.md | 2 +- .../src/auth/createLegacyAuthAdapters.ts | 55 ++++++++----------- packages/backend-plugin-api/api-report.md | 19 ++++--- .../src/services/definitions/AuthService.ts | 10 ++-- .../services/definitions/HttpAuthService.ts | 4 -- .../src/next/services/MockAuthService.ts | 11 ++-- .../src/next/services/mockServices.ts | 5 +- yarn.lock | 1 + 11 files changed, 65 insertions(+), 76 deletions(-) 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 index 16f05ef9d0..f24264930a 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.test.ts @@ -51,8 +51,9 @@ describe('authServiceFactory', () => { const searchAuth = await tester.get('search'); const catalogAuth = await tester.get('catalog'); - const { token: searchToken } = await searchAuth.issueServiceToken({ - forward: await searchAuth.getOwnCredentials(), + const { token: searchToken } = await searchAuth.getPluginRequestToken({ + onBehalfOf: await searchAuth.getOwnServiceCredentials(), + targetPluginId: 'catalog', }); await expect(searchAuth.authenticate(searchToken)).resolves.toEqual( @@ -81,8 +82,8 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); await expect( - catalogAuth.issueServiceToken({ - forward: { + catalogAuth.getPluginRequestToken({ + onBehalfOf: { $$type: '@backstage/BackstageCredentials', version: 'v1', authMethod: 'token', @@ -92,6 +93,7 @@ describe('authServiceFactory', () => { userEntityRef: 'user:default/alice', }, } as InternalBackstageCredentials, + targetPluginId: 'catalog', }), ).resolves.toEqual({ token: 'alice-token' }); }); @@ -103,8 +105,8 @@ describe('authServiceFactory', () => { const catalogAuth = await tester.get('catalog'); - const { token } = await catalogAuth.issueServiceToken({ - forward: { + const { token } = await catalogAuth.getPluginRequestToken({ + onBehalfOf: { $$type: '@backstage/BackstageCredentials', version: 'v1', authMethod: 'token', @@ -114,6 +116,7 @@ describe('authServiceFactory', () => { subject: 'external:upstream-service', }, } as InternalBackstageCredentials, + targetPluginId: 'catalog', }); expect(decodeJwt(token)).toEqual( diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 7e9ad3077f..dac8ae6d9e 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -154,16 +154,17 @@ class DefaultAuthService implements AuthService { return true; } - async getOwnCredentials(): Promise< + async getOwnServiceCredentials(): Promise< BackstageCredentials > { return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`); } - async issueServiceToken(options: { - forward: BackstageCredentials; + async getPluginRequestToken(options: { + onBehalfOf: BackstageCredentials; + targetPluginId: string; }): Promise<{ token: string }> { - const internalForward = toInternalBackstageCredentials(options.forward); + const internalForward = toInternalBackstageCredentials(options.onBehalfOf); const { type } = internalForward.principal; switch (type) { diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 1723e3b76c..2182e94684 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -63,7 +63,7 @@ type RequestWithCredentials = Request & { [credentialsSymbol]?: Promise; }; -class DefaultHttpAuthService implements HttpAuthService { +export class DefaultHttpAuthService implements HttpAuthService { constructor( private readonly auth: AuthService, private readonly discovery: DiscoveryService, @@ -132,14 +132,6 @@ class DefaultHttpAuthService implements HttpAuthService { return credentials as any; } - async requestHeaders(options: { - forward: BackstageCredentials; - }): Promise> { - return { - Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, - }; - } - async issueUserCookie(res: Response): Promise { const credentials = await this.credentials(res.req, { allow: ['user'] }); diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index cd4d5ab309..1940a79b78 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -234,7 +234,7 @@ export function createDatabaseClient( }, ): knexFactory.Knex; -// @public (undocumented) +// @public export function createLegacyAuthAdapters< TOptions extends { auth?: AuthService; diff --git a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts index 932c8c421a..98d21ba559 100644 --- a/packages/backend-common/src/auth/createLegacyAuthAdapters.ts +++ b/packages/backend-common/src/auth/createLegacyAuthAdapters.ts @@ -63,42 +63,43 @@ class AuthCompat implements AuthService { return true; } - async getOwnCredentials(): Promise< + async getOwnServiceCredentials(): Promise< BackstageCredentials > { return createCredentialsWithServicePrincipal('external:backstage-plugin'); } async authenticate(token: string): Promise { - const { sub, aud } = decodeJwt(token); + const { aud } = decodeJwt(token); - // Legacy service-to-service token - if (sub === 'backstage-server' && !aud) { - await this.tokenManager.authenticate(token); - return createCredentialsWithServicePrincipal('external:backstage-plugin'); + 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, + ); } - // User Backstage token - const identity = await this.identity.getIdentity({ - request: { - headers: { authorization: `Bearer ${token}` }, - }, - } as IdentityApiGetIdentityRequest); + await this.tokenManager.authenticate(token); - if (!identity) { - throw new AuthenticationError('Invalid user token'); - } - - return createCredentialsWithUserPrincipal( - identity.identity.userEntityRef, - token, - ); + return createCredentialsWithServicePrincipal('external:backstage-plugin'); } - async issueServiceToken(options: { - forward: BackstageCredentials; + async getPluginRequestToken(options: { + onBehalfOf: BackstageCredentials; + targetPluginId: string; }): Promise<{ token: string }> { - const internalForward = toInternalBackstageCredentials(options.forward); + const internalForward = toInternalBackstageCredentials(options.onBehalfOf); const { type } = internalForward.principal; switch (type) { @@ -199,14 +200,6 @@ class HttpAuthCompat implements HttpAuthService { return credentials as any; } - async requestHeaders(options: { - forward: BackstageCredentials; - }): Promise> { - return { - Authorization: `Bearer ${await this.auth.issueServiceToken(options)}`, - }; - } - async issueUserCookie(_res: Response): Promise {} } diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 27dfeb13b6..537e7a8a15 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -22,16 +22,21 @@ export interface AuthService { // (undocumented) authenticate(token: string): Promise; // (undocumented) - getOwnCredentials(): Promise>; + getOwnServiceCredentials(): Promise< + BackstageCredentials + >; + // (undocumented) + getPluginRequestToken(options: { + onBehalfOf: BackstageCredentials; + targetPluginId: string; + }): Promise<{ + token: string; + }>; // (undocumented) isPrincipal( credentials: BackstageCredentials, type: TType, ): credentials is BackstageCredentials; - // (undocumented) - issueServiceToken(options: { forward: BackstageCredentials }): Promise<{ - token: string; - }>; } // @public (undocumented) @@ -295,10 +300,6 @@ export interface HttpAuthService { ): Promise>; // (undocumented) issueUserCookie(res: Response_2): Promise; - // (undocumented) - requestHeaders(options: { - forward: BackstageCredentials; - }): Promise>; } // @public (undocumented) diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index 4566d4a3fa..dc38b572c5 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -70,10 +70,12 @@ export interface AuthService { type: TType, ): credentials is BackstageCredentials; - getOwnCredentials(): Promise>; + getOwnServiceCredentials(): Promise< + BackstageCredentials + >; - // TODO: should the caller provide the target plugin ID? - issueServiceToken(options: { - forward: 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 index cc8aace8ba..bcc31880b3 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -27,9 +27,5 @@ export interface HttpAuthService { }, ): Promise>; - requestHeaders(options: { - forward: BackstageCredentials; - }): Promise>; - issueUserCookie(res: Response): Promise; } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 9af9be4818..925ee1bb6d 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -44,7 +44,7 @@ export class MockAuthService implements AuthService { throw new AuthenticationError('Invalid token'); } - async getOwnCredentials(): Promise< + async getOwnServiceCredentials(): Promise< BackstageCredentials > { return { @@ -68,10 +68,11 @@ export class MockAuthService implements AuthService { return true; } - async issueServiceToken(options: { - forward: BackstageCredentials; + async getPluginRequestToken(options: { + onBehalfOf: BackstageCredentials; + targetPluginId: string; }): Promise<{ token: string }> { - const principal = options.forward.principal as + const principal = options.onBehalfOf.principal as | BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal; @@ -80,7 +81,7 @@ export class MockAuthService implements AuthService { case 'user': return { token: 'mock-user-token' }; case 'service': - return { token: 'mock-service-token' }; + return { token: `mock-service-token-for-${options.targetPluginId}` }; default: throw new AuthenticationError( `Refused to issue service token for credential type '${principal.type}'`, diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index ba43aff008..63f8ffbd93 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -180,9 +180,9 @@ export namespace mockServices { }); export const mock = simpleMock(coreServices.auth, () => ({ authenticate: jest.fn(), - getOwnCredentials: jest.fn(), + getOwnServiceCredentials: jest.fn(), isPrincipal: jest.fn() as any, - issueServiceToken: jest.fn(), + getPluginRequestToken: jest.fn(), })); } @@ -217,7 +217,6 @@ export namespace mockServices { export const mock = simpleMock(coreServices.httpAuth, () => ({ credentials: jest.fn(), issueUserCookie: jest.fn(), - requestHeaders: jest.fn(), })); } diff --git a/yarn.lock b/yarn.lock index 6d841a09db..218faf39a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3287,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 From f52a439239f6d752427631a4a30814cd5cef6e04 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 19:47:48 +0100 Subject: [PATCH 17/42] backend-plugin-api: fix HttpAuthService Request type parameters Signed-off-by: Patrik Oldsberg --- packages/backend-plugin-api/api-report.md | 2 +- .../src/services/definitions/HttpAuthService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 537e7a8a15..320e05dd3d 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -292,7 +292,7 @@ export interface ExtensionPointConfig { export interface HttpAuthService { // (undocumented) credentials( - req: Request_2, + req: Request_2, options?: { allow?: Array; allowedAuthMethods?: Array<'token' | 'cookie'>; diff --git a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts index bcc31880b3..44696bd777 100644 --- a/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/HttpAuthService.ts @@ -20,7 +20,7 @@ import { BackstageCredentials, BackstagePrincipalTypes } from './AuthService'; /** @public */ export interface HttpAuthService { credentials( - req: Request, + req: Request, options?: { allow?: Array; allowedAuthMethods?: Array<'token' | 'cookie'>; From de0244f538b8b9559be2181e396fa1c5dcbc54aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 Feb 2024 19:48:19 +0100 Subject: [PATCH 18/42] backend-test-utils: add mockCredentials and use them to implement MockAuthService Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 28 ++++ .../src/next/services/MockAuthService.ts | 66 ++++++++-- .../src/next/services/index.ts | 1 + .../src/next/services/mockCredentials.ts | 120 ++++++++++++++++++ 4 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 packages/backend-test-utils/src/next/services/mockCredentials.ts diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index cff8a8fda9..05074c7f60 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -9,6 +9,10 @@ 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'; @@ -42,6 +46,30 @@ export function createMockDirectory( // @public (undocumented) export function isDockerDisabledForTests(): boolean; +// @public (undocumented) +export namespace mockCredentials { + export function none(): BackstageCredentials; + export function service( + subject?: string, + ): BackstageCredentials; + export namespace service { + export function token(payload?: TokenPayload): string; + export type TokenPayload = { + subject?: string; + targetPluginId?: string; + }; + } + export function user( + userEntityRef?: string, + ): BackstageCredentials; + export namespace user { + export function token(payload?: TokenPayload): string; + export type TokenPayload = { + userEntityRef: string; + }; + } +} + // @public export interface MockDirectory { addContent(root: MockDirectoryContent): void; diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 925ee1bb6d..b73bb7875f 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -23,25 +23,50 @@ import { 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, + DEFAULT_MOCK_USER_ENTITY_REF, + DEFAULT_MOCK_SERVICE_SUBJECT, +} from './mockCredentials'; /** @internal */ export class MockAuthService implements AuthService { constructor(private readonly pluginId: string) {} async authenticate(token: string): Promise { - if (token === 'mock-user-token') { - return { - $$type: '@backstage/BackstageCredentials', - principal: { type: 'user', userEntityRef: 'user:default/mock' }, - }; - } else if (token === 'mock-service-token') { - return { - $$type: '@backstage/BackstageCredentials', - principal: { type: 'service', subject: 'external:test-service' }, - }; + if (token === MOCK_USER_TOKEN) { + return mockCredentials.user(); + } + if (token === MOCK_SERVICE_TOKEN) { + return mockCredentials.service(); } - throw new AuthenticationError('Invalid token'); + if (token.startsWith(MOCK_USER_TOKEN_PREFIX)) { + const { userEntityRef }: mockCredentials.user.TokenPayload = JSON.parse( + token.slice(MOCK_USER_TOKEN_PREFIX.length), + ); + + return mockCredentials.user(userEntityRef); + } + + if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) { + const { targetPluginId, subject }: mockCredentials.service.TokenPayload = + JSON.parse(token.slice(MOCK_SERVICE_TOKEN_PREFIX.length)); + + if (targetPluginId && targetPluginId !== this.pluginId) { + throw new AuthenticationError( + `Invalid mock token target, got ${targetPluginId} but expected ${this.pluginId}`, + ); + } + + return mockCredentials.service(subject); + } + + throw new AuthenticationError('Invalid mock token'); } async getOwnServiceCredentials(): Promise< @@ -79,9 +104,24 @@ export class MockAuthService implements AuthService { switch (principal.type) { case 'user': - return { token: 'mock-user-token' }; + if (principal.userEntityRef === DEFAULT_MOCK_USER_ENTITY_REF) { + return { token: mockCredentials.user.token() }; + } + return { + token: mockCredentials.user.token({ + userEntityRef: principal.userEntityRef, + }), + }; case 'service': - return { token: `mock-service-token-for-${options.targetPluginId}` }; + return { + token: mockCredentials.service.token({ + targetPluginId: options.targetPluginId, + subject: + principal.subject === DEFAULT_MOCK_SERVICE_SUBJECT + ? undefined + : principal.subject, + }), + }; default: throw new AuthenticationError( `Refused to issue service token for credential type '${principal.type}'`, 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.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts new file mode 100644 index 0000000000..3b77cee9a5 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -0,0 +1,120 @@ +/* + * 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_USER_TOKEN = 'mock-user-token'; +export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:'; +export const MOCK_SERVICE_TOKEN = 'mock-service-token'; +export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:'; + +/** + * @public + */ +export namespace mockCredentials { + /** + * Creates a mocked credentials object for a unauthenticated principal. + */ + export function none(): BackstageCredentials { + return { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'none' }, + }; + } + + /** + * 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 { + return { + $$type: '@backstage/BackstageCredentials', + principal: { type: 'user', userEntityRef }, + }; + } + + /** + * Utilities related to user credentials. + */ + export namespace user { + /** + * The payload that can be encoded into a mock user token. + */ + export type TokenPayload = { userEntityRef?: string }; + + /** + * 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(payload?: TokenPayload): string { + if (payload) { + return `${MOCK_USER_TOKEN_PREFIX}:${JSON.stringify(payload)}`; + } + return MOCK_USER_TOKEN; + } + } + + /** + * 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 { + /** + * The payload that can be encoded into a mock service token. + */ + export type TokenPayload = { + subject?: string; + targetPluginId?: string; + }; + + /** + * Creates a mocked service 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(payload?: TokenPayload): string { + if (payload) { + return `${MOCK_SERVICE_TOKEN_PREFIX}:${payload}`; + } + return MOCK_SERVICE_TOKEN; + } + } +} From 25609ed6623226592dbfc72f9fb589b923c90454 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 11:47:37 +0100 Subject: [PATCH 19/42] backend-test-utils: add MockHttpAuthService Signed-off-by: Patrik Oldsberg --- .../httpAuth/httpAuthServiceFactory.ts | 2 +- .../src/next/services/MockHttpAuthService.ts | 97 +++++++++++++++++++ .../src/next/services/mockServices.ts | 16 ++- 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 packages/backend-test-utils/src/next/services/MockHttpAuthService.ts diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 2182e94684..393991c6d0 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -63,7 +63,7 @@ type RequestWithCredentials = Request & { [credentialsSymbol]?: Promise; }; -export class DefaultHttpAuthService implements HttpAuthService { +class DefaultHttpAuthService implements HttpAuthService { constructor( private readonly auth: AuthService, private readonly discovery: DiscoveryService, 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..d63d8a1769 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -0,0 +1,97 @@ +/* + * 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 { mockCredentials } from './mockCredentials'; +import { MockAuthService } from './MockAuthService'; +import { NotAllowedError, NotImplementedError } from '@backstage/errors'; + +// TODO: support mock cookie auth? +export class MockHttpAuthService implements HttpAuthService { + #auth: AuthService; + + constructor(pluginId: string) { + this.#auth = new MockAuthService(pluginId); + } + + async #getCredentials(req: Request) { + const header = req.headers.authorization; + const token = + typeof header === 'string' + ? header.match(/^Bearer[ ]+(\S+)$/i)?.[1] + : undefined; + if (!token) { + return mockCredentials.none(); + } + + 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, 'unauthenticated')) { + if (allowedPrincipalTypes.includes('none' as TAllowed)) { + return credentials as any; + } + + throw new NotAllowedError( + `This endpoint does not allow 'unauthenticated' credentials`, + ); + } 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/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 63f8ffbd93..0d88c6b377 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -38,7 +38,6 @@ import { rootLifecycleServiceFactory, schedulerServiceFactory, urlReaderServiceFactory, - httpAuthServiceFactory, discoveryServiceFactory, HostDiscovery, } from '@backstage/backend-app-api'; @@ -47,8 +46,7 @@ import { JsonObject } from '@backstage/types'; import { MockIdentityService } from './MockIdentityService'; import { MockRootLoggerService } from './MockRootLoggerService'; import { MockAuthService } from './MockAuthService'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { DefaultHttpAuthService } from '../../../../backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory'; +import { MockHttpAuthService } from './MockHttpAuthService'; /** @internal */ function simpleFactory< @@ -206,14 +204,14 @@ export namespace mockServices { } export function httpAuth(options?: { pluginId?: string }): HttpAuthService { - return new DefaultHttpAuthService( - auth(), - discovery(), - options?.pluginId ?? 'test', - ); + return new MockHttpAuthService(options?.pluginId ?? 'test'); } export namespace httpAuth { - export const factory = httpAuthServiceFactory; + export const factory = createServiceFactory({ + service: coreServices.httpAuth, + deps: { plugin: coreServices.pluginMetadata }, + factory: ({ plugin }) => new MockHttpAuthService(plugin.getId()), + }); export const mock = simpleMock(coreServices.httpAuth, () => ({ credentials: jest.fn(), issueUserCookie: jest.fn(), From 4c72edd4c03cc19f719dbd54b0ac9b1ad0362001 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 12:17:06 +0100 Subject: [PATCH 20/42] backend-app-api: always return true from auth.isPrincipal for unknown checks Signed-off-by: Patrik Oldsberg --- .../src/services/implementations/auth/authServiceFactory.ts | 4 ++++ .../backend-test-utils/src/next/services/MockAuthService.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index dac8ae6d9e..86aeeea74d 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -147,6 +147,10 @@ class DefaultAuthService implements AuthService { | BackstageUserPrincipal | BackstageServicePrincipal; + if (type === 'unknown') { + return true; + } + if (principal.type !== type) { return false; } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index b73bb7875f..6be358b83a 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -86,6 +86,11 @@ export class MockAuthService implements AuthService { | BackstageUserPrincipal | BackstageServicePrincipal | BackstageNonePrincipal; + + if (type === 'unknown') { + return true; + } + if (principal.type !== type) { return false; } From ddb7060e081d54019dfbded8227b5fd8ac862e48 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 12:18:35 +0100 Subject: [PATCH 21/42] backend-plugin-api: switch principal mapping to use none instead of unauthenticated Signed-off-by: Patrik Oldsberg --- packages/backend-plugin-api/api-report.md | 2 +- .../src/services/definitions/AuthService.ts | 2 +- .../src/next/services/MockHttpAuthService.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 320e05dd3d..d676de2fce 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -115,7 +115,7 @@ export type BackstageNonePrincipal = { export type BackstagePrincipalTypes = { user: BackstageUserPrincipal; service: BackstageServicePrincipal; - unauthenticated: BackstageNonePrincipal; + none: BackstageNonePrincipal; unknown: unknown; }; diff --git a/packages/backend-plugin-api/src/services/definitions/AuthService.ts b/packages/backend-plugin-api/src/services/definitions/AuthService.ts index dc38b572c5..eab0a7fac7 100644 --- a/packages/backend-plugin-api/src/services/definitions/AuthService.ts +++ b/packages/backend-plugin-api/src/services/definitions/AuthService.ts @@ -55,7 +55,7 @@ export type BackstageCredentials = { export type BackstagePrincipalTypes = { user: BackstageUserPrincipal; service: BackstageServicePrincipal; - unauthenticated: BackstageNonePrincipal; + none: BackstageNonePrincipal; unknown: unknown; }; diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index d63d8a1769..02eb305915 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -60,13 +60,13 @@ export class MockHttpAuthService implements HttpAuthService { return credentials as any; } - if (this.#auth.isPrincipal(credentials, 'unauthenticated')) { + if (this.#auth.isPrincipal(credentials, 'none')) { if (allowedPrincipalTypes.includes('none' as TAllowed)) { return credentials as any; } throw new NotAllowedError( - `This endpoint does not allow 'unauthenticated' credentials`, + `This endpoint does not allow 'none' credentials`, ); } else if (this.#auth.isPrincipal(credentials, 'user')) { if (allowedPrincipalTypes.includes('user' as TAllowed)) { From f69b9dadf96a2b089632a9a900863bd6eae72e9f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 12:19:09 +0100 Subject: [PATCH 22/42] backend-test-utils: add MockServiceAuth tests + fixes Signed-off-by: Patrik Oldsberg --- .../src/next/services/MockAuthService.test.ts | 169 ++++++++++++++++++ .../src/next/services/MockAuthService.ts | 7 +- .../src/next/services/mockCredentials.ts | 9 +- 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 packages/backend-test-utils/src/next/services/MockAuthService.test.ts 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..e94f944d8b --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockAuthService.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 { 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('test'); + + it('should reject invalid tokens', async () => { + await expect(auth.authenticate('')).rejects.toThrow('Invalid mock token'); + await expect(auth.authenticate('not-a-mock-token')).rejects.toThrow( + 'Invalid 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({ userEntityRef: 'user:default/other' }), + ), + ).resolves.toEqual(mockCredentials.user('user:default/other')); + }); + + 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({ subject: 'plugin:catalog' }), + ), + ).resolves.toEqual(mockCredentials.service('plugin:catalog')); + + await expect( + auth.authenticate( + mockCredentials.service.token({ + targetPluginId: 'test', + }), + ), + ).resolves.toEqual(mockCredentials.service()); + + await expect( + auth.authenticate( + mockCredentials.service.token({ + targetPluginId: 'other', + }), + ), + ).rejects.toThrow( + "Invalid mock token target plugin ID, got 'other' but expected 'test'", + ); + }); + + 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.user.token() }); + + await expect( + auth.getPluginRequestToken({ + onBehalfOf: mockCredentials.service(), + targetPluginId: 'test', + }), + ).resolves.toEqual({ + token: mockCredentials.service.token({ + targetPluginId: 'test', + }), + }); + + await expect( + auth.getPluginRequestToken({ + onBehalfOf: mockCredentials.service('external:other'), + targetPluginId: 'test', + }), + ).resolves.toEqual({ + token: mockCredentials.service.token({ + subject: 'external:other', + targetPluginId: 'test', + }), + }); + + await expect( + auth.getPluginRequestToken({ + onBehalfOf: await auth.getOwnServiceCredentials(), + targetPluginId: 'other', + }), + ).resolves.toEqual({ + token: mockCredentials.service.token({ + subject: 'plugin:test', + targetPluginId: 'other', + }), + }); + }); +}); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 6be358b83a..2ac7fff3a1 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -59,7 +59,7 @@ export class MockAuthService implements AuthService { if (targetPluginId && targetPluginId !== this.pluginId) { throw new AuthenticationError( - `Invalid mock token target, got ${targetPluginId} but expected ${this.pluginId}`, + `Invalid mock token target plugin ID, got '${targetPluginId}' but expected '${this.pluginId}'`, ); } @@ -72,10 +72,7 @@ export class MockAuthService implements AuthService { async getOwnServiceCredentials(): Promise< BackstageCredentials > { - return { - $$type: '@backstage/BackstageCredentials', - principal: { type: 'service', subject: `plugin:${this.pluginId}` }, - }; + return mockCredentials.service(`plugin:${this.pluginId}`); } isPrincipal( diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 3b77cee9a5..2e7a4933a6 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -73,7 +73,8 @@ export namespace mockCredentials { */ export function token(payload?: TokenPayload): string { if (payload) { - return `${MOCK_USER_TOKEN_PREFIX}:${JSON.stringify(payload)}`; + const { userEntityRef } = payload; // for fixed ordering + return `${MOCK_USER_TOKEN_PREFIX}${JSON.stringify({ userEntityRef })}`; } return MOCK_USER_TOKEN; } @@ -112,7 +113,11 @@ export namespace mockCredentials { */ export function token(payload?: TokenPayload): string { if (payload) { - return `${MOCK_SERVICE_TOKEN_PREFIX}:${payload}`; + const { subject, targetPluginId } = payload; // for fixed ordering + return `${MOCK_SERVICE_TOKEN_PREFIX}${JSON.stringify({ + subject, + targetPluginId, + })}`; } return MOCK_SERVICE_TOKEN; } From 67e53eb38feb6c640acb7463bec84935cf368926 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 14:13:10 +0100 Subject: [PATCH 23/42] backend-test-utils: added header utils to mockCredentials Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 4 +++- .../src/next/services/mockCredentials.ts | 18 ++++++++++++++++++ .../src/next/wiring/TestBackend.test.ts | 9 +++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 05074c7f60..5c22cfb149 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -53,6 +53,7 @@ export namespace mockCredentials { subject?: string, ): BackstageCredentials; export namespace service { + export function header(payload?: TokenPayload): string; export function token(payload?: TokenPayload): string; export type TokenPayload = { subject?: string; @@ -63,9 +64,10 @@ export namespace mockCredentials { userEntityRef?: string, ): BackstageCredentials; export namespace user { + export function header(payload?: TokenPayload): string; export function token(payload?: TokenPayload): string; export type TokenPayload = { - userEntityRef: string; + userEntityRef?: string; }; } } diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 2e7a4933a6..7eda645150 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -78,6 +78,15 @@ export namespace mockCredentials { } 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(payload?: TokenPayload): string { + return `Bearer ${token(payload)}`; + } } /** @@ -121,5 +130,14 @@ export namespace mockCredentials { } return MOCK_SERVICE_TOKEN; } + + /** + * Returns an authorization header with a mocked service 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(payload?: TokenPayload): string { + return `Bearer ${token(payload)}`; + } } } 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' }); }); From f3e11ff0ccc117d43e7da8f65f692e0a20fb92ca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 14:36:25 +0100 Subject: [PATCH 24/42] backend-test-utils: mockCredentials refactor for ref validation, header, inline payload + tests Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 4 +- .../src/next/services/MockAuthService.test.ts | 4 +- .../src/next/services/MockAuthService.ts | 4 +- .../src/next/services/mockCredentials.test.ts | 142 ++++++++++++++++++ .../src/next/services/mockCredentials.ts | 19 ++- 5 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 packages/backend-test-utils/src/next/services/mockCredentials.test.ts diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 5c22cfb149..b553d20e48 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -64,8 +64,8 @@ export namespace mockCredentials { userEntityRef?: string, ): BackstageCredentials; export namespace user { - export function header(payload?: TokenPayload): string; - export function token(payload?: TokenPayload): string; + export function header(userEntityRef?: string): string; + export function token(userEntityRef?: string): string; export type TokenPayload = { userEntityRef?: string; }; diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index e94f944d8b..b354301c53 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -55,9 +55,7 @@ describe('MockAuthService', () => { ).resolves.toEqual(mockCredentials.user(DEFAULT_MOCK_USER_ENTITY_REF)); await expect( - auth.authenticate( - mockCredentials.user.token({ userEntityRef: 'user:default/other' }), - ), + auth.authenticate(mockCredentials.user.token('user:default/other')), ).resolves.toEqual(mockCredentials.user('user:default/other')); }); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 2ac7fff3a1..38a068adeb 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -110,9 +110,7 @@ export class MockAuthService implements AuthService { return { token: mockCredentials.user.token() }; } return { - token: mockCredentials.user.token({ - userEntityRef: principal.userEntityRef, - }), + token: mockCredentials.user.token(principal.userEntityRef), }; case 'service': return { 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..aaa2626309 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -0,0 +1,142 @@ +/* + * 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 user tokens and headers', () => { + expect(mockCredentials.user.token()).toBe('mock-user-token'); + expect(mockCredentials.user.token('user:default/other')).toBe( + 'mock-user-token:{"userEntityRef":"user:default/other"}', + ); + expect(mockCredentials.user.header()).toBe('Bearer mock-user-token'); + expect(mockCredentials.user.header('user:default/other')).toBe( + 'Bearer mock-user-token:{"userEntityRef":"user:default/other"}', + ); + }); + + it('creates service tokens and headers', () => { + expect(mockCredentials.service.token()).toBe('mock-service-token'); + expect(mockCredentials.service.token({ subject: 'external:other' })).toBe( + 'mock-service-token:{"subject":"external:other"}', + ); + expect( + mockCredentials.service.token({ + targetPluginId: 'other', + }), + ).toBe('mock-service-token:{"targetPluginId":"other"}'); + expect( + mockCredentials.service.token({ + subject: 'external:other', + targetPluginId: 'other', + }), + ).toBe( + 'mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + ); + // Object keys are reordered, ordering in the token should be stable + expect( + mockCredentials.service.token({ + targetPluginId: 'other', + subject: 'external:other', + }), + ).toBe( + 'mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + ); + + expect(mockCredentials.service.header()).toBe('Bearer mock-service-token'); + expect(mockCredentials.service.header({ subject: 'external:other' })).toBe( + 'Bearer mock-service-token:{"subject":"external:other"}', + ); + expect( + mockCredentials.service.header({ + targetPluginId: 'other', + }), + ).toBe('Bearer mock-service-token:{"targetPluginId":"other"}'); + expect( + mockCredentials.service.header({ + subject: 'external:other', + targetPluginId: 'other', + }), + ).toBe( + 'Bearer mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + ); + // Object keys are reordered, ordering in the token should be stable + expect( + mockCredentials.service.header({ + targetPluginId: 'other', + subject: 'external:other', + }), + ).toBe( + 'Bearer mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + ); + }); + + 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 index 7eda645150..e86916db3b 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -29,6 +29,14 @@ export const MOCK_USER_TOKEN_PREFIX = 'mock-user-token:'; export const MOCK_SERVICE_TOKEN = 'mock-service-token'; export const MOCK_SERVICE_TOKEN_PREFIX = 'mock-service-token:'; +function validateUserEntityRef(ref: string) { + if (!ref.match(/^.+:.+\/.+$/)) { + throw new TypeError( + `Invalid user entity reference '${ref}', expected :/`, + ); + } +} + /** * @public */ @@ -51,6 +59,7 @@ export namespace mockCredentials { export function user( userEntityRef: string = DEFAULT_MOCK_USER_ENTITY_REF, ): BackstageCredentials { + validateUserEntityRef(userEntityRef); return { $$type: '@backstage/BackstageCredentials', principal: { type: 'user', userEntityRef }, @@ -71,9 +80,9 @@ export namespace mockCredentials { * into the token and forwarded to the credentials object when authenticated * by the mock auth service. */ - export function token(payload?: TokenPayload): string { - if (payload) { - const { userEntityRef } = payload; // for fixed ordering + export function token(userEntityRef?: string): string { + if (userEntityRef) { + validateUserEntityRef(userEntityRef); return `${MOCK_USER_TOKEN_PREFIX}${JSON.stringify({ userEntityRef })}`; } return MOCK_USER_TOKEN; @@ -84,8 +93,8 @@ export namespace mockCredentials { * provided it will be encoded into the token and forwarded to the * credentials object when authenticated by the mock auth service. */ - export function header(payload?: TokenPayload): string { - return `Bearer ${token(payload)}`; + export function header(userEntityRef?: string): string { + return `Bearer ${token(userEntityRef)}`; } } From 74fb755273290021653f0e4dfe8e3d8cd569562e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 15:00:37 +0100 Subject: [PATCH 25/42] backend-test-utils: added mockCredentials utils for invalid credentials Signed-off-by: Patrik Oldsberg --- .../src/next/services/MockAuthService.test.ts | 8 ++++++++ .../src/next/services/MockAuthService.ts | 17 ++++++++++++----- .../src/next/services/mockCredentials.test.ts | 11 +++++++++++ .../src/next/services/mockCredentials.ts | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index b354301c53..7a6e562c98 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -57,6 +57,10 @@ describe('MockAuthService', () => { 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 () => { @@ -91,6 +95,10 @@ describe('MockAuthService', () => { ).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 () => { diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 38a068adeb..7d1bbc0733 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -31,6 +31,8 @@ import { MOCK_SERVICE_TOKEN_PREFIX, DEFAULT_MOCK_USER_ENTITY_REF, DEFAULT_MOCK_SERVICE_SUBJECT, + MOCK_INVALID_USER_TOKEN, + MOCK_INVALID_SERVICE_TOKEN, } from './mockCredentials'; /** @internal */ @@ -38,11 +40,16 @@ export class MockAuthService implements AuthService { constructor(private readonly pluginId: string) {} async authenticate(token: string): Promise { - if (token === MOCK_USER_TOKEN) { - return mockCredentials.user(); - } - if (token === MOCK_SERVICE_TOKEN) { - return mockCredentials.service(); + 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'); + default: } if (token.startsWith(MOCK_USER_TOKEN_PREFIX)) { diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index aaa2626309..3d7540a0ee 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -53,10 +53,15 @@ describe('mockCredentials', () => { expect(mockCredentials.user.token('user:default/other')).toBe( 'mock-user-token:{"userEntityRef":"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:{"userEntityRef":"user:default/other"}', ); + expect(mockCredentials.user.invalidHeader()).toBe( + 'Bearer mock-invalid-user-token', + ); }); it('creates service tokens and headers', () => { @@ -86,6 +91,9 @@ describe('mockCredentials', () => { ).toBe( 'mock-service-token:{"subject":"external:other","targetPluginId":"other"}', ); + expect(mockCredentials.service.invalidToken()).toBe( + 'mock-invalid-service-token', + ); expect(mockCredentials.service.header()).toBe('Bearer mock-service-token'); expect(mockCredentials.service.header({ subject: 'external:other' })).toBe( @@ -113,6 +121,9 @@ describe('mockCredentials', () => { ).toBe( 'Bearer mock-service-token:{"subject":"external:other","targetPluginId":"other"}', ); + expect(mockCredentials.service.invalidHeader()).toBe( + 'Bearer mock-invalid-service-token', + ); }); it('should throw on invalid user entity refs', () => { diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index e86916db3b..cf516c403a 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -26,8 +26,10 @@ export const DEFAULT_MOCK_SERVICE_SUBJECT = 'external:test-service'; 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(/^.+:.+\/.+$/)) { @@ -96,6 +98,14 @@ export namespace mockCredentials { 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()}`; + } } /** @@ -148,5 +158,13 @@ export namespace mockCredentials { export function header(payload?: TokenPayload): string { return `Bearer ${token(payload)}`; } + + export function invalidToken(): string { + return MOCK_INVALID_SERVICE_TOKEN; + } + + export function invalidHeader(): string { + return `Bearer ${invalidToken()}`; + } } } From fe78da86f443e050528b2cd4cd6a389fcea905ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 15:23:01 +0100 Subject: [PATCH 26/42] backend-test-utils: added tests for MockHttpAuthService + fixes Signed-off-by: Patrik Oldsberg --- .../src/next/services/MockAuthService.test.ts | 4 +- .../src/next/services/MockAuthService.ts | 4 +- .../next/services/MockHttpAuthService.test.ts | 91 +++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 7a6e562c98..cf7b23e999 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -27,9 +27,9 @@ describe('MockAuthService', () => { const auth = new MockAuthService('test'); it('should reject invalid tokens', async () => { - await expect(auth.authenticate('')).rejects.toThrow('Invalid mock token'); + await expect(auth.authenticate('')).rejects.toThrow('Token is empty'); await expect(auth.authenticate('not-a-mock-token')).rejects.toThrow( - 'Invalid mock token', + "Unknown mock token 'not-a-mock-token'", ); await expect(auth.authenticate(MOCK_USER_TOKEN_PREFIX)).rejects.toThrow( 'Unexpected end of JSON input', diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 7d1bbc0733..b5debd5c7d 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -49,6 +49,8 @@ export class MockAuthService implements AuthService { 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: } @@ -73,7 +75,7 @@ export class MockAuthService implements AuthService { return mockCredentials.service(subject); } - throw new AuthenticationError('Invalid mock token'); + throw new AuthenticationError(`Unknown mock token '${token}'`); } async getOwnServiceCredentials(): Promise< 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..84d7c84ec3 --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -0,0 +1,91 @@ +/* + * 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'; + +describe('MockHttpAuthService', () => { + const httpAuth = new MockHttpAuthService('test'); + + it('should authenticate requests', async () => { + await expect( + httpAuth.credentials({ + headers: {}, + } as Request), + ).resolves.toEqual(mockCredentials.none()); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.user.header(), + }, + } as Request), + ).resolves.toEqual(mockCredentials.user()); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.user.header('user:default/other'), + }, + } as Request), + ).resolves.toEqual(mockCredentials.user('user:default/other')); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.service.header(), + }, + } as Request), + ).resolves.toEqual(mockCredentials.service()); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.service.header({ + subject: 'plugin:other', + }), + }, + } as Request), + ).resolves.toEqual(mockCredentials.service('plugin:other')); + }); + + it('should reject invalid credentials', async () => { + await expect( + httpAuth.credentials({ + headers: { + authorization: 'Bearer bad', + }, + } as Request), + ).rejects.toThrow("Unknown mock token 'bad'"); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.user.invalidHeader(), + }, + } as Request), + ).rejects.toThrow('User token is invalid'); + + await expect( + httpAuth.credentials({ + headers: { + authorization: mockCredentials.service.invalidHeader(), + }, + } as Request), + ).rejects.toThrow('Service token is invalid'); + }); +}); From cacf422f1f4c29fb016aea8d18c94b72657e534d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 16 Feb 2024 16:55:39 +0100 Subject: [PATCH 27/42] backend-test-utils: more test coverage for mock auth services Signed-off-by: Patrik Oldsberg --- .../src/next/services/MockAuthService.test.ts | 18 +++ .../next/services/MockHttpAuthService.test.ts | 110 +++++++++++------- 2 files changed, 86 insertions(+), 42 deletions(-) diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index cf7b23e999..63a3e83c89 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -137,6 +137,15 @@ describe('MockAuthService', () => { }), ).resolves.toEqual({ token: mockCredentials.user.token() }); + await expect( + auth.getPluginRequestToken({ + onBehalfOf: mockCredentials.user('user:default/other'), + targetPluginId: 'test', + }), + ).resolves.toEqual({ + token: mockCredentials.user.token('user:default/other'), + }); + await expect( auth.getPluginRequestToken({ onBehalfOf: mockCredentials.service(), @@ -171,5 +180,14 @@ describe('MockAuthService', () => { 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/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts index 84d7c84ec3..62810604a6 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -21,71 +21,97 @@ import { mockCredentials } from './mockCredentials'; describe('MockHttpAuthService', () => { const httpAuth = new MockHttpAuthService('test'); - it('should authenticate requests', async () => { + 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({ - headers: {}, - } as Request), + httpAuth.credentials(makeAuthReq(), { allow: ['none'] }), ).resolves.toEqual(mockCredentials.none()); await expect( - httpAuth.credentials({ - headers: { - authorization: mockCredentials.user.header(), - }, - } as Request), + httpAuth.credentials(makeAuthReq(), { allow: ['user'] }), + ).rejects.toThrow("This endpoint does not allow 'none' credentials"); + + 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({ - headers: { - authorization: mockCredentials.user.header('user:default/other'), - }, - } as Request), - ).resolves.toEqual(mockCredentials.user('user:default/other')); + httpAuth.credentials(makeAuthReq(mockCredentials.user.header()), { + allow: ['user'], + }), + ).resolves.toEqual(mockCredentials.user()); await expect( - httpAuth.credentials({ - headers: { - authorization: mockCredentials.service.header(), - }, - } as Request), + 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({ - headers: { - authorization: mockCredentials.service.header({ - subject: 'plugin:other', - }), - }, - } as Request), + httpAuth.credentials(makeAuthReq(mockCredentials.service.header()), { + allow: ['none', 'service'], + }), + ).resolves.toEqual(mockCredentials.service()); + + await expect( + httpAuth.credentials( + makeAuthReq( + mockCredentials.service.header({ subject: 'plugin:other' }), + ), + ), ).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 reject invalid credentials', async () => { await expect( - httpAuth.credentials({ - headers: { - authorization: 'Bearer bad', - }, - } as Request), + httpAuth.credentials(makeAuthReq('Bearer bad')), ).rejects.toThrow("Unknown mock token 'bad'"); await expect( - httpAuth.credentials({ - headers: { - authorization: mockCredentials.user.invalidHeader(), - }, - } as Request), + httpAuth.credentials(makeAuthReq(mockCredentials.user.invalidHeader())), ).rejects.toThrow('User token is invalid'); await expect( - httpAuth.credentials({ - headers: { - authorization: mockCredentials.service.invalidHeader(), - }, - } as Request), + 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', + ); + }); }); From 663fce7457d911fc724900a1d6186747a15499fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 17 Feb 2024 01:16:22 +0100 Subject: [PATCH 28/42] backend-app-api: fix HttpRouter credentials barrier Signed-off-by: Patrik Oldsberg --- .../implementations/httpRouter/createCredentialsBarrier.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts index 6cdff9d32c..d786df1ecc 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -73,9 +73,9 @@ export function createCredentialsBarrier(options: { unauthenticatedPredicates.push(createPathPolicyPredicate(policy.path)); } else if (policy.allow === 'user-cookie') { cookiePredicates.push(createPathPolicyPredicate(policy.path)); + } else { + throw new Error('Invalid auth policy'); } - - throw new Error('Invalid auth policy'); }; return { middleware, addAuthPolicy }; From caf1af65415adc77ad6a8fc14096bc82094a5d14 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 17 Feb 2024 12:57:39 +0100 Subject: [PATCH 29/42] backend-test-utils: add default credentials for mock http auth + explicit none credentials header Signed-off-by: Patrik Oldsberg --- .../next/services/MockHttpAuthService.test.ts | 50 ++++++++++++++++++- .../src/next/services/MockHttpAuthService.ts | 14 ++++-- .../src/next/services/mockCredentials.test.ts | 4 ++ .../src/next/services/mockCredentials.ts | 20 ++++++++ .../src/next/services/mockServices.ts | 49 +++++++++++++++--- 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts index 62810604a6..d441fd0108 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -19,7 +19,7 @@ import { MockHttpAuthService } from './MockHttpAuthService'; import { mockCredentials } from './mockCredentials'; describe('MockHttpAuthService', () => { - const httpAuth = new MockHttpAuthService('test'); + const httpAuth = new MockHttpAuthService('test', mockCredentials.none()); function makeAuthReq(header?: string) { return { headers: { authorization: header } } as Request; @@ -93,6 +93,54 @@ describe('MockHttpAuthService', () => { ).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')), diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 02eb305915..79a940789a 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -21,26 +21,34 @@ import { HttpAuthService, } from '@backstage/backend-plugin-api'; import { Request, Response } from 'express'; -import { mockCredentials } from './mockCredentials'; import { MockAuthService } from './MockAuthService'; import { 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) { + constructor(pluginId: string, defaultCredentials: BackstageCredentials) { this.#auth = new MockAuthService(pluginId); + 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 mockCredentials.none(); + return this.#defaultCredentials; } return await this.#auth.authenticate(token); diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index 3d7540a0ee..7b0ff1e9be 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -48,6 +48,10 @@ describe('mockCredentials', () => { }); }); + 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( diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index cf516c403a..3b440b3a31 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -24,6 +24,7 @@ import { 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'; @@ -53,6 +54,25 @@ export namespace mockCredentials { }; } + /** + * 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. * diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 0d88c6b377..e87319c669 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -26,6 +26,7 @@ import { AuthService, DiscoveryService, HttpAuthService, + BackstageCredentials, } from '@backstage/backend-plugin-api'; import { cacheServiceFactory, @@ -47,6 +48,7 @@ import { MockIdentityService } from './MockIdentityService'; import { MockRootLoggerService } from './MockRootLoggerService'; import { MockAuthService } from './MockAuthService'; import { MockHttpAuthService } from './MockHttpAuthService'; +import { mockCredentials } from './mockCredentials'; /** @internal */ function simpleFactory< @@ -203,15 +205,48 @@ export namespace mockServices { })); } - export function httpAuth(options?: { pluginId?: string }): HttpAuthService { - return new MockHttpAuthService(options?.pluginId ?? 'test'); + /** + * 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 { - export const factory = createServiceFactory({ - service: coreServices.httpAuth, - deps: { plugin: coreServices.pluginMetadata }, - factory: ({ plugin }) => new MockHttpAuthService(plugin.getId()), - }); + /** + * 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(), From faf5190d739c54b11d38a5be3c189e22530a9531 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 17 Feb 2024 18:00:42 +0100 Subject: [PATCH 30/42] backend-test-utils: refactor mock service auth tokens to mirror creation options Signed-off-by: Patrik Oldsberg --- packages/backend-test-utils/api-report.md | 39 +++++++--- .../src/next/services/MockAuthService.test.ts | 30 ++++++-- .../src/next/services/MockAuthService.ts | 53 ++++++------- .../next/services/MockHttpAuthService.test.ts | 5 +- .../src/next/services/mockCredentials.test.ts | 42 +++-------- .../src/next/services/mockCredentials.ts | 74 +++++++++++++------ 6 files changed, 138 insertions(+), 105 deletions(-) diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index b553d20e48..949caddcfa 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -49,15 +49,22 @@ 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(payload?: TokenPayload): string; - export function token(payload?: TokenPayload): string; - export type TokenPayload = { - subject?: string; - targetPluginId?: string; + 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( @@ -65,10 +72,11 @@ export namespace mockCredentials { ): 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; - export type TokenPayload = { - userEntityRef?: string; - }; } } @@ -159,12 +167,19 @@ export namespace mockServices { partialImpl?: Partial | undefined, ) => ServiceMock; } - // (undocumented) - export function httpAuth(options?: { pluginId?: string }): HttpAuthService; + export function httpAuth(options?: { + pluginId?: string; + defaultCredentials?: BackstageCredentials; + }): HttpAuthService; // (undocumented) export namespace httpAuth { - const // (undocumented) - factory: () => ServiceFactory; + const factory: ( + options?: + | { + defaultCredentials?: BackstageCredentials | undefined; + } + | undefined, + ) => ServiceFactory; const // (undocumented) mock: ( partialImpl?: Partial | undefined, diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 63a3e83c89..261bc90f77 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -72,23 +72,32 @@ describe('MockAuthService', () => { 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({ subject: 'plugin:catalog' }), + 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()); + ).resolves.toEqual(mockCredentials.service('plugin:test')); await expect( auth.authenticate( mockCredentials.service.token({ + onBehalfOf: await auth.getOwnServiceCredentials(), targetPluginId: 'other', }), ), @@ -135,7 +144,12 @@ describe('MockAuthService', () => { onBehalfOf: mockCredentials.user(), targetPluginId: 'test', }), - ).resolves.toEqual({ token: mockCredentials.user.token() }); + ).resolves.toEqual({ + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.user(), + targetPluginId: 'test', + }), + }); await expect( auth.getPluginRequestToken({ @@ -143,7 +157,10 @@ describe('MockAuthService', () => { targetPluginId: 'test', }), ).resolves.toEqual({ - token: mockCredentials.user.token('user:default/other'), + token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.user('user:default/other'), + targetPluginId: 'test', + }), }); await expect( @@ -153,6 +170,7 @@ describe('MockAuthService', () => { }), ).resolves.toEqual({ token: mockCredentials.service.token({ + onBehalfOf: mockCredentials.service(), targetPluginId: 'test', }), }); @@ -164,7 +182,7 @@ describe('MockAuthService', () => { }), ).resolves.toEqual({ token: mockCredentials.service.token({ - subject: 'external:other', + onBehalfOf: mockCredentials.service('external:other'), targetPluginId: 'test', }), }); @@ -176,7 +194,7 @@ describe('MockAuthService', () => { }), ).resolves.toEqual({ token: mockCredentials.service.token({ - subject: 'plugin:test', + onBehalfOf: await mockCredentials.service('plugin:test'), targetPluginId: 'other', }), }); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index b5debd5c7d..5c58240b42 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -29,10 +29,10 @@ import { MOCK_USER_TOKEN_PREFIX, MOCK_SERVICE_TOKEN, MOCK_SERVICE_TOKEN_PREFIX, - DEFAULT_MOCK_USER_ENTITY_REF, - DEFAULT_MOCK_SERVICE_SUBJECT, MOCK_INVALID_USER_TOKEN, MOCK_INVALID_SERVICE_TOKEN, + UserTokenPayload, + ServiceTokenPayload, } from './mockCredentials'; /** @internal */ @@ -55,7 +55,7 @@ export class MockAuthService implements AuthService { } if (token.startsWith(MOCK_USER_TOKEN_PREFIX)) { - const { userEntityRef }: mockCredentials.user.TokenPayload = JSON.parse( + const { sub: userEntityRef }: UserTokenPayload = JSON.parse( token.slice(MOCK_USER_TOKEN_PREFIX.length), ); @@ -63,16 +63,20 @@ export class MockAuthService implements AuthService { } if (token.startsWith(MOCK_SERVICE_TOKEN_PREFIX)) { - const { targetPluginId, subject }: mockCredentials.service.TokenPayload = - JSON.parse(token.slice(MOCK_SERVICE_TOKEN_PREFIX.length)); + const { sub, target, obo }: ServiceTokenPayload = JSON.parse( + token.slice(MOCK_SERVICE_TOKEN_PREFIX.length), + ); - if (targetPluginId && targetPluginId !== this.pluginId) { + if (target && target !== this.pluginId) { throw new AuthenticationError( - `Invalid mock token target plugin ID, got '${targetPluginId}' but expected '${this.pluginId}'`, + `Invalid mock token target plugin ID, got '${target}' but expected '${this.pluginId}'`, ); } + if (obo) { + return mockCredentials.user(obo); + } - return mockCredentials.service(subject); + return mockCredentials.service(sub); } throw new AuthenticationError(`Unknown mock token '${token}'`); @@ -113,28 +117,17 @@ export class MockAuthService implements AuthService { | BackstageServicePrincipal | BackstageNonePrincipal; - switch (principal.type) { - case 'user': - if (principal.userEntityRef === DEFAULT_MOCK_USER_ENTITY_REF) { - return { token: mockCredentials.user.token() }; - } - return { - token: mockCredentials.user.token(principal.userEntityRef), - }; - case 'service': - return { - token: mockCredentials.service.token({ - targetPluginId: options.targetPluginId, - subject: - principal.subject === DEFAULT_MOCK_SERVICE_SUBJECT - ? undefined - : principal.subject, - }), - }; - default: - throw new AuthenticationError( - `Refused to issue service token for credential type '${principal.type}'`, - ); + 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 index d441fd0108..6f21c709f7 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -81,7 +81,10 @@ describe('MockHttpAuthService', () => { await expect( httpAuth.credentials( makeAuthReq( - mockCredentials.service.header({ subject: 'plugin:other' }), + mockCredentials.service.header({ + onBehalfOf: mockCredentials.service('plugin:other'), + targetPluginId: 'test', + }), ), ), ).resolves.toEqual(mockCredentials.service('plugin:other')); diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts index 7b0ff1e9be..67cda92be8 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.test.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.test.ts @@ -55,13 +55,13 @@ describe('mockCredentials', () => { 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:{"userEntityRef":"user:default/other"}', + '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:{"userEntityRef":"user:default/other"}', + 'Bearer mock-user-token:{"sub":"user:default/other"}', ); expect(mockCredentials.user.invalidHeader()).toBe( 'Bearer mock-invalid-user-token', @@ -70,60 +70,38 @@ describe('mockCredentials', () => { it('creates service tokens and headers', () => { expect(mockCredentials.service.token()).toBe('mock-service-token'); - expect(mockCredentials.service.token({ subject: 'external:other' })).toBe( - 'mock-service-token:{"subject":"external:other"}', - ); expect( mockCredentials.service.token({ + onBehalfOf: mockCredentials.service('external:other'), targetPluginId: 'other', }), - ).toBe('mock-service-token:{"targetPluginId":"other"}'); + ).toBe('mock-service-token:{"sub":"external:other","target":"other"}'); expect( mockCredentials.service.token({ - subject: 'external:other', + onBehalfOf: mockCredentials.user('user:default/other'), targetPluginId: 'other', }), - ).toBe( - 'mock-service-token:{"subject":"external:other","targetPluginId":"other"}', - ); - // Object keys are reordered, ordering in the token should be stable - expect( - mockCredentials.service.token({ - targetPluginId: 'other', - subject: 'external:other', - }), - ).toBe( - 'mock-service-token:{"subject":"external: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({ subject: 'external:other' })).toBe( - 'Bearer mock-service-token:{"subject":"external:other"}', - ); expect( mockCredentials.service.header({ - targetPluginId: 'other', - }), - ).toBe('Bearer mock-service-token:{"targetPluginId":"other"}'); - expect( - mockCredentials.service.header({ - subject: 'external:other', + onBehalfOf: mockCredentials.service('external:other'), targetPluginId: 'other', }), ).toBe( - 'Bearer mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + 'Bearer mock-service-token:{"sub":"external:other","target":"other"}', ); - // Object keys are reordered, ordering in the token should be stable expect( mockCredentials.service.header({ + onBehalfOf: mockCredentials.user('user:default/other'), targetPluginId: 'other', - subject: 'external:other', }), ).toBe( - 'Bearer mock-service-token:{"subject":"external:other","targetPluginId":"other"}', + 'Bearer mock-service-token:{"obo":"user:default/other","target":"other"}', ); expect(mockCredentials.service.invalidHeader()).toBe( 'Bearer mock-invalid-service-token', diff --git a/packages/backend-test-utils/src/next/services/mockCredentials.ts b/packages/backend-test-utils/src/next/services/mockCredentials.ts index 3b440b3a31..8dac9c1a4b 100644 --- a/packages/backend-test-utils/src/next/services/mockCredentials.ts +++ b/packages/backend-test-utils/src/next/services/mockCredentials.ts @@ -40,6 +40,24 @@ function validateUserEntityRef(ref: string) { } } +/** + * 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 */ @@ -92,11 +110,6 @@ export namespace mockCredentials { * Utilities related to user credentials. */ export namespace user { - /** - * The payload that can be encoded into a mock user token. - */ - export type TokenPayload = { userEntityRef?: string }; - /** * 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 @@ -105,7 +118,9 @@ export namespace mockCredentials { export function token(userEntityRef?: string): string { if (userEntityRef) { validateUserEntityRef(userEntityRef); - return `${MOCK_USER_TOKEN_PREFIX}${JSON.stringify({ userEntityRef })}`; + return `${MOCK_USER_TOKEN_PREFIX}${JSON.stringify({ + sub: userEntityRef, + } satisfies UserTokenPayload)}`; } return MOCK_USER_TOKEN; } @@ -147,36 +162,47 @@ export namespace mockCredentials { */ export namespace service { /** - * The payload that can be encoded into a mock service token. + * Options for the creation of mock service tokens. */ - export type TokenPayload = { - subject?: string; - targetPluginId?: string; + export type TokenOptions = { + onBehalfOf: BackstageCredentials; + targetPluginId: string; }; /** - * Creates a mocked service 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. + * 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(payload?: TokenPayload): string { - if (payload) { - const { subject, targetPluginId } = payload; // for fixed ordering + 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({ - subject, - targetPluginId, - })}`; + sub: subject, + obo, + target: targetPluginId, + } satisfies ServiceTokenPayload)}`; } return MOCK_SERVICE_TOKEN; } /** - * Returns an authorization header with a mocked service 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. + * 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(payload?: TokenPayload): string { - return `Bearer ${token(payload)}`; + export function header(options?: TokenOptions): string { + return `Bearer ${token(options)}`; } export function invalidToken(): string { From cb993f9dc80b05f662f99bf635d092c2f6387c5d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Feb 2024 16:25:59 +0100 Subject: [PATCH 31/42] backend-app-api: treat rejected none credentials as 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- .../implementations/httpAuth/httpAuthServiceFactory.ts | 3 +++ .../src/next/services/MockHttpAuthService.test.ts | 3 ++- .../src/next/services/MockHttpAuthService.ts | 10 ++++++---- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 393991c6d0..44199f4256 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -124,6 +124,9 @@ class DefaultHttpAuthService implements HttpAuthService { 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`, ); diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts index 6f21c709f7..922b3daaf4 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.test.ts @@ -17,6 +17,7 @@ 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()); @@ -36,7 +37,7 @@ describe('MockHttpAuthService', () => { await expect( httpAuth.credentials(makeAuthReq(), { allow: ['user'] }), - ).rejects.toThrow("This endpoint does not allow 'none' credentials"); + ).rejects.toThrow(AuthenticationError); await expect(httpAuth.credentials(makeAuthReq())).resolves.toEqual( mockCredentials.none(), diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 79a940789a..2f067e9dcb 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -22,7 +22,11 @@ import { } from '@backstage/backend-plugin-api'; import { Request, Response } from 'express'; import { MockAuthService } from './MockAuthService'; -import { NotAllowedError, NotImplementedError } from '@backstage/errors'; +import { + AuthenticationError, + NotAllowedError, + NotImplementedError, +} from '@backstage/errors'; import { mockCredentials } from './mockCredentials'; // TODO: support mock cookie auth? @@ -73,9 +77,7 @@ export class MockHttpAuthService implements HttpAuthService { return credentials as any; } - throw new NotAllowedError( - `This endpoint does not allow 'none' credentials`, - ); + throw new AuthenticationError(); } else if (this.#auth.isPrincipal(credentials, 'user')) { if (allowedPrincipalTypes.includes('user' as TAllowed)) { return credentials as any; From a2b90a82d43da4a50aea776b005b293bdd671f8c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Feb 2024 18:46:33 +0100 Subject: [PATCH 32/42] backend-app-api: added backend.auth.dangerouslyDisableDefaultAuthPolicy config + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- packages/backend-app-api/config.d.ts | 20 ++ .../auth/authServiceFactory.ts | 24 ++- .../httpRouter/createCredentialsBarrier.ts | 15 +- .../httpRouterServiceFactory.test.ts | 194 ++++++++++++++++++ .../httpRouter/httpRouterServiceFactory.ts | 5 +- packages/backend-test-utils/api-report.md | 5 +- .../src/next/services/MockAuthService.test.ts | 5 +- .../src/next/services/MockAuthService.ts | 15 +- .../src/next/services/MockHttpAuthService.ts | 5 +- .../src/next/services/mockServices.ts | 27 ++- 10 files changed, 302 insertions(+), 13 deletions(-) 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/src/services/implementations/auth/authServiceFactory.ts b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts index 86aeeea74d..72d5635f61 100644 --- a/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/auth/authServiceFactory.ts @@ -111,6 +111,7 @@ class DefaultAuthService implements AuthService { private readonly tokenManager: TokenManager, private readonly identity: IdentityService, private readonly pluginId: string, + private readonly disableDefaultAuthPolicy: boolean, ) {} async authenticate(token: string): Promise { @@ -171,6 +172,15 @@ class DefaultAuthService implements AuthService { 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': @@ -200,8 +210,18 @@ export const authServiceFactory = createServiceFactory({ createRootContext({ config, logger }) { return ServerTokenManager.fromConfig(config, { logger }); }, - async factory({ discovery, plugin }, tokenManager) { + async factory({ discovery, config, plugin }, tokenManager) { const identity = DefaultIdentityClient.create({ discovery }); - return new DefaultAuthService(tokenManager, identity, plugin.getId()); + 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/httpRouter/createCredentialsBarrier.ts b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts index d786df1ecc..801c87d430 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/createCredentialsBarrier.ts @@ -17,6 +17,7 @@ import { HttpAuthService, HttpRouterServiceAuthPolicy, + RootConfigService, } from '@backstage/backend-plugin-api'; import { RequestHandler } from 'express'; import { pathToRegexp } from 'path-to-regexp'; @@ -37,11 +38,23 @@ export function createPathPolicyPredicate(policyPath: string) { export function createCredentialsBarrier(options: { httpAuth: HttpAuthService; + config: RootConfigService; }): { middleware: RequestHandler; addAuthPolicy: (policy: HttpRouterServiceAuthPolicy) => void; } { - const { httpAuth } = options; + 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>(); 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 6c89834f2b..e72d091b96 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.ts @@ -40,18 +40,19 @@ 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({ httpAuth, 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 }); + const credentialsBarrier = createCredentialsBarrier({ httpAuth, config }); router.use(createLifecycleMiddleware({ lifecycle })); router.use(credentialsBarrier.middleware); diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 949caddcfa..a9ebb402c4 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -128,7 +128,10 @@ export interface MockDirectoryOptions { // @public (undocumented) export namespace mockServices { // (undocumented) - export function auth(options?: { pluginId?: string }): AuthService; + export function auth(options?: { + pluginId?: string; + disableDefaultAuthPolicy?: boolean; + }): AuthService; // (undocumented) export namespace auth { const // (undocumented) diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts index 261bc90f77..dfb54b9762 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.test.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.test.ts @@ -24,7 +24,10 @@ import { } from './mockCredentials'; describe('MockAuthService', () => { - const auth = new MockAuthService('test'); + const auth = new MockAuthService({ + pluginId: 'test', + disableDefaultAuthPolicy: false, + }); it('should reject invalid tokens', async () => { await expect(auth.authenticate('')).rejects.toThrow('Token is empty'); diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 5c58240b42..5735755f85 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -37,7 +37,16 @@ import { /** @internal */ export class MockAuthService implements AuthService { - constructor(private readonly pluginId: string) {} + 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) { @@ -117,6 +126,10 @@ export class MockAuthService implements AuthService { | 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}'`, diff --git a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts index 2f067e9dcb..9b133f996b 100644 --- a/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockHttpAuthService.ts @@ -35,7 +35,10 @@ export class MockHttpAuthService implements HttpAuthService { #defaultCredentials: BackstageCredentials; constructor(pluginId: string, defaultCredentials: BackstageCredentials) { - this.#auth = new MockAuthService(pluginId); + this.#auth = new MockAuthService({ + pluginId, + disableDefaultAuthPolicy: false, + }); this.#defaultCredentials = defaultCredentials; } diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index e87319c669..f72f229434 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -169,14 +169,33 @@ export namespace mockServices { })); } - export function auth(options?: { pluginId?: string }): AuthService { - return new MockAuthService(options?.pluginId ?? 'test'); + 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 }, - factory: ({ plugin }) => new MockAuthService(plugin.getId()), + 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(), From badc55b6c7671e6b142773eef328c084e263ab36 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Feb 2024 19:08:53 +0100 Subject: [PATCH 33/42] kubernetes-backend: workaround for failing integration tests Signed-off-by: Patrik Oldsberg --- .../kubernetes-backend/src/routes/resourceRoutes.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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'), From 7fe5355575aa992f9a12778c0b1e1920e526029d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Feb 2024 14:52:14 +0100 Subject: [PATCH 34/42] app-config: disable default auth policy for development for now Signed-off-by: Patrik Oldsberg --- app-config.yaml | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 9c4588183d23d2c0c7c510e5eea10edb0aad1ceb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Feb 2024 14:53:52 +0100 Subject: [PATCH 35/42] backend-defaults: add new auth services Signed-off-by: Patrik Oldsberg --- packages/backend-defaults/src/CreateBackend.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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(), ]; From b68b36b4558c9409e64c977f41530c5afce51a63 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Feb 2024 14:54:37 +0100 Subject: [PATCH 36/42] backend-next: add auth backend with github provider for local development Signed-off-by: Patrik Oldsberg --- packages/backend-next/package.json | 2 + .../src/authModuleGithubProvider.ts | 57 +++++++++++++++++++ packages/backend-next/src/index.ts | 3 + yarn.lock | 4 +- 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/backend-next/src/authModuleGithubProvider.ts diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index 98ae4c6ac4..c1f09cd0cb 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -30,6 +30,8 @@ "@backstage/backend-tasks": "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..5b71f35567 --- /dev/null +++ b/packages/backend-next/src/authModuleGithubProvider.ts @@ -0,0 +1,57 @@ +/* + * 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 { 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 = `user:default/${userId}`; + + 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/yarn.lock b/yarn.lock index 218faf39a1..3726b83cf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27358,6 +27358,8 @@ __metadata: "@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:^" @@ -32125,7 +32127,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.15.4": +"jose@npm:^4.15.4, jose@npm:^4.6.0": version: 4.15.4 resolution: "jose@npm:4.15.4" checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b From 4a3d434095c4bac92a03f4486f636d95e0fe768a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 21 Feb 2024 14:59:53 +0100 Subject: [PATCH 37/42] add changesets, bump to jose 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/big-yaks-film.md | 7 +++++++ .changeset/eleven-cows-learn.md | 9 +++++++++ .changeset/smart-owls-tease.md | 7 +++++++ .changeset/thirty-shirts-allow.md | 7 +++++++ packages/backend-app-api/package.json | 2 +- yarn.lock | 4 ++-- 6 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 .changeset/big-yaks-film.md create mode 100644 .changeset/eleven-cows-learn.md create mode 100644 .changeset/smart-owls-tease.md create mode 100644 .changeset/thirty-shirts-allow.md 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/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/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index d5985d1172..448b4c500a 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -66,7 +66,7 @@ "express-promise-router": "^4.1.0", "fs-extra": "^11.2.0", "helmet": "^6.0.0", - "jose": "^4.6.0", + "jose": "^5.0.0", "lodash": "^4.17.21", "logform": "^2.3.2", "minimatch": "^5.0.0", diff --git a/yarn.lock b/yarn.lock index 3726b83cf2..5948d58b31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3251,7 +3251,7 @@ __metadata: fs-extra: ^11.2.0 helmet: ^6.0.0 http-errors: ^2.0.0 - jose: ^4.6.0 + jose: ^5.0.0 lodash: ^4.17.21 logform: ^2.3.2 minimatch: ^5.0.0 @@ -32127,7 +32127,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.15.4, jose@npm:^4.6.0": +"jose@npm:^4.15.4": version: 4.15.4 resolution: "jose@npm:4.15.4" checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b From ea8f7362a19a3dba682af7ea880cb1b9cac741a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 21 Feb 2024 20:35:15 +0100 Subject: [PATCH 38/42] scripts/verify-links: allow all absolute URLs from changesets Signed-off-by: Patrik Oldsberg --- scripts/verify-links.js | 3 +++ 1 file changed, 3 insertions(+) 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' }; } From d2600ca208e749e97e799eec368eebd814b45801 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Feb 2024 15:35:03 +0100 Subject: [PATCH 39/42] Update packages/backend-plugin-api/src/services/definitions/coreServices.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- .../backend-plugin-api/src/services/definitions/coreServices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index a7c9d09445..c760afc7b4 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -23,7 +23,7 @@ import { createServiceRef } from '../system'; */ export namespace coreServices { /** - * The service reference for the plugin scoped {@link IdentityService}. + * The service reference for the plugin scoped {@link AuthService}. * * @public */ From db62da78d90a9c5bb37aae53e30ea134f0c73407 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Feb 2024 17:33:33 +0100 Subject: [PATCH 40/42] backend-next: use stringifyEntityRef in github provider module Signed-off-by: Patrik Oldsberg --- packages/backend-next/package.json | 1 + packages/backend-next/src/authModuleGithubProvider.ts | 10 +++++++++- yarn.lock | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index c1f09cd0cb..74016539b0 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -28,6 +28,7 @@ "@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:^", diff --git a/packages/backend-next/src/authModuleGithubProvider.ts b/packages/backend-next/src/authModuleGithubProvider.ts index 5b71f35567..9245600d2c 100644 --- a/packages/backend-next/src/authModuleGithubProvider.ts +++ b/packages/backend-next/src/authModuleGithubProvider.ts @@ -15,6 +15,10 @@ */ 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, @@ -40,7 +44,11 @@ export default createBackendModule({ ); } - const userEntityRef = `user:default/${userId}`; + const userEntityRef = stringifyEntityRef({ + kind: 'User', + name: userId, + namespace: DEFAULT_NAMESPACE, + }); return ctx.issueToken({ claims: { diff --git a/yarn.lock b/yarn.lock index 5948d58b31..faf73dd135 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27355,6 +27355,7 @@ __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:^" From 74062bce288749c0b617d44fb0f88ca022b4f88e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Feb 2024 17:41:42 +0100 Subject: [PATCH 41/42] backend-{app-api,test-utils}: minor cleanup Signed-off-by: Patrik Oldsberg --- .../services/implementations/httpAuth/httpAuthServiceFactory.ts | 2 +- .../backend-test-utils/src/next/services/MockAuthService.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts index 44199f4256..1bd9d3cf2a 100644 --- a/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts +++ b/packages/backend-app-api/src/services/implementations/httpAuth/httpAuthServiceFactory.ts @@ -91,7 +91,7 @@ class DefaultHttpAuthService implements HttpAuthService { return credentials; } - async #getCredentials(req: /* */ RequestWithCredentials) { + async #getCredentials(req: RequestWithCredentials) { return (req[credentialsSymbol] ??= this.#extractCredentialsFromRequest(req)); } diff --git a/packages/backend-test-utils/src/next/services/MockAuthService.ts b/packages/backend-test-utils/src/next/services/MockAuthService.ts index 5735755f85..6a18711798 100644 --- a/packages/backend-test-utils/src/next/services/MockAuthService.ts +++ b/packages/backend-test-utils/src/next/services/MockAuthService.ts @@ -61,6 +61,7 @@ export class MockAuthService implements AuthService { case '': throw new AuthenticationError('Token is empty'); default: + break; } if (token.startsWith(MOCK_USER_TOKEN_PREFIX)) { From 7cbb7606c007cd32ed94d4c0262d3b7c5f3957ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Feb 2024 17:43:16 +0100 Subject: [PATCH 42/42] .changesets: added changeset for auth services addition to backend-defaults Signed-off-by: Patrik Oldsberg --- .changeset/perfect-taxis-give.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perfect-taxis-give.md 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.