From 2d1ebc1e25064bcaefe893e88c7b44d4240d47a8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 1 Jul 2025 15:09:47 +0200 Subject: [PATCH 1/7] chore: start a refactor of the existing oidc implementation Signed-off-by: benjdlambert --- .../UserInfoDatabase.test.ts} | 13 ++++-- .../UserInfoDatabase.ts} | 12 ++++-- .../src/identity/StaticTokenIssuer.test.ts | 14 +++---- .../src/identity/StaticTokenIssuer.ts | 10 ++--- .../src/identity/TokenFactory.test.ts | 2 +- .../auth-backend/src/identity/TokenFactory.ts | 10 ++--- .../src/identity/issueUserToken.ts | 8 ++-- .../auth-backend/src/identity/router.test.ts | 22 ++++------ plugins/auth-backend/src/identity/router.ts | 12 +++--- .../auth-backend/src/service/OidcService.ts | 41 +++++++++++++++++++ plugins/auth-backend/src/service/router.ts | 28 +++++++------ 11 files changed, 111 insertions(+), 61 deletions(-) rename plugins/auth-backend/src/{identity/UserInfoDatabaseHandler.test.ts => database/UserInfoDatabase.test.ts} (90%) rename plugins/auth-backend/src/{identity/UserInfoDatabaseHandler.ts => database/UserInfoDatabase.ts} (81%) create mode 100644 plugins/auth-backend/src/service/OidcService.ts diff --git a/plugins/auth-backend/src/identity/UserInfoDatabaseHandler.test.ts b/plugins/auth-backend/src/database/UserInfoDatabase.test.ts similarity index 90% rename from plugins/auth-backend/src/identity/UserInfoDatabaseHandler.test.ts rename to plugins/auth-backend/src/database/UserInfoDatabase.test.ts index 4d696aa8f2..c91df7873b 100644 --- a/plugins/auth-backend/src/identity/UserInfoDatabaseHandler.test.ts +++ b/plugins/auth-backend/src/database/UserInfoDatabase.test.ts @@ -17,7 +17,8 @@ import { resolvePackagePath } from '@backstage/backend-plugin-api'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Knex } from 'knex'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from './UserInfoDatabase'; +import { AuthDatabase } from './AuthDatabase'; const migrationsDir = resolvePackagePath( '@backstage/plugin-auth-backend', @@ -26,7 +27,7 @@ const migrationsDir = resolvePackagePath( jest.setTimeout(60_000); -describe('UserInfoDatabaseHandler', () => { +describe('UserInfoDatabase', () => { const databases = TestDatabases.create(); async function createDatabaseHandler(databaseId: TestDatabaseId) { @@ -38,7 +39,11 @@ describe('UserInfoDatabaseHandler', () => { return { knex, - dbHandler: new UserInfoDatabaseHandler(knex), + dbHandler: await UserInfoDatabase.create({ + database: AuthDatabase.create({ + getClient: async () => knex, + }), + }), }; } @@ -46,7 +51,7 @@ describe('UserInfoDatabaseHandler', () => { 'should support database %p', databaseId => { let knex: Knex; - let dbHandler: UserInfoDatabaseHandler; + let dbHandler: UserInfoDatabase; beforeEach(async () => { ({ knex, dbHandler } = await createDatabaseHandler(databaseId)); diff --git a/plugins/auth-backend/src/identity/UserInfoDatabaseHandler.ts b/plugins/auth-backend/src/database/UserInfoDatabase.ts similarity index 81% rename from plugins/auth-backend/src/identity/UserInfoDatabaseHandler.ts rename to plugins/auth-backend/src/database/UserInfoDatabase.ts index c0b31de8e4..785a6d56ac 100644 --- a/plugins/auth-backend/src/identity/UserInfoDatabaseHandler.ts +++ b/plugins/auth-backend/src/database/UserInfoDatabase.ts @@ -17,7 +17,8 @@ import { DateTime } from 'luxon'; import { Knex } from 'knex'; -import { BackstageTokenPayload } from './TokenFactory'; +import { BackstageTokenPayload } from '../identity/TokenFactory'; +import { AuthDatabase } from './AuthDatabase'; const TABLE = 'user_info'; @@ -31,8 +32,8 @@ type UserInfo = { claims: Omit; }; -export class UserInfoDatabaseHandler { - constructor(private readonly client: Knex) {} +export class UserInfoDatabase { + private constructor(private readonly client: Knex) {} async addUserInfo(userInfo: UserInfo): Promise { await this.client(TABLE) @@ -59,4 +60,9 @@ export class UserInfoDatabaseHandler { const userInfo = JSON.parse(info.user_info); return userInfo; } + + static async create(options: { database: AuthDatabase }) { + const client = await options.database.get(); + return new UserInfoDatabase(client); + } } diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts index ef6f7bd148..b2aa9fa148 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts @@ -18,7 +18,7 @@ import { createLocalJWKSet, jwtVerify } from 'jose'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { StaticKeyStore } from './StaticKeyStore'; import { mockServices } from '@backstage/backend-test-utils'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfo } from '../database/UserInfoDatabase'; import { omit } from 'lodash'; const logger = mockServices.logger.mock(); @@ -29,9 +29,9 @@ const entityRef = stringifyEntityRef({ }); describe('StaticTokenIssuer', () => { - const mockUserInfoDatabaseHandler = { + const mockUserInfo = { addUserInfo: jest.fn().mockResolvedValue(undefined), - } as unknown as UserInfoDatabaseHandler; + } as unknown as UserInfo; const staticKeyStore = { listKeys: () => { @@ -85,7 +85,7 @@ describe('StaticTokenIssuer', () => { logger, issuer: 'my-issuer', sessionExpirationSeconds: keyDurationSeconds, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + UserInfo: mockUserInfo, }, staticKeyStore as unknown as StaticKeyStore, ); @@ -119,7 +119,7 @@ describe('StaticTokenIssuer', () => { expect(verifyResult.payload.exp).toBe( verifyResult.payload.iat! + keyDurationSeconds, ); - expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({ + expect(mockUserInfo.addUserInfo).toHaveBeenCalledWith({ claims: omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']), }); }); @@ -131,7 +131,7 @@ describe('StaticTokenIssuer', () => { logger, issuer: 'my-issuer', sessionExpirationSeconds: keyDurationSeconds, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + UserInfo: mockUserInfo, omitClaimsFromToken: ['ent'], }, staticKeyStore as unknown as StaticKeyStore, @@ -165,7 +165,7 @@ describe('StaticTokenIssuer', () => { expect(verifyResult.payload.exp).toBe( verifyResult.payload.iat! + keyDurationSeconds, ); - expect(mockUserInfoDatabaseHandler.addUserInfo).toHaveBeenCalledWith({ + expect(mockUserInfo.addUserInfo).toHaveBeenCalledWith({ claims: { ...omit(verifyResult.payload, ['aud', 'iat', 'iss', 'uip']), ent: [entityRef], diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts index 6875e47457..04f4262e85 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.ts @@ -22,7 +22,7 @@ import { BackstageSignInResult, TokenParams, } from '@backstage/plugin-auth-node'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { issueUserToken } from './issueUserToken'; export type Config = { @@ -42,7 +42,7 @@ export type Options = { * A list of claims to omit from issued tokens and only store in the user info database */ omitClaimsFromToken?: string[]; - userInfoDatabaseHandler: UserInfoDatabaseHandler; + userInfo: UserInfoDatabase; }; /** @@ -55,7 +55,7 @@ export class StaticTokenIssuer implements TokenIssuer { private readonly keyStore: StaticKeyStore; private readonly sessionExpirationSeconds: number; private readonly omitClaimsFromToken?: string[]; - private readonly userInfoDatabaseHandler: UserInfoDatabaseHandler; + private readonly userInfo: UserInfoDatabase; public constructor(options: Options, keyStore: StaticKeyStore) { this.issuer = options.issuer; @@ -63,7 +63,7 @@ export class StaticTokenIssuer implements TokenIssuer { this.sessionExpirationSeconds = options.sessionExpirationSeconds; this.keyStore = keyStore; this.omitClaimsFromToken = options.omitClaimsFromToken; - this.userInfoDatabaseHandler = options.userInfoDatabaseHandler; + this.userInfo = options.userInfo; } public async issueToken(params: TokenParams): Promise { @@ -76,7 +76,7 @@ export class StaticTokenIssuer implements TokenIssuer { logger: this.logger, omitClaimsFromToken: this.omitClaimsFromToken, params, - userInfoDatabaseHandler: this.userInfoDatabaseHandler, + userInfo: this.userInfo, }); } diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 764b26eda4..0a03dc4df5 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -24,7 +24,7 @@ import { import { omit } from 'lodash'; import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabaseHandler } from '../database/UserInfoDatabase'; import { tokenTypes } from '@backstage/plugin-auth-node'; import { mockServices } from '@backstage/backend-test-utils'; diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 5a8a789d17..4b6a5c6c1f 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -25,7 +25,7 @@ import { } from '@backstage/plugin-auth-node'; import { AnyJWK, KeyStore, TokenIssuer } from './types'; import { JsonValue } from '@backstage/types'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { issueUserToken } from './issueUserToken'; /** @@ -92,7 +92,7 @@ type Options = { * A list of claims to omit from issued tokens and only store in the user info database */ omitClaimsFromToken?: string[]; - userInfoDatabaseHandler: UserInfoDatabaseHandler; + userInfo: UserInfoDatabase; }; /** @@ -116,7 +116,7 @@ export class TokenFactory implements TokenIssuer { private readonly keyDurationSeconds: number; private readonly algorithm: string; private readonly omitClaimsFromToken?: string[]; - private readonly userInfoDatabaseHandler: UserInfoDatabaseHandler; + private readonly userInfo: UserInfoDatabase; private keyExpiry?: Date; private privateKeyPromise?: Promise; @@ -128,7 +128,7 @@ export class TokenFactory implements TokenIssuer { this.keyDurationSeconds = options.keyDurationSeconds; this.algorithm = options.algorithm ?? 'ES256'; this.omitClaimsFromToken = options.omitClaimsFromToken; - this.userInfoDatabaseHandler = options.userInfoDatabaseHandler; + this.userInfo = options.userInfo; } async issueToken(params: TokenParams): Promise { @@ -141,7 +141,7 @@ export class TokenFactory implements TokenIssuer { logger: this.logger, omitClaimsFromToken: this.omitClaimsFromToken, params, - userInfoDatabaseHandler: this.userInfoDatabaseHandler, + userInfo: this.userInfo, }); } diff --git a/plugins/auth-backend/src/identity/issueUserToken.ts b/plugins/auth-backend/src/identity/issueUserToken.ts index d411439e6e..e9d2730cb3 100644 --- a/plugins/auth-backend/src/identity/issueUserToken.ts +++ b/plugins/auth-backend/src/identity/issueUserToken.ts @@ -22,7 +22,7 @@ import { tokenTypes, } from '@backstage/plugin-auth-node'; import { omit } from 'lodash'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { LoggerService } from '@backstage/backend-plugin-api'; import { GeneralSign, importJWK, JWK, KeyLike, SignJWT } from 'jose'; import { BackstageTokenPayload } from './TokenFactory'; @@ -37,7 +37,7 @@ export async function issueUserToken({ logger, omitClaimsFromToken, params, - userInfoDatabaseHandler, + userInfo, }: { issuer: string; key: JWK; @@ -45,7 +45,7 @@ export async function issueUserToken({ logger: LoggerService; omitClaimsFromToken?: string[]; params: TokenParams; - userInfoDatabaseHandler: UserInfoDatabaseHandler; + userInfo: UserInfoDatabase; }): Promise { const { sub, ent = [sub], ...additionalClaims } = params.claims; const aud = tokenTypes.user.audClaim; @@ -111,7 +111,7 @@ export async function issueUserToken({ // Store the user info in the database upon successful token // issuance so that it can be retrieved later by limited user tokens - await userInfoDatabaseHandler.addUserInfo({ + await userInfo.addUserInfo({ claims: omit(claims, ['aud', 'iat', 'iss', 'uip']), }); diff --git a/plugins/auth-backend/src/identity/router.test.ts b/plugins/auth-backend/src/identity/router.test.ts index 9ede44f195..0a7cb7cbd9 100644 --- a/plugins/auth-backend/src/identity/router.test.ts +++ b/plugins/auth-backend/src/identity/router.test.ts @@ -22,19 +22,19 @@ import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; import Router from 'express-promise-router'; import request from 'supertest'; import { bindOidcRouter } from './router'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; describe('bindOidcRouter', () => { it('should return user info for full tokens', async () => { const auth = mockServices.auth.mock(); - const mockUserInfoDatabaseHandler = { + const mockUserInfo = { getUserInfo: jest.fn().mockResolvedValue({ claims: { sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'], }, }), - } as unknown as UserInfoDatabaseHandler; + } as unknown as UserInfoDatabase; const { server } = await startTestBackend({ features: [ @@ -49,7 +49,7 @@ describe('bindOidcRouter', () => { baseUrl: 'http://localhost:7000', auth, tokenIssuer: {} as any, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfo, }); httpRouter.use(router); httpRouter.addAuthPolicy({ @@ -81,21 +81,19 @@ describe('bindOidcRouter', () => { }, }); - expect(mockUserInfoDatabaseHandler.getUserInfo).toHaveBeenCalledWith( - 'k/ns:n', - ); + expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); }); it('should return user info for limited tokens', async () => { const auth = mockServices.auth.mock(); - const mockUserInfoDatabaseHandler = { + const mockUserInfo = { getUserInfo: jest.fn().mockResolvedValue({ claims: { sub: 'k/ns:n', ent: ['k/ns:a', 'k/ns:b'], }, }), - } as unknown as UserInfoDatabaseHandler; + } as unknown as UserInfoDatabase; const { server } = await startTestBackend({ features: [ @@ -110,7 +108,7 @@ describe('bindOidcRouter', () => { baseUrl: 'http://localhost:7000', auth, tokenIssuer: {} as any, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfo, }); httpRouter.use(router); httpRouter.addAuthPolicy({ @@ -140,8 +138,6 @@ describe('bindOidcRouter', () => { }, }); - expect(mockUserInfoDatabaseHandler.getUserInfo).toHaveBeenCalledWith( - 'k/ns:n', - ); + expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); }); }); diff --git a/plugins/auth-backend/src/identity/router.ts b/plugins/auth-backend/src/identity/router.ts index 3952ec64b5..994abd7d1e 100644 --- a/plugins/auth-backend/src/identity/router.ts +++ b/plugins/auth-backend/src/identity/router.ts @@ -20,7 +20,7 @@ import { TokenIssuer } from './types'; import { AuthService } from '@backstage/backend-plugin-api'; import { decodeJwt } from 'jose'; import { AuthenticationError, InputError } from '@backstage/errors'; -import { UserInfoDatabaseHandler } from './UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; export function bindOidcRouter( targetRouter: express.Router, @@ -28,10 +28,10 @@ export function bindOidcRouter( baseUrl: string; auth: AuthService; tokenIssuer: TokenIssuer; - userInfoDatabaseHandler: UserInfoDatabaseHandler; + userInfo: UserInfoDatabase; }, ) { - const { baseUrl, auth, tokenIssuer, userInfoDatabaseHandler } = options; + const { baseUrl, auth, tokenIssuer, userInfo } = options; const router = Router(); targetRouter.use(router); @@ -99,12 +99,12 @@ export function bindOidcRouter( throw new Error('Invalid user token, user entity ref must be a string'); } - const userInfo = await userInfoDatabaseHandler.getUserInfo(userEntityRef); - if (!userInfo) { + const info = await userInfo.getUserInfo(userEntityRef); + if (!info) { res.status(404).send('User info not found'); return; } - res.json(userInfo); + res.json(info); }); } diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts new file mode 100644 index 0000000000..c66c9cf526 --- /dev/null +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2025 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 } from '@backstage/backend-plugin-api'; +import { TokenIssuer } from '../identity/types'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; + +export class OidcService { + constructor( + private readonly auth: AuthService, + private readonly tokenIssuer: TokenIssuer, + private readonly baseUrl: string, + private readonly userInfo: UserInfoDatabase, + ) {} + + static create(options: { + auth: AuthService; + tokenIssuer: TokenIssuer; + baseUrl: string; + userInfo: UserInfoDatabase; + }) { + return new OidcService( + options.auth, + options.tokenIssuer, + options.baseUrl, + options.userInfo, + ); + } +} diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index fd9373d5a3..39e45abe35 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -27,10 +27,9 @@ import { import { AuthOwnershipResolver } from '@backstage/plugin-auth-node'; import { CatalogService } from '@backstage/plugin-catalog-node'; import { NotFoundError } from '@backstage/errors'; -import { bindOidcRouter } from '../identity/router'; import { KeyStores } from '../identity/KeyStores'; import { TokenFactory } from '../identity/TokenFactory'; -import { UserInfoDatabaseHandler } from '../identity/UserInfoDatabaseHandler'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import session from 'express-session'; import connectSessionKnex from 'connect-session-knex'; import passport from 'passport'; @@ -40,6 +39,7 @@ import { TokenIssuer } from '../identity/types'; import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; +import { OidcService } from './OidcService'; interface RouterOptions { logger: LoggerService; @@ -60,7 +60,7 @@ export async function createRouter( logger, config, discovery, - database, + database: db, tokenFactoryAlgorithm, providerFactories = {}, } = options; @@ -70,16 +70,16 @@ export async function createRouter( const appUrl = config.getString('app.baseUrl'); const authUrl = await discovery.getExternalBaseUrl('auth'); const backstageTokenExpiration = readBackstageTokenExpiration(config); - const authDb = AuthDatabase.create(database); + const database = AuthDatabase.create(db); const keyStore = await KeyStores.fromConfig(config, { logger, - database: authDb, + database, }); - const userInfoDatabaseHandler = new UserInfoDatabaseHandler( - await authDb.get(), - ); + const userInfo = await UserInfoDatabase.create({ + database, + }); const omitClaimsFromToken = config.getOptionalBoolean( 'auth.omitIdentityTokenOwnershipClaim', @@ -94,7 +94,7 @@ export async function createRouter( logger: logger.child({ component: 'token-factory' }), issuer: authUrl, sessionExpirationSeconds: backstageTokenExpiration, - userInfoDatabaseHandler, + userInfo, omitClaimsFromToken, }, keyStore as StaticKeyStore, @@ -108,7 +108,7 @@ export async function createRouter( algorithm: tokenFactoryAlgorithm ?? config.getOptionalString('auth.identityTokenAlgorithm'), - userInfoDatabaseHandler, + userInfo, omitClaimsFromToken, }); } @@ -126,7 +126,7 @@ export async function createRouter( cookie: { secure: enforceCookieSSL ? 'auto' : false }, store: new KnexSessionStore({ createtable: false, - knex: await authDb.get(), + knex: await database.get(), }), }), ); @@ -148,13 +148,15 @@ export async function createRouter( auth: options.auth, }); - bindOidcRouter(router, { + const oidcService = OidcService.create({ auth: options.auth, tokenIssuer, baseUrl: authUrl, - userInfoDatabaseHandler, + userInfo, }); + router.use(oidcService.getRouter()); + // Gives a more helpful error message than a plain 404 router.use('/:provider/', req => { const { provider } = req.params; From 1477001b429c12516e095565fe85aec1d4f2bbd0 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 1 Jul 2025 16:34:16 +0200 Subject: [PATCH 2/7] feat: moving things around a little bit more Signed-off-by: benjdlambert --- .../auth-backend/src/identity/router.test.ts | 143 ----------------- plugins/auth-backend/src/identity/router.ts | 110 ------------- .../src/service/OidcService.test.ts | 151 ++++++++++++++++++ .../auth-backend/src/service/OidcService.ts | 80 ++++++++++ plugins/auth-backend/src/service/router.ts | 16 +- 5 files changed, 239 insertions(+), 261 deletions(-) delete mode 100644 plugins/auth-backend/src/identity/router.test.ts delete mode 100644 plugins/auth-backend/src/identity/router.ts create mode 100644 plugins/auth-backend/src/service/OidcService.test.ts diff --git a/plugins/auth-backend/src/identity/router.test.ts b/plugins/auth-backend/src/identity/router.test.ts deleted file mode 100644 index 0a7cb7cbd9..0000000000 --- a/plugins/auth-backend/src/identity/router.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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'; -import { UserInfoDatabase } from '../database/UserInfoDatabase'; - -describe('bindOidcRouter', () => { - it('should return user info for full tokens', async () => { - const auth = mockServices.auth.mock(); - const mockUserInfo = { - getUserInfo: jest.fn().mockResolvedValue({ - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }), - } as unknown as UserInfoDatabase; - - 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, - userInfo: mockUserInfo, - }); - 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, { - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }); - - expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); - }); - - it('should return user info for limited tokens', async () => { - const auth = mockServices.auth.mock(); - const mockUserInfo = { - getUserInfo: jest.fn().mockResolvedValue({ - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }), - } as unknown as UserInfoDatabase; - - 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, - userInfo: mockUserInfo, - }); - 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' }))}.s`, - ) - .expect(200, { - claims: { - sub: 'k/ns:n', - ent: ['k/ns:a', 'k/ns:b'], - }, - }); - - expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); - }); -}); diff --git a/plugins/auth-backend/src/identity/router.ts b/plugins/auth-backend/src/identity/router.ts deleted file mode 100644 index 994abd7d1e..0000000000 --- a/plugins/auth-backend/src/identity/router.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 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'; -import { UserInfoDatabase } from '../database/UserInfoDatabase'; - -export function bindOidcRouter( - targetRouter: express.Router, - options: { - baseUrl: string; - auth: AuthService; - tokenIssuer: TokenIssuer; - userInfo: UserInfoDatabase; - }, -) { - const { baseUrl, auth, tokenIssuer, userInfo } = options; - - const router = Router(); - targetRouter.use(router); - - const config = { - issuer: baseUrl, - token_endpoint: `${baseUrl}/v1/token`, - userinfo_endpoint: `${baseUrl}/v1/userinfo`, - jwks_uri: `${baseUrl}/.well-known/jwks.json`, - response_types_supported: ['id_token'], - subject_types_supported: ['public'], - id_token_signing_alg_values_supported: [ - 'RS256', - 'RS384', - 'RS512', - 'ES256', - 'ES384', - 'ES512', - 'PS256', - 'PS384', - 'PS512', - 'EdDSA', - ], - scopes_supported: ['openid'], - token_endpoint_auth_methods_supported: [], - claims_supported: ['sub', 'ent'], - grant_types_supported: [], - }; - - router.get('/.well-known/openid-configuration', (_req, res) => { - res.json(config); - }); - - router.get('/.well-known/jwks.json', async (_req, res) => { - const { keys } = await tokenIssuer.listPublicKeys(); - res.json({ keys }); - }); - - router.get('/v1/token', (_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 } = decodeJwt(token); - - if (typeof userEntityRef !== 'string') { - throw new Error('Invalid user token, user entity ref must be a string'); - } - - const info = await userInfo.getUserInfo(userEntityRef); - if (!info) { - res.status(404).send('User info not found'); - return; - } - - res.json(info); - }); -} diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcService.test.ts new file mode 100644 index 0000000000..ea6248e400 --- /dev/null +++ b/plugins/auth-backend/src/service/OidcService.test.ts @@ -0,0 +1,151 @@ +/* + * 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 { OidcService } from './OidcService'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; + +describe('OidcService', () => { + describe('/v1/userinfo', () => { + it('should return user info for full tokens', async () => { + const auth = mockServices.auth.mock(); + const mockUserInfo = { + getUserInfo: jest.fn().mockResolvedValue({ + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + }, + }), + } as unknown as UserInfoDatabase; + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + const router = Router(); + + router.use( + OidcService.create({ + auth, + tokenIssuer: {} as any, + baseUrl: 'http://localhost:7000', + userInfo: mockUserInfo, + }).getRouter(), + ); + 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, { + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + }, + }); + + expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); + }); + + it('should return user info for limited tokens', async () => { + const auth = mockServices.auth.mock(); + const mockUserInfo = { + getUserInfo: jest.fn().mockResolvedValue({ + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + }, + }), + } as unknown as UserInfoDatabase; + + const { server } = await startTestBackend({ + features: [ + createBackendPlugin({ + pluginId: 'auth', + register(reg) { + reg.registerInit({ + deps: { httpRouter: coreServices.httpRouter }, + async init({ httpRouter }) { + const router = Router(); + + router.use( + OidcService.create({ + auth, + tokenIssuer: {} as any, + baseUrl: 'http://localhost:7000', + userInfo: mockUserInfo, + }).getRouter(), + ); + 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' }))}.s`, + ) + .expect(200, { + claims: { + sub: 'k/ns:n', + ent: ['k/ns:a', 'k/ns:b'], + }, + }); + + expect(mockUserInfo.getUserInfo).toHaveBeenCalledWith('k/ns:n'); + }); + }); +}); diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index c66c9cf526..81249805be 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -16,6 +16,9 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import Router from 'express-promise-router'; +import { AuthenticationError, InputError } from '@backstage/errors'; +import { decodeJwt } from 'jose'; export class OidcService { constructor( @@ -38,4 +41,81 @@ export class OidcService { options.userInfo, ); } + + public getRouter() { + const router = Router(); + + const config = { + issuer: this.baseUrl, + token_endpoint: `${this.baseUrl}/v1/token`, + userinfo_endpoint: `${this.baseUrl}/v1/userinfo`, + jwks_uri: `${this.baseUrl}/.well-known/jwks.json`, + response_types_supported: ['id_token'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: [ + 'RS256', + 'RS384', + 'RS512', + 'ES256', + 'ES384', + 'ES512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA', + ], + scopes_supported: ['openid'], + token_endpoint_auth_methods_supported: [], + claims_supported: ['sub', 'ent'], + grant_types_supported: [], + }; + + router.get('/.well-known/openid-configuration', (_req, res) => { + res.json(config); + }); + + router.get('/.well-known/jwks.json', async (_req, res) => { + const { keys } = await this.tokenIssuer.listPublicKeys(); + res.json({ keys }); + }); + + router.get('/v1/token', (_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 this.auth.authenticate(token, { + allowLimitedAccess: true, + }); + if (!this.auth.isPrincipal(credentials, 'user')) { + throw new InputError( + 'Userinfo endpoint must be called with a token that represents a user principal', + ); + } + + const { sub: userEntityRef } = decodeJwt(token); + + if (typeof userEntityRef !== 'string') { + throw new Error('Invalid user token, user entity ref must be a string'); + } + const userInfo = await this.userInfo.getUserInfo(userEntityRef); + if (!userInfo) { + res.status(404).send('User info not found'); + return; + } + + res.json(userInfo); + }); + + return router; + } } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 39e45abe35..ce7d563817 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -148,14 +148,14 @@ export async function createRouter( auth: options.auth, }); - const oidcService = OidcService.create({ - auth: options.auth, - tokenIssuer, - baseUrl: authUrl, - userInfo, - }); - - router.use(oidcService.getRouter()); + router.use( + OidcService.create({ + auth: options.auth, + tokenIssuer, + baseUrl: authUrl, + userInfo, + }).getRouter(), + ); // Gives a more helpful error message than a plain 404 router.use('/:provider/', req => { From 207778c09f38c65dd85a1b4924f566683260e05d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 1 Jul 2025 16:35:02 +0200 Subject: [PATCH 3/7] chore: internal refactor changeset Signed-off-by: benjdlambert --- .changeset/tender-dogs-allow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-dogs-allow.md diff --git a/.changeset/tender-dogs-allow.md b/.changeset/tender-dogs-allow.md new file mode 100644 index 0000000000..2be8d77a18 --- /dev/null +++ b/.changeset/tender-dogs-allow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Internal refactor of OIDC endpoints and `UserInfoDatabase` From 1314dfd945986752b783640f7c3870e3e6d8bfaf Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 1 Jul 2025 16:48:58 +0200 Subject: [PATCH 4/7] chore: make private Signed-off-by: benjdlambert --- plugins/auth-backend/src/service/OidcService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 81249805be..7af4a1e8ed 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -21,7 +21,7 @@ import { AuthenticationError, InputError } from '@backstage/errors'; import { decodeJwt } from 'jose'; export class OidcService { - constructor( + private constructor( private readonly auth: AuthService, private readonly tokenIssuer: TokenIssuer, private readonly baseUrl: string, From 29ceab3d17d6088ff6114be562c6b1f6d710ac7b Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 1 Jul 2025 16:59:42 +0200 Subject: [PATCH 5/7] chore: fix typescript issues Signed-off-by: benjdlambert --- .../src/identity/StaticTokenIssuer.test.ts | 8 ++++---- .../src/identity/TokenFactory.test.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts index b2aa9fa148..81d9e072f9 100644 --- a/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts +++ b/plugins/auth-backend/src/identity/StaticTokenIssuer.test.ts @@ -18,7 +18,7 @@ import { createLocalJWKSet, jwtVerify } from 'jose'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { StaticKeyStore } from './StaticKeyStore'; import { mockServices } from '@backstage/backend-test-utils'; -import { UserInfo } from '../database/UserInfoDatabase'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { omit } from 'lodash'; const logger = mockServices.logger.mock(); @@ -31,7 +31,7 @@ const entityRef = stringifyEntityRef({ describe('StaticTokenIssuer', () => { const mockUserInfo = { addUserInfo: jest.fn().mockResolvedValue(undefined), - } as unknown as UserInfo; + } as unknown as UserInfoDatabase; const staticKeyStore = { listKeys: () => { @@ -85,7 +85,7 @@ describe('StaticTokenIssuer', () => { logger, issuer: 'my-issuer', sessionExpirationSeconds: keyDurationSeconds, - UserInfo: mockUserInfo, + userInfo: mockUserInfo, }, staticKeyStore as unknown as StaticKeyStore, ); @@ -131,7 +131,7 @@ describe('StaticTokenIssuer', () => { logger, issuer: 'my-issuer', sessionExpirationSeconds: keyDurationSeconds, - UserInfo: mockUserInfo, + userInfo: mockUserInfo, omitClaimsFromToken: ['ent'], }, staticKeyStore as unknown as StaticKeyStore, diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 0a03dc4df5..c6f87885ac 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -24,7 +24,7 @@ import { import { omit } from 'lodash'; import { MemoryKeyStore } from './MemoryKeyStore'; import { TokenFactory } from './TokenFactory'; -import { UserInfoDatabaseHandler } from '../database/UserInfoDatabase'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; import { tokenTypes } from '@backstage/plugin-auth-node'; import { mockServices } from '@backstage/backend-test-utils'; @@ -47,7 +47,7 @@ const entityRef = stringifyEntityRef({ describe('TokenFactory', () => { const mockUserInfoDatabaseHandler = { addUserInfo: jest.fn().mockResolvedValue(undefined), - } as unknown as UserInfoDatabaseHandler; + } as unknown as UserInfoDatabase; it('should issue valid tokens signed by a listed key', async () => { const keyDurationSeconds = 5; @@ -56,7 +56,7 @@ describe('TokenFactory', () => { keyStore: new MemoryKeyStore(), keyDurationSeconds, logger, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] }); @@ -139,7 +139,7 @@ describe('TokenFactory', () => { keyStore: new MemoryKeyStore(), keyDurationSeconds: 5, logger, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); const { token: token1 } = await factory.issueToken({ @@ -185,7 +185,7 @@ describe('TokenFactory', () => { keyStore: new MemoryKeyStore(), keyDurationSeconds, logger, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); await expect(() => { @@ -203,7 +203,7 @@ describe('TokenFactory', () => { keyDurationSeconds, logger, algorithm: '', - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); await expect(() => { @@ -219,7 +219,7 @@ describe('TokenFactory', () => { keyStore: new MemoryKeyStore(), keyDurationSeconds: 5, logger, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); await expect(() => { @@ -238,7 +238,7 @@ describe('TokenFactory', () => { keyStore: new MemoryKeyStore(), keyDurationSeconds, logger, - userInfoDatabaseHandler: mockUserInfoDatabaseHandler, + userInfo: mockUserInfoDatabaseHandler, }); const { token } = await factory.issueToken({ From 6fc7847e2f422860a5798854c1b5714224d1d077 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 2 Jul 2025 19:47:44 +0200 Subject: [PATCH 6/7] chore: another small cleanup Signed-off-by: benjdlambert --- ...OidcService.test.ts => OidcRouter.test.ts} | 9 ++- .../auth-backend/src/service/OidcRouter.ts | 73 +++++++++++++++++++ .../auth-backend/src/service/OidcService.ts | 71 ++++++------------ plugins/auth-backend/src/service/router.ts | 17 +++-- 4 files changed, 108 insertions(+), 62 deletions(-) rename plugins/auth-backend/src/service/{OidcService.test.ts => OidcRouter.test.ts} (96%) create mode 100644 plugins/auth-backend/src/service/OidcRouter.ts diff --git a/plugins/auth-backend/src/service/OidcService.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts similarity index 96% rename from plugins/auth-backend/src/service/OidcService.test.ts rename to plugins/auth-backend/src/service/OidcRouter.test.ts index ea6248e400..4edfd99464 100644 --- a/plugins/auth-backend/src/service/OidcService.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -21,10 +21,11 @@ import { import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; import Router from 'express-promise-router'; import request from 'supertest'; -import { OidcService } from './OidcService'; +import { OidcRouter } from './OidcRouter'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; +import { OidcService } from './OidcService'; -describe('OidcService', () => { +describe('OidcRouter', () => { describe('/v1/userinfo', () => { it('should return user info for full tokens', async () => { const auth = mockServices.auth.mock(); @@ -48,7 +49,7 @@ describe('OidcService', () => { const router = Router(); router.use( - OidcService.create({ + OidcRouter.create({ auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', @@ -110,7 +111,7 @@ describe('OidcService', () => { const router = Router(); router.use( - OidcService.create({ + OidcRouter.create({ auth, tokenIssuer: {} as any, baseUrl: 'http://localhost:7000', diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts new file mode 100644 index 0000000000..6ada071c44 --- /dev/null +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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 Router from 'express-promise-router'; +import { OidcService } from './OidcService'; +import { AuthenticationError } from '@backstage/errors'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { TokenIssuer } from '../identity/types'; +import { UserInfoDatabase } from '../database/UserInfoDatabase'; + +export class OidcRouter { + private constructor(private readonly oidc: OidcService) {} + + static create(options: { + auth: AuthService; + tokenIssuer: TokenIssuer; + baseUrl: string; + userInfo: UserInfoDatabase; + }) { + return new OidcRouter(OidcService.create(options)); + } + + public getRouter() { + const router = Router(); + + router.get('/.well-known/openid-configuration', (_req, res) => { + res.json(this.oidc.getConfiguration()); + }); + + router.get('/.well-known/jwks.json', async (_req, res) => { + const { keys } = await this.oidc.listPublicKeys(); + res.json({ keys }); + }); + + router.get('/v1/token', (_req, res) => { + res.status(501).send('Not Implemented'); + }); + + // This endpoint doesn't use the regular HttpAuthoidc, 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 userInfo = await this.oidc.getUserInfo({ token }); + + if (!userInfo) { + res.status(404).send('User info not found'); + return; + } + + res.json(userInfo); + }); + + return router; + } +} diff --git a/plugins/auth-backend/src/service/OidcService.ts b/plugins/auth-backend/src/service/OidcService.ts index 7af4a1e8ed..5024b2cc8c 100644 --- a/plugins/auth-backend/src/service/OidcService.ts +++ b/plugins/auth-backend/src/service/OidcService.ts @@ -16,8 +16,7 @@ import { AuthService } from '@backstage/backend-plugin-api'; import { TokenIssuer } from '../identity/types'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import Router from 'express-promise-router'; -import { AuthenticationError, InputError } from '@backstage/errors'; +import { InputError } from '@backstage/errors'; import { decodeJwt } from 'jose'; export class OidcService { @@ -42,10 +41,8 @@ export class OidcService { ); } - public getRouter() { - const router = Router(); - - const config = { + public getConfiguration() { + return { issuer: this.baseUrl, token_endpoint: `${this.baseUrl}/v1/token`, userinfo_endpoint: `${this.baseUrl}/v1/userinfo`, @@ -69,53 +66,27 @@ export class OidcService { claims_supported: ['sub', 'ent'], grant_types_supported: [], }; + } - router.get('/.well-known/openid-configuration', (_req, res) => { - res.json(config); + public async listPublicKeys() { + return await this.tokenIssuer.listPublicKeys(); + } + + public async getUserInfo({ token }: { token: string }) { + const credentials = await this.auth.authenticate(token, { + allowLimitedAccess: true, }); + if (!this.auth.isPrincipal(credentials, 'user')) { + throw new InputError( + 'Userinfo endpoint must be called with a token that represents a user principal', + ); + } - router.get('/.well-known/jwks.json', async (_req, res) => { - const { keys } = await this.tokenIssuer.listPublicKeys(); - res.json({ keys }); - }); + const { sub: userEntityRef } = decodeJwt(token); - router.get('/v1/token', (_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 this.auth.authenticate(token, { - allowLimitedAccess: true, - }); - if (!this.auth.isPrincipal(credentials, 'user')) { - throw new InputError( - 'Userinfo endpoint must be called with a token that represents a user principal', - ); - } - - const { sub: userEntityRef } = decodeJwt(token); - - if (typeof userEntityRef !== 'string') { - throw new Error('Invalid user token, user entity ref must be a string'); - } - const userInfo = await this.userInfo.getUserInfo(userEntityRef); - if (!userInfo) { - res.status(404).send('User info not found'); - return; - } - - res.json(userInfo); - }); - - return router; + if (typeof userEntityRef !== 'string') { + throw new Error('Invalid user token, user entity ref must be a string'); + } + return await this.userInfo.getUserInfo(userEntityRef); } } diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index ce7d563817..2723af4775 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -40,6 +40,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; import { OidcService } from './OidcService'; +import { OidcRouter } from './OidcRouter'; interface RouterOptions { logger: LoggerService; @@ -148,14 +149,14 @@ export async function createRouter( auth: options.auth, }); - router.use( - OidcService.create({ - auth: options.auth, - tokenIssuer, - baseUrl: authUrl, - userInfo, - }).getRouter(), - ); + const oidcRouter = OidcRouter.create({ + auth: options.auth, + tokenIssuer, + baseUrl: authUrl, + userInfo, + }); + + router.use(oidcRouter.getRouter()); // Gives a more helpful error message than a plain 404 router.use('/:provider/', req => { From 6c4f29b8806f21af10fbad53127d6f16688524f1 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 3 Jul 2025 08:33:40 +0200 Subject: [PATCH 7/7] chore: fixing linting issues Signed-off-by: benjdlambert --- plugins/auth-backend/src/service/OidcRouter.test.ts | 1 - plugins/auth-backend/src/service/router.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 4edfd99464..dbc6c88f93 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -23,7 +23,6 @@ import Router from 'express-promise-router'; import request from 'supertest'; import { OidcRouter } from './OidcRouter'; import { UserInfoDatabase } from '../database/UserInfoDatabase'; -import { OidcService } from './OidcService'; describe('OidcRouter', () => { describe('/v1/userinfo', () => { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 2723af4775..725d4e0211 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -39,7 +39,6 @@ import { TokenIssuer } from '../identity/types'; import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; -import { OidcService } from './OidcService'; import { OidcRouter } from './OidcRouter'; interface RouterOptions {