What happened
blue login --force retires the old OAuth refresh grant but not the old gateway session, so an inference JWT minted before the forced re-login keeps working for up to 12 hours.
logout does both: revoke_gateway_session() then revoke() (crates/gh-cli/src/commands.rs:808-811), as does detach_tenant (:332-335). The --force path calls only session.revoke() (:726), and so does retire_superseded_session_with on the implicit-reauth path (:459).
That gap matters because revoking the gateway session is what kills already-issued JWTs. The resolver query requires ga.revoked_at is null (services/control-api/src/lib.rs:4237), and renew_gateway_auth_session records reactivated_at, which the proxy enforces as a not-before against the JWT's signed iat (services/inference-proxy/src/main.rs:1837). Its doc comment states the intent directly — "A binding revoked by blue logout is reactivated only by a token issued after the revocation, which keeps every pre-logout JWT dead" (services/control-api/src/gateway_auth.rs:31-33). Skipping the call leaves revoked_at NULL and reactivated_at unset, so neither check fires.
Both outcomes of a forced re-login leave the old JWT live:
- New browser session — the old
gateway_auth_sessions row is untouched and still satisfies revoked_at is null, so the old JWT resolves until the old browser session expires.
- Existing browser session reused — same
oauth_session_id, no new binding, nothing invalidates the old JWT at all.
Either way the ceiling is the 12h inference JWT TTL (gateway_inference_token_ttl_seconds: 43_200), clamped by the backing browser session.
This contradicts what we document --force for. apps/docs/next/concepts/authentication.mdx:41 — "replacing a session you no longer trust ... so no grant is left live server-side" — and apps/docs/next/cli/commands.mdx:15 — "revokes the stored session". The JWT is the credential that actually buys inference, and SECURITY.md notes it is written into agent config/env, which is exactly what leaks when a session stops being trustworthy.
Expected
--force revokes the gateway session as well, matching logout, so no inference JWT survives the re-login.
Implementation note: revoke_gateway_session authenticates with the bearer access token, so the fix has to mirror logout's ordering — adopt_refresh_context → refresh_if_needed → revoke_gateway_session → revoke. The --force branch currently calls revoke() on stored with none of that setup. Best-effort/warn-only is fine, as elsewhere.
Secondary: retire_superseded_session_with skips revocation entirely when the previous session has no refresh token, so the gateway session is never revoked on that path either. Lower risk, since implicit reauth usually follows a browser session that already expired (the resolver's source_expires_at > now() and "expiresAt" > now() predicates already fail), but it can also fire on a 403.
Introduced by #28 — --force does not exist on main, so nothing released is affected. Mechanism came from #17.
Steps to reproduce
Gateway-mode deployment:
blue login, then run blue once so a gateway inference JWT is minted; copy it out of the managed agent config.
blue login --force and complete the new device authorization.
- Send a request to the inference proxy with the JWT captured in step 1.
Observed: the request is authorized — it succeeds until the JWT's exp (up to 12h).
Expected: 401, as it is after blue logout && blue login.
Check select oauth_session_id, revoked_at, reactivated_at from public.gateway_auth_sessions where user_id = ... — after --force the pre-existing row still has revoked_at NULL and reactivated_at NULL; after logout it does not.
Blue version
0.1.0 — branch fix/cli-login-cannot-recover-dead-session (#28), commit 41c595c. Not present in any release.
OS / environment
Any; the gap is in the CLI revocation sequence, not platform-specific.
What happened
blue login --forceretires the old OAuth refresh grant but not the old gateway session, so an inference JWT minted before the forced re-login keeps working for up to 12 hours.logoutdoes both:revoke_gateway_session()thenrevoke()(crates/gh-cli/src/commands.rs:808-811), as doesdetach_tenant(:332-335). The--forcepath calls onlysession.revoke()(:726), and so doesretire_superseded_session_withon the implicit-reauth path (:459).That gap matters because revoking the gateway session is what kills already-issued JWTs. The resolver query requires
ga.revoked_at is null(services/control-api/src/lib.rs:4237), andrenew_gateway_auth_sessionrecordsreactivated_at, which the proxy enforces as a not-before against the JWT's signediat(services/inference-proxy/src/main.rs:1837). Its doc comment states the intent directly — "A binding revoked byblue logoutis reactivated only by a token issued after the revocation, which keeps every pre-logout JWT dead" (services/control-api/src/gateway_auth.rs:31-33). Skipping the call leavesrevoked_atNULL andreactivated_atunset, so neither check fires.Both outcomes of a forced re-login leave the old JWT live:
gateway_auth_sessionsrow is untouched and still satisfiesrevoked_at is null, so the old JWT resolves until the old browser session expires.oauth_session_id, no new binding, nothing invalidates the old JWT at all.Either way the ceiling is the 12h inference JWT TTL (
gateway_inference_token_ttl_seconds: 43_200), clamped by the backing browser session.This contradicts what we document
--forcefor.apps/docs/next/concepts/authentication.mdx:41— "replacing a session you no longer trust ... so no grant is left live server-side" — andapps/docs/next/cli/commands.mdx:15— "revokes the stored session". The JWT is the credential that actually buys inference, andSECURITY.mdnotes it is written into agent config/env, which is exactly what leaks when a session stops being trustworthy.Expected
--forcerevokes the gateway session as well, matchinglogout, so no inference JWT survives the re-login.Implementation note:
revoke_gateway_sessionauthenticates with the bearer access token, so the fix has to mirror logout's ordering —adopt_refresh_context→refresh_if_needed→revoke_gateway_session→revoke. The--forcebranch currently callsrevoke()onstoredwith none of that setup. Best-effort/warn-only is fine, as elsewhere.Secondary:
retire_superseded_session_withskips revocation entirely when the previous session has no refresh token, so the gateway session is never revoked on that path either. Lower risk, since implicit reauth usually follows a browser session that already expired (the resolver'ssource_expires_at > now()and"expiresAt" > now()predicates already fail), but it can also fire on a 403.Introduced by #28 —
--forcedoes not exist onmain, so nothing released is affected. Mechanism came from #17.Steps to reproduce
Gateway-mode deployment:
blue login, then runblueonce so a gateway inference JWT is minted; copy it out of the managed agent config.blue login --forceand complete the new device authorization.Observed: the request is authorized — it succeeds until the JWT's
exp(up to 12h).Expected:
401, as it is afterblue logout && blue login.Check
select oauth_session_id, revoked_at, reactivated_at from public.gateway_auth_sessions where user_id = ...— after--forcethe pre-existing row still hasrevoked_atNULL andreactivated_atNULL; afterlogoutit does not.Blue version
0.1.0— branchfix/cli-login-cannot-recover-dead-session(#28), commit41c595c. Not present in any release.OS / environment
Any; the gap is in the CLI revocation sequence, not platform-specific.