ADR-013: App auth boundary, RequestContext, and local edge flows
1. Status
- Status: accepted
- Last updated: 2026-04-07
- Notes: The app now owns the outer auth boundary, resolves authenticated request identity from a DD internal JWT, and keeps
view-asset-registry-actorsas the first explicit authenticated-endpoint example.
2. Context
The application needs a stable internal identity object and a clear HTTP boundary.
Two things are true at once:
- WorkOS remains the external identity provider
- the platform still needs a stable inner-app contract that is independent of raw provider artifacts
The platform also needs a clearer split between the browser-facing auth layer and the inner application. That outer auth layer currently runs in the same JVM service, but it is still a real boundary with its own responsibilities.
3. Decision
We adopt an app boundary based on:
- an outer auth boundary that owns
/auth/login,/auth/callback,/logout, and/auth/validate - a forwarded DD internal JWT in
X-DataDescriber-Auth-Token - app-side JWT verification in
modules/webfor downstream business routes - mapping of verified claims into
RequestContext - authenticated-by-default behavior on business routes, with explicit route exemptions for health/auth/public/debug flows where needed
3.1 Authentication boundary
- Browser login/session handling lives in the outer auth boundary.
- The outer auth boundary resolves tenant context from the request host and normalizes WorkOS identity state into a sealed app session.
- The inner application receives a signed DD internal JWT from the boundary via Traefik.
- The inner application verifies:
- signature
- issuer
- audience
- expiry
nbf
- Invalid or missing tokens fail closed.
3.2 RequestContext
RequestContext in modules/core remains the typed application identity contract.
It contains:
tenantIdprincipalIdpermissions- optional
email - optional
groups - optional claim map
- optional correlation and trace IDs
The HTTP layer resolves it once per request and downstream mutation-capable services consume it explicitly.
3.3 Request metadata split
- Identity claims come from the verified DD internal JWT.
- Correlation and trace metadata remain request-header pass-through.
That avoids polluting the JWT contract with transport-level diagnostics.
3.4 Route behavior
/healthremains unauthenticated./auth/*,/logout,/public/*, and selected local debug/helper routes have explicit auth-boundary or Traefik exemptions where required.- All other business routes require a resolved
RequestContext. AuthRequestInterceptorremains the coarse entry guard.- The interceptor also attaches the resolved
RequestContextto the TapirServerRequest.
3.5 Endpoint design direction
The preferred route shape is explicit authenticated endpoint composition:
- the endpoint layer should make authenticated context visible
- mutation handlers should accept
RequestContextexplicitly - service calls that stamp audit/user metadata should receive
RequestContextexplicitly
view-asset-registry-actors is the first canonical example of this style.
3.6 Local development flows
Two local flows remain:
just dev- may use the explicit dev bypass when enabled
- bypass is dev-only and off by default
Routed local edge
- local routed auth flows are provided by
just infra-up - the published app image is opt-in via
docker compose --profile image up - both routes exercise the same Traefik + app-owned auth-boundary flow rather than static identity injection
- local routed auth flows are provided by
4. Rationale
This gives the app a stronger and more honest trust boundary:
- browser/provider mechanics stay concentrated at the outer boundary
- the inner application no longer trusts unsigned identity headers as proof
- the downstream contract is platform-owned rather than provider-owned
RequestContextremains the stable internal contract- route/service authorization stays close to business logic
It also establishes a pattern other modules can copy without forcing a repo-wide rewrite.
5. Alternatives Considered
Keep plain trusted identity headers as the app contract
- Rejected because that is too easy to spoof if the deployment boundary is misconfigured.
Keep the inner app directly coupled to raw WorkOS access tokens
- Rejected because it makes the downstream contract provider-specific and weakens the platform-owned boundary.
Delay explicit authenticated endpoint design until later
- Rejected because hidden request-scope lookup will otherwise calcify into the de facto style.
6. Consequences
- JWT verification is now part of the
modules/webresponsibility. - The normal runtime path no longer uses plain identity headers or raw WorkOS tokens as the auth source for business routes.
- Existing non-migrated modules still use
RequestContextScopeas a compatibility layer. - The next modules should copy the actors pattern:
- constructor-injected endpoint handlers
- explicit authenticated endpoint input
- explicit
RequestContextmutation service contracts
7. Implementation Notes
AuthRequestInterceptorremains the coarse route gate.RequestContextResolverresolvesRequestContextfrom the forwarded DD internal JWT plus request headers.AuthenticatedRequestContextprovides the Tapir-visible explicit authenticated endpoint seam.view-asset-registry-actorsis the first golden-path module; other modules are not yet migrated.
8. References
- ADR-002
- ADR-012
- ADR-014
- docs/architecture/auth-boundary.md
- docs/development/auth-local-dev.md
- docs/development/docker.md
9. Clarification (2026-08-24)
The route statement in section 3.4 records the boundary at the time of this ADR. The current probe contract supersedes it:
/livez,/startupz, and/readyzare unauthenticated Kubernetes probes./healthzis authenticated and exposes structured startup, build, and backend diagnostics./healthhas been removed and is not an alias.