Skip to content

feat: move authentication to Go, behind a feature flag - #203

Open
pjcdawkins wants to merge 23 commits into
mainfrom
cli-189-go-auth-feature-flag
Open

pjcdawkins wants to merge 23 commits into
mainfrom
cli-189-go-auth-feature-flag

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • Go stores credentials (system keychain via go-keyring, or files under <writable dir>/auth/) and refreshes tokens under a per-session flock.
  • login/auth:browser-login, auth:api-token-login, logout/auth:logout and auth:token are native Go commands, with the legacy options and messages.
  • The legacy CLI gets tokens and auth state from Go via a hidden auth:internal command, 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).
  • init uses 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:

  • The integration tests run with legacy auth by default and with Go auth if TEST_CLI_GO_AUTH=1 is set; CI runs both.
  • Tested manually on:
    • Windows 11 (Credential Manager) and in WSL2 (file storage): see the comment below.
    • MacOS (Intel) which used the keychain as expected.
    • Linux + GNOME on Wayland (including with a locked keyring).

🤖 Generated with Claude Code

pjcdawkins and others added 9 commits October 8, 2026 14:25
… 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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 1 warning · 🔵 1 minor point

🔍 Full review · 50 files reviewed

Verification
  • Manager.refresh re-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, Save and logoutOne run migrate before locking, and loggedOut/Revoke take no session lock.
  • PHP's legacy keychain key format (slug/&lt;id> with cli- stripped, and slug/&lt;id>/api-token) matches what ExportSessionsCommand parses back into session IDs.
  • CLIWrapper.init releases its cache flock before running PHP, so the nested Go→PHP→Go auth:internal→PHP export chain cannot deadlock on it.
  • auth:internal writes the login-required JSON as the last stderr line and exits with code 3 through exitError, which matches how GoAuth::run parses 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

Comment thread commands/init.go
Comment thread go.mod Outdated
pjcdawkins and others added 2 commits October 8, 2026 15:52
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>
@upsun-dispatch
upsun-dispatch Bot dismissed their stale review October 8, 2026 14:53

Superseded: the latest Upsun Dispatch review no longer requests changes.

pjcdawkins and others added 3 commits October 8, 2026 17:47
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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental · 2 files reviewed

Verification
  • keychainErrMu sits in a struct that is only used by pointer (*store.Store in manager.go and in the tests), so the mutex is never copied.
  • Remembering failures stops a Forget followed by Save from prompting again: the first save's keychainSet returns the remembered error, so the session falls back to BackendFile.
  • 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 details

Review 4 of 10 for this pull request · View the full run

Comment thread internal/auth/store/store.go Outdated
pjcdawkins and others added 2 commits October 8, 2026 19:48
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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental · 2 files reviewed

Verification
  • secretServiceAvailable uses SessionBusPrivateNoAutoStartup, 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.
  • KeychainSupported is only reached through UseKeychain in 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 details

Review 6 of 10 for this pull request · View the full run

Comment thread internal/auth/store/keychain.go
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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental · 5 files reviewed

Verification
  • keychainUsableOnLinux returns 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 GetProperty failure path is safe: v.Value() on a zero Variant returns nil, and the comma-ok type assertion then gives unlocked=false.
  • keychain() now stops remembering keyring.ErrSetDataTooBig as well as ErrNotFound, so one oversized entry no longer disables the keychain for other sessions.
  • api.Error now has Unwrap, and go.mod pins golang.org/x/oauth2 back 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 details

Review 7 of 10 for this pull request · View the full run

Comment thread internal/auth/store/store.go Outdated
…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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 2 warnings

🔁 Incremental · 2 files reviewed

Verification
  • keychain(false, …) maps to limit 0 and keychain(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, so Save no longer waits without limit on a new session.
  • The onLate callback runs only when the abandoned call later returns a nil error, which TestStore_KeychainLimit exercises.

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 details

Review 8 of 10 for this pull request · View the full run

Comment thread internal/auth/store/store.go Outdated
Comment thread internal/auth/store/store.go Outdated
…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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 2 warnings

🔁 Incremental · 6 files reviewed

Verification
  • v.Value() on the zero dbus.Variant returned with a GetProperty error is nil, so secretServiceUnlocked returns false without panicking.
  • Load, Save and Delete all use sf.Account for 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 by KeychainTimeout and 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 details

Review 9 of 10 for this pull request · View the full run

Comment thread internal/auth/store/store.go
Comment thread internal/auth/store/keychain_linux.go Outdated
pjcdawkins and others added 2 commits October 8, 2026 20:15
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>

@upsun-dispatch upsun-dispatch 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.

Warning

Changes suggested — 🟡 1 warning · ⚪ 1 nitpick

🔁 Incremental · 3 files reviewed

⚪ Nitpick

  • internal/auth/store/store.go:294 — keychainCall repeats the whole body of keychain: the lock, the remembered-error check, and the rule for which errors are entry errors. keychain could simply return s.keychainCall(wait, fn, nil). As written, the two copies of the error-classification rule can drift apart.
Verification
  • keyringCollection picks collection/login when the Service's Collections property lists it, otherwise aliases/default. That matches go-keyring's GetLoginCollection fallback.
  • The onLate goroutine captures set/del locally, so the test's restoring of setSecret/deleteSecret cannot race with it.
  • A file-mode session's Account survives re-saves (newFile.Account = sf.Account), so a later Delete still 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 details

Review 10 of 10 for this pull request · View the full run

Comment thread internal/auth/store/store.go Outdated
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>
@pjcdawkins

Copy link
Copy Markdown
Contributor Author

@upsun-dispatch review

@upsun-dispatch upsun-dispatch 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.

Note

Reviewed — No blocking findings · 🔵 1 minor point

🔁 Incremental · 2 files reviewed

🔵 Minor point

  • internal/auth/store/store.go:304 — keychainCleanUp discards every failure, timeouts included, so the error is never stored in s.keychainErr. A timeout here usually means the keychain is locked and an unlock prompt is pending, which is exactly the case keychainErr exists for (its comment: "each try could prompt the user"). Example: logout → DeleteAll while the keychain is locked. The first file-mode session with Account set 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 calls keychain(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
  • keychain now just calls keychainCall(wait, fn, nil), so there is one copy of the lock, the remembered-error check and the entry-error rule.
  • keychainCleanUp takes keychainErrMu before calling keychainCallLocked, so it is serialized with other keychain calls and never writes keychainErr.
  • Delete's file-mode branch for a recorded Account is now the only caller of keychainCleanUp, and the error it ignores is no longer stored in keychainErr.

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).

Review details

View the full run

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>
@pjcdawkins

Copy link
Copy Markdown
Contributor Author

Tested manually on Windows 11 (native, and WSL2), with UPSUN_CLI_GO_AUTH=1 unless noted.

Native Windows (PowerShell):

  • Legacy login without the flag, then a PHP command with the flag: the session was migrated to Credential Manager (upsun-cli-auth:default-<suffix>), the session file holds only backend and account, the legacy credential was deleted and the SSH certificate kept.
  • PHP commands with Go tokens: environments, ssh-cert:info, auth:info.
  • Refresh: 4 Go (auth:token) and 4 PHP (auth:info) processes in parallel, within the 2-minute refresh window. The token was refreshed once; all 8 got the same new token, with no errors.
  • logout: deleted the Credential Manager entry, the session file and the session's SSH key, certificate and config.
  • auth:token when logged out offered a browser login; login then stored the session under a new keychain account.

WSL2 (Ubuntu 24.04, no Secret Service): login used file storage (auth/default.json, mode 600, in a 700 directory), PHP commands and the SSH config prompt worked, and logout removed the session files.

🤖 Generated with Claude Code

@pjcdawkins

Copy link
Copy Markdown
Contributor Author

@upsun-dispatch review

This branch has not been deployed

No deployments
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