auth-backend: add initial userinfo endpoint

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-04-08 15:58:31 +02:00
parent 1e5f38046d
commit bf4d71a729
6 changed files with 127 additions and 5 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -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`,
});
});
+6
View File
@@ -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,
@@ -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');
});
});
+41 -4
View File
@@ -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 });
});
}
@@ -151,6 +151,7 @@ export async function createRouter(
});
bindOidcRouter(router, {
auth,
tokenIssuer,
baseUrl: authUrl,
});