feat: moving things around a little bit more
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user