1.3 Backend Auth Middleware — Protected & Public Routes
Sprint Key: 1-3-backend-auth-middleware-protected-and-public-routes
Epic: 1
PRD: keycloak-idp
Story 1.3: Backend Auth Middleware — Protected & Public Routes
Status: review
Story
As a backend developer, I want an Express middleware that validates Keycloak tokens on protected routes while leaving public routes accessible, So that API endpoints are secured by default.
Acceptance Criteria
-
Given the Express backend is running with Keycloak available When a request hits a protected route (e.g.
/api/chat/*,/api/users/*) without a validAuthorization: Bearerheader Then the middleware returns HTTP 401 with{ error: "TOKEN_INVALID", message: "...", details: {} } -
Given the Express backend is running with Keycloak available When a request hits a public route (
/health,/api-docs, static assets) Then the request returns its response without requiring a token (FR23) -
Given the Express backend is running with Keycloak available When a request hits a protected route with a valid
Authorization: Bearertoken Then the middleware extractsiss,sub,exp,audclaims from the JWT for downstream use -
Given the Express backend is running with Keycloak available When a request hits a protected route with an invalid token format (not a valid JWT) Then the middleware returns 401 with
TOKEN_INVALIDerror code
Tasks / Subtasks
-
Create keycloak-auth-service.js— JWKS token verification service (AC: #1 (closed), #3 (closed), #4 (closed))-
Create components/gov-chat-backend/services/keycloak-auth-service.js -
Implement verifyToken(token)usingjose.jwtVerify()withcreateRemoteJWKS() -
OIDC discovery: fetch /.well-known/openid-configurationfrom Keycloak to resolveissuerandjwks_uri(lazy singleton, triggered on first token verification) -
Validate claims: iss(must match expected issuer),aud(must matchKEYCLOAK_CLIENT_ID),exp(must not be expired) -
Return decoded payload on success, throw structured error on failure -
Use CommonJS ( require/module.exports) — no ES imports -
Use existing { logger }from../shared-libfor logging
-
-
Create shared JWT mock fixture for tests (AC: #1 (closed), #3 (closed), #4 (closed)) -
Create components/gov-chat-backend/__tests__/mocks/mockJwtPayload.js -
Export minimum valid payload: sub,iss,email,name,realm_access,exp,aud,iat -
Export a helper to generate mock JWT strings for testing
-
-
Create keycloak-auth-middleware.js— Express middleware (AC: #1 (closed), #2 (closed), #3 (closed), #4 (closed))-
Create components/gov-chat-backend/middleware/keycloak-auth-middleware.js -
Export keycloakAuthMiddlewareobject withauthenticatemethod -
authenticate(req, res, next):-
Extract Bearer token from Authorizationheader -
Return 401 { error: "TOKEN_INVALID", message: "Missing or malformed Authorization header", details: {} }if no Bearer token -
Call keycloakAuthService.verifyToken(token)to validate -
Return 401 { error: "TOKEN_INVALID", message: "Token verification failed", details: {} }on verification failure -
Return 401 { error: "TOKEN_EXPIRED", message: "Token has expired", details: {} }on expired token -
On success: attach decoded payload to req.user(withiss_subcomposite key) and callnext()
-
-
Export public route paths array: PUBLIC_PATHS = ['/health', '/api-docs', '/api-docs/', '/docs'] -
Export isPublicRoute(path)helper to check if a path is public -
Use CommonJS — no ES imports
-
-
Add josedependency (AC: #1 (closed), #3 (closed), #4 (closed))-
Run npm install joseincomponents/gov-chat-backend/ -
Verify joseworks with CommonJSrequire()(it ships dual CJS/ESM)
-
-
Wire middleware into Express app — protected routes (AC: #1 (closed), #2 (closed)) -
Import keycloakAuthMiddlewareincomponents/gov-chat-backend/index.js -
Apply keycloakAuthMiddleware.authenticateto all/api/*routes EXCEPT public auth callback routes -
Public routes remain unprotected: /health,/api-docs, static assets -
OIDC callback routes ( /api/auth/callback,/api/auth/logout/callback) must remain public (added in later stories, but plan for them now) -
Do NOT remove existing authMiddlewareyet (legacy removal is Story 1.11) -
The new middleware coexists alongside existing auth — route files that need Keycloak auth will use the new middleware
-
-
Add KEYCLOAK_URLandKEYCLOAK_CLIENT_IDto backend config (AC: #3 (closed))-
Add env var reads in components/gov-chat-backend/config.jswith sensible defaults -
KEYCLOAK_URLdefault:http://keycloak:8080(internal Docker network) -
KEYCLOAK_CLIENT_IDdefault:genie-app(matches Story 1.1 config) -
KEYCLOAK_REALMdefault:genie(matches Story 1.1 config)
-
-
Write unit tests for keycloak-auth-service.js(AC: #1 (closed), #3 (closed), #4 (closed))-
Create components/gov-chat-backend/__tests__/keycloak-auth-service.test.js -
Test: valid token returns decoded payload with iss,sub,exp,aud -
Test: expired token throws error with TOKEN_EXPIRED -
Test: invalid signature throws error with TOKEN_INVALID -
Test: wrong audclaim throws error -
Test: missing claims throws error -
Use mock JWT strings for testing -
Use shared mock fixture from __tests__/mocks/mockJwtPayload.js
-
-
Write unit tests for keycloak-auth-middleware.js(AC: #1 (closed), #2 (closed), #3 (closed), #4 (closed))-
Create components/gov-chat-backend/__tests__/keycloak-auth-middleware.test.js -
Test: request without Authorization header returns 401 TOKEN_INVALID -
Test: request with malformed Authorization header returns 401 TOKEN_INVALID -
Test: request with valid token passes through (calls next()) -
Test: request with expired token returns 401 TOKEN_EXPIRED -
Test: request with invalid token returns 401 TOKEN_INVALID -
Test: req.useris populated with decoded payload includingiss_sub -
Test: isPublicRoute()correctly identifies public vs protected paths -
Mock keycloakAuthService.verifyTokento isolate middleware from actual JWKS calls
-
-
Run all tests and verify no regressions (AC: all) -
Run npm testincomponents/gov-chat-backend/ -
Verify all new tests pass -
Verify existing backend functionality is not broken
-
Dev Notes
Architecture Decisions
D2 — Backend JWKS library: jose [Source: architecture.md#D2]
- Lightweight, modern API with native JWKS multi-issuer support via
createRemoteJWKS() - Single-call verification with
jose.jwtVerify() - Node.js 22 Web Crypto API compatible
- Install:
npm install jose(CommonJS:const { jwtVerify, createRemoteJWKS } = require('jose')) - Rejected alternative:
jsonwebtoken+jwks-rsa— additional dependency, older API
D3 — JWKS caching strategy [Source: architecture.md#D3]
- Cache key:
{iss}(issuer URL) — enables multi-issuer - TTL: 5 minutes
- Force-refresh on 401 with valid
exp— two-attempt pattern - Note for this story: Full JWKS caching with multi-issuer support is Story 2.2. This story (1.3) implements basic single-issuer JWKS verification. The service should be structured to allow easy addition of caching later.
Auth Error Response Format (MANDATORY)
All error responses MUST follow this format [Source: architecture.md#Format Patterns]:
{
"error": "ERROR_CODE",
"message": "Human-readable description",
"details": {}
}
Error codes for this story:
| HTTP | Code | When |
|---|---|---|
| 401 | TOKEN_INVALID |
Missing/malformed Bearer header, invalid signature, wrong claims |
| 401 | TOKEN_EXPIRED |
Token exp claim exceeded |
Critical Implementation Rules
-
CommonJS ONLY —
const { jwtVerify, createRemoteJWKS } = require('jose')andmodule.exports = .... NEVER use ESimport/export. [Source: project-context.md] -
Per-route middleware — NEVER apply auth middleware globally. Apply
keycloakAuthMiddleware.authenticateto specific route groups only. [Source: project-context.md#Anti-Patterns] -
Existing auth middleware preserved — Do NOT modify or remove existing
middleware/auth-middleware.js. The newkeycloak-auth-middleware.jscoexists. Legacy removal is Story 1.11. - No ArangoDB in this story — This story only validates tokens and extracts claims. ArangoDB user lookup/JIT provisioning is Story 1.6.
-
No global middleware — The new middleware must be applied per-route in
index.js, not viaapp.use().
Route Security Matrix [Source: prd.md#Route Security Matrix]
| Route | Auth Required | This Story |
|---|---|---|
/health |
No | Public (no change) |
/api-docs |
No | Public (no change) |
| Static assets | No | Public (no change) |
/api/chat/* |
Yes | Protected with new middleware |
/api/users/* |
Yes | Protected with new middleware |
/api/analytics/* |
Yes | Protected with new middleware |
/api/admin/* |
Yes | Protected with new middleware |
/api/files/* |
Yes | Protected with new middleware |
/api/categories/* |
Yes | Protected with new middleware |
/api/auth/* |
Mixed | Login callback public, others protected |
Public Paths (no auth required)
/health
/api-docs
/api-docs/
/docs
/api/auth/callback (OIDC callback — added in later story)
/api/auth/logout/callback (OIDC logout callback — added in later story)
Backend File Structure
components/gov-chat-backend/
├── middleware/
│ ├── auth-middleware.js # EXISTING — legacy, do NOT modify (Story 1.11)
│ └── keycloak-auth-middleware.js # NEW — JWKS validation middleware
├── services/
│ ├── auth-service.js # EXISTING — legacy, do NOT modify (Story 1.11)
│ └── keycloak-auth-service.js # NEW — JWKS verification service
├── __tests__/
│ ├── mocks/
│ │ └── mockJwtPayload.js # NEW — shared JWT payload fixture
│ ├── keycloak-auth-service.test.js # NEW — service tests
│ └── keycloak-auth-middleware.test.js # NEW — middleware tests
├── config.js # MODIFY — add KEYCLOAK_* vars
├── index.js # MODIFY — wire new middleware to routes
└── package.json # MODIFY — add jose dependency
Key Existing Code Patterns (DO NOT change these)
-
Service singleton pattern — Existing services export a singleton object. Follow same pattern for
keycloakAuthService. -
Middleware export pattern — Existing middleware exports an object with methods. Follow same pattern:
module.exports = keycloakAuthMiddleware. -
Logger usage — Import from shared-lib:
const { logger } = require('../shared-lib'). - Error handling — try/catch in handlers, structured error responses.
-
Route protection — Applied per-route, not globally. Check
index.jsfor how existingauthMiddleware.authenticateis applied to route groups.
Previous Story Intelligence (Story 1.1)
- Keycloak is running at
keycloak:8080internally (Docker network) - NGINX proxies
/auth/to Keycloak via Kong - OIDC discovery:
https://<domain>/auth/realms/genie/.well-known/openid-configuration - Client ID default:
genie-app(fromKC_CLIENT_IDenv var ingenie-realm.yaml) - Realm default:
genie(fromKC_REALMenv var) - Env vars are available:
KEYCLOAK_URL,KEYCLOAK_REALM,KEYCLOAK_CLIENT_ID,KEYCLOAK_CLIENT_SECRET
Dependencies to Install
npm install jose # JWT/JWKS verification library
jose CommonJS usage:
const { jwtVerify, createRemoteJWKS, SignJWT } = require('jose');
Testing Standards
- Framework: Jest (already in devDependencies)
-
Module system: CommonJS —
require()/module.exports -
File location:
__tests__/directory at backend root -
Naming:
*.test.js -
Structure:
describe()/it()/expect() - Mocks: Mock external services at module level
-
Shared fixture:
__tests__/mocks/mockJwtPayload.js— mandatory for all auth tests [Source: architecture.md#Test Patterns]
Token Verification Flow (this story — simplified, no caching)
1. Extract Bearer token from Authorization header
2. If missing/malformed → 401 TOKEN_INVALID
3. Ensure OIDC discovery initialized (lazy singleton with 30s retry cooldown)
4. Extract unverified `iss` from token payload → lookup in trusted issuer map
5. If issuer not in map → 401 TOKEN_INVALID ("Unknown issuer")
6. Verify JWT signature + claims via jose jwtVerify() (iss, aud, exp, sub, alg=RS256)
7. If expired → 401 TOKEN_EXPIRED
8. If signature/claims invalid → 401 TOKEN_INVALID
9. Attach decoded payload to req.user with iss_sub composite key
10. Call next()
Note: Full JWKS caching with force-refresh (D3 two-attempt pattern) is Story 2.2. This story uses direct createRemoteJWKS() without caching.
References
- [Source: architecture.md#D2] —
joselibrary selection - [Source: architecture.md#D3] — JWKS caching strategy (Story 2.2)
- [Source: architecture.md#Format Patterns] — Auth error response format
- [Source: architecture.md#Auth Middleware Flow] — Full middleware flow (JIT provisioning in Story 1.6)
- [Source: architecture.md#Structure Patterns] — OIDC callback routes (public)
- [Source: architecture.md#Test Patterns] — Shared mock fixture requirement
- [Source: architecture.md#Project Structure] — New files list for backend
- [Source: prd.md#FR22] — Protected route authentication
- [Source: prd.md#FR23] — Public route access
- [Source: prd.md#Route Security Matrix] — Route protection matrix
- [Source: epics.md#Story 1.3] — Story definition and acceptance criteria
- [Source: project-context.md#Testing Rules] — Jest testing conventions
- [Source: project-context.md#Anti-Patterns] — Never use global auth middleware, never use ES imports in backend
- [Source: project-context.md#Framework-Specific Rules] — Express route structure, auth middleware per-route
Dev Agent Record
Agent Model Used
Claude Opus 4.6 (GLM-5-Turbo)
Debug Log References
-
josev6 is ESM-only — Jest cannot usejest.requireActual('jose'). Solution: mockjosecompletely in service tests, usecreateTokenWithPayload()helper with base64url-encoded mock JWT strings. -
shared-libmodule doesn't exist in this worktree (it's atcomponents/shared/lib/but no resolve alias exists). Solution: usejest.mock('../shared-lib', ..., { virtual: true })in tests. -
jest.mock()factory cannot reference out-of-scope variables. Solution: movegetPublicJwkinto the factory function or userequire()inside the factory.
Completion Notes List
-
keycloak-auth-service.js: Implements
verifyToken(token)using OIDC discovery pattern. On first call, fetches/.well-known/openid-configurationfrom Keycloak to resolve canonicalissuerandjwks_uri. Stores trusted issuers in anissuerMap(Map<issuer, JWKS>). Token's unverifiedissis used only for map lookup (whitelist pattern). All validation (signature, iss, aud, exp, sub, alg) delegated tojose.jwtVerify(). Lazy singleton with 30-second retry cooldown on init failure. Multi-IdP ready viainit(url). Returns decoded payload withiss_subcomposite key. Structured errors viaTokenVerificationErrorclass. -
keycloak-auth-middleware.js: Express middleware with
authenticate(req, res, next). Extracts Bearer token, calls service, attaches decoded user toreq.userwithiss_sub,sub,iss,email,name,roles. Returns standardized error format{ error, message, details }. ExportsPUBLIC_PATHSandisPublicRoute()helper. -
config.js: Added
keycloaksection withurl,realm,clientIdfrom env vars with sensible defaults matching Story 1.1. -
index.js: Added
keycloakAuthMiddlewareimport. AddedkeycloakAuth: trueflag to protected route configs. Routes without flag (auth-routes, logger-routes, database-operations-routes) remain unprotected. -
jose v6.2.2: ESM-only module. Works with Node.js CJS
require()at runtime via Node's synthetic ESM support, but Jest needs the module fully mocked. -
Jest config: Created
jest.config.jswithtestEnvironment: 'node'andtestMatchfor__tests__/**/*.test.js.
File List
| File | Action | Description |
|---|---|---|
components/gov-chat-backend/services/keycloak-auth-service.js |
CREATED | JWKS token verification service |
components/gov-chat-backend/middleware/keycloak-auth-middleware.js |
CREATED | Express auth middleware with public route helper |
components/gov-chat-backend/__tests__/mocks/mockJwtPayload.js |
CREATED | Shared JWT payload mock fixture |
components/gov-chat-backend/__tests__/keycloak-auth-service.test.js |
CREATED | Service unit tests (15 tests) |
components/gov-chat-backend/__tests__/keycloak-auth-middleware.test.js |
CREATED | Middleware unit tests (23 tests) |
components/gov-chat-backend/jest.config.js |
CREATED | Jest configuration |
components/gov-chat-backend/config.js |
MODIFIED | Added keycloak config section |
components/gov-chat-backend/index.js |
MODIFIED | Added keycloak middleware import and per-route auth |
components/gov-chat-backend/package.json |
MODIFIED | Added jose dependency |