Skip to content

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

  1. Forward the user JWT unchanged as Authorization: Bearer at 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's require_principal gate validates it.
  2. Token exchange at the edge — the route exchanges the user JWT for a scoped/downscoped token (e.g., RFC 8693) before calling middle-core.
  3. 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.
  4. 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:

  1. Token at rest (frontend-core). The signed-in user's JWT is stored in a server- readable httpOnly session cookie (replacing the current localStorage token — see ARC-ADR-007). This lets the App Router route handler read it without exposing it to client JS (D3, D5).
  2. Hop 1 — frontend-core → middle-core. app/api/copilotkit/route.ts reads the JWT from the session and sets Authorization: Bearer <jwt> on the outbound request to ${MIDDLE_CORE_URL}/copilotkit. The browser itself only ever calls same-origin /api/copilotkit (D4).
  3. 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.
  4. Enforcement — backend-core. require_principal validates 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/copilotkit returns 200; on POST, the request to middle-core carries Authorization: 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 localStorage after 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_URL from env (missing ⇒ throw at startup, per issue #13).
  • Do not attach the JWT to the browser→/api/copilotkit call as a readable header; the browser relies on the same-origin session cookie, and the route injects the bearer.
  • Logging: scrub Authorization from 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.

  • 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)