2.3 Token Passthrough Headers Injection to Upstream
Sprint Key: 2-3-token-passthrough-headers-injection-to-upstream
Epic: 2
PRD: keycloak-idp
Story 2.3: Token Passthrough — Headers Injection to Upstream
Status: done
Story
As a backend system, I want to inject authenticated user identity as HTTP headers to upstream OPEA services, So that downstream services receive user context without ever seeing raw Keycloak tokens (NFR7).
Acceptance Criteria
-
Given a request has been authenticated by the backend middleware When the request is forwarded to an upstream OPEA service Then the following headers are injected:
X-User-Id({iss}#{sub}),X-User-Roles(comma-separated),X-Issuer(issuer URL) (FR24) -
Given headers are being prepared for injection When the raw Keycloak token would normally be forwarded Then the token is NOT forwarded to downstream services — only signed headers are injected (NFR7)
-
Given the
X-User-Idheader value is being constructed When multiple Keycloak realms are configured ThenX-User-Iduses the complete{iss}#{sub}composite key guaranteeing uniqueness across realms (D4) -
Given a request passes through backend auth middleware with a valid token When the request is forwarded to OPEA microservices via HTTP client Then OPEA services communicate via internal Docker network without additional authentication (network isolation, FR37) And the authenticated user's identity is passed via the existing payload structure including
user_idfield (FR38) -
Given the
X-User-Rolesheader is being constructed When the JWT contains role information fromrealm_access.rolesThen roles are extracted and joined as comma-separated values And if therealm_access.rolesclaim is missing or empty, the header contains an empty string -
Given Kong is deployed (optional deployment mode, see Story 2.5) When Kong validates tokens and injects headers Then the header names and formats match the backend implementation to ensure consistency (FR13) Note: Kong implementation is deferred to Story 2.5 — this story covers backend-only implementation
Tasks / Subtasks
-
Task 1: Refactor middleware to preserve JWT claims and build headers (AC: #1 (closed), #3 (closed), #5 (closed)) -
1.1 Attach verified JWT claims to request after token verification - Currently (line 80 in middleware):
decodedis used for provisioning then discarded - Add:
req.claims = decodedafter line 85 to preserve iss, sub, realm_access.roles for headers
- Currently (line 80 in middleware):
-
1.2 Create helper function buildUserHeaders(claims)that returns header object- Returns:
{ 'X-User-Id': string, 'X-User-Roles': string, 'X-Issuer': string } - X-User-Id: composite key
${claims.iss}#${claims.sub}(D4) - X-User-Roles:
${claims.realm_access?.roles?.join(',') || ''}(empty if missing) - X-Issuer:
${claims.iss}(full issuer URL)
- Returns:
-
1.3 Store headers on req.user for downstream access - Attach:
req.user.opeaHeaders = buildUserHeaders(req.claims)after provisioning - Headers are available to route handlers via
req.user.opeaHeaders
- Attach:
-
-
Task 2: Extend worker thread protocol for headers (AC: #1 (closed), #4 (closed)) -
2.1 Extend worker message protocol to include headers - Current:
worker.postMessage({ url, payload })(line 91 in query-service.js) - Update to:
worker.postMessage({ url, payload, headers: req.user?.opeaHeaders })
- Current:
-
2.2 Modify opea-worker.js to receive and apply headers - Worker currently receives:
{ url, payload }(line 17) - Update to:
const { url, payload, headers } = task; - Apply headers in axios call:
headers: { 'Content-Type': 'application/json', ...headers }
- Worker currently receives:
-
2.3 Document service-to-service authentication approach (FR37, AC #4 (closed)) - Current reality: OPEA services communicate via internal Docker network with NO authentication
- Only
Content-Type: application/jsonheader is sent (line 24 in opea-worker.js) - This is "network isolation" trust model, not a formal authentication mechanism
- Add explicit note: "Service-to-service authentication = internal Docker network trust (no auth headers)"
-
-
Task 3: Update user_id in OPEA payload to use composite key (AC: #3 (closed), #4 (closed), FR38) -
3.1 Identify where userId is constructed for OPEA payload - Search for:
userId: queryData.userIdor similar patterns in query-service.js - Current OPEA payload structure:
{ user_id: ..., query: ..., context: ... }
- Search for:
-
3.2 Replace userId with composite key from JWT claims - Change:
userId: req.user.iss_sub(already computed in Story 2.2, line 168 in keycloak-auth-service.js) - OPEA services treat user_id as opaque string - this is NOT a breaking change
- Change:
-
3.3 Verify OPEA compatibility - OPEA services don't parse user_id format, they just store it
- This is backwards compatible - composite key is still a string
-
-
Task 4: Verify raw token is NOT forwarded to OPEA (AC: #2 (closed), NFR7) -
4.1 Verify Authorization header is NOT included in worker axios call - Worker currently only sends:
{ 'Content-Type': 'application/json' }(line 24) - Confirm: Authorization header is NOT passed from main thread to worker
- This is already secure - worker cannot access req.headers
- Worker currently only sends:
-
4.2 Verify req.user.opeaHeaders does NOT include raw token - headers object contains only X-User-Id, X-User-Roles, X-Issuer
- No Authorization or other sensitive data included
-
-
Task 5: Add unit tests for header extraction logic (AC: all) -
5.1 Test: buildUserHeaders()correctly constructs all three headers -
5.2 Test: Composite key {iss}#{sub}is correctly formatted -
5.3 Test: Roles array is correctly joined with commas -
5.4 Test: Missing realm_access.rolesclaim returns empty string header -
5.5 Test: Empty roles array returns empty string header - 5.6 Test: Headers are attached to req.user.opeaHeaders in middleware
-
5.7 Test: Worker receives and applies headers correctly -
5.8 Test: Multiple realm tokens produce different X-User-Id values
-
-
Task 6: Add integration tests for end-to-end header flow (AC: #1 (closed), #4 (closed)) -
6.1 Test: Authenticated request → headers extracted → OPEA call includes headers -
6.2 Test: Mock OPEA service receives correct headers in request -
6.3 Test: Multiple realm tokens produce different X-User-Id values (uniqueness) -
6.4 Test: Existing payload structure is preserved with updated user_id -
6.5 Note: Kong consistency testing deferred to Story 2.5 ( Kong optional deployment)
-
Dev Notes
Architecture Context (Decision D4 — Multi-realm user_id for OPEA)
Decision: X-User-Id header (and user_id payload field for OPEA) uses the full {iss}#{sub} composite key.
Rationale: Guarantees uniqueness across realms. Using sub alone risks collision if different realms use the same user ID format (e.g., email-based sub).
Implementation:
const issSub = `${verifiedPayload.iss}#${verifiedPayload.sub}`;
headers['X-User-Id'] = issSub;
Impact on OPEA: Existing user_id field in OPEA payload structure becomes {iss}#{sub} instead of just sub. OPEA services must handle this format — they remain Keycloak-agnostic as the format is a composite string, not a Keycloak-specific structure.
Critical Constraint: No Raw Token Forwarding (NFR7)
Security Requirement: Downstream OPEA services MUST NOT receive raw Keycloak tokens.
Implementation Approach:
- Option 1: Explicitly strip
Authorizationheader when proxying to OPEA - Option 2: Use allowlist for headers to forward (exclude Authorization)
- Option 3: Use separate HTTP client for OPEA calls (no Authorization attached)
Recommended: Option 3 — Use separate axios instance or configuration for OPEA service calls that does NOT include the Authorization header. This prevents accidental token leakage.
Process Patterns: Header Injection Flow
1. Request arrives with Authorization: Bearer <token>
2. Auth middleware validates token via JWKS (Story 2.2)
3. On successful validation:
- Extract claims: iss, sub, realm_access.roles
- Build user headers: X-User-Id, X-User-Roles, X-Issuer
- Attach to req.headers or req.userHeaders
4. Route handler processes request
5. If upstream OPEA call needed:
- Use HTTP client configured for OPEA (no Authorization header)
- Inject user headers into request
- Preserve existing service-to-service auth
- Send request
6. OPEA service receives:
- Existing service-to-service JWT (for inter-service auth)
- User identity headers (new)
- Existing payload with user_id field (unchanged structure)
Integration with Story 2-2 (JWKS Force-Refresh)
Dependency: Story 2-2 must be completed first as it implements the token verification logic.
Integration Point: Headers are extracted AFTER successful token verification in verifyToken(). The force-refresh logic from Story 2.2 runs first; only on successful verification do we extract headers.
Code Location: Headers should be built in the auth middleware after jwtVerify() succeeds, or in a helper function called by route handlers after middleware succeeds.
OPEA Service Authentication (FR37, FR38)
Existing Pattern: Backend-to-OPEA uses service-to-service JWT with user_id in payload.
Required Behavior: This pattern MUST NOT change — we ADD headers, we do NOT REPLACE existing authentication.
Example existing payload (to be preserved):
{
"user_id": "user-123", // Will become "{iss}#{sub}" after this story
"query": "user question",
"context": {...}
}
New headers (additive):
X-User-Id: http://keycloak/realms/genie#12345678-1234-1234-123456789012
X-User-Roles: user,admin
X-Issuer: http://keycloak/realms/genie
Backend-Only Mode (No Kong)
Requirement: System must work identically with or without Kong (FR9, NFR8).
Implementation: Backend handles header injection independently. Kong provides an additional validation layer when deployed, but the backend's header injection logic is the primary mechanism.
Consistency: Header names and formats MUST match between backend and Kong implementations to avoid confusing OPEA services.
Project Structure Notes
Backend Service: components/gov-chat-backend/services/
- Auth middleware location:
middleware/auth-middleware.js(or similar) - Keycloak auth service:
services/keycloak-auth-service.js(from Story 2.2) - OPEA service calls: Search for axios/fetch to OPEA endpoints
Code Standards (from project-context.md):
- CommonJS only (
require/module.exports) - 2-space indentation, single quotes, semicolons
- Async/await preferred over raw Promises
- Controller → Service pattern for route handlers
Testing Requirements
Unit Tests:
- Mock JWT payload with required claims
- Test header construction logic
- Test that Authorization header is stripped/not forwarded
- Test missing claim handling
Integration Tests:
- Full request flow: token → verification → headers → OPEA call
- Verify headers reach OPEA service correctly
- Test with multiple realm tokens
Test File Location: components/gov-chat-backend/__tests__/ (alongside existing tests)
Existing Code Analysis (Current Reality)
Auth Middleware (keycloak-auth-middleware.js):
- Line 80:
const decoded = await keycloakAuthService.verifyToken(token)— JWT claims available here - Line 85:
user = await userProvisioningService.provisionUser(decoded)— only ArangoDB user preserved -
PROBLEM:
decodedis NOT preserved — needed for header construction - Line 105:
req.user = user— only ArangoDB document attached to request -
FIX NEEDED: Add
req.claims = decodedafter provisioning to preserve JWT claims
OPEA Worker Thread (opea-worker.js):
- Line 17:
const { url, payload } = task;— current message structure - Line 23-24:
axiosInstance.post(url, payload, { headers: { 'Content-Type': 'application/json' } }) - NO AUTHENTICATION HEADERS currently sent — this is "network isolation" trust model
-
PROTOCOL EXTENSION NEEDED: Add
headersparameter to message structure
Query Service (query-service.js):
- Line 91:
worker.postMessage({ url, payload });— where worker is invoked -
CHANGE NEEDED: Pass
req.user?.opeaHeadersin message
Current Data Flow:
Request → Middleware → verifyToken() → provisionUser(decoded) → req.user = ArangoDB doc
↓
decoded LOST (claims not preserved)
Required Data Flow:
Request → Middleware → verifyToken() → provisionUser(decoded) → req.user = ArangoDB doc
↓ req.claims = decoded (NEW)
↓
req.user.opeaHeaders = buildUserHeaders(req.claims)
↓
worker.postMessage({ url, payload, headers: req.user.opeaHeaders })
Security Considerations (Verified Against Code)
Token Leakage Prevention:
-
✅ Worker thread CANNOT access req.headers — isolation by design -
✅ Worker receives only what's explicitly passed via postMessage -
✅ Authorization header is NOT passed to worker (security guaranteed)
Header Injection Safety:
-
✅ Claims already validated by Story 2.2 (JWT verification) -
✅ Node.js axios automatically handles header encoding (no CRLF injection risk) -
✅ Array.join(',') is safe for comma-separated values -
✅ No manual header string construction needed
Service-to-Service Authentication:
- Current reality: OPEA uses internal Docker network with NO authentication
- This is correct: "Network isolation" trust model (FR37 satisfied)
- NO CHANGE needed: Do NOT add service-to-service JWT or other auth
- Clarification: Service-to-service auth ≠ formal auth mechanisms (JWT, mTLS)
- This story ADDS user identity headers to existing unauthenticated OPEA calls
Multi-Realm Isolation:
-
{iss}#{sub}composite key guarantees uniqueness - Different realms produce different X-User-Id values
- Roles from one realm don't leak to another
Existing Code Analysis (from Story 2.2)
Current Token Verification (keycloak-auth-service.js):
-
verifyToken()returns:{ ...verifiedPayload, iss_sub: issSub } -
iss_subis already computed as{iss}#${sub} - This can be leveraged for header construction
Auth Middleware (to be analyzed):
- Current location: Check for
middleware/orroutes/for auth middleware - Integration point: Where headers are attached after verification
Worktree Assignment
This story will be implemented in the epic2-backend worktree (same as Story 2-2).
References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.3: Token Passthrough — Headers Injection to Upstream]
- [Source: _bmad-output/planning-artifacts/architecture.md#Decision D4 — Multi-realm user_id for OPEA]
- [Source: _bmad-output/planning-artifacts/architecture.md#Process Patterns — Header Injection Flow]
- [Source: _bmad-output/planning-artifacts/architecture.md#Data Architecture — Decision D1]
- [Source: _bmad-output/planning-artifacts/architecture.md#Authentication & Security — Decision D3]
- [Source: components/gov-chat-backend/services/keycloak-auth-service.js — Story 2.2 implementation]
- [Source: _bmad-output/project-context.md#Backend (Node.js) — Testing Rules]
- [Source: _bmad-output/implementation-artifacts/2-2-jwks-force-refresh-on-validation-failure.md — Story 2.2 completion notes]
E2E Testing (Conditional - OPEA Infrastructure Required)
IMPORTANT: This story includes conditional E2E tests that require a full OPEA deployment.
E2E Test File: docs/e2e-test-plan-external-idp.md
Execution Conditions:
- E2E tests should ONLY run if OPEA infrastructure is available
- Tests are automated and precise - follow the test plan exactly, NO improvisation
- Use environment variable or config flag to conditionally enable E2E tests
How to Run E2E Tests:
# Check if OPEA is available
if curl -s http://localhost:8080/health > /dev/null; then
echo "OPEA infrastructure detected - running E2E tests"
npm run test:e2e
else
echo "OPEA infrastructure NOT available - skipping E2E tests"
echo "Run unit tests instead: npm test"
fi
E2E Tests for Story 2-3:
- Verify headers are injected correctly in authenticated requests
- Verify OPEA services receive X-User-Id, X-User-Roles, X-Issuer headers
- Verify Authorization header is NOT forwarded to OPEA services
- Verify composite key {iss}#{sub} is correctly passed to OPEA
Note for Story 2-2: Story 2-2 (JWKS force-refresh) should also include E2E tests - add to retrospective as lesson learned.
Dev Agent Record
Agent Model Used
- claude-opus-4-6 (Claude 4.6 Opus)
Debug Log References
- No debug logs required for this story
Completion Notes List
Implementation Summary
-
Middleware Enhancement (keycloak-auth-middleware.js):
- Added
buildUserHeaders(claims)helper function to construct user identity headers - Headers: X-User-Id (
{iss}#{sub}), X-User-Roles (comma-separated), X-Issuer (issuer URL) - Attached JWT claims to request:
req.claims = decoded - Built and attached OPEA headers:
req.user.opeaHeaders = buildUserHeaders(req.claims)
- Added
-
Worker Thread Protocol Extension (query-service.js, opea-worker.js):
- Extended
runOPEAWorker(url, payload, headers)to accept optional headers parameter - Extended
createQuery(queryData, headers, authenticatedUserId)to pass headers and authenticated user ID - Worker receives and applies headers:
const { url, payload, headers } = task; - Headers merged in axios call:
headers: { 'Content-Type': 'application/json', ...(headers || {}) }
- Extended
-
Route Handler Update (query-routes.js):
- Pass headers and authenticated user ID from middleware to service:
queryService.createQuery(req.body, req.user?.opeaHeaders, req.user?.iss_sub)
-
OPEA Payload Update (query-service.js):
- Use authenticated user's composite key for OPEA payload:
user_id: authenticatedUserId || queryData.userId- Ensures OPEA services receive
{iss}#{sub}instead of justsub
-
Security Documentation (opea-worker.js):
- Added comprehensive documentation explaining "network isolation" trust model
- OPEA services communicate via internal Docker network with NO authentication
- User identity passed via headers, NOT raw tokens
Technical Decisions
- Worker thread isolation: Cannot access req.headers, only receives explicitly passed data
- Authorization header is NEVER forwarded to OPEA (security guaranteed by design)
- Composite key
{iss}#{sub}guarantees uniqueness across Keycloak realms - OPEA treats
user_idas opaque string - backwards compatible change
Files Modified
File List
- components/gov-chat-backend/middleware/keycloak-auth-middleware.js (modified)
- components/gov-chat-backend/services/query-service.js (modified)
- components/gov-chat-backend/services/opea-worker.js (modified)
- components/gov-chat-backend/routes/query-routes.js (modified)
- components/gov-chat-backend/tests/keycloak-auth-middleware.test.js (modified)
- components/gov-chat-backend/tests/token-passthrough-integration.test.js (created)
- docs/e2e-test-plan-external-idp.md (modified)