Skip to content

Expired DPoP session returns HTTP 200 with authenticated:false, so clients report it as a permissions denial #8326

Description

@psicilia5179

What happened

A relay client's 1-hour DPoP session lapses while its WebSocket stays alive. /api/auth/session
then returns HTTP 200 with authenticated:false. Because 200 is not an error, the client's
optimistic path never fires, and the UI reports the lapse as a permissions problem rather than
an expired session.

Diagnosis

/api/auth/session is the only auth route that cannot answer 401, and the authenticated:false
body it returns instead is indistinguishable from "this client never had a session". Three links:

1. The contract gives the route no 401 shape.
packages/contracts/src/environmentHttp.ts:412-416 declares the endpoint with
error: [EnvironmentInternalError]. Sibling auth routes carry
.middleware(EnvironmentAuthenticatedAuth); session deliberately does not, so an
authentication failure has nowhere to go in the response type.

2. The server collapses every credential failure into one body.
apps/server/src/auth/EnvironmentAuth.ts:646-651:

Effect.catchIf(isServerAuthCredentialError, () =>
  Effect.succeed({
    authenticated: false,
    auth: descriptor,
  } satisfies AuthSessionState),
),

Expired, malformed, revoked and absent credentials all produce the same value. The false branch
also omits scopes, sessionMethod and expiresAt (all optionalKey on AuthSessionState,
packages/contracts/src/auth.ts:341-347), so the response carries no evidence that a session
ever existed or when it lapsed.

3. The client's optimistic fallback is unreachable for a 200.
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts:86-102. The fallback at :94 is
return input.hasError ? "granted" : "denied", and it sits inside if (input.session === null).
A 200 with a well-formed body leaves session non-null and hasError false, so control reaches
:96, if (!input.session.authenticated) return "denied" — which renders as a permissions state.

The comment at :91-93 states the intent: a failed session fetch is a transport problem, not a
permission decision, so stay optimistic. An expired session is neither, and nothing on the wire
lets the client tell it apart from a genuine denial.

Why the socket outlives the credential. The WebSocket authenticates once at upgrade via the
wsTicket query param (EnvironmentAuth.ts:945), not per-frame against the access token. The
access token TTL is Duration.hours(1) (EnvironmentAuth.ts:709). Nothing renegotiates when it
lapses, so no reconnect forces re-auth and the stale state persists.

Not fixed on main. getSessionState last changed 2026-07-27 (23b55022); main is identical
to v0.0.34 for all three files above, client logic included.

Steps to reproduce

Against any running environment server, no expiry wait needed — a malformed DPoP credential takes
the same isServerAuthCredentialError branch as an expired one:

  1. curl -i http://127.0.0.1:<port>/api/auth/session → 200, authenticated:false.
  2. curl -i -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/session
    → 200, byte-identical body. No www-authenticate, no reason.
  3. curl -i -X POST -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' http://127.0.0.1:<port>/api/auth/websocket-ticket
    → 401, showing the asymmetry between this route and its siblings.

For the full user-visible path: connect a desktop client to an environment over the relay, leave
it idle past the 1-hour access-token TTL without letting the WebSocket drop, then open provider
settings. The panel reports denied permissions rather than an expired session.

Version

0.0.34 (v0.0.34, latest stable at time of filing)

Environment

Server: Linux x64 6.8.0-138-generic, Node v22.23.0. Client: T3 Code desktop on Windows 10,
Electron 41.5.0 / Chrome 146, connecting through a managed relay (*.t3coderelay.com).

Evidence

# Live against the running server. Bodies below are verbatim except for the
# environment-identifying cookie-name suffix.

$ curl -s -o /dev/null -w '%{http_code}' /api/auth/session
200
{"authenticated":false,"auth":{"policy":"loopback-browser","bootstrapMethods":["one-time-token"],
 "sessionMethods":["browser-session-cookie","bearer-access-token","dpop-access-token"],
 "sessionCookieName":"t3_session_<redacted>"}}

$ curl -s -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' \
       -o /dev/null -w '%{http_code}' /api/auth/session
200
# body byte-identical to the unauthenticated request above

$ curl -s -X POST -H 'authorization: DPoP not-a-real-token' -H 'dpop: not-a-real-proof' \
       -o /dev/null -w '%{http_code}' /api/auth/websocket-ticket
401

# server.trace.ndjson — the relay client's session probe, resolving Success at 200
{"type":"effect-span","name":"environment.auth.session","traceId":"4f949cdd2b7b6d14507b49f8bc9b3052",
 "attributes":{"environment.endpoint":"HttpApiEndpoint","http.request.method":"GET",
 "url.path":"/api/auth/session"},"exit":{"_tag":"Success"}}

Related issues

#7756 (browser pairing returns authenticated but omits the t3_session cookie) and #8112
(environment credential invalid after a clean relink) both touch session state, but neither
covers an expired credential being reported as 200 authenticated:false. #7878 concerns a
missing re-login affordance for Claude provider auth, not environment sessions. No duplicate found.

Fix applied or workaround

None. The mismatch is in the wire contract, so there is no configuration or service-level
remedy to apply on the affected machine. Nothing was written to the user's database or settings
during triage; the investigation was read-only apart from the three curl probes above.

Filed by

claude (Opus 5) via t3 triage

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions