Skip to content

Make miren login ephemeral by default#968

Merged
evanphx merged 5 commits into
mainfrom
mir-1385-add-ephemeral-miren-login-that-doesn-t-persistent
Jul 24, 2026
Merged

Make miren login ephemeral by default#968
evanphx merged 5 commits into
mainfrom
mir-1385-add-ephemeral-miren-login-that-doesn-t-persistent

Conversation

@evanphx

@evanphx evanphx commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

miren login registered a brand new ed25519 key with the cloud on every single login. That persistent key mechanism is genuinely nice for long-lived sessions, but it means every routine login writes a new key server-side, and the vast majority of users never need that. It's a lot of backend load for a feature most people don't use.

The cloud already grew the other half of the answer: the device flow returns a refresh token now, and /auth/refresh rotates a pair. So the client just needs to be able to hold tokens instead of a key. Login now stores the access and refresh token it already receives and renews them in the background. --persistent-key opts back into the old dance if you want it, and an identity that already has a key keeps it on re-login, so nobody's existing setup changes under them.

The subtle part, and the bit most worth a reviewer's eye, is that the refresh endpoint consumes the token you hand it. Two miren processes refreshing at the same moment would leave one holding a spent token and a confusing "access denied" for a user who is perfectly logged in. So refreshes serialize on a file lock and re-read the identity from disk inside it, which turns the loser of a race into a cheap no-op rather than a 401. In the same spirit, only a definitive 401 counts as "session expired". A 5xx or a dropped connection keeps the existing tokens, because mapping a cloud blip to a logout would stampede every CLI user into re-authenticating at once, which is exactly the load we're trying to shed.

Getting there needed a refactor first. Eight call sites had each grown their own copy of "load the keypair, mint a JWT, use it as a bearer token", and several outright rejected any identity that wasn't a keypair. Flipping the default without touching them would have broken whoami, doctor, cluster switch and cluster add for anyone logging in fresh, so the first commit routes them all through one seam.

A couple of other things fell out along the way. Logout now revokes the refresh token before deleting the local files (best effort, it never blocks logout), since a copied config would otherwise keep renewing for a week after you thought you'd logged out. A 401 also stopped telling people to go ask their administrator for access when the real problem is usually just an expired session. And two incidental bugs got fixed: SetLeafConfig was silently dropping the Keys map when building its in-memory view, and leaf configs were written non-atomically, which is a torn read waiting to happen once they're rewritten on every token refresh.

Closes MIR-1385

evanphx added 2 commits July 23, 2026 17:17
Logging in registered a fresh ed25519 key with the cloud every time,
which is great for long-lived sessions but puts real load on the
backend for a feature most people never use. The cloud already grew
the other half of the answer (the device flow now returns a refresh
token, and /auth/refresh rotates a pair), so the client needs a way
to hold a token instead of a key.

Adds a "token" identity carrying an access and refresh token, and a
single TokenForIdentity seam that resolves any identity to a bearer
token: challenge-response for keypair, cached-or-refreshed for token.

The refresh endpoint consumes the token it is handed, so two miren
processes refreshing at once would leave one holding a spent token
and a spurious logout. Refreshes therefore serialize on a file lock
and re-read the identity inside it, which turns the loser of a race
into a no-op instead of a 401. Only a definitive 401 means re-login;
a 5xx or a network blip keeps the existing tokens rather than
stampeding every CLI user into re-authenticating at once.

Two incidental fixes fell out along the way: SetLeafConfig was
dropping the Keys map when building its in-memory view, and leaf
configs were written non-atomically, which is a torn read waiting to
happen now that they are rewritten on a token refresh.
Eight call sites had each grown their own copy of "load the keypair,
mint a JWT, use it as a bearer token", and several hard-rejected any
identity that was not a keypair. Adding a token identity without
touching them would have broken whoami, doctor, cluster switch and
cluster add for anyone logging in fresh, so they all now go through
TokenForIdentity and accept both kinds.

With that in place, login stores the device flow tokens instead of
registering a key. --persistent-key opts back into the old dance, and
an identity that already has a key keeps it on re-login so we never
silently orphan a key the server still knows about. If the cloud is
old enough that it returns no refresh token, login falls back to
registering a key rather than saving credentials that cannot renew.

Logout now revokes the refresh token before deleting the local files,
best effort, since a copied config would otherwise keep renewing for
a week after the user thought they had logged out. A 401 also stops
blaming permissions for what is usually just an expired session.
@evanphx
evanphx requested a review from a team as a code owner July 24, 2026 00:20
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a659a298-c29e-4fbb-b94f-400a3f1ecca2

📥 Commits

Reviewing files that changed from the base of the PR and between bf6b57e and 1cb0443.

📒 Files selected for processing (16)
  • cli/commands/cluster_switch.go
  • cli/commands/config_bind_helpers.go
  • cli/commands/debug_connection.go
  • cli/commands/doctor.go
  • cli/commands/login.go
  • cli/commands/login_ephemeral_test.go
  • cli/commands/login_keys_test.go
  • cli/commands/logout.go
  • cli/commands/whoami.go
  • clientconfig/clientconfig.go
  • clientconfig/clientconfig_modification_test.go
  • clientconfig/clientconfig_save_test.go
  • clientconfig/local.go
  • clientconfig/token.go
  • clientconfig/token_identity_test.go
  • docs/docs/command/login.md
🚧 Files skipped from review as they are similar to previous changes (11)
  • docs/docs/command/login.md
  • clientconfig/token_identity_test.go
  • cli/commands/doctor.go
  • clientconfig/local.go
  • cli/commands/whoami.go
  • cli/commands/login_ephemeral_test.go
  • cli/commands/cluster_switch.go
  • cli/commands/config_bind_helpers.go
  • cli/commands/debug_connection.go
  • clientconfig/token.go
  • cli/commands/login.go

📝 Walkthrough

Walkthrough

The change adds typed token identity storage and shared token acquisition, including refresh rotation, persistence, locking, freshness checks, and revocation. Device-flow login now supports ephemeral token identities, persistent keys, and fallback when refresh tokens are absent. Cluster discovery, RBAC checks, debugging, diagnosis, and user lookup use the shared authentication path. Logout revokes refresh tokens best-effort, configuration writes are atomic, and related tests and documentation are added.


Comment @coderabbitai help to get the list of available commands.

@miren-code-agent miren-code-agent 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.

🍪 biscuit: ⚠️ ready with caveats — auto-review, non-blocking

Review: Make miren login ephemeral by default

This is a well-designed change that introduces a new "token" identity type with automatic JWT refresh, cross-process locking, and atomic file writes. The core design decisions are sound, the test suite is genuinely good (the subprocess-based concurrency test for the single-use refresh token is particularly useful), and the code comments explain the less obvious choices clearly. That said, there are a couple of concrete issues worth knowing about before merging.


Correctness issue: persistIdentityTokens reads-then-writes under the lock, but readIdentityFromFile is a separate pass

In token.go, tokenForTokenIdentity holds the flock, re-reads the identity via readIdentityFromFile, and then calls persistIdentityTokens — which reads the file a second time before writing. That's three disk reads total per locked refresh, and between read 2 (inside readIdentityFromFile) and read 3 (inside persistIdentityTokens), a concurrent writer that isn't using the lock (e.g. miren login --force) could change the file. The comment says "the caller must hold the lock", but that invariant is only partially enforced. Minor, but worth noting — the locked re-read already validated freshness, so persistIdentityTokens only needs a write, not another full unmarshal-then-marshal round-trip. Not a blocker by itself, but it's a genuine TOCTOU window inside the locked section.

Real concern: identity in main config file can't refresh

tokenForTokenIdentity calls c.GetIdentitySource(name) and returns an error if it gets "". GetIdentitySource returns "" for identities that live directly in the main config file (not a leaf). The typical install path (saveTokenIdentityToConfig) always writes token identities as leaf configs in clientconfig.d/, so in practice this won't be hit — but a user who manually edits their clientconfig.yaml or who still has a main-config identity from an old version will see cannot locate config file for identity "cloud" on the first token refresh instead of the real auth error. The error message at token.go line 218 doesn't explain this, so it would be confusing.

RevokeRefreshToken called with a possibly-expired access token

In logout.go line 119, identity.Token is passed as the bearer credential. That's the cached access token, which may be expired by the time the user logs out. The server-side revoke endpoint is documented as best-effort and the logout continues regardless, but if the endpoint requires a valid bearer, the revoke will silently fail on every real-world logout (tokens expire). It would be more robust to attempt a refresh before revoking, or to note in the comment that an expired access token is expected here.

Minor: tmp.Chmod may not be portable

atomicWriteFile calls tmp.Chmod(perm) after writing but before renaming. On Linux this is fine. On macOS it's also fine. But (*os.File).Chmod is not guaranteed by the Go spec to be the same as os.Chmod(path, perm) on all platforms (Windows). This is a CLI tool that presumably runs only on Unix, so it's low risk, but using os.Chmod(tmpPath, perm) would be clearer and portable.


What's good

  • The flock design — lock file left in place to avoid inode-racing — is correct and the comment explains exactly why.
  • The fast-path (no lock, no network) for a still-fresh access token is properly placed before the flock acquisition.
  • The 5-minute tokenExpiryBuffer constant is a named, tested value rather than a magic number.
  • The backward-compatibility check for an existing keypair identity (so re-login doesn't silently orphan a registered server key) is thoughtful.
  • The TestTokenForIdentityConcurrentRefresh test covering both goroutines and subprocesses is exactly what you want for a single-use rotating refresh token.
  • The distinction between ErrLoginRequired (401 / definitive) and transient errors (5xx / network) is correct and tested.

I'm marking this CAVEATS rather than NOT_READY because the main-config identity issue is an edge case that won't affect the default install path, and the access-token-expiry on logout is best-effort by design. But both are worth addressing before shipping.


🍪 full review note · comment /biscuit review to run biscuit again.

Comment thread clientconfig/token.go Outdated
Comment thread cli/commands/logout.go Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
clientconfig/token.go (1)

230-234: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Use TryLockContext for the refresh lock
flock.Lock() can still block after the caller’s context is canceled or its deadline expires. The refresh path already honors ctx; use TryLockContext(ctx, retryDelay) here so lock acquisition stops on cancellation instead of waiting indefinitely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clientconfig/token.go` around lines 230 - 234, Update the refresh-lock
acquisition in the token refresh path to call flock’s TryLockContext with the
existing ctx and an appropriate retry delay instead of lock.Lock. Preserve the
current error wrapping and deferred unlock behavior, while ensuring cancellation
or deadline expiry stops acquisition promptly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/commands/login.go`:
- Around line 265-376: Update printUnsavedCredentials and its caller so
--no-save --persistent-key registers the generated or existing public key with
the cloud before printing the private key. Pass the required cloud URL and
access token into printUnsavedCredentials, invoke registerPublicKey using the
keypair and keyName, and return an error if registration fails; leave the
token-credential path unchanged.

In `@cli/commands/logout_revoke_test.go`:
- Around line 47-58: Synchronize request capture in the httptest handler by
replacing the unsynchronized gotAuth and gotRefresh variables with channel-based
delivery or a mutex-backed capture struct. Update the test’s later reads to use
the synchronized mechanism while preserving the existing revokeHits tracking and
captured header/body values.

In `@cli/commands/logout.go`:
- Around line 118-123: Update the revocation branch in the logout command around
RevokeRefreshToken to check that identity.Token is non-empty before reporting
server-side success. Treat a missing access token as a failed or bypassed
revocation, emit the existing warning instead of “Revoked refresh token,” and
preserve best-effort removal of local credentials.

---

Nitpick comments:
In `@clientconfig/token.go`:
- Around line 230-234: Update the refresh-lock acquisition in the token refresh
path to call flock’s TryLockContext with the existing ctx and an appropriate
retry delay instead of lock.Lock. Preserve the current error wrapping and
deferred unlock behavior, while ensuring cancellation or deadline expiry stops
acquisition promptly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dabb3ff8-0023-4679-a1c2-8fa7f574bee0

📥 Commits

Reviewing files that changed from the base of the PR and between f661797 and 469ec0c.

📒 Files selected for processing (22)
  • cli/commands/cluster_add.go
  • cli/commands/cluster_switch.go
  • cli/commands/config_bind_helpers.go
  • cli/commands/debug_connection.go
  • cli/commands/doctor.go
  • cli/commands/global.go
  • cli/commands/login.go
  • cli/commands/login_deviceflow_test.go
  • cli/commands/login_ephemeral_test.go
  • cli/commands/logout.go
  • cli/commands/logout_revoke_test.go
  • cli/commands/whoami.go
  • clientconfig/clientconfig.go
  • clientconfig/leafwrite_test.go
  • clientconfig/local.go
  • clientconfig/setleafconfig_keys_test.go
  • clientconfig/token.go
  • clientconfig/token_identity_test.go
  • clientconfig/token_refresh_concurrency_test.go
  • clientconfig/token_test.go
  • docs/docs/command/login.md
  • go.mod

Comment thread cli/commands/login.go
Comment thread cli/commands/logout_revoke_test.go Outdated
Comment thread cli/commands/logout.go Outdated
The refactor that split login into persist/print paths moved key
registration inside the persisted branch, so --no-save --persistent-key
printed a private key the cloud had never seen. Register before
printing, since an unregistered key cannot authenticate anything.

Logout revocation had a similar hole in practice: the stored access
token lives an hour and is usually expired by the time anyone logs out,
so the revoke call would quietly fail and the refresh token would stay
live for its full week. Get a live token first, then revoke whatever
refresh token that left current, and only claim success when a request
actually succeeded. RevokeRefreshToken now says so rather than
returning nil when it has no bearer to authorize with.

Also take the refresh lock with the caller context so a cancelled
command stops waiting, and synchronize the revoke test capture.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/commands/login_ephemeral_test.go`:
- Around line 162-163: Update the login test assertion near mc.keyBeginHits to
also verify the mock’s key registration completion counter, ensuring the printed
key is fully registered rather than only having the begin endpoint called.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9b696790-9a5c-477b-b02a-e15a672dd683

📥 Commits

Reviewing files that changed from the base of the PR and between 469ec0c and bf6b57e.

📒 Files selected for processing (6)
  • cli/commands/login.go
  • cli/commands/login_ephemeral_test.go
  • cli/commands/logout.go
  • cli/commands/logout_revoke_test.go
  • clientconfig/token.go
  • clientconfig/token_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • cli/commands/logout.go
  • cli/commands/logout_revoke_test.go
  • clientconfig/token_test.go
  • clientconfig/token.go
  • cli/commands/login.go

Comment thread cli/commands/login_ephemeral_test.go
The mock cloud short-circuited key registration with a 409, so the
tests only proved that registration began. Have it return a real
envelope and challenge and check the signature comes back, then assert
completion too, so the registration tests prove a key is actually
usable rather than merely attempted.
@evanphx

evanphx commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

/biscuit review

@miren-code-agent miren-code-agent 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.

🍪 biscuit: ✅ ready to merge — auto-review, non-blocking

Both concerns I raised in my previous review have been genuinely addressed.

Logout revocation — this was the most important fix, and it was done properly rather than just documented. revokeIdentitySession now calls TokenForIdentity first to get a live access token (fast-path if the cached token is still fresh, automatic refresh via /auth/refresh if it isn't), then re-reads the identity from disk to get the refresh token that is actually current (not the one a concurrent refresh may have just spent), and then calls RevokeRefreshToken with a valid bearer. RevokeRefreshToken now also returns an error instead of nil when accessToken == "", which closes the silent-false-success bug CodeRabbit also caught. The new test in logout_revoke_test.go is properly synchronized via a mutex-backed revokeCapture struct and covers both the happy path (correct bearer, correct refresh token) and the failure path (server 500 must not block local logout).

Error message for sourceless identity — the author's pushback was correct. GetIdentitySource does fall back to c.sourcePath for identities in the main config, so main-config token identities work fine. The "" case is genuinely only for in-memory configs that were never loaded from disk (e.g., DecodeConfig on a raw byte slice with no sourcePath), where there's nowhere to write rotated tokens. The new error message ("identity %q has no config file on disk to store refreshed tokens in; run 'miren login' to save it") is clear and actionable. I was wrong on the premise, and I'm satisfied the edge case is now documented rather than silently broken.

The TOCTOU note from my first review (two reads inside the locked section) is still present but remains a minor paper concern — both reads hold the lock, so the only risk is from a writer that deliberately ignores the lock, and persistIdentityTokens needs the full file contents to preserve other fields anyway.

This is ready to merge.


🍪 full review note · comment /biscuit review to run biscuit again.

@phinze phinze self-assigned this Jul 24, 2026

@phinze phinze left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, nice work — the bits that had to be careful (the single-use refresh race, only-a-401-logs-you-out) are, and the client lines up with the real cloud refresh handler on all of it: the 401/5xx split, single-use rotation, the sliding 7-day window. Bot catches look properly fixed too.

Dropped a handful of inline notes as I passed through. Only the ephemeral naming one is actually asking for anything — the rest are just spots where the identity code's got a bit of an ODOUR (under-typed, half-hydrated, a row of positional bools). None of it bites today and it's all right in the path of the RBAC / App-Auth pass (MIR-891), so leave it for that sweep — I'm flagging in situ mostly since it's what I'd be mumbling out loud if we were talking through this live.

--p+🤖

Comment thread cli/commands/login.go Outdated
Comment thread clientconfig/clientconfig.go Outdated
Comment thread clientconfig/token.go
Comment thread cli/commands/login.go
Comment thread cli/commands/logout.go
phinze noted the identity Type was a bare string, so the exhaustive
linter could not watch the switches on it; adding "token" meant
hand-auditing every arm for the missing-case bug a named type would
catch for free. Give it a type and consts. The switches now fail lint
if a future identity type forgets an arm.

Also rename the one place "ephemeral" leaked to users, the
--persistent-key flag help, to "renewable": "ephemeral" collides with
the unrelated ephemeral-deploy / ephemeral-version concept. Internal
naming is left alone. Regenerated login.md to match.
@evanphx
evanphx merged commit 2689e86 into main Jul 24, 2026
24 checks passed
@evanphx
evanphx deleted the mir-1385-add-ephemeral-miren-login-that-doesn-t-persistent branch July 24, 2026 17:53
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.

2 participants