Repository navigation
feat: move authentication to Go, behind a feature flag - #203
pjcdawkins wants to merge 23 commits into
Conversation
… flag config.Auth() reads the auth keys (tokens, session ID, endpoints, and behavior settings) from the embedded config, the user's config.yaml and env vars, with the legacy CLI's precedence, aliases and type casting. The schema gains the API token keys and session ID validation. GoAuthEnabled() reads <PREFIX>GO_AUTH, the feature flag for Go auth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One entry per session ID, in the system keychain (go-keyring, no cgo) or in <writable dir>/auth/<id>.json. The backend is chosen when a session is first saved, and recorded in the session file. On Linux the keychain is used under the legacy CLI's conditions, plus a check for the Secret Service on D-Bus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auth.Manager resolves tokens (stored API tokens, api.token(_file), api.access_token, stored sessions), and refreshes them under a per-session flock, re-reading the store under the lock because refresh tokens rotate. A refresh is not retried once sent, as the server may have rotated the token. Sessions are migrated once from the legacy CLI's storage, via a hidden PHP export command. The previous token source, which runs the legacy CLI, is kept for when Go auth is disabled, with its transport renamed to legacyTransport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With <PREFIX>GO_AUTH=1, these commands are native: auth:browser-login (login), auth:api-token-login, auth:logout (logout) and auth:token, with the legacy options and messages, plus the hidden auth:internal command that the legacy CLI uses for tokens and auth state. init uses Go auth, with the API URL from the auth settings. Without the flag, the commands are handled by the legacy CLI as before, and init gets tokens from it. Errors are printed by Execute, which handles exit codes, and respects --verbose and --debug overriding --quiet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With <PREFIX>GO_AUTH=1, Api gets tokens and auth state from the Go wrapper (GoAuth, via auth:internal) instead of its own storage, and the login prompt runs the Go login. The hidden commands auth:post-login (SSH set-up) and auth:export-sessions (migration) are added for Go. Without the flag, the legacy implementation is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The suite runs with legacy auth by default, and with Go auth if TEST_CLI_GO_AUTH=1 is set; CI runs both. Tests of Go-only behavior (login flows, refresh concurrency, migration, step-up, keychain recovery) always use Go auth. The mock auth server gains refresh-token rotation with reuse detection, failures, and step-up responses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transport retried a 401 once with a refreshed token, but returned a step-up challenge on the retry as a raw 401, losing the required authentication methods and max age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Go auth, init only offered a login before its first API request. A step-up challenge from the organization requests then failed the command. They now run with the same login handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 🔵 1 minor point
🔍 Full review · 50 files reviewed
Verification
Manager.refreshre-reads the store after taking the session flock, and saves rotated tokens before returning, so a rotated refresh token is never sent twice.- None of the lock holders take the lock again:
refresh,SaveandlogoutOnerunmigratebefore locking, andloggedOut/Revoketake no session lock. - PHP's legacy keychain key format (
slug/<id>withcli-stripped, andslug/<id>/api-token) matches whatExportSessionsCommandparses back into session IDs. CLIWrapper.initreleases its cache flock before running PHP, so the nested Go→PHP→Goauth:internal→PHP export chain cannot deadlock on it.auth:internalwrites the login-required JSON as the last stderr line and exits with code 3 throughexitError, which matches howGoAuth::runparses it.
The PR adds unit tests (manager_test.go, store_test.go, config/auth_test.go, commands/auth*_test.go) and integration tests (auth_flows_test.go, auth_go_test.go). CI's integration-test job now runs them a second time with TEST_CLI_GO_AUTH=1. No test covers a step-up challenge on init's organization request, so the broken retry in init.go goes unnoticed.
Review details
- Commit: 4154949
- Model: claude-opus-5-5
Review 1 of 10 for this pull request · View the full run
api.Error had no Unwrap method, so errors.As could not find a login-required error from the transport. With Go auth, a step-up challenge on init's organization request failed the command instead of offering a login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was downgraded to v0.34.0 by `go mod tidy` when the go.mod from the earlier branch, which no longer required it, was carried over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Superseded: the latest Upsun Dispatch review no longer requests changes.
The legacy token source's transport is renamed in place, and the new transport goes in manager_transport.go, so the diff shows the new code rather than a rewritten file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dbus.SessionBusPrivate runs dbus-launch if no session bus address is found, which could start a dbus-daemon on a system without a desktop session. The check now only connects to an existing session bus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each keychain call can prompt the user, e.g. to unlock the keyring, and a dismissed prompt made the next call prompt again. A login with a locked keyring could prompt three times. The store now remembers the first failure, and returns it instead of trying again, so that a new session falls back to a file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning
🔁 Incremental · 2 files reviewed
Verification
keychainErrMusits in a struct that is only used by pointer (*store.Storein manager.go and in the tests), so the mutex is never copied.- Remembering failures stops a
Forgetfollowed bySavefrom prompting again: the first save'skeychainSetreturns the remembered error, so the session falls back toBackendFile. - Because the error is remembered under the mutex, a new keychain call can no longer start while a call abandoned by a non-wait timeout is still running.
The diff adds TestStore_KeychainFailureRemembered and TestStore_FirstSaveAfterKeychainFailure. Neither covers an entry-level error such as ErrSetDataTooBig followed by a load of a different keychain session. The Go tests run in the CI workflow (.github/workflows/ci.yml).
Review 4 of 10 for this pull request · View the full run
ErrSetDataTooBig is about one entry, and the session falls back to a file. Remembering it made calls for other keychain sessions in the same process fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilable The check kept the legacy CLI's conditions, which required DISPLAY and a GNOME session. Neither is needed: tested with GNOME Keyring, keychain calls work without DISPLAY, and an unlock prompt is shown by the Secret Service on the desktop, whatever the CLI's environment. The conditions excluded Wayland sessions without XWayland, other Secret Service providers such as KWallet or KeePassXC, and SSH sessions into an unlocked desktop. The snap and container checks are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning
🔁 Incremental · 2 files reviewed
Verification
secretServiceAvailableusesSessionBusPrivateNoAutoStartup, so the Linux check never spawns a new session bus.- The new test unsets SNAP_CONTEXT, container and DOCKER_IP when a case does not set them, so a CI runner's environment cannot leak into a case.
KeychainSupportedis only reached throughUseKeychainin manager.go, so the relaxed check affects only where new sessions are first saved.
This pass adds TestKeychainSupported_Linux. Its expected value depends on the host's Secret Service, so on CI it most likely only checks the environment-variable exclusions. No test covers a locked or prompt-requiring keyring, and the PR description says the keychain still needs manual testing.
Review 6 of 10 for this pull request · View the full run
The Secret Service shows an unlock prompt on the desktop's display, so over SSH a locked keychain blocked a new session's first save for 10s and prompted on a screen the user could not see. A new session now uses the keychain only if its default collection is unlocked, or if there is an X11 or Wayland display, as a sign that the user can answer the prompt. The first write of a session is also waited for, like other changes, instead of timing out: a write abandoned after the timeout could finish later, leaving a copy in the keychain beside the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning
🔁 Incremental · 5 files reviewed
Verification
keychainUsableOnLinuxreturns false for a locked or merely activatable Secret Service with no DISPLAY or WAYLAND_DISPLAY, so a plain SSH session falls back to files.- The
GetPropertyfailure path is safe:v.Value()on a zero Variant returns nil, and the comma-ok type assertion then gives unlocked=false. keychain()now stops rememberingkeyring.ErrSetDataTooBigas well asErrNotFound, so one oversized entry no longer disables the keychain for other sessions.api.Errornow hasUnwrap, and go.mod pinsgolang.org/x/oauth2back to v0.37.0.
The new TestKeychainUsableOnLinux, TestHasDisplay and TestKeychainSupported_Container unit-test the pure decision function and the env checks. Nothing tests secretServiceStatus against a real or mock D-Bus, or the first Save now waiting with no time limit.
Review 7 of 10 for this pull request · View the full run
…nute The first write was waited for without a limit, so a login or migration could hang, holding its lock, e.g. with an unlock prompt that nobody answers or a Secret Service that never replies. It now gives up after a minute, enough to answer a prompt, and the session falls back to a file. If the abandoned write succeeds later, while the process runs, it is deleted again. Changes to existing keychain sessions are still waited for without a limit, as a change that finishes after the session's lock is released could overwrite newer credentials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 2 warnings
🔁 Incremental · 2 files reviewed
Verification
keychain(false, …)maps to limit 0 andkeychain(true, …)maps to limit -1, so existing reads and later writes behave as before this push.- The bounded first write now falls back to a file after
firstWriteTimeout, soSaveno longer waits without limit on a new session. - The
onLatecallback runs only when the abandoned call later returns a nil error, whichTestStore_KeychainLimitexercises.
TestStore_KeychainLimit (added in this push) covers the limited wait and the late clean-up callback. Nothing tests the late write racing other processes, or a write that outlives the process. Both are left to the Go test job in .github/workflows/ci.yml.
Review 8 of 10 for this pull request · View the full run
…d keychains A new session's first keychain write had no time limit, then a limit with a late clean-up. Either way, a write that took too long could hang the login, or finish after the lock was released and overwrite (or delete) the secret of a session saved since, or leave a copy that logout could not find. Instead: - On Linux, a new session uses the keychain only if the Secret Service's default collection is already unlocked, so the first write does not wait for an unlock prompt, which the user may not see (e.g. over SSH). - The first write has the normal timeout again, and falls back to a file. - Each session's secret is stored under its own keychain account, a random name recorded in the session file, so a late write lands in an account that no session uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 2 warnings
🔁 Incremental · 6 files reviewed
Verification
v.Value()on the zerodbus.Variantreturned with aGetPropertyerror is nil, sosecretServiceUnlockedreturns false without panicking.Load,SaveandDeleteall usesf.Accountfor sessions in the keychain, so they agree on which item they read, write and remove.- An abandoned first write now goes to a unique account, so it cannot overwrite the secret of a session saved later under the same id.
- The first write uses
keychain(false, …), so it is bounded byKeychainTimeoutand falls back to a file. Updates to existing keychain sessions still wait while the lock is held.
TestStore_KeychainAccountPerSession was added: it covers the per-session account and that a stray write under the bare id is ignored. Nothing tests the late-write path after a first-write timeout, or secretServiceUnlocked itself. The Linux display tests were removed, and only the container-env test remains.
Review 9 of 10 for this pull request · View the full run
When a new session's first keychain write timed out, the session used a file, but the write could finish later and leave live credentials in the keychain that no command could find. The process now deletes such a late write, which is safe as each session has its own account, and the file session records the account, so that deleting the session (e.g. logout) also deletes a late write from a process that has ended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check read the "default" alias, but go-keyring writes to the "login" collection if it exists, which can be locked while the default is unlocked, or the other way around. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · ⚪ 1 nitpick
🔁 Incremental · 3 files reviewed
⚪ Nitpick
internal/auth/store/store.go:294—keychainCallrepeats the whole body ofkeychain: the lock, the remembered-error check, and the rule for which errors are entry errors.keychaincould simplyreturn s.keychainCall(wait, fn, nil). As written, the two copies of the error-classification rule can drift apart.
Verification
keyringCollectionpickscollection/loginwhen the Service's Collections property lists it, otherwisealiases/default. That matches go-keyring'sGetLoginCollectionfallback.- The
onLategoroutine capturesset/dellocally, so the test's restoring ofsetSecret/deleteSecretcannot race with it. - A file-mode session's
Accountsurvives re-saves (newFile.Account = sf.Account), so a laterDeletestill removes a late keychain write. - Save records the account only when the error is
errKeychainTimeout, not when it is ErrSetDataTooBig or another immediate failure.
The new TestStore_FirstSaveTimeout covers the in-process onLate deletion and the deletion of a recorded account by a later Delete. The Linux collection selection in keyringCollection has no test that touches D-Bus. CI runs the Go tests through .github/workflows/ci.yml.
Review 10 of 10 for this pull request · View the full run
Deleting the late write of a file session is optional, but a failure was remembered, so other keychain sessions in the same process, e.g. in logout --all, failed without trying the keychain. keychain() now delegates to keychainCall(), so the error rule is in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@upsun-dispatch review |
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 2 files reviewed
🔵 Minor point
internal/auth/store/store.go:304—keychainCleanUpdiscards every failure, timeouts included, so the error is never stored ins.keychainErr. A timeout here usually means the keychain is locked and an unlock prompt is pending, which is exactly the casekeychainErrexists for (its comment: "each try could prompt the user"). Example:logout→DeleteAllwhile the keychain is locked. The first file-mode session withAccountset blocks for 10s on its clean-up delete and gives up. Each later such session blocks for another 10s and may prompt again. A keychain-backed session after it (ids are sorted) then callskeychain(true, …), which prompts again and waits with no upper bound. Before this change that session would have failed fast with the remembered error. Not remembering entry errors and hard failures is fine. A timeout should still be remembered, or at least end the clean-ups for the rest of the process.
Verification
keychainnow just callskeychainCall(wait, fn, nil), so there is one copy of the lock, the remembered-error check and the entry-error rule.keychainCleanUptakeskeychainErrMubefore callingkeychainCallLocked, so it is serialized with other keychain calls and never writeskeychainErr.Delete's file-mode branch for a recordedAccountis now the only caller ofkeychainCleanUp, and the error it ignores is no longer stored inkeychainErr.
The new test TestStore_CleanupFailureNotRemembered covers a clean-up that fails immediately and checks that keychainErr stays nil. No test covers a clean-up that times out. The Go unit tests run in CI (.github/workflows/ci.yml).
A timeout usually means an unlock prompt, so other keychain calls in the process, e.g. in logout --all, should not wait or prompt again. Other clean-up failures are still not remembered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Tested manually on Windows 11 (native, and WSL2), with Native Windows (PowerShell):
WSL2 (Ubuntu 24.04, no Secret Service): 🤖 Generated with Claude Code |
|
@upsun-dispatch review |
Moves authentication from the legacy PHP CLI to Go, behind a feature flag:
<PREFIX>GO_AUTH=1(e.g.UPSUN_CLI_GO_AUTH=1). Without it, the CLI behaves as before. This replaces #195, which made the switch unconditionally.With the flag on:
<writable dir>/auth/) and refreshes tokens under a per-session flock.login/auth:browser-login,auth:api-token-login,logout/auth:logoutandauth:tokenare native Go commands, with the legacy options and messages.auth:internalcommand, and Go runs hidden PHP commands for SSH set-up after login (auth:post-login) and a one-time migration of the legacy credentials (auth:export-sessions).inituses Go auth.Switching the flag off again means logging in again with legacy auth, as the migration deletes the legacy copies.
The commits are ordered for review: config, credential store, token refresh, Go commands, the PHP side, tests. On the PHP side the legacy code is unchanged, apart from being moved into methods where it now branches on the flag.
Verification:
TEST_CLI_GO_AUTH=1is set; CI runs both.🤖 Generated with Claude Code