Repository navigation
Expired DPoP session returns HTTP 200 with authenticated:false, so clients report it as a permissions denial #8326
Description
Activity
Same root cause, different (and harder-failing) surface: the pull request Code tab.
What happened
Mac desktop (T3 Code 0.0.34) connected through T3 Connect to a Linux environment (desktop-managed server 0.0.34). About an hour after connecting, opening a GitLab MR shows Summary and Timeline normally, but the Code tab shows
0 filesand:The environment rejected this client's credentials (invalid_credential).
The MR has 6 files;
glab api …/merge_requests/N/diffsworks on the environment host.Diagnosis
Same lapse as described above — the relay client's 1-hour DPoP access token expires while the WebSocket (authenticated once at upgrade via
wsTicket) stays alive — but here it hits a route that does carry the 401 shape:- The Code tab is the one PR view that goes over HTTP instead of the RPC socket:
packages/contracts/src/environmentHttp.ts—EnvironmentPullRequestsHttpApi … post("diff", "/api/pull-requests/diff", …).middleware(EnvironmentAuthenticatedAuth)("Large, compressible pull-request payloads travel over HTTP rather than the RPC socket"). Summary/Timeline usepullRequests.detail/pullRequests.activityover the socket, so they keep working. packages/client-runtime/src/state/pullRequestDiffHttp.ts→fetchEnvironmentPullRequestDiffbuilds the request withbuildEnvironmentAuthHeaders(prepared.httpAuthorization, "POST", url, signer), i.e.Authorization: DPoP <accessToken>+ a fresh proof — but the access token is whateverPreparedConnection.httpAuthorizationcaptured at connect time.packages/client-runtime/src/connection/remote.tsonly checksexpiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MSwhen (re)building the connection. Nothing refreshes the token before an HTTP call, and there is no retry onEnvironmentAuthInvalidError.- Server side,
apps/server/src/auth/EnvironmentAuth.ts:sessions.issue({ … ttl: hours(1) })for DPoP sessions; an expired session failsauthenticateToken→ServerAuthInvalidCredentialError→failEnvironmentAuthInvalid("invalid_credential")→ HTTP 401 with the message above.
So on
/api/auth/sessionthe lapse is hidden behind a 200 (this issue); on/api/pull-requests/diffit is a hard 401 that the UI shows verbatim. The fix should be the same in both places: refresh the DPoP access token before HTTP calls once it is inside the expiry margin, and retry once onEnvironmentAuthInvalidError, rather than only fixing the/api/auth/sessionresponse shape.Steps to reproduce
- Link an environment to T3 Connect; connect to it from T3 Code desktop on another machine (macOS here).
- Leave the connection up for > 1 hour without it dropping.
- Open any linked pull/merge request: Summary and Timeline load; Code tab →
0 files+The environment rejected this client's credentials (invalid_credential). - Reconnect (or restart the client): the Code tab loads for another hour.
Version
Environment server 0.0.34 (desktop-managed, Linux x64); Mac client T3 Code 0.0.34; also observed with the iOS client on the same environment (1-hour DPoP sessions as well).
Environment
Server host: Linux x64 (CachyOS, kernel 7.2), Node 26.7. Client: macOS, Electron desktop 0.0.34, via managed relay.
Evidence
auth_sessionsin the environment'sstate.sqlite(token columns not read; timestamps UTC):subject method client issued_at expires_at last_connected_at desktop-bootstrap bearer-access-token T3 Code Desktop (local) 22:12:00 +30 days 22:12:01 cloud-connect dpop-access-token Electron desktop, MacIntel, 0.0.34 23:45:00 00:45:00 23:45:01 cloud-connect dpop-access-token Electron desktop, MacIntel, 0.0.34 22:14:30 23:14:30 22:14:32 The screenshot with the error was taken at 00:55 UTC — ten minutes after the Mac's token expired, with no newer Mac session issued (the socket from 23:45:01 was still up). DPoP proof verification itself is healthy on this server: verified-proof records from another client continued at 00:30–00:43 UTC, so this is not the clock-skew case from #5332.
Related issues
#8326 (this one): identical mechanism, reported via
/api/auth/session→ settings panel. #5332 / #7170 / #5712: sameEnvironmentAuthInvalidErrortext but different causes (clock skew, token exchange, relink).Fix applied or workaround
None persistent. Reconnecting the client mints a new 1-hour token and the Code tab loads until it expires again. The local desktop on the environment host is unaffected (30-day bearer session).
Filed by
claude (Claude Fable 5) following the
t3 triageplaybook (t3 triage0.0.35 was used to generate the context file; the interactive agent launch was not used).Reacted by psicilia5179- The Code tab is the one PR view that goes over HTTP instead of the RPC socket:
Thanks @eugkhp, this is excellent evidence and belongs in this issue. It confirms the same underlying lifecycle bug on a second, higher-impact HTTP path: the WebSocket remains connected after the one-hour DPoP access token expires, while later HTTP requests continue using the token captured when the connection was prepared.
The different symptoms come from the endpoint contracts:
- /api/auth/session collapses the expired credential into 200 authenticated:false.
- /api/pull-requests/diff returns the expected 401, causing the PR/MR Code tab to fail even though socket-backed Summary and Timeline still work.
This also clarifies the fix scope: changing only the session response would improve the error classification but would not restore diff loading. The client should refresh the DPoP token before HTTP requests when it is near expiry and retry once after EnvironmentAuthInvalidError. The server’s session response can then be corrected separately so expired and absent credentials are distinguishable.
What happened
A relay client's 1-hour DPoP session lapses while its WebSocket stays alive.
/api/auth/sessionthen returns HTTP 200 with
authenticated:false. Because 200 is not an error, the client'soptimistic path never fires, and the UI reports the lapse as a permissions problem rather than
an expired session.
Diagnosis
/api/auth/sessionis the only auth route that cannot answer 401, and theauthenticated:falsebody 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-416declares the endpoint witherror: [EnvironmentInternalError]. Sibling auth routes carry.middleware(EnvironmentAuthenticatedAuth);sessiondeliberately does not, so anauthentication 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:Expired, malformed, revoked and absent credentials all produce the same value. The false branch
also omits
scopes,sessionMethodandexpiresAt(alloptionalKeyonAuthSessionState,packages/contracts/src/auth.ts:341-347), so the response carries no evidence that a sessionever 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 isreturn input.hasError ? "granted" : "denied", and it sits insideif (input.session === null).A 200 with a well-formed body leaves
sessionnon-null andhasErrorfalse, 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
wsTicketquery param (EnvironmentAuth.ts:945), not per-frame against the access token. Theaccess token TTL is
Duration.hours(1)(EnvironmentAuth.ts:709). Nothing renegotiates when itlapses, so no reconnect forces re-auth and the stale state persists.
Not fixed on
main.getSessionStatelast changed 2026-07-27 (23b55022);mainis identicalto
v0.0.34for 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
isServerAuthCredentialErrorbranch as an expired one:curl -i http://127.0.0.1:<port>/api/auth/session→ 200,authenticated:false.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, noreason.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
Related issues
#7756 (browser pairing returns authenticated but omits the
t3_sessioncookie) 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 amissing 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
curlprobes above.Filed by
claude (Opus 5) via
t3 triage