diff --git a/Dockerfile b/Dockerfile index fb861d79..945073b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN npm -g install npm@10.x.x COPY package*.json ./ RUN npm ci --omit-dev +COPY public ./public COPY --from=builder /veritable-ui/build ./build EXPOSE 80 diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/veritable.json similarity index 99% rename from docker/keycloak/realm-export.json rename to docker/keycloak/veritable.json index 127bc8ea..71f4bb0f 100644 --- a/docker/keycloak/realm-export.json +++ b/docker/keycloak/veritable.json @@ -772,7 +772,8 @@ "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "redirectUris": [ - "http://localhost:3000/auth/redirect" + "http://localhost:3000/auth/redirect", + "http://localhost:3000/swagger/oauth2-redirect.html" ], "webOrigins": [ "http://localhost:3000" @@ -2216,4 +2217,4 @@ "clientPolicies": { "policies": [] } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 1b371ae7..a38097f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.3.2", + "version": "0.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.3.2", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.0", diff --git a/package.json b/package.json index f31090ed..efaecc2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.3.2", + "version": "0.3.3", "description": "UI for Veritable", "main": "src/index.ts", "type": "commonjs", diff --git a/src/authentication.ts b/src/authentication.ts index 73ea235a..91505b95 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -15,8 +15,9 @@ const tokenCookieOpts: express.CookieOptions = { } const tokenStore: AuthOptions = { - jwksUri: () => idp.jwksUri, - getAccessToken: async (req) => req.signedCookies['VERITABLE_ACCESS_TOKEN'], + jwksUri: () => Promise.resolve(idp.jwksUri('INTERNAL')), + getAccessToken: async (req) => + req.headers.authorization?.substring('bearer '.length) || req.signedCookies['VERITABLE_ACCESS_TOKEN'], getScopesFromToken: async (decoded) => { if (typeof decoded === 'string') { return [] diff --git a/src/controllers/auth.ts b/src/controllers/AuthController.ts similarity index 94% rename from src/controllers/auth.ts rename to src/controllers/AuthController.ts index 4abd09ca..02c11f0a 100644 --- a/src/controllers/auth.ts +++ b/src/controllers/AuthController.ts @@ -1,7 +1,7 @@ import type * as express from 'express' import { randomBytes } from 'node:crypto' -import { Get, Produces, Query, Request, Route, SuccessResponse } from 'tsoa' +import { Get, Hidden, Produces, Query, Request, Route, SuccessResponse } from 'tsoa' import { inject, injectable, singleton } from 'tsyringe' import { Env } from '../env.js' @@ -34,6 +34,7 @@ const tokenCookieOpts: express.CookieOptions = { @injectable() @Route('/auth') @Produces('text/html') +@Hidden() export class AuthController extends HTMLController { private redirectUrl: string private logger: ILogger @@ -66,7 +67,7 @@ export class AuthController extends HTMLController { // setup for final redirect res.cookie('VERITABLE_REDIRECT', path, nonceCookieOpts) - const redirect = new URL(await this.idp.authorizationEndpoint) + const redirect = new URL(this.idp.authorizationEndpoint('PUBLIC')) redirect.search = new URLSearchParams({ response_type: 'code', client_id: this.env.get('IDP_CLIENT_ID'), diff --git a/src/controllers/__tests__/auth.test.ts b/src/controllers/__tests__/auth.test.ts index b3e3168e..b274f508 100644 --- a/src/controllers/__tests__/auth.test.ts +++ b/src/controllers/__tests__/auth.test.ts @@ -7,11 +7,11 @@ import { mockEnv, mockLogger } from './helpers' import { ForbiddenError, InternalError } from '../../errors.js' import IDPService from '../../models/idpService.js' -import { AuthController } from '../auth.js' +import { AuthController } from '../AuthController.js' const idpMock = { - get authorizationEndpoint() { - return Promise.resolve('http://www.example.com/auth') + authorizationEndpoint(network: string) { + return `http://${network}.example.com/auth` }, getTokenFromCode: sinon .stub() @@ -107,7 +107,7 @@ describe('AuthController', () => { // get the nonce that was generated const cookieStub = req.res.cookie const nonce = cookieStub.firstCall.args[1] - const expectedUrl = new URL('http://www.example.com/auth') + const expectedUrl = new URL('http://public.example.com/auth') expectedUrl.search = new URLSearchParams({ response_type: 'code', client_id: 'veritable-ui', diff --git a/src/env.ts b/src/env.ts index a7d9ad01..e6b1ae74 100644 --- a/src/env.ts +++ b/src/env.ts @@ -32,9 +32,24 @@ const envConfig = { DB_PORT: envalid.port({ default: 5432 }), COOKIE_SESSION_KEYS: strArrayValidator({ devDefault: ['secret'] }), PUBLIC_URL: envalid.url({ devDefault: 'http://localhost:3000' }), - IDP_CLIENT_ID: envalid.str({ default: 'veritable-ui' }), - IDP_OIDC_CONFIG_URL: envalid.url({ - devDefault: 'http://localhost:3080/realms/veritable/.well-known/openid-configuration', + API_SWAGGER_BG_COLOR: envalid.str({ default: '#fafafa' }), + API_SWAGGER_TITLE: envalid.str({ default: 'Veritable' }), + API_SWAGGER_HEADING: envalid.str({ default: 'Veritable' }), + IDP_CLIENT_ID: envalid.str({ devDefault: 'veritable-ui' }), + IDP_PUBLIC_URL_PREFIX: envalid.url({ + devDefault: 'http://localhost:3080/realms/veritable/protocol/openid-connect', + }), + IDP_INTERNAL_URL_PREFIX: envalid.url({ + devDefault: 'http://localhost:3080/realms/veritable/protocol/openid-connect', + }), + IDP_AUTH_PATH: envalid.url({ + default: '/auth', + }), + IDP_TOKEN_PATH: envalid.url({ + default: '/token', + }), + IDP_JWKS_PATH: envalid.url({ + default: '/certs', }), COMPANY_HOUSE_API_URL: envalid.str({ default: 'https://api.company-information.service.gov.uk' }), COMPANY_PROFILE_API_KEY: envalid.str(), diff --git a/src/models/__tests__/idpService.test.ts b/src/models/__tests__/idpService.test.ts index 2c6dee6b..1e40b748 100644 --- a/src/models/__tests__/idpService.test.ts +++ b/src/models/__tests__/idpService.test.ts @@ -8,8 +8,13 @@ import IDPService from '../idpService.js' const mockEnv: Env = { get: (name: string) => { - if (name === 'IDP_OIDC_CONFIG_URL') return 'https://keycloak.example.com/.well_known/jwks.json' if (name === 'IDP_CLIENT_ID') return 'veritable-ui' + if (name === 'IDP_PUBLIC_URL_PREFIX') return 'http://public.example.com' + if (name === 'IDP_INTERNAL_URL_PREFIX') return 'http://internal.example.com' + if (name === 'IDP_INTERNAL_URL_PREFIX') return 'http://public.example.com' + if (name === 'IDP_AUTH_PATH') return '/auth' + if (name === 'IDP_TOKEN_PATH') return '/token' + if (name === 'IDP_JWKS_PATH') return '/jwks' return '' }, } as Env @@ -35,70 +40,49 @@ describe('IDPService', () => { const originalDispatcher = getGlobalDispatcher() const mockAgent = new MockAgent() - const mockOidc = mockAgent.get(`https://keycloak.example.com`) + const mockOidc = mockAgent.get(`http://internal.example.com`) beforeEach(function () { setGlobalDispatcher(mockAgent) - mockOidc - .intercept({ - path: '/.well_known/jwks.json', - method: 'GET', - }) - .reply(200, { - issuer: 'ISSUER', - authorization_endpoint: 'AUTHORIZATION_ENDPOINT', - token_endpoint: 'https://keycloak.example.com/TOKEN_ENDPOINT', - introspection_endpoint: 'INTROSPECTION_ENDPOINT', - userinfo_endpoint: 'USERINFO_ENDPOINT', - end_session_endpoint: 'END_SESSION_ENDPOINT', - jwks_uri: 'JWKS_URI', - }) - .persist() }) afterEach(function () { setGlobalDispatcher(originalDispatcher) }) - test('issuer', async function () { + test('authorizationEndpoint (INTERNAL)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.issuer - expect(result).to.equal('ISSUER') + const result = await idpService.authorizationEndpoint('INTERNAL') + expect(result).to.equal('http://internal.example.com/auth') }) - test('authorizationEndpoint', async function () { + test('tokenEndpoint (INTERNAL)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.authorizationEndpoint - expect(result).to.equal('AUTHORIZATION_ENDPOINT') + const result = await idpService.tokenEndpoint('INTERNAL') + expect(result).to.equal('http://internal.example.com/token') }) - test('tokenEndpoint', async function () { + test('jwksUri (INTERNAL)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.tokenEndpoint - expect(result).to.equal('https://keycloak.example.com/TOKEN_ENDPOINT') + const result = await idpService.jwksUri('INTERNAL') + expect(result).to.equal('http://internal.example.com/jwks') }) - test('introspectionEndpoint', async function () { + test('authorizationEndpoint (PUBLIC)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.introspectionEndpoint - expect(result).to.equal('INTROSPECTION_ENDPOINT') + const result = await idpService.authorizationEndpoint('PUBLIC') + expect(result).to.equal('http://public.example.com/auth') }) - test('userinfoEndpoint', async function () { + test('tokenEndpoint (PUBLIC)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.userinfoEndpoint - expect(result).to.equal('USERINFO_ENDPOINT') + const result = await idpService.tokenEndpoint('PUBLIC') + expect(result).to.equal('http://public.example.com/token') }) - test('endSessionEndpoint', async function () { + test('jwksUri (PUBLIC)', async function () { const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.endSessionEndpoint - expect(result).to.equal('END_SESSION_ENDPOINT') - }) - - test('jwksUri', async function () { - const idpService = new IDPService(mockEnv, mockLogger) - const result = await idpService.jwksUri - expect(result).to.equal('JWKS_URI') + const result = await idpService.jwksUri('PUBLIC') + expect(result).to.equal('http://public.example.com/jwks') }) describe('getTokenFromCode', function () { @@ -106,7 +90,7 @@ describe('IDPService', () => { mockOidc .intercept({ method: 'POST', - path: '/TOKEN_ENDPOINT', + path: '/token', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -177,7 +161,7 @@ describe('IDPService', () => { mockOidc .intercept({ method: 'POST', - path: '/TOKEN_ENDPOINT', + path: '/token', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, diff --git a/src/models/idpService.ts b/src/models/idpService.ts index 0f71e791..4fe85c23 100644 --- a/src/models/idpService.ts +++ b/src/models/idpService.ts @@ -5,17 +5,6 @@ import { z } from 'zod' import { ForbiddenError } from '../errors.js' import { Logger, type ILogger } from '../logger' -const oidcConfig = z.object({ - issuer: z.string(), - authorization_endpoint: z.string(), - token_endpoint: z.string(), - introspection_endpoint: z.string(), - userinfo_endpoint: z.string(), - end_session_endpoint: z.string(), - jwks_uri: z.string(), -}) -type IOidcConfig = z.infer - const tokenResponse = z.object({ access_token: z.string(), expires_in: z.number(), @@ -27,70 +16,28 @@ const tokenResponse = z.object({ scope: z.string(), }) +type fromNetwork = 'PUBLIC' | 'INTERNAL' + @singleton() @injectable() export default class IDPService { - private config?: IOidcConfig - constructor( private env: Env, @inject(Logger) private logger: ILogger ) {} - private async fetchConfig() { - const getConfigResponse = await fetch(this.env.get('IDP_OIDC_CONFIG_URL')) - if (!getConfigResponse.ok) { - this.logger.error('Error fetching OIDC config (%s)', getConfigResponse.statusText) - this.logger.trace('Error fetching OIDC config body: %s', await getConfigResponse.text()) - return - } - - try { - this.config = oidcConfig.parse(await getConfigResponse.json()) - } catch (err) { - if (err instanceof Error) { - this.logger.error('Error parsing OIDC config: %s', err.message) - return - } - this.logger.error('Error parsing OIDC config: unknown') - } - } - - private async fetchConfigValue(field: keyof IOidcConfig) { - if (this.config) { - return this.config[field] - } - await this.fetchConfig() - if (!this.config) { - throw new Error('Oidc config was not loaded') - } - return this.config[field] - } - - get issuer(): Promise { - return this.fetchConfigValue('issuer') - } - get authorizationEndpoint(): Promise { - return this.fetchConfigValue('authorization_endpoint') - } - get tokenEndpoint(): Promise { - return this.fetchConfigValue('token_endpoint') - } - get introspectionEndpoint(): Promise { - return this.fetchConfigValue('introspection_endpoint') - } - get userinfoEndpoint(): Promise { - return this.fetchConfigValue('userinfo_endpoint') + authorizationEndpoint(fromNetwork: fromNetwork): string { + return `${this.env.get(fromNetwork === 'PUBLIC' ? 'IDP_PUBLIC_URL_PREFIX' : 'IDP_INTERNAL_URL_PREFIX')}${this.env.get('IDP_AUTH_PATH')}` } - get endSessionEndpoint(): Promise { - return this.fetchConfigValue('end_session_endpoint') + tokenEndpoint(fromNetwork: fromNetwork): string { + return `${this.env.get(fromNetwork === 'PUBLIC' ? 'IDP_PUBLIC_URL_PREFIX' : 'IDP_INTERNAL_URL_PREFIX')}${this.env.get('IDP_TOKEN_PATH')}` } - get jwksUri(): Promise { - return this.fetchConfigValue('jwks_uri') + jwksUri(fromNetwork: fromNetwork): string { + return `${this.env.get(fromNetwork === 'PUBLIC' ? 'IDP_PUBLIC_URL_PREFIX' : 'IDP_INTERNAL_URL_PREFIX')}${this.env.get('IDP_JWKS_PATH')}` } async getTokenFromCode(code: string, redirectUrl: string) { - const tokenReq = await fetch(await this.tokenEndpoint, { + const tokenReq = await fetch(this.tokenEndpoint('INTERNAL'), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -111,7 +58,7 @@ export default class IDPService { } async getTokenFromRefresh(refreshToken: string) { - const tokenReq = await fetch(await this.tokenEndpoint, { + const tokenReq = await fetch(this.tokenEndpoint('INTERNAL'), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/src/server.ts b/src/server.ts index 089dcf7b..64fc784b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,6 @@ import bodyParser from 'body-parser' import compression from 'compression' import cookieParser from 'cookie-parser' import express, { Express } from 'express' -import fs from 'fs/promises' -import path from 'path' import requestLogger from 'pino-http' import { SwaggerUiOptions, serve, setup } from 'swagger-ui-express' import { container } from 'tsyringe' @@ -14,17 +12,37 @@ import { Env } from './env.js' import { ForbiddenError, HttpError } from './errors.js' import { ILogger, Logger } from './logger.js' import { RegisterRoutes } from './routes.js' +import loadApiSpec from './swagger.js' -export default async (): Promise => { - const swaggerBuffer = await fs.readFile(path.join(__dirname, './swagger.json')) - const swaggerJson = JSON.parse(swaggerBuffer.toString('utf8')) +const env = container.resolve(Env) + +const API_SWAGGER_BG_COLOR = env.get('API_SWAGGER_BG_COLOR') +const API_SWAGGER_TITLE = env.get('API_SWAGGER_TITLE') + +const customCssToInject: string = ` + body { background-color: ${API_SWAGGER_BG_COLOR}; } + .swagger-ui .scheme-container { background-color: inherit; } + .swagger-ui .opblock .opblock-section-header { background: inherit; } + .topbar { display: none; } + .swagger-ui .btn.authorize { background-color: #f7f7f7; } + .swagger-ui .opblock.opblock-post { background: rgba(73,204,144,.3); } + .swagger-ui .opblock.opblock-get { background: rgba(97,175,254,.3); } + .swagger-ui .opblock.opblock-put { background: rgba(252,161,48,.3); } + .swagger-ui .opblock.opblock-delete { background: rgba(249,62,62,.3); } + .swagger-ui section.models { background-color: #f7f7f7; } +` - const env = container.resolve(Env) +export default async (): Promise => { const logger = container.resolve(Logger) const app: Express = express() const options: SwaggerUiOptions = { - swaggerOptions: { url: '/api-docs', oauth: { usePkceWithAuthorizationCodeGrant: true } }, + swaggerOptions: { + url: '/api-docs', + oauth: { clientId: env.get('IDP_CLIENT_ID'), usePkceWithAuthorizationCodeGrant: true }, + }, + customCss: customCssToInject, + customSiteTitle: API_SWAGGER_TITLE, } app.use( @@ -40,7 +58,9 @@ export default async (): Promise => { app.use('/public', express.static('public')) app.use('/lib/htmx.org', express.static('node_modules/htmx.org/dist')) - app.get('/api-docs', (_req, res) => res.json(swaggerJson)) + + const apiSpec = await loadApiSpec(env) + app.get('/api-docs', (_req, res) => res.json(apiSpec)) app.use('/swagger', serve, setup(undefined, options)) app.get('/', (_, res) => res.sendStatus(404)) diff --git a/src/swagger.ts b/src/swagger.ts new file mode 100644 index 00000000..76855a27 --- /dev/null +++ b/src/swagger.ts @@ -0,0 +1,24 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { Env } from './env.js' + +/** + * Monkey-patch the generated swagger JSON so that when it is valid for the deployed environment + * @param env Environment containing configuration for monkey-patching the swagger + * @returns OpenAPI spec object + */ +export default async function loadApiSpec(env: Env): Promise { + const API_SWAGGER_HEADING = env.get('API_SWAGGER_HEADING') + const authorizationUrl = `${env.get('IDP_PUBLIC_URL_PREFIX')}${env.get('IDP_AUTH_PATH')}` + const tokenUrl = `${env.get('IDP_PUBLIC_URL_PREFIX')}${env.get('IDP_TOKEN_PATH')}` + + const swaggerBuffer = await fs.readFile(path.join(__dirname, './swagger.json')) + const swaggerJson = JSON.parse(swaggerBuffer.toString('utf8')) + swaggerJson.info.title += `:${API_SWAGGER_HEADING}` + swaggerJson.components.securitySchemes.oauth2.flows.authorizationCode.authorizationUrl = authorizationUrl + swaggerJson.components.securitySchemes.oauth2.flows.authorizationCode.tokenUrl = tokenUrl + swaggerJson.components.securitySchemes.oauth2.flows.authorizationCode.refreshUrl = tokenUrl + + return swaggerJson +} diff --git a/tsoa.json b/tsoa.json index 66364aaf..fb2c7dfd 100644 --- a/tsoa.json +++ b/tsoa.json @@ -18,8 +18,6 @@ "type": "oauth2", "flows": { "authorizationCode": { - "authorizationUrl": "http://localhost:3080/realms/veritable/protocol/openid-connect/auth", - "tokenUrl": "http://localhost:3080/realms/veritable/protocol/openid-connect/token", "scopes": [] } }