Skip to content

feat(auth)!: OAuth login as the default for mux login - #74

Open
daniel-hayes wants to merge 10 commits into
mainfrom
spec/oauth-login
Open

feat(auth)!: OAuth login as the default for mux login#74
daniel-hayes wants to merge 10 commits into
mainfrom
spec/oauth-login

Conversation

@daniel-hayes

@daniel-hayes daniel-hayes commented Aug 19, 2026

Copy link
Copy Markdown

Description

mux login now opens the browser by default to kick off the OAuth flow. You pick an organization and environment in the Mux Dashboard, the CLI catches the redirect on a loopback port, exchanges the code for tokens, and handles refresh from then on. Mux API access tokens keep working exactly as before.

The four ways to authenticate are now explicit and mutually exclusive: --oauth (default), --interactive, --env-file, --from-env.

Breaking changes

Read these before approving. The first two are deliberate; the third is a consequence of the config format.

  1. mux login errors when MUX_TOKEN_ID/MUX_TOKEN_SECRET are set. It used to save them silently. Since env vars always outrank the config, that produced an entry that did nothing until you unset them. It now prints the four explicit options and exits 1 without writing. CI that runs mux login with injected credentials needs --from-env.
  2. mux login with no flags opens a browser instead of prompting for a Token ID and Secret. Anything scripted against those prompts needs --interactive.
  3. Configs written by 3.x can't be read by 2.x. Credentials moved into nested oauth / token blocks. Reading old flat entries works fine and needs no migration, but not the reverse — and note that any write converts every entry, including ones the command didn't touch. A downgrade means re-running mux login. Nothing is lost: the credentials are still in config.json, just nested one level deeper. Worth saying explicitly in the release notes, since Mux only shows an access token secret once at creation and people may otherwise mint a replacement.
  4. --oauth and --interactive require a TTY. They fail immediately under --json, in agent mode, or with piped stdin rather than hanging.
  5. mux env list and mux auth status output changed shape, so text parsing of them breaks. Both have --json.
  6. mux logout now makes a network call to revoke refresh tokens. Failure prints a warning and still removes the local credentials.
  7. The env-var shadow notice on stderr now names the shadowed environment.

mux login --json output and its source values are unchanged.

What's new

  • mux auth status — every credential source, which is active, and why. No network calls, never prints token material.
  • mux logout --all, and revocation on logout.
  • mux env switch with no argument gives an interactive picker; --json on env list, env switch, and logout.
  • An environment can hold both an OAuth login and an access token pair. OAuth is preferred when present — it goes stale unless exercised and refreshed, so the CLI won't quietly live on the token pair.
  • Automatic refresh: proactively before expiry, and once on an unexpected 401. Concurrent mux processes coordinate through a lock file so a rotating refresh token is never spent twice. webhooks listen refreshes and reconnects instead of dying mid-stream.
  • A terminally failed credential is flagged, not deleted: mux auth status explains it, and a token pair on the same environment silently takes over.

Read these six, in this order

  1. src/lib/credentials.ts (236) — start here. The credential model: what an environment holds, how the two historical config layouts are normalized on read, and getPreferredCredential, which decides OAuth-vs-token including the fallback when a credential is flagged as failing. Everything else assumes this.
  2. src/lib/mux.ts (+264) — the choke point. resolveCredentials() returns a discriminated ResolvedCredentials, which is why Bearer vs Basic needed no changes in ~98 command files. Also where the SDK client is built, and where the pre-existing MUX_AUTHORIZATION_TOKEN footgun is defused.
  3. src/lib/token-refresh.ts + src/lib/refresh-lock.ts (130 + 170) — the riskiest code in the PR. Proactive refresh, the re-read under lock that stops two processes spending one rotating refresh token, and flag-don't-delete on terminal failure. The lock deliberately fails rather than breaking a live holder's lock; that tradeoff is the thing to argue with.
  4. src/lib/oauth-loopback.ts (299) — the security surface. Binds 127.0.0.1 only, one path, one accepted callback, constant-time state compare, forced close. If you're only going to scrutinize one file for safety, this is it.
  5. src/lib/oauth.ts (503) — endpoint resolution (env override → discovery → built-in) the three grant calls, scope policy, and error normalization. The top ~90 lines are configuration and comments explaining why each default is what it is.
  6. src/commands/login-mode.ts (88) — pure function, no I/O, and it encodes the breaking behavior: four mutually exclusive methods, and the error when shell credentials are set. Quickest way to review the UX contract.

Worth a reviewer's attention

The SDK already supported bearer auth. @mux/mux-node has an authorizationToken option, so no custom client was needed. It also revealed a pre-existing bug: that option defaults to process.env.MUX_AUTHORIZATION_TOKEN, and the SDK builds the bearer header after the Basic one, so a stray variable in someone's shell would silently override Basic auth. We now pass null explicitly for whichever credential kind isn't in use.

Endpoints derive from one base, and are discoverable. All three live on the API host (/ui/v1/oauth/authorize for the browser leg, /auth/v1/oauth/{token,revoke} for the back channel), so MUX_BASE_URL moves the API calls, discovery, and the sign-in flow together — the token endpoint can't end up on a different host than the authorize endpoint. On top of that, RFC 8414 / OIDC discovery is consulted on login and refresh (cached a day) so Mux can move endpoints without stranding installed binaries. Discovery is never load-bearing: any failure falls back to the built-ins. Discovered endpoints are validated to be https: on a .mux.com host (dot-boundary matched, so mux.com.evil.test fails) or the document's own origin, because a document that can repoint token_endpoint could otherwise collect authorization codes.

Scopes are hardcoded deliberately. video/data/robots/system read+write — the union of what the commands need. Not taken from the server's scopes_supported, which would mean silently requesting any scope Mux adds later. No openid/profile/email: no id_token is requested or consumed, and identity comes from /system/v1/whoami, which reports what the access token can actually do. That also keeps JWKS out of the client.

The 401 retry is one function, not 98 changes. It's the SDK's fetch implementation, so every command inherits refresh-and-retry.

Refresh is lock-guarded. Acquisition uses link() of a fully-written temp file — an open('wx') lock is briefly empty, and a competitor reading that mistakes a live holder for a crashed one. A waiter never breaks a live holder's lock, and release is ownership-checked.

Caveats

  • The production client_id is still a placeholder. It's the one value discovery can't supply, so it has to be right before this ships.
  • Discovery documents aren't deployed yet, so the built-in paths are what's in use.
  • Mux's refresh-token TTL is unknown to me. It determines how often people re-login after idle time, and how much the "log in again" wording matters.
  • No CLI surface for removing a single credential block. removeCredential(name, kind) exists and is tested, but mux logout removes the whole environment.
  • Device authorization grant is deliberately deferred. Until it lands, SSH sessions need ssh -L 51372:127.0.0.1:51372 plus mux login --port 51372, or --interactive.
  • Keychain storage is a follow-up; refresh tokens live in the same 0600 config file that already holds token secrets and signing keys.
  • The reactive-401 reconnect in webhooks listen is only exercised against a local fake server — the function it calls is unit-tested, the SSE loop wiring isn't.

Testing

1200 tests pass, up from 1014 on main. Beyond unit coverage, the flows were driven against real staging and a local fake authorization server, and verified on the wire:

  • Bearer for OAuth vs Basic for token pairs on the same command
  • a 401 producing refresh → retry → rotated token persisted, with the user seeing only normal output
  • a flagged OAuth credential falling back to Basic on the same environment
  • a server that moved its token endpoint being followed via discovery rather than the compiled-in default
  • the callback page rendering from a compiled binary, which has no filesystem to read the HTML from

No secrets in the diff: scanned the commits, working tree, and untracked files for sk-ant-, eyJ, private key blocks, client_secret, and the staging client ID. The only high-entropy strings are the RFC 7636 Appendix B PKCE test vectors.


Note

High Risk
Changes authentication defaults, config on-disk shape, credential precedence, and token refresh/revocation across all API commands—security-critical behavior with deliberate breaking changes for CI and 2.x configs.

Overview
Browser sign-in is now the default for mux login, with explicit alternatives (--interactive, --env-file, --from-env, --oauth) that cannot be combined. If MUX_TOKEN_ID and MUX_TOKEN_SECRET are already set, bare mux login refuses to guess and exits without writing config (CI should use --from-env).

Stored credentials move to nested oauth and token blocks per environment; legacy flat tokenId/tokenSecret entries are read on load. One environment can hold both kinds—OAuth is preferred for API calls, with fallback to the token pair when OAuth is flagged dead. Config writes are atomic, and re-login updates entries in place so default environment and signing keys are not accidentally dropped.

New mux auth command group (auth status, plus login/logout aliases) reports every credential source locally with no secrets in output. mux env list / switch / logout gain richer human output, --json, interactive env switch, logout --all, and server-side refresh-token revocation on OAuth logout (best-effort).

Runtime auth in lib/mux resolves Bearer vs Basic from the preferred credential, refreshes OAuth proactively, and wires a 401 retry fetch into the SDK. webhooks listen refreshes and reconnects on auth failure; asset signing can run with signing keys only when the active login has no token pair.

Reviewed by Cursor Bugbot for commit b5cff85. Bugbot is set up for automated code reviews on this repo. Configure here.

@daniel-hayes
daniel-hayes marked this pull request as ready for review August 19, 2026 16:30
@daniel-hayes
daniel-hayes marked this pull request as draft August 19, 2026 16:30
Comment thread src/commands/login.ts Outdated
Comment thread src/lib/oauth-login.ts
Comment thread src/lib/browser.ts Outdated
@daniel-hayes
daniel-hayes marked this pull request as ready for review August 19, 2026 17:01
Comment thread src/commands/login.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6cc4e63. Configure here.

Comment thread .claude/settings.local.json Outdated
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