Skip to content

fix(cli): stop losing the OAuth session on refresh - #385

Open
andrelandgraf wants to merge 2 commits into
mainfrom
review/auth-token-refresh
Open

fix(cli): stop losing the OAuth session on refresh#385
andrelandgraf wants to merge 2 commits into
mainfrom
review/auth-token-refresh

Conversation

@andrelandgraf

Copy link
Copy Markdown
Collaborator

The problem

Neon's authorization server rotates refresh tokens. Verified against https://oauth2.neon.tech while writing this:

POST /oauth2/token  grant_type=refresh_token          -> 200, response carries a NEW refresh_token
POST /oauth2/token  (replaying the consumed one)      -> 400 {"error":"invalid_grant"}

So the instant an exchange returns, the rotated token set is the only working credential in existence — the one we presented is already dead server-side. The refresh path did not treat it that way, and three ordinary situations therefore ended in a browser login.

1. A failure after the exchange threw the new token away. handleExistingToken refreshed and then called preserveCredentials, which did await apiClient.getCurrentUserInfo() before writeFileSync. Any failure on that request — a 500, a timeout, a dropped connection — hit the catch, became AUTH_REFRESH_FAILED, and nothing was written. The rotated set existed only in memory and went with the process.

2. Two commands at once invalidated each other. No serialization and a direct writeFileSync to the live path. Both read the same expired set, both exchanged it; the loser got invalid_grant and demanded a new sign-in.

3. A 401 deleted the refresh token instead of using it. index.ts called deleteCredentials(configDir) on any 401 from stored credentials. An access token that expires mid-command is indistinguishable from a revoked one, and only the authorization server can tell them apart — so this signed the user out every time the answer would have been "just expired". It also cleared DEFAULT's credentials when the failure belonged to a named profile, since it resolved by config directory rather than by the file actually in use.

The solution

packages/cli/src/credentials.ts is new and owns the file: validation, durable writes, and race recovery. Pure logic (isAccessTokenUsable, toStoredCredentials) is separated from the I/O.

  • Persist before anything else can fail. The account lookup is gone from the refresh path entirely — a refresh cannot change who is signed in, so user_id is carried over from the previous token set. authFlow likewise writes the token set the moment the browser flow returns, then looks up the account, then merges user_id in.
  • Recover from a lost race. On a failed exchange the loser re-reads the credentials file and adopts a different usable token that is already there. Re-read is retried for ~500ms, because losing the race means the winner was mid-exchange and its write may still be a moment away.
  • Refresh on a 401, delete only on proof. The handler forces a refresh ignoring expires_at, retries the command on success, and deletes only when the server confirms the grant is dead. A transient failure keeps the session. Nothing is mutated on the final attempt.
  • Atomic, symlink-safe writes. Temp file created wx at 0600, fsync, rename over the resolved target, fsync of the directory. ~/.config/neonctl is a symlink on machines holding several accounts and profiles.json may point anywhere, so a naive rename would replace the link and strand the real file.

Interface

No new commands, and no change to the shape of credentials.json. What changes is what a user sees.

Refresh no longer touches the API, and the rotated set lands on disk before anything else runs:

$ neon me                       # stored session expired an hour ago
DEBUG: Access token is missing or expired, attempting refresh
DEBUG: Saved credentials to /Users/me/.config/neon/credentials.json
DEBUG: Token refresh successful

A 401 recovers instead of signing you out:

$ neon projects list
DEBUG: Access token was rejected, attempting refresh
DEBUG: Refreshed the stored session after a 401; retrying

An unreachable authorization server keeps the session and names the right host. It previously read Could not reach the Neon API. … check https://neonstatus.com, which is a different service:

$ neon me
ERROR: Could not reach the Neon authorization server to refresh the stored session: fetch failed

A dead grant says so, and only then are credentials cleared:

$ neon me
ERROR: The Neon authorization server rejected the stored session: invalid_grant — The provided authorization grant … is invalid, expired, revoked …
ERROR: Run `neon auth` to sign in again.

A damaged credentials file is reported rather than silently replaced with a browser login:

$ neon me
ERROR: Credentials file /Users/me/.config/neon/credentials.json is not valid JSON: Unexpected end of JSON input. Delete it and run `neon auth` to sign in again.

One new hidden flag, --allow-unsafe-tls. allowUnsafeTls was already threaded through every OAuth call site but unreachable from the command line, so the auth flow could not be exercised as a real process against a local authorization server. It sits alongside the equally hidden and equally sharp --oauth-host, and only affects requests to that host.

Verification

pnpm --filter neon test — 3308 passed, 6 skipped (the skips are pre-existing skipIf guards for Windows and opt-in psql integration runs). packages/env, packages/config and packages/init also pass, since they read credentials.json too. pnpm lint clean.

Against production. The behaviour the fix is built on was measured directly against https://oauth2.neon.tech — rotation, one-time use, and the exact error shapes (400 invalid_grant, and 401 token_inactive for an immediate replay). src/test_utils/rotating_oauth_server.ts reproduces that contract, and the tests in auth_refresh.test.ts fork the built dist/index.js against it with its own config directory, so the top-level 401 retry loop in index.ts is exercised as a real process rather than called in-process.

The concurrency test forces the collision rather than hoping for it: neither exchange is answered until both have arrived, so both must present the same refresh token and only one can win. It fails when the recovery re-read is reduced to a single attempt, which is how I know it isn't vacuous.

Not verified live: the built CLI refreshing against production oauth2.neon.tech. That needs a live OAuth session, and re-establishing one requires a browser login. Everything below that line — the server's contract, and the CLI against a server implementing it — is covered.

CI=true is set on every child process in the tests, so an unexpected fall-through to the browser flow fails loudly instead of opening a browser on whoever runs the suite.

Incidental changes

  • AuthContext became a discriminated union carrying the credentials path, the token that was sent, whether this run already refreshed, and the OAuth settings. The credentialsPath is what fixes the named-profile deletion bug above.
  • AUTH_REFRESH_FAILED, a sentinel string matched by message, is replaced by an AuthRefreshError class with a terminal flag. That flag is the whole basis for "delete the session" versus "keep it and report".
  • Removed an unused Date computation in auth() and a refresh_token as string cast in refreshToken.
  • commands/auth.test.ts was rewritten. It previously spied on our own auth and refreshToken exports, which AGENTS.md forbids, and two of its cases asserted behaviour this PR deliberately changes. The refresh and 401 paths moved to the cross-process suite; what remains in-process is the browser flow and the per-command auth skips.

Flagging

  • --allow-unsafe-tls is a real footgun, even hidden. It disables HTTPS enforcement toward --oauth-host. I judged it acceptable because it is inert without --oauth-host and because the alternative was keeping the auth flow untestable as a process, but say the word and I will gate it behind an env var only.
  • Refresh-token reuse detection revokes the whole chain. Replaying an already-consumed refresh token during diagnosis killed the current access token and refresh token too, not just the replayed one. This is correct, standard server behaviour, but it is worth knowing: there is no "retry the refresh with the old token" escape hatch, which is exactly why a lost write had to become impossible rather than merely unlikely.
  • A SIGKILL between the exchange and the write is still unrecoverable. Nothing short of a recovery journal or server-side reuse grace fixes that; the window is now microseconds rather than a network round trip.
  • Out of scope, found while reading. analytics.ts reads user_id from the DEFAULT credentials path even under --profile. packages/init/src/lib/auth.ts reads access_token straight off disk with no expiry check and posts it to /api_keys, so neon init on an hour-old session surfaces a bare 401. Neither is touched here.

Neon's authorization server rotates refresh tokens and retires the presented
one, so the moment an exchange returns, the rotated set is the only working
credential in existence. The refresh path did not treat it that way.

- Persist before anything else can fail. The refresh used to call
  `GET /users/me` and write the file afterwards, so a failed lookup discarded
  a token set whose predecessor was already dead. `user_id` is carried over
  from the previous set instead, since a refresh cannot change who is signed in.
- Recover from a lost race. Two commands refreshing at once left the loser with
  `invalid_grant`; it now adopts the winner's already-persisted result.
- Refresh on a 401 rather than deleting the refresh token, and delete only once
  the authorization server confirms the session is gone. Nothing is mutated on
  the final attempt, where no retry remains.
- Write atomically through resolved symlinks, report a corrupt credentials file
  instead of silently re-authenticating, and name the authorization server when
  it is the host that is unreachable.

Covered by cross-process tests that run the built CLI against a local
authorization server reproducing the rotation and reuse semantics verified
against oauth2.neon.tech.
…andling

Engineering review follow-ups.

- Terminal is now an allowlist (invalid_grant, token_inactive, invalid_token)
  rather than "any 4xx". Deleting the session is the only consequence of
  terminal, and a 429 or an invalid_client is not evidence that the user's
  refresh token is dead.
- Classify WWWAuthenticateChallengeError too. A 401 carrying a challenge header
  arrives as that class rather than ResponseBodyError, and would otherwise read
  as a network failure.
- Use the rejected token recorded in AuthContext: when the file already holds a
  different usable token, retry with it instead of spending a refresh.
- Follow symlinks when deleting credentials, so removal doesn't detach a
  configured path the write side is careful to preserve.
- Clean up the temp file when the write or the flush fails, not only the rename.
- Report a corrupt credentials file at warning level on the commands that
  continue without credentials; `dev` dropping env injection silently reads as
  a bug in the user's app.
- Build the stored shape from validated fields instead of casting the parsed
  object, and treat an explicit null as absent.
- Drop the `force` parameter that no caller reached.

Tests: null and non-finite field handling, and symlink-following deletion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant