From c5a7cf4a4f635560a8eaea529ec4f9608b705a80 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Feb 2024 15:01:19 +0100 Subject: [PATCH] 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