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 => {