From bf4d71a729a8e2ca2403c8dd67acf2f3e16841a0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 8 Apr 2024 15:58:31 +0200 Subject: [PATCH] auth-backend: add initial userinfo endpoint Signed-off-by: Patrik Oldsberg --- .changeset/stale-bikes-bake.md | 5 ++ plugins/auth-backend/src/authPlugin.test.ts | 2 +- plugins/auth-backend/src/authPlugin.ts | 6 ++ .../auth-backend/src/identity/router.test.ts | 73 +++++++++++++++++++ plugins/auth-backend/src/identity/router.ts | 45 +++++++++++- plugins/auth-backend/src/service/router.ts | 1 + 6 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .changeset/stale-bikes-bake.md create mode 100644 plugins/auth-backend/src/identity/router.test.ts diff --git a/.changeset/stale-bikes-bake.md b/.changeset/stale-bikes-bake.md new file mode 100644 index 0000000000..2da1fc082f --- /dev/null +++ b/.changeset/stale-bikes-bake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Initial implementation of the `/v1/userinfo` endpoint, which is now able to parse and return the `sub` and `ent` claims from a Backstage user token. diff --git a/plugins/auth-backend/src/authPlugin.test.ts b/plugins/auth-backend/src/authPlugin.test.ts index d8a15eba3e..008df01295 100644 --- a/plugins/auth-backend/src/authPlugin.test.ts +++ b/plugins/auth-backend/src/authPlugin.test.ts @@ -38,7 +38,7 @@ describe('authPlugin', () => { ); expect(res.status).toBe(200); expect(res.body).toMatchObject({ - claims_supported: ['sub'], + claims_supported: ['sub', 'ent'], issuer: `http://localhost:${server.port()}/api/auth`, }); }); diff --git a/plugins/auth-backend/src/authPlugin.ts b/plugins/auth-backend/src/authPlugin.ts index 756d8b80a1..f531ffabc6 100644 --- a/plugins/auth-backend/src/authPlugin.ts +++ b/plugins/auth-backend/src/authPlugin.ts @@ -54,6 +54,8 @@ export const authPlugin = createBackendPlugin({ database: coreServices.database, discovery: coreServices.discovery, tokenManager: coreServices.tokenManager, + auth: coreServices.auth, + httpAuth: coreServices.httpAuth, catalogApi: catalogServiceRef, }, async init({ @@ -63,6 +65,8 @@ export const authPlugin = createBackendPlugin({ database, discovery, tokenManager, + auth, + httpAuth, catalogApi, }) { const router = await createRouter({ @@ -71,6 +75,8 @@ export const authPlugin = createBackendPlugin({ database, discovery, tokenManager, + auth, + httpAuth, catalogApi, providerFactories: Object.fromEntries(providers), disableDefaultProviderFactories: true, diff --git a/plugins/auth-backend/src/identity/router.test.ts b/plugins/auth-backend/src/identity/router.test.ts new file mode 100644 index 0000000000..9880d2931c --- /dev/null +++ b/plugins/auth-backend/src/identity/router.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2020 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 { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import Router from 'express-promise-router'; +import request from 'supertest'; +import { bindOidcRouter } from './router'; + +describe('bindOidcRouter', () => { + it('should return user info', async () => { + const auth = mockServices.auth.mock(); + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + const router = Router(); + bindOidcRouter(router, { + baseUrl: 'http://localhost:7000', + auth, + tokenIssuer: {} as any, + }); + httpRouter.use(router); + httpRouter.addAuthPolicy({ + path: '/', + allow: 'unauthenticated', + }); + }, + }); + }, + }), + ], + }); + + auth.authenticate.mockResolvedValueOnce({} as any); + auth.isPrincipal.mockReturnValueOnce(true); + + await request(server) + .get('/api/auth/v1/userinfo') + .set( + 'Authorization', + `Bearer h.${btoa( + JSON.stringify({ sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'] }), + )}.s`, + ) + .expect(200, { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + }); + + expect('test').toBe('test'); + }); +}); diff --git a/plugins/auth-backend/src/identity/router.ts b/plugins/auth-backend/src/identity/router.ts index c3c419e9ea..3936298c72 100644 --- a/plugins/auth-backend/src/identity/router.ts +++ b/plugins/auth-backend/src/identity/router.ts @@ -17,15 +17,19 @@ import express from 'express'; import Router from 'express-promise-router'; import { TokenIssuer } from './types'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { decodeJwt } from 'jose'; +import { AuthenticationError, InputError } from '@backstage/errors'; export function bindOidcRouter( targetRouter: express.Router, options: { baseUrl: string; + auth: AuthService; tokenIssuer: TokenIssuer; }, ) { - const { baseUrl, tokenIssuer } = options; + const { baseUrl, auth, tokenIssuer } = options; const router = Router(); targetRouter.use(router); @@ -51,7 +55,7 @@ export function bindOidcRouter( ], scopes_supported: ['openid'], token_endpoint_auth_methods_supported: [], - claims_supported: ['sub'], + claims_supported: ['sub', 'ent'], grant_types_supported: [], }; @@ -68,7 +72,40 @@ export function bindOidcRouter( res.status(501).send('Not Implemented'); }); - router.get('/v1/userinfo', (_req, res) => { - res.status(501).send('Not Implemented'); + // This endpoint doesn't use the regular HttpAuthService, since the contract + // is specifically for the header to be communicated in the Authorization + // header, regardless of token type + router.get('/v1/userinfo', async (req, res) => { + const matches = req.headers.authorization?.match(/^Bearer[ ]+(\S+)$/i); + const token = matches?.[1]; + if (!token) { + throw new AuthenticationError('No token provided'); + } + + const credentials = await auth.authenticate(token, { + allowLimitedAccess: true, + }); + if (!auth.isPrincipal(credentials, 'user')) { + throw new InputError( + 'Userinfo endpoint must be called with a token that represents a user principal', + ); + } + + const { sub: userEntityRef, ent: ownershipEntityRefs = [] } = + decodeJwt(token); + + if (typeof userEntityRef !== 'string') { + throw new Error('Invalid user token, user entity ref must be a string'); + } + if ( + !Array.isArray(ownershipEntityRefs) || + ownershipEntityRefs.some(ref => typeof ref !== 'string') + ) { + throw new Error( + 'Invalid user token, ownership entity refs must be an array of strings', + ); + } + + res.json({ sub: userEntityRef, ent: ownershipEntityRefs }); }); } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 5628e7be55..f1487d07a2 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -151,6 +151,7 @@ export async function createRouter( }); bindOidcRouter(router, { + auth, tokenIssuer, baseUrl: authUrl, });