chore: another small cleanup

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-07-02 19:47:44 +02:00
parent 29ceab3d17
commit 6fc7847e2f
4 changed files with 108 additions and 62 deletions
@@ -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',
@@ -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;
}
}
+21 -50
View File
@@ -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);
}
}
+9 -8
View File
@@ -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 => {