Skip to content

Commit

Permalink
Fix authentication when running in a docker compose (#27)
Browse files Browse the repository at this point in the history
* Fix authentication when running in a docker compose

* remove commented out docker compose sections
  • Loading branch information
mattdean-digicatapult authored May 17, 2024
1 parent 559a239 commit 55d7e02
Show file tree
Hide file tree
Showing 13 changed files with 124 additions and 132 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ RUN npm -g install [email protected]
COPY package*.json ./
RUN npm ci --omit-dev

COPY public ./public
COPY --from=builder /veritable-ui/build ./build

EXPOSE 80
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2216,4 +2217,4 @@
"clientPolicies": {
"policies": []
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
5 changes: 3 additions & 2 deletions src/controllers/auth.ts → src/controllers/AuthController.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down
8 changes: 4 additions & 4 deletions src/controllers/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand Down
21 changes: 18 additions & 3 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
70 changes: 27 additions & 43 deletions src/models/__tests__/idpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,78 +40,57 @@ 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 () {
const setupMock = (code: number, response: string | object) => {
mockOidc
.intercept({
method: 'POST',
path: '/TOKEN_ENDPOINT',
path: '/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
Expand Down Expand Up @@ -177,7 +161,7 @@ describe('IDPService', () => {
mockOidc
.intercept({
method: 'POST',
path: '/TOKEN_ENDPOINT',
path: '/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
Expand Down
73 changes: 10 additions & 63 deletions src/models/idpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof oidcConfig>

const tokenResponse = z.object({
access_token: z.string(),
expires_in: z.number(),
Expand All @@ -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<string> {
return this.fetchConfigValue('issuer')
}
get authorizationEndpoint(): Promise<string> {
return this.fetchConfigValue('authorization_endpoint')
}
get tokenEndpoint(): Promise<string> {
return this.fetchConfigValue('token_endpoint')
}
get introspectionEndpoint(): Promise<string> {
return this.fetchConfigValue('introspection_endpoint')
}
get userinfoEndpoint(): Promise<string> {
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<string> {
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<string> {
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',
Expand All @@ -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',
Expand Down
Loading

0 comments on commit 55d7e02

Please sign in to comment.