Overview
The EPR LAPS Backend API uses JWT (JSON Web Token) authentication integrated with Defra ID , an OpenID Connect (OIDC) identity provider. All API endpoints are protected by default unless explicitly configured otherwise.
Authentication Flow
1. OIDC Discovery
At server startup, the auth plugin fetches the OIDC discovery document once and caches it (src/plugins/auth.js:93-100):
const discoveryUrl = config . get ( 'auth.discoveryUrl' )
try {
const discoveryRes = await Wreck . get ( discoveryUrl , { json: true })
cachedDiscovery = discoveryRes . payload
} catch ( e ) {
throw Boom . internal ( 'Cannot fetch OIDC discovery document' , e )
}
The discovery document URL defaults to http://localhost:3200/cdp-defra-id-stub/.well-known/openid-configuration for local development.
2. JWT Strategy Registration
The auth plugin registers a JWT authentication strategy with Hapi (src/plugins/auth.js:102-111):
server . auth . strategy ( 'jwt' , 'jwt' , {
key: getKey ,
validate: jwtValidate ,
verifyOptions: {
algorithms: [ 'RS256' ],
issuer: config . get ( 'auth.issuer' )
}
})
server . auth . default ( 'jwt' )
Only RS256 algorithm is supported. HS256 and other symmetric algorithms are not allowed.
3. Public Key Retrieval
The getKey function retrieves the public key from the JWKS endpoint for signature verification (src/plugins/auth.js:16-37):
export const getKey = async ( _header ) => {
if ( ! cachedDiscovery ?. jwks_uri ) {
throw Boom . internal ( 'No jwks_uri found in discovery document' )
}
const jwksUri = cachedDiscovery . jwks_uri
try {
const { payload } = await Wreck . get ( jwksUri , { json: true })
const keys = payload ?. keys || []
if ( ! keys . length ) {
throw Boom . unauthorized ( 'No JWKS keys found' )
}
const pem = jwkToPem ( keys [ 0 ])
return { key: pem }
} catch ( err ) {
throw Boom . internal ( `Cannot verify auth token: ${ err . message } ` )
}
}
The system uses the first key from the JWKS endpoint. Key rotation is supported through JWKS polling.
JWT Token Structure
The JWT token decoded by the system contains:
JWT Payload
Extracted Credentials
{
"sub" : "user-id-123" ,
"roles" : [
"org-123:Chief Executive Officer:Local Authority Name"
],
"relationships" : [
"rel-456:org-123:Local Authority Name" ,
"rel-789:org-999:Another Authority"
],
"currentRelationshipId" : "rel-456" ,
"iss" : "http://localhost:3200/cdp-defra-id-stub" ,
"exp" : 1234567890
}
Token Validation
The jwtValidate function performs custom validation and extracts user credentials (src/plugins/auth.js:40-68):
export const jwtValidate = ( decoded , request , _h ) => {
const { sub : userId , roles } = decoded
request . logger . debug ( `DecodedJWT is ${ JSON . stringify ( decoded ) } ` )
const currentOrganisation = extractCurrentLocalAuthority ( decoded )
if ( ! roles ) {
return { isValid: false }
}
// Extract role
let role = null
if ( Array . isArray ( roles ) && roles . length > 0 ) {
const firstRoleParts = roles [ 0 ]. split ( ':' )
role = firstRoleParts [ 1 ] || null
}
return {
isValid: true ,
credentials: {
userId ,
role ,
currentOrganisation ,
... decoded
}
}
}
Validation Rules
Roles Required - Token must contain at least one role
Algorithm Check - Only RS256 is accepted
Issuer Validation - Token issuer must match configured issuer
Signature Verification - Token signature validated against JWKS public key
Expiration Check - Token must not be expired
If any validation rule fails, the request is rejected with a 401 Unauthorized response.
The system extracts the current local authority from the relationship structure (src/plugins/auth.js:70-86):
export const extractCurrentLocalAuthority = ( token ) => {
let organisationName = ''
if ( Array . isArray ( token . relationships ) && token . currentRelationshipId ) {
const matched = token . relationships . find (( rel ) => {
const parts = rel . split ( ':' )
return parts [ 0 ] === token . currentRelationshipId
})
if ( matched ) {
const parts = matched . split ( ':' )
if ( parts . length >= 3 ) {
organisationName = parts [ 2 ] // Index 2 is the org name
}
}
}
return organisationName
}
relationshipId:organisationId:organisationName
Example Relationship Parsing
Input: relationships: ["rel-456:org-123:Birmingham Council"]
currentRelationshipId: "rel-456"
Output: currentOrganisation: "Birmingham Council"
Configuration
Authentication is configured in src/config.js:132-146:
auth : {
discoveryUrl : {
doc : 'URI for fetching Metadata document for the signup signin policy' ,
format : String ,
default : 'http://localhost:3200/cdp-defra-id-stub/.well-known/openid-configuration' ,
env : 'DEFRA_ID_DISCOVERY_URL'
},
issuer : {
doc : 'The expected issuer for JWT validation' ,
format : String ,
default : 'http://localhost:3200/cdp-defra-id-stub' ,
env : 'DEFRA_ID_ISSUER'
}
}
Environment Variables
Variable Description Example DEFRA_ID_DISCOVERY_URLOIDC discovery endpoint https://defra-id.example.com/.well-known/openid-configurationDEFRA_ID_ISSUERExpected JWT issuer https://defra-id.example.com
Using Authentication in Routes
By default, all routes require authentication:
// Authentication required (default)
server . route ({
method: 'GET' ,
path: '/bank-details/{localAuthority}' ,
handler : async ( request , h ) => {
// Access authenticated user
const { userId , role , currentOrganisation } = request . auth . credentials
// ...
}
})
Accessing Credentials
Within route handlers, access the authenticated user’s credentials:
const credentials = request . auth . credentials
console . log ( credentials . userId ) // "user-id-123"
console . log ( credentials . role ) // "Chief Executive Officer"
console . log ( credentials . currentOrganisation ) // "Birmingham Council"
Health Check Exemption
The /health endpoint is exempt from authentication to allow load balancers and monitoring tools to check service status without credentials.
See the Authorization page for details on role-based access control applied after authentication.