Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions .github/workflows/check-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

- name: Setup npm
uses: actions/setup-node@v5
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.18
cache: npm

- name: Install and audit
run: |
npm audit --omit=dev
npm ci
npm ci --ignore-scripts
npm run postinstall

- name: Verify semantic version bump
Expand Down Expand Up @@ -71,26 +71,44 @@ jobs:
- name: Jest Coverage Comment (with base snapshot)
if: steps.base-cov.outputs.available == 'true'
uses: ArtiomTr/jest-coverage-report-action@v2
# NOTE: need to switch to vitest!
with:
test-script: npm test
base-coverage-file: base-coverage/report.json

- name: Jest Coverage Comment (no base snapshot)
if: steps.base-cov.outputs.available != 'true'
uses: ArtiomTr/jest-coverage-report-action@v2
# NOTE: need to switch to vitest!
with:
test-script: npm test

- name: Acceptance & environment emulation tests
run: |
npm run test:acceptance:docker && \
npm run test:acceptance:proxy
set +e
docker compose -f compose.yml -f test/acceptance/compose.yml \
run --rm --build --pull always --quiet-pull test
if [ $? -ne 0 ]; then
echo -e '\n\n====> Dumping DAL logs...\n'
docker logs fcp-dal-api-fcp-dal-api-1 | jq
docker compose down --remove-orphans
exit 1
fi
docker compose -f compose.yml -f test/acceptance/compose.yml -f test/acceptance/cdp-mock-check.yml \
run --rm --quiet-pull test # no need to build or pull again
if [ $? -ne 0 ]; then
echo -e '\n\n====> Dumping DAL logs...\n'
docker logs fcp-dal-api-fcp-dal-api-1 | jq
docker compose down --remove-orphans
exit 1
fi
docker compose down --remove-orphans
# TODO: re-enable mTLS tests when startup health check issue is resolved
# && npm run test:acceptance:proxy:mtls

- name: Sonarqube Cloud Scan
- name: Sonar Cloud Scan
if: github.actor != 'dependabot[bot]'
uses: SonarSource/sonarqube-scan-action@v6
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
11 changes: 6 additions & 5 deletions .github/workflows/publish-hotfix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,30 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # Depth 0 is required for branch-based versioning

- name: Test code and Create Test Coverage Reports
uses: actions/setup-node@v5
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
- run: |
npm audit --omit=dev
npm ci
npm ci --ignore-scripts
npm run postinstall
npm run lint
npm test

- name: Publish Hot Fix
uses: DEFRA/cdp-build-action/build-hotfix@main
uses: DEFRA/cdp-build-action/build-hotfix@f13fc42004f99a1df2e21ce1ff488cc72c897e40 # 17 Jul 2026
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

# FIXME: this step should be run before the tag publishing in case there are problems found!
- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v6
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
14 changes: 7 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ jobs:
name: Snapshot base coverage for PR comparison
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm
- run: |
npm ci
npm ci --ignore-scripts
npm run postinstall
- name: Run unit + graphql tests with coverage
run: npm test -- --ci --json --testLocationInResults --outputFile=report.json
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-report-main
path: report.json
Expand All @@ -41,7 +41,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0

Expand All @@ -56,7 +56,7 @@ jobs:
echo "value=${VERSION}" >> $GITHUB_OUTPUT

- name: Build and Publish
uses: DEFRA/cdp-build-action/build@main
uses: DEFRA/cdp-build-action/build@f13fc42004f99a1df2e21ce1ff488cc72c897e40 # 17 Jul 2026
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ steps.version.outputs.value }}
Expand All @@ -67,7 +67,7 @@ jobs:
git push --tags

- name: SonarCloud Scan
uses: SonarSource/sonarqube-scan-action@v6
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,9 @@
}
},
"jestrunner.jestCommand": "NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG_PATH=./.env.test ./node_modules/jest/bin/jest.js",
"jestrunner.runOptions": ["--runInBand", "--coverage", "--setupFiles", "dotenv/config"]
"jestrunner.runOptions": ["--runInBand", "--coverage", "--setupFiles", "dotenv/config"],
"sonarlint.connectedMode.project": {
"connectionId": "defra",
"projectKey": "DEFRA_fcp-dal-api"
}
}
17 changes: 12 additions & 5 deletions app/auth/authenticate.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
import { Unit } from 'aws-embedded-metrics'
import { defaultFieldResolver } from 'graphql'
import jwt from 'jsonwebtoken'
import { decodeProtectedHeader, jwtVerify } from 'jose'
import { config } from '../config.js'
import { Unauthorized } from '../errors/graphql.js'
import { DAL_REQUEST_AUTHENTICATION_001 } from '../logger/codes.js'
Expand All @@ -20,11 +20,13 @@ export async function getAuth(request, jwkDatasource) {
code: DAL_REQUEST_AUTHENTICATION_001,
request: { remoteAddress: request?.info?.remoteAddress }
})
const decodedToken = jwt.decode(token, { complete: true })
const decodedToken = decodeProtectedHeader(token)
const requestStart = Date.now()
const signingKey = await jwkDatasource.getPublicKey(decodedToken.header.kid)
const signingKey = await jwkDatasource.getPublicKey(decodedToken.kid)
const requestTimeMs = Date.now() - requestStart
const verified = jwt.verify(token, signingKey)
const { payload: verified } = await jwtVerify(token, signingKey, {
algorithms: ['RS256']
})
sendMetric('RequestTime', requestTimeMs, Unit.Milliseconds, {
code: DAL_REQUEST_AUTHENTICATION_001
})
Expand Down Expand Up @@ -56,9 +58,14 @@ export async function getAuth(request, jwkDatasource) {
})
}
})

return verified
} catch (error) {
if (error.name === 'TokenExpiredError') {
if (
error.name === 'TokenExpiredError' ||
error.code === 'ERR_JWT_EXPIRED' ||
error.name === 'JWTExpired'
) {
logger.warn('#DAL - request authentication - token expired', {
error,
code: DAL_REQUEST_AUTHENTICATION_001,
Expand Down
1 change: 0 additions & 1 deletion app/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,6 @@ export const config = convict({
doc: 'Hitachi base API URL',
format: String,
default: 'https://api.example.com',
nullable: true,
env: 'HITACHI_BASE_URL'
},
timeoutMs: {
Expand Down
24 changes: 24 additions & 0 deletions app/data-sources/JWKS.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createRemoteJWKSet } from 'jose'
import { config } from '../config.js'

let jwksSet = null

export class JWKS {
getRemoteJwksSet() {
if (!jwksSet) {
const jwksUri = config.get('oidc.jwksURI')
const url = new URL(jwksUri)

jwksSet = createRemoteJWKSet(url, {
timeout: config.get('oidc.timeoutMs')
})
}

return jwksSet
}

async getPublicKey(kid) {
const getKey = this.getRemoteJwksSet()
return getKey({ kid, alg: 'RS256' })
}
}
44 changes: 0 additions & 44 deletions app/data-sources/mongo/JWKS.js

This file was deleted.

4 changes: 2 additions & 2 deletions app/graphql/context.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken'
import { getAuth, getRequestingGroup } from '../auth/authenticate.js'
import { HitachiPayments } from '../data-sources/hitachi/HitachiPayments.js'
import { JWKS } from '../data-sources/JWKS.js'
import { MongoBusiness } from '../data-sources/mongo/Business.js'
import { MongoCustomer } from '../data-sources/mongo/Customer.js'
import { MongoJWKS } from '../data-sources/mongo/JWKS.js'
import { RuralPaymentsBusiness } from '../data-sources/rural-payments/RuralPaymentsBusiness.js'
import { RuralPaymentsCustomer } from '../data-sources/rural-payments/RuralPaymentsCustomer.js'
import { RuralPaymentsReferenceData } from '../data-sources/rural-payments/RuralPaymentsReferenceData.js'
Expand All @@ -29,7 +29,7 @@ export const extractOrgIdFromDefraIdToken = (sbi, token) => {
}

export async function context({ request }) {
const auth = await getAuth(request, new MongoJWKS({ modelOrCollection: db.collection('jwks') }))
const auth = await getAuth(request, new JWKS())

const requestLogger = logger.child({
transactionId: request.transactionId,
Expand Down
1 change: 1 addition & 0 deletions app/logger/codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export const HITACHI_API_REQUEST_001 = 'HITACHI_API_REQUEST_001'
export const HITACHI_API_NOT_FOUND_001 = 'HITACHI_API_NOT_FOUND_001'

export const MONGO_DB_ERROR_001 = 'MONGO_DB_ERROR_001'
export const JWKS_FETCH_ERROR_001 = 'JWKS_FETCH_ERROR_001'
5 changes: 3 additions & 2 deletions app/utils/health/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { logger } from '../../logger/logger.js'
import { healthCheck as jwks } from './jwks.js'
import { healthCheck as mongo } from './mongo.js'
import { healthCheck as ruralPayments } from './rural-payments.js'
import { logger } from '../../logger/logger.js'

const healthChecks = [mongo, ruralPayments]
const healthChecks = [mongo, jwks, ruralPayments]

/**
* Runs all registered health checks.
Expand Down
40 changes: 40 additions & 0 deletions app/utils/health/jwks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { config } from '../../config.js'
import { JWKS } from '../../data-sources/JWKS.js'
import { JWKS_FETCH_ERROR_001 } from '../../logger/codes.js'
import { logger } from '../../logger/logger.js'

export const healthCheck = async () => {
if (!config.get('auth.disabled')) {
try {
logger.info(`Fetching JWKS keys from ${config.get('oidc.jwksURI')}`)
const res = await fetch(config.get('oidc.jwksURI'))
if (!res.ok) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few coverage-related warnings below

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered 👍

logger.error('#DAL - Error fetching JWKS keys', {
res,
code: JWKS_FETCH_ERROR_001,
tenant: { message: await res.text() }
})
throw new Error(`Problem fetching JWKS keys, status: ${res.status}`)
}

const json = await res.json()
if (!json.keys || !Array.isArray(json.keys)) {
logger.error('#DAL - Error parsing JWKS keys', { res, code: JWKS_FETCH_ERROR_001 })
throw new Error('Problem inspecting JWKS keys response')
}

const [firstKey] = json.keys
if (!firstKey?.kid) {
logger.error('#DAL - Error no matching JWKS key', { res, code: JWKS_FETCH_ERROR_001 })
throw new Error('Missing JWKS keys')
}

const jwks = new JWKS()
await jwks.getPublicKey(firstKey.kid)
logger.info(`SUCCESS: Resolved first JWKS key for kid: ${firstKey.kid}`)
} catch (error) {
logger.error('#DAL - Error checking JWKS keys', { error, code: JWKS_FETCH_ERROR_001 })
throw error
}
}
}
4 changes: 3 additions & 1 deletion app/utils/health/mongo.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { config } from '../../config.js'
import { MONGO_DB_ERROR_001 } from '../../logger/codes.js'
import { logger } from '../../logger/logger.js'
import { mongoClient } from '../../mongo.js'

export const healthCheck = async () => {
try {
logger.info(`Connecting to MongoDB on ${config.get('mongo.mongoUrl')}`)
await mongoClient.connect()
logger.info('Connected to MongoDB')
logger.info('SUCCESS: Connected to MongoDB')
} catch (err) {
logger.error('#DAL - Error connecting to MongoDB', { error: err, code: MONGO_DB_ERROR_001 })
throw err
Expand Down
4 changes: 1 addition & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ C4Context

Boundary(cdp, "Core Development Platform (CDP)", "AWS VPC") {
System(dal, "FCP-DAL-API", "Data Access Layer (DAL) provides GraphQL API")
System_Ext(mongo, "Mongo DB", "Caches JWKS public keys,
<br />
relations between organisation IDs / SBIs,
System_Ext(mongo, "Mongo DB", "Caches relations between organisation IDs / SBIs,
<br />
and relations between person IDs / CRNs")
System_Ext(squid, "Squid Proxy", "Routes all outbound requests")
Expand Down
Loading
Loading