feat: moving things around a little bit more

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-07-01 16:34:16 +02:00
parent 2d1ebc1e25
commit 1477001b42
5 changed files with 239 additions and 261 deletions
@@ -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');
});
});
-110
View File
@@ -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);
});
}
@@ -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');
});
});
});
@@ -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;
}
}
+8 -8
View File
@@ -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 => {