feat: move authentication from PHP to Go - #195
pjcdawkins wants to merge 34 commits into
Conversation
Add Config.Auth, which reads the auth keys (api.token, api.token_file, api.access_token, api.session_id, the API/auth URLs, the client ID, disable_credential_helpers, skip_ssl and disable_locks) from the embedded config, the user's config.yaml and env vars, with the legacy CLI's precedence and boolean casting. It also reads the session-id file and applies the URL defaults under api.auth_url. Validate session IDs with the legacy CLI's rules, and add the browser_login config key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Store one entry per session ID in the system keychain (via go-keyring, without cgo) or in a file under <writable dir>/auth/. The backend is chosen when a session is first saved and recorded in the session file, so a later keychain failure returns an error rather than silently switching to a file. Keychain calls have a 10s timeout. On Linux the keychain is only used under the legacy CLI's conditions (a display, GNOME, not in a snap or container), with a check for the Secret Service D-Bus name in place of the libsecret check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add auth.Manager, which replaces the token source that ran the legacy CLI's auth:token and auth:info commands. It resolves tokens with the legacy CLI's precedence (a stored API token, api.token, api.token_file, then api.access_token, then the stored session), and exchanges API tokens with the api_token grant under an api-token-<hash> session ID. Refreshes run under a blocking flock on <writable dir>/auth/<id>.lock, held for the whole read-refresh-write sequence, plus a mutex per session within the process. The stored entry is re-read under the lock, so a rotated refresh token is never sent again. Tokens are refreshed 2 minutes before they expire. invalid_grant clears the session; invalid_request (concurrent use) keeps it. Connection errors before sending are retried twice, and timeouts or 5xx errors once. Add a one-time migration from the legacy CLI's storage, through a hidden export command, recorded in a marker file. The Transport retries once on 401, and returns a LoginRequiredError for step-up challenges (RFC 9470). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add native auth:browser-login (login), auth:api-token-login, auth:logout (logout) and auth:token commands, with the legacy CLI's options and messages. They are listed and abbreviated alongside the legacy commands, and replace them in the list output. Browser login serves the OAuth 2.0 redirect from a net/http listener on 127.0.0.1:5000-5010 (PKCE S256), using the browser_login page template. After a login, Go runs the legacy CLI's hidden auth:post-login command for SSH certificates and config; after a logout, auth:post-logout. Add the hidden auth:internal command (token, status), through which the legacy CLI gets tokens and auth state. It writes only JSON to stdout, never prompts, and skips update checks. Exit code 3 means login is required. The legacy CLI is given the wrapper's path in <PREFIX>WRAPPER_EXECUTABLE. init now uses the Go token source, and offers a browser login when needed, as the legacy CLI's AutoLoginListener did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The legacy CLI no longer stores or refreshes credentials. It runs the Go wrapper's hidden auth:internal command (found via <PREFIX>WRAPPER_EXECUTABLE) for tokens and auth state, and caches the results in memory. The OAuth 2.0 middleware gets a new token from Go when its copy expires (2 minutes early) or after a 401, passing the rejected token. A login prompt now runs the Go browser login. Add hidden commands for the Go wrapper: - auth:export-sessions [--delete] exports (or deletes) sessions and API tokens from the old keychain and file storage, for the one-time migration. It reads the keychain only if the credential helper is already installed, and never calls back into Go. - auth:post-login generates SSH certificates and config, as Login::finalize() did. - auth:post-logout flushes the cache and deletes the sessions' SSH certificates and config. Remove the PHP auth:browser-login, auth:api-token-login, auth:logout and auth:token commands, the OAuth listener, the refresh lock, and the API token storage. PHP unit tests use a stub for the Go wrapper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Print each invalid token error once, and validate --max-age with the legacy CLI's message. Update the logout --other integration test: sessions written in the legacy CLI's format are now migrated, and the legacy copies deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add integration tests for the Go auth stack: - migration of legacy sessions and API tokens, and deletion of the legacy copies (keeping SSH certificates) - Go and PHP commands in parallel across several short token lifetimes: one refresh per expiry and no refresh token reuse - a process killed while refreshing does not block others - 5xx errors on refresh keep the session - a step-up challenge in a PHP command - the auth:internal JSON output Extend the mock auth server with token lifetimes, refresh counts and failures, and the mock API with step-up challenges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Keep lock files when deleting all sessions, as another process may hold a lock on them. - Allow a new login to replace a session whose keychain cannot be used. - Bound authorization code exchange and revocation requests by a 30s timeout, as for refreshes. - In the export, let keychain credentials take precedence over stale files, as the legacy CLI only used files without a keychain. - Export file sessions whose IDs start with "cli-", and only delete their JSON file, as its directory can hold another session's SSH certificates. - Open URLs on Windows with rundll32, which handles "&" in URLs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 5 warnings · 🔵 2 minor points
🔍 Full review · 59 files reviewed
Verification
Manager.refreshre-reads the entry withStore.Loadafter taking both the in-process mutex and the flock, so a refresh token is never sent twice from parallel processes.Migrator.Runre-checks the marker after taking.migrate.lock, andauth:export-sessionsdoes not call back into Go, so the export cannot deadlock on that lock.saveLoginreleases the session lock (insideManager.Save) before running theauth:post-loginhook, so the nested PHP→auth:internalcall does not block on it.- The Go authorize URL and code exchange match the removed PHP listener: same params,
offline_accessscope, space-joinedamr,%20encoding, and Basic auth with the client ID and an empty secret.
New unit tests cover the Manager (refresh, concurrency, lock timeout, migration, transport), the store and config.Auth. New integration tests in integration-tests/auth_go_test.go cover migration, parallel refreshes, a crash during refresh, 5xx retries and step-up. These run in ci.yml (make test, make integration-test) on Linux only. The Windows job only runs TestWindows in internal/legacy, and no test covers keychain backends, legacy global flags on the native auth commands, or disabled-command config.
Review details
- Commit: a5767fa
- Model: claude-opus-5-5
Review 1 of 10 for this pull request · View the full run
- Retry a failed legacy export or delete after an hour, with a warning, instead of failing every auth command or starting PHP on every run. - Fall back to a file when a new session is too big for the keychain. - Retry replacing session files on Windows while another process has them open, so a rotated refresh token is not lost. - Accept the legacy global options -n/--no, --ansi and --no-ansi on the native auth commands. - Respect disabled_commands and wrapped_disabled_commands for them. - Do not panic on a --browser value of only whitespace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Superseded: the latest Upsun Dispatch review no longer requests changes.
Check the legacy session files by name before running the export. If there is no legacy storage, the migration is marked done without starting PHP. If a failed export is pending but every legacy session is already in the Go store (e.g. the user logged in again), only the deletion is left. With a credential helper installed, keychain sessions cannot be listed, so the export is still retried. 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
- The globs in
legacyState(sess-*/sess-*.jsonwith a matching directory name, andsess-cli-*/api-token) match the ones inExportSessionsCommand::sessionFiles/apiTokenFiles. legacyStateskipsapi-token-IDs and IDs that failValidateSessionID, the same asimportSessions, so a session the import would skip never keeps the state at unknown.- The credential-helper check uses the same
<writable dir>/credential-helper[.exe]path as PHP'sManager::getExecutablePath, so keychain sessions keep the export running. - When the export is pending and the state is
legacyImported, the import is skipped and the delete still runs. If the delete fails, the marker changes toDeletePendingwith a retry delay, so no command loops on the early-retry case.
This change adds TestMigrator_SkipsUnneededExports and updates TestMigrator to create legacy files. Both use a stubbed Export and the same home directory for Go and PHP, so neither covers a case where PHP's home directory differs from Go's.
Review 3 of 10 for this pull request · View the full run
auth:post-login now only sets up SSH (host keys, a certificate and SSH config). Go prints the login messages and account summary, and deletes the legacy API cache and SSH files on login and logout itself, so auth:post-logout is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After getClient(false), the connector held an expired placeholder token until the first request, so code reading the token from the session (e.g. getMyUserId() decoding the JWT) did not see it. Replace it whenever a client is requested with login. The middleware's placeholder refresh token now identifies its access token, so a token that was replaced locally is not mistaken for one the API rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… switches Add integration tests for: - accepting the login prompt in a PHP command (which runs the Go login) and in a Go command - a login replacing a session: revocation, the new tokens, and the SSH certificate - a PHP command getting a new token after a 401 - logout --all revoking every session and keeping locks - logging out after auth:api-token-login - PHP and Go using the same session after session:switch The mock auth server can issue unique access tokens, the mock API can reject a token, and it serves /me. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
📋 PR Summary This PR moves authentication out of the legacy PHP CLI and into Go. Go now stores and refreshes credentials: it uses the system keychain or JSON files, refreshes under a per-session lock, provides native login, logout and token commands, and migrates sessions from the legacy storage once. PHP now gets tokens and auth state from Go through a hidden Changes
|
There was a problem hiding this comment.
Warning
Changes suggested — 🟡 1 warning · 1 still open
🔁 Incremental · 13 files reviewed
Outstanding from earlier reviews:
- 🟡 #4155102652 —
internal/auth/migrate.go:122: Where the home directories differ, migration is silently skipped forever and users are logged out. — legacyState still reads legacy files under filepath.Dir(m.Store.Dir), from Go's home directory, and writes an empty marker when it finds nothing there. legacy.go still passes PHP no home directory.
Verification
- The cleanup paths
cache,.session/sess-cli-<id>andssh/session.configmatch PHP's CacheFactory, Config::getSessionIdSlug and SshConfig::getCliSshDir when the home directories agree. - Session IDs passed to clearLegacySessionFiles have already been checked by ValidateSessionID (
^[a-z0-9_-]+$), so dropping PHP's validateSessionId does not allow path traversal. - refreshFromGo marks a token as rejected only when the middleware's
go:<token>placeholder matches the unexpired static goToken. This matches GuzzleMiddleware::acquireAccessToken, which passes the current refresh token to on_refresh_start. - The
--otherlogout path still leavesssh/session.configalone, becauseothersexcludes the current session, as in the removed PostLogoutCommand.
This push adds integration tests in integration-tests/auth_flows_test.go: login prompts in PHP and Go, a login replacing an existing session, a refresh after a rejected token, logout --all removing .session, logout after an API-token login, and session switching. They use a fake curl-based browser and are skipped on Windows. The Windows home-directory mismatch above has no test.
Review 4 of 10 for this pull request · View the full run
Go and PHP share the writable dir: Go now keeps its credentials there and cleans up the legacy CLI's files in it. Resolve it the same way: - The home dir comes from <PREFIX>HOME, then HOME, then USERPROFILE. On Windows HOME can differ from USERPROFILE, e.g. in MSYS2. - If the writable dir cannot be written, e.g. on an application container, use <temp dir>/<tmp_sub_dir>. Otherwise the migration could miss the legacy sessions, and logout could leave the legacy SSH certificate and API cache in place. 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
HomeDirnow checks<PREFIX>HOME, thenHOME, thenUSERPROFILE, the same order as PHPConfig::getHomeDirectory().migrate.golegacyStateandauth_cleanup.goboth get their directory fromWritableUserDir, so both now follow the home directory PHP resolves.- When the home directory is unwritable, the fallback
os.TempDir()/<TempSubDir>matches PHP'ssys_get_temp_dir()/tmp_sub_dir. - In
TestWritableUserDir_ReadOnlyHome, the chmod-restore cleanup is registered aftert.TempDir, so it runs before the directory is removed.
This push adds internal/config/dir_test.go. It covers the order HomeDir checks its environment variables in and the temp fallback for a read-only home directory (Unix only). Nothing covers Windows HOME≠USERPROFILE end to end, or failures other than permissions, such as a full disk.
Review 5 of 10 for this pull request · View the full run
Fall back to the temp dir only when the directory is not writable by permissions (access W_OK, or the read-only attribute on Windows), as PHP's Filesystem::canWrite does, rather than on any write failure. On a full disk or with Windows ACLs, Go and PHP otherwise chose different directories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 1 minor point
🔁 Incremental · 1 file reviewed
🔵 Minor point
internal/config/dir_test.go:70— The new "a file in place of the directory" subtest has no Windows skip, unlike the "read-only home" case. It redirects the temp directory only throughTMPDIR. On Windows,os.TempDir()readsTMP/TEMP/USERPROFILEand ignoresTMPDIR. SoWritableUserDirreturns%TMP%\example-cli-tmp, and the assertionfilepath.Join(tmp, "example-cli-tmp")fails whenever this test runs on Windows. SettingTMPas well, or skipping on Windows, fixes it.
Verification
canWritereturns false when a regular file is at the writable dir path (info.IsDir()check), which matches PHP'sgetWritableUserDir!is_dirfallback.HomeDirchecks<PREFIX>HOME, thenHOME, thenUSERPROFILE, the same order as PHP'sConfig::getHomeDirectory().browserCommandnow requiresstrings.TrimSpace(browserOption) != ""before it indexesfields[0], so an option made only of whitespace can no longer cause an empty slice index.
This push only changes TestWritableUserDir_TempFallback in internal/config. The ci.yml Go test job runs it on Linux. The Windows job runs only TestWindows in internal/legacy, so the new subtest never runs on Windows.
Review 7 of 10 for this pull request · View the full run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hide auth:export-sessions and auth:post-login with the AsCommand attribute instead of hiddenInList. Both keep them out of the list, but only the attribute excludes them from abbreviation matching, so e.g. "auth:ex" no longer prints exported credentials. 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
- Both commands still run by exact name: Symfony's hidden attribute only stops abbreviation matching, and Go calls them by full name.
- Plain
list(without --all) still leaves both commands out, because ApplicationDescription checks the LazyCommand's attribute-based isHidden(). - Go abbreviation is unaffected by
hiddenturning false: a single match on a non-native legacy command is never expanded by Go.
This commit adds and changes no tests. Nothing in the repository checks that these commands stay hidden in completion or in list --all --format=json.
Review 9 of 10 for this pull request · View the full run
CommandBase::isHidden() ignores the AsCommand attribute, so completion and "list --all --format=json" showed the commands once loaded. Keep $hiddenInList as well as the attribute, which stops abbreviations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Superseded: the latest Upsun Dispatch review no longer requests changes.
If the server consumed the refresh token but the response was lost, a retry sends the same single-use token again, which fails with invalid_grant and logs the user out. Refresh requests are now retried only when they were not sent. API token exchanges keep one retry after sending, as the API token is reusable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The config schema omitted api.token, api.token_file, api.access_token and api.disable_locks, so these were discarded from the embedded config or CLI_CONFIG_FILE, unlike in the legacy CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the previous session could not be logged out because the keychain was unusable, only the user session was forgotten. Re-entering the same API token then saved to its existing api-token-* session, still marked as keychain-backed, and failed again. The recovery is moved to Manager.LogoutToReplace, which also forgets the session for the new API token. Also read the test auth server's counters under its lock, fixing a data race in the lost-response test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh requests are no longer retried once sent, so a single server error fails the command, and the kept session refreshes on the next run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser login listened on the first free port between 5000 and 5010, a limit carried over from the legacy CLI. It now listens on port 0, so the system assigns a free port. The auth server accepts any port in loopback redirect URIs (RFC 8252, section 7.3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The legacy CLI passed an access token that the API rejected to `auth:internal token --rejected <token>`, exposing it in process listings. A token rejected by one endpoint may still be valid. The `--rejected` flag is now a boolean, and the token is read from stdin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A keychain write or deletion that timed out kept running after the session lock was released, so it could overwrite or delete newer credentials. Changes to an existing keychain entry are now waited for, with a notice after the timeout. Reads, and the first write that chooses the backend, keep the timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a refresh failed with invalid_grant, or an expired token could not be refreshed, only the credentials were deleted. The legacy CLI also deleted the API cache and the session's SSH certificate and config, as `auth:logout` does. Manager.OnLoggedOut now runs the same cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root pre-run ignores --quiet when --verbose or --debug is also set, but errors were still suppressed, e.g. `auth:token -qv --unknown` printed nothing. Errors now use the same condition, via isQuiet(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DeleteAll removed every file in the auth directory after deleting each session, including the files of sessions whose keychain deletion failed. The secrets were then stranded in the keychain. Those session files are now kept, so that the deletion can be retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS the CLI opens URLs with `open`, which the fake browser helpers did not stub, so login tests opened a real browser or waited for the login timeout. The helpers now stub both `open` and `xdg-open`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moves authentication from the legacy PHP CLI to Go. Go becomes the only component that stores or refreshes credentials; PHP gets tokens and auth state from Go.
Go
config.Auth()reads the auth keys (api.token,api.token_file,api.access_token,api.session_id, the auth URLs and client ID,disable_credential_helpers,skip_ssl,disable_locks) from the embedded config, the user'sconfig.yamland env vars, with the legacy CLI's precedence and casting.internal/auth/storesaves one entry per session ID in the system keychain (go-keyring, no cgo) or in<writable dir>/auth/<id>.json. The backend is chosen once, at the first save. No credential-helper binaries are downloaded.auth.Managerresolves tokens and refreshes them under a blocking flock per session, held for the whole read-refresh-write sequence. The stored entry is re-read under the lock, so a rotated refresh token is never sent twice. Tokens are refreshed 2 minutes before expiry.invalid_grantclears the session;invalid_requestand transient errors keep it.auth:browser-login(login),auth:api-token-login,auth:logout(logout) andauth:token, with the legacy options and messages. Browser login uses anet/httplistener on 127.0.0.1, on a port assigned by the system, with PKCE.auth:internal token|statuscommand for PHP. It writes only JSON, never prompts, and exits with code 3 when login is required.auth/.migrated. The legacy copies are then deleted, except SSH certificates.PHP
Apigets tokens from Go (GoAuth) through the OAuth middleware's refresh hook, passing the rejected token after a 401. Login state, session IDs and stored API tokens also come from Go.auth:export-sessions [--delete]andauth:post-login(SSH host keys, certificate and config). Go prints the login messages and deletes the legacy cache and SSH files on login and logout.auth:infostays in PHP, as a client of Go tokens.Tests
The existing auth and session integration tests pass against the Go implementation. One expectation changed: legacy session files are now migrated and deleted. New integration tests cover migration, Go and PHP commands in parallel across several token expiries (one refresh per expiry, no refresh-token reuse), a process killed while refreshing, 5xx errors on refresh, step-up challenges, a PHP command getting a new token after a 401, accepting the login prompt in PHP and Go commands, the effects of login and logout, and session switches.
Keychain storage needs manual testing on macOS, Windows and GNOME (including a locked keyring), as CI has no keychain.
🤖 Generated with Claude Code