ARC-ADR-002: JWT-forwarding auth contract (frontend-core → middle-core → backend-core)¶
Metadata¶
| Field | Value |
|---|---|
| ID | ARC-ADR-002 |
| Status | Proposed |
| Date | 2026-05-25 |
| Deciders | Architecture Review |
| Supersedes | — |
| Superseded by | — |
| Tags | auth, jwt, rbac, copilotkit, cross-layer, security |
Spoke-authored draft. Referenced as proposed by epic #12 / issue #13 at the hub path
docs/decisions/ARC-ADR-002-jwt-forwarding-auth-contract.md, but not yet published. This file mirrors that ID/filename so the issue links resolve once upstreamed; it captures frontend-core's side of the contract pending hub ratification.
Context and Problem Statement¶
In the CopilotKit architecture (epic #12), the browser talks to a thin Next.js runtime route
(app/api/copilotkit/route.ts) that proxies to middle-core's /copilotkit endpoint.
middle-core runs the LangGraph agent; the agent's tools call backend-core over HTTP.
backend-core is the single authoritative RBAC source — its require_principal gate maps
roles to capabilities (reader → search, contributor → ingest, admin → delete).
The problem: backend-core can only enforce RBAC if it receives the signed-in user's identity, but that user signs in at the frontend and the request now traverses two hops (frontend-core → middle-core → backend-core) before reaching the gate. We must define exactly how the user's credential travels those hops so that (a) backend-core authorizes the real user (not a shared service identity), and (b) the credential is never weakened, swapped, or exposed along the way.
A complication specific to frontend-core: today the JWT lives in browser localStorage
(backend-core-token) and is attached client-side (src/lib/api/client.ts). The CopilotKit
runtime route runs server-side and cannot read localStorage. The contract must specify
where the route reads the token from.
Decision Drivers¶
| # | Driver |
|---|---|
| D1 | backend-core must authorize the actual end user; RBAC decisions must reflect the user's role, not a privileged proxy identity. |
| D2 | The credential must pass through middle-core unchanged — no minting, downgrading, or re-signing of a different principal. |
| D3 | The runtime route runs server-side, so the JWT must be readable server-side (not from browser localStorage). |
| D4 | No new browser CORS surface; the browser calls only same-origin /api/copilotkit (epic #12 keeps CORS a backend concern). |
| D5 | Least exposure: the token should not be readable by client JavaScript if avoidable, to reduce XSS token-theft risk. |
| D6 | The contract must be simple enough for both the Next.js route and middle-core to implement without bespoke per-layer logic. |
Considered Options¶
- Forward the user JWT unchanged as
Authorization: Bearerat each hop (chosen) — the route reads the user's JWT server-side and attaches it to the request to middle-core; middle-core forwards the same bearer to backend-core; backend-core'srequire_principalgate validates it. - Token exchange at the edge — the route exchanges the user JWT for a scoped/downscoped token (e.g., RFC 8693) before calling middle-core.
- Service-account identity + user-id claim — middle-core/route authenticates to backend-core as a service principal and passes the user id as a separate header/claim; backend-core trusts the service to assert the user.
- No forwarding — agent calls backend-core with a shared key — the agent uses a single privileged credential; RBAC is approximated in middle-core.
Decision Outcome¶
Option 1 — forward the user JWT unchanged as Authorization: Bearer is adopted.
The contract:
- Token at rest (frontend-core). The signed-in user's JWT is stored in a server-
readable httpOnly session cookie (replacing the current
localStoragetoken — see ARC-ADR-007). This lets the App Router route handler read it without exposing it to client JS (D3, D5). - Hop 1 — frontend-core → middle-core.
app/api/copilotkit/route.tsreads the JWT from the session and setsAuthorization: Bearer <jwt>on the outbound request to${MIDDLE_CORE_URL}/copilotkit. The browser itself only ever calls same-origin/api/copilotkit(D4). - Hop 2 — middle-core → backend-core. middle-core forwards the same bearer token, unchanged, on every tool call to backend-core (D2). It does not mint, re-sign, or substitute a different principal.
- Enforcement — backend-core.
require_principalvalidates the JWT and enforces RBAC (reader/contributor/admin). backend-core remains the single source of truth; no layer above it makes authorization decisions (D1).
Confirmation Criteria¶
GET /api/copilotkitreturns 200; onPOST, the request to middle-core carriesAuthorization: Bearer <user-jwt>read from the server-side session (verifiable against a mocked middle-core endpoint asserting the header).- A request with a reader-role JWT can search but is denied delete by backend-core; an admin-role JWT is permitted — proving the user's role (not a service identity) is what backend-core sees (full check requires middle-core + backend-core running).
- The JWT is not present in any client-side bundle or
localStorageafter the ARC-ADR-007 session-cookie change; it is sent only server-side from the route handler. - No browser request goes cross-origin to middle-core/backend-core (network panel shows only
same-origin
/api/copilotkit).
Pros and Cons¶
Option 1 — Forward user JWT unchanged (chosen)¶
Pros:
- backend-core authorizes the real user with zero RBAC logic duplicated upstream (D1).
- Dead simple per hop: read header, set header. No token service, no key management (D6).
- The credential is never weakened or swapped, so the security review surface is small (D2).
- httpOnly session keeps the token out of client JS (D5) while remaining server-readable (D3).
Cons:
- The user JWT must be valid for backend-core's audience across both hops; token lifetime/ refresh must be handled so long agent runs don't fail mid-stream.
- middle-core sees the user's bearer in transit (it must, to forward it); transport must be TLS and middle-core must not log it.
Option 2 — Token exchange at the edge¶
Pros:
- Can downscope the token to exactly what the agent needs (least privilege per request).
Cons:
- Requires a token-exchange authority and trust config in the route — new infrastructure and failure modes (violates D6 simplicity).
- backend-core would need to accept exchanged tokens, expanding its trust model; overkill for the current reader/contributor/admin model.
Option 3 — Service-account identity + user-id claim¶
Pros:
- middle-core ↔ backend-core can use a stable, rotatable service credential.
Cons:
- Violates D1: backend-core would authorize a service, then trust an asserted user id — privilege-confusion risk if any upstream hop is compromised or buggy.
- Splits the source of truth: the "who" comes from a header while the "what" comes from the gate; harder to audit.
Option 4 — Shared key, RBAC approximated in middle-core¶
Pros:
- Fewest moving parts to get a demo working.
Cons:
- Violates D1 and D2 outright: a single privileged key erases per-user RBAC and makes middle-core a de-facto authorization authority it was explicitly not meant to be (epic #12: "RBAC enforcement stays in backend-core").
- Catastrophic blast radius if the shared key leaks.
Positive Consequences¶
- One credential, one authority: the user's JWT and backend-core's gate. Nothing else decides authorization, which keeps the cross-layer security model auditable.
- frontend-core's runtime route stays a thin, stateless proxy — no token vault, no signing.
Negative Consequences¶
- Couples this contract to the ARC-ADR-007 session-cookie change; the localStorage→cookie move must land before the route can forward the JWT.
- Requires a token-lifetime strategy for long-running agent turns (refresh or sufficiently long-lived tokens) to avoid mid-stream 401s.
Implementation Notes¶
- frontend-core: route handler reads JWT from the httpOnly session and sets the bearer on
the middle-core request; never expose it to client JS.
MIDDLE_CORE_URLfrom env (missing ⇒ throw at startup, per issue #13). - Do not attach the JWT to the browser→
/api/copilotkitcall as a readable header; the browser relies on the same-origin session cookie, and the route injects the bearer. - Logging: scrub
Authorizationfrom request logs in the route and (contract ask) in middle-core. - This ADR governs only the CopilotKit path. The existing direct browser→backend-core calls
(
src/lib/api/client.ts) keep their current bearer-attach behavior until/if folded into the session model.
Related Decisions¶
- Depends on: ARC-ADR-007 (Next.js migration provides the server route + session cookie).
- Pairs with: ARC-ADR-003 (the Empty adapter ensures the route is a pure proxy with no LLM key, so forwarding the JWT is its only server responsibility).
- Feeds: ARC-ADR-006 (backend-core RBAC, reached via this forwarded JWT, is the authoritative check behind the HITL delete gate).
- Relates to: epic #12, issue #13; hub plan
docs/plans/copilotkit-generative-ui.md("user JWT flows frontend-core → middle-core → backend-core unchanged").
Caveats¶
- End-to-end verification requires middle-core and backend-core (private repos, not reachable
from this spoke). frontend-core verifies its hop against a mocked
/copilotkit.
Revision History¶
| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-05-25 | Architecture Review | Initial proposal (spoke draft) |