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-actors as 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/web for 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:

  • tenantId
  • principalId
  • permissions
  • 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

  • /health remains 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.
  • AuthRequestInterceptor remains the coarse entry guard.
  • The interceptor also attaches the resolved RequestContext to the Tapir ServerRequest.

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 RequestContext explicitly
  • service calls that stamp audit/user metadata should receive RequestContext explicitly

view-asset-registry-actors is the first canonical example of this style.

3.6 Local development flows

Two local flows remain:

  1. just dev

    • may use the explicit dev bypass when enabled
    • bypass is dev-only and off by default
  2. 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

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
  • RequestContext remains 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

  1. Keep plain trusted identity headers as the app contract

    • Rejected because that is too easy to spoof if the deployment boundary is misconfigured.
  2. 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.
  3. 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/web responsibility.
  • 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 RequestContextScope as a compatibility layer.
  • The next modules should copy the actors pattern:
    • constructor-injected endpoint handlers
    • explicit authenticated endpoint input
    • explicit RequestContext mutation service contracts

7. Implementation Notes

  • AuthRequestInterceptor remains the coarse route gate.
  • RequestContextResolver resolves RequestContext from the forwarded DD internal JWT plus request headers.
  • AuthenticatedRequestContext provides the Tapir-visible explicit authenticated endpoint seam.
  • view-asset-registry-actors is the first golden-path module; other modules are not yet migrated.

8. References

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 /readyz are unauthenticated Kubernetes probes.
  • /healthz is authenticated and exposes structured startup, build, and backend diagnostics.
  • /health has been removed and is not an alias.