From fe0e7ba63cdd4e1d76e5b51604c8b72658d74a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 17 Jun 2024 14:17:44 +0200 Subject: [PATCH] break out class and make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../userInfo/DefaultUserInfoService.test.ts | 169 ++++++++++++++++++ .../userInfo/DefaultUserInfoService.ts | 90 ++++++++++ 2 files changed, 259 insertions(+) create mode 100644 packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.ts diff --git a/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.test.ts b/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.test.ts new file mode 100644 index 0000000000..b63b24fb18 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.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 { BackstageUserPrincipal } from '@backstage/backend-plugin-api'; +import { + mockServices, + setupRequestMockHandlers, +} from '@backstage/backend-test-utils'; +import { JsonObject } from '@backstage/types'; +import { SignJWT, base64url, importJWK } from 'jose'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { InternalBackstageCredentials } from '../auth/types'; +import { DefaultUserInfoService } from './DefaultUserInfoService'; + +describe('DefaultUserInfoService', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + + const mockPublicKey = { + kty: 'EC', + x: 'GHlwg744e8JekzukPTdtix6R868D6fcWy0ooOx-NEZI', + y: 'Lyujcm0M6X9_yQi3l1eH09z0brU8K9cwrLml_fRFKro', + crv: 'P-256', + kid: 'mock', + alg: 'ES256', + }; + const mockPrivateKey = { + ...mockPublicKey, + d: 'KEn_mDqXYbZdRHb-JnCrW53LDOv5x4NL1FnlKcqBsFI', + }; + + function encodeData(data: JsonObject) { + return base64url.encode(JSON.stringify(data)); + } + + async function createToken(options: { + header: JsonObject; + payload: JsonObject; + signature?: string; + }) { + if (options.signature) { + const header = encodeData(options.header); + const payload = encodeData(options.payload); + + return `${header}.${payload}.${options.signature}`; + } + + return await new SignJWT(options.payload) + .setProtectedHeader({ ...options.header, alg: 'ES256' }) + .sign(await importJWK(mockPrivateKey)); + } + + const discovery = mockServices.discovery.mock({ + getBaseUrl: async pluginId => `https://example.com/api/${pluginId}`, + }); + + it('makes the expected call when no ent in the token', async () => { + const token = await createToken({ + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + sub: 'user:default/alice', + }, + }); + const credentials = { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token: token, + principal: { + type: 'user', + userEntityRef: 'user:default/alice', + }, + } as InternalBackstageCredentials; + + server.use( + rest.get('https://example.com/api/auth/v1/userinfo', (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json({ claims: { ent: ['group:default/my-team'] } })); + }), + ); + + const service = new DefaultUserInfoService({ discovery }); + await expect(service.getUserInfo(credentials)).resolves.toEqual({ + userEntityRef: 'user:default/alice', + ownershipEntityRefs: ['group:default/my-team'], + }); + }); + + it('uses the ent from the token when present', async () => { + const token = await createToken({ + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + sub: 'user:default/alice', + ent: ['group:default/my-team'], + }, + }); + const credentials = { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token: token, + principal: { + type: 'user', + userEntityRef: 'user:default/alice', + }, + } as InternalBackstageCredentials; + + const service = new DefaultUserInfoService({ discovery }); + await expect(service.getUserInfo(credentials)).resolves.toEqual({ + userEntityRef: 'user:default/alice', + ownershipEntityRefs: ['group:default/my-team'], + }); + }); + + it('passes on server errors', async () => { + const token = await createToken({ + header: { + typ: 'vnd.backstage.user', + alg: 'ES256', + kid: mockPublicKey.kid, + }, + payload: { + sub: 'user:default/alice', + }, + }); + const credentials = { + $$type: '@backstage/BackstageCredentials', + version: 'v1', + token: token, + principal: { + type: 'user', + userEntityRef: 'user:default/alice', + }, + } as InternalBackstageCredentials; + + server.use( + rest.get('https://example.com/api/auth/v1/userinfo', (_req, res, ctx) => { + return res(ctx.status(404)); + }), + ); + + const service = new DefaultUserInfoService({ discovery }); + await expect( + service.getUserInfo(credentials), + ).rejects.toMatchInlineSnapshot( + `[ResponseError: Request failed with 404 Not Found]`, + ); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.ts b/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.ts new file mode 100644 index 0000000000..83bbba89b7 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/userInfo/DefaultUserInfoService.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 { + UserInfoService, + BackstageUserInfo, + DiscoveryService, + BackstageCredentials, +} from '@backstage/backend-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { decodeJwt } from 'jose'; +import fetch from 'node-fetch'; +import { toInternalBackstageCredentials } from '../auth/helpers'; + +export type Options = { + discovery: DiscoveryService; +}; + +export class DefaultUserInfoService implements UserInfoService { + private readonly discovery: DiscoveryService; + + constructor(options: Options) { + this.discovery = options.discovery; + } + + async getUserInfo( + credentials: BackstageCredentials, + ): Promise { + const internalCredentials = toInternalBackstageCredentials(credentials); + if (internalCredentials.principal.type !== 'user') { + throw new Error('Only user credentials are supported'); + } + if (!internalCredentials.token) { + throw new Error('User credentials is unexpectedly missing token'); + } + const { sub: userEntityRef, ent: tokenEnt } = decodeJwt( + internalCredentials.token, + ); + + if (typeof userEntityRef !== 'string') { + throw new Error('User entity ref must be a string'); + } + + let ownershipEntityRefs = tokenEnt; + + if (!ownershipEntityRefs) { + const userInfoResp = await fetch( + `${await this.discovery.getBaseUrl('auth')}/v1/userinfo`, + { + headers: { + Authorization: `Bearer ${internalCredentials.token}`, + }, + }, + ); + + if (!userInfoResp.ok) { + throw await ResponseError.fromResponse(userInfoResp); + } + + const { + claims: { ent }, + } = await userInfoResp.json(); + ownershipEntityRefs = ent; + } + + if (!ownershipEntityRefs) { + throw new Error('Ownership entity refs can not be determined'); + } else if ( + !Array.isArray(ownershipEntityRefs) || + ownershipEntityRefs.some(ref => typeof ref !== 'string') + ) { + throw new Error('Ownership entity refs must be an array of strings'); + } + + return { userEntityRef, ownershipEntityRefs }; + } +}