What happened
After #28, every path that starts a new device flow goes through reauthenticate_replacing (crates/gh-cli/src/commands.rs:463), which only opens a browser when the refresh grant either succeeds or is rejected with exactly {"error":"invalid_grant"}. Anything else comes back as RefreshFailure::Temporary and is propagated as a hard error.
The problem is that Temporary also covers sessions that can never refresh:
- no
refresh_token / token_endpoint / client_id on the session — Session::refreshed and refresh_form return Temporary(Unauthorized(...)) (crates/gh-service/src/identity.rs:107, :149). You get this from an OIDC deployment whose configured scopes omit offline_access; the list is operator-supplied and only validated for empty strings (discovery.rs:90).
- a dead grant rejected with anything other than
invalid_grant — classify_refresh_rejection (identity.rs:288) maps invalid_client, unauthorized_client, or an HTML 401 from a proxy in front of the token endpoint to Temporary(Service(...)). That happens when the OAuth client is rotated or deleted in the IdP.
In that state blue, blue login, and blue login --force all fail with "login expired — run blue login again" and never open a browser. blue logout && blue login is the only way out, and nothing tells the user that. It's the same dead end as #27, except the message now names the command that can't work.
Sign-in looks fine, because the access token is still valid — it only shows up at the first refresh, ~15 minutes later.
Expected: blue login --force should always be able to authorize a replacement. A session that can't be refreshed should be treated like invalid_grant (best-effort gateway revoke, then a device flow), not like an outage. Genuine transport failures and 5xx should keep today's behaviour of refusing to open a browser.
Suggested fix: split the classification — keep Temporary for transport errors and 5xx, and add an "unusable" variant for absent credentials and 4xx rejections, handled the same way InvalidGrant is today. Rule of thumb: a 4xx from a token endpoint means the grant is dead.
Not reachable on the reference deployment: deploy/blue.toml requests offline_access, and Better Auth returns invalid_grant for a revoked grant — which is why the e2e coverage passes.
Steps to reproduce
blue login against an OIDC deployment, and let it succeed.
- Simulate a session that cannot refresh — either drop
refresh_token from ~/.config/blue/session.json (keeping the file valid JSON), or point the deployment at an identity.scopes list without offline_access and log in again.
- Age the session past its access-token expiry: set
expires_at to a past timestamp.
- Run
blue login --force.
Expected: a device-authorization URL. Actual: exits non-zero with "login expired — run blue login again", no browser. Same for blue login and bare blue. Only blue logout && blue login recovers.
Blue version
0.1.0 (fix/cli-login-cannot-recover-dead-session, 315efd7)
OS / environment
Linux; any OIDC deployment. Found by code inspection while reviewing #28, not hit on a live tenant.
What happened
After #28, every path that starts a new device flow goes through
reauthenticate_replacing(crates/gh-cli/src/commands.rs:463), which only opens a browser when the refresh grant either succeeds or is rejected with exactly{"error":"invalid_grant"}. Anything else comes back asRefreshFailure::Temporaryand is propagated as a hard error.The problem is that
Temporaryalso covers sessions that can never refresh:refresh_token/token_endpoint/client_idon the session —Session::refreshedandrefresh_formreturnTemporary(Unauthorized(...))(crates/gh-service/src/identity.rs:107,:149). You get this from an OIDC deployment whose configuredscopesomitoffline_access; the list is operator-supplied and only validated for empty strings (discovery.rs:90).invalid_grant—classify_refresh_rejection(identity.rs:288) mapsinvalid_client,unauthorized_client, or an HTML 401 from a proxy in front of the token endpoint toTemporary(Service(...)). That happens when the OAuth client is rotated or deleted in the IdP.In that state
blue,blue login, andblue login --forceall fail with "login expired — runblue loginagain" and never open a browser.blue logout && blue loginis the only way out, and nothing tells the user that. It's the same dead end as #27, except the message now names the command that can't work.Sign-in looks fine, because the access token is still valid — it only shows up at the first refresh, ~15 minutes later.
Expected:
blue login --forceshould always be able to authorize a replacement. A session that can't be refreshed should be treated likeinvalid_grant(best-effort gateway revoke, then a device flow), not like an outage. Genuine transport failures and 5xx should keep today's behaviour of refusing to open a browser.Suggested fix: split the classification — keep
Temporaryfor transport errors and 5xx, and add an "unusable" variant for absent credentials and 4xx rejections, handled the same wayInvalidGrantis today. Rule of thumb: a 4xx from a token endpoint means the grant is dead.Not reachable on the reference deployment:
deploy/blue.tomlrequestsoffline_access, and Better Auth returnsinvalid_grantfor a revoked grant — which is why the e2e coverage passes.Steps to reproduce
blue loginagainst an OIDC deployment, and let it succeed.refresh_tokenfrom~/.config/blue/session.json(keeping the file valid JSON), or point the deployment at anidentity.scopeslist withoutoffline_accessand log in again.expires_atto a past timestamp.blue login --force.Expected: a device-authorization URL. Actual: exits non-zero with "login expired — run
blue loginagain", no browser. Same forblue loginand bareblue. Onlyblue logout && blue loginrecovers.Blue version
0.1.0 (
fix/cli-login-cannot-recover-dead-session, 315efd7)OS / environment
Linux; any OIDC deployment. Found by code inspection while reviewing #28, not hit on a live tenant.