Conversation
Add browser-based OAuth2 Authorization Code + PKCE flow for CLI authentication. Uses magic client_id="rootly-cli" so no configuration is needed — just `rootly login`. - `rootly login` opens browser, runs local callback server on :19797, exchanges code for tokens - `rootly logout` clears stored tokens - Tokens stored in ~/.rootly-cli/tokens.yaml (mode 0600) with auto-refresh via oauth2.Transport - API client falls back to OAuth tokens when no API key is set - Supports localhost dev servers (http) and production (https with api.->app. rewrite) - Uses golang.org/x/oauth2 built-in PKCE (GenerateVerifier, S256ChallengeOption, VerifierOption) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Downgrade golang.org/x/oauth2 to v0.28.0 (Go 1.24 compatible) - Pin go.mod to go 1.24.2 (CI runs Go 1.24) - Fix errcheck: add _, _ to fmt.Fprint/Fprintf return values - Fix noctx: use net.ListenConfig.Listen and exec.CommandContext - Fix staticcheck ST1023: use type inference for transport - Fix goimports: align config.go struct fields - Fix noctx in tests: use http.NewRequestWithContext instead of client.Get 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- errcheck: check fmt.Fprintf return in logout.go - gocritic/httpNoBody: use http.NoBody instead of nil in test requests - unconvert: split transport declaration and assignment to satisfy both staticcheck and unconvert 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Remove duplicate deriveAuthBaseURL from client.go and login.go - Export as oauth.DeriveAuthBaseURL for shared use - Remove dead `_ = t` assignment in client.go - Move DeriveAuthBaseURL tests to oauth package 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Remove 24 redundant ensureScheme() calls — endpoint already normalized in NewClient() - Add oauth.HasTokens() for cheap file-exists check, avoid double LoadTokens() reads - Replace fragile command-name string matching with Annotations["skipAuth"] pattern - Update getAPIClient() in all 6 resource packages to use HasTokens() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Store OAuth tokens under an `oauth` key in ~/.rootly-cli/config.yaml instead of a separate tokens.yaml file. One config file to rule them all. - Add OAuthData struct to config.Config with `yaml:"oauth,omitempty"` - Rewrite oauth/tokens.go to read/write from config.yaml, preserving existing fields - SaveTokens/ClearTokens now do read-modify-write to preserve api_key, api_host, etc. - Remove separate tokens.yaml file concept entirely - Update all tests for new storage format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Windows uses USERPROFILE for os.UserHomeDir(), not HOME. Add USERPROFILE to all test setups that override HOME. Also skip file permission checks on Windows and fix double blank line. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
When --api-host is localhost or 127.0.0.1 without an explicit path, automatically append /api since the Rails monolith serves under /api/v1. Before: rootly incidents list --api-host=localhost:22166/api After: rootly incidents list --api-host=localhost:22166 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
When the refresh token is expired or revoked, surface a user-friendly error message instead of a cryptic oauth2 error. Also update CHANGELOG with all unreleased changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Greptile SummaryThis PR adds browser-based OAuth2 login/logout ( Key findings:
Confidence Score: 4/5Safe to merge for initial access-token usage; token auto-refresh will fail in production after expiry due to mismatched OAuth endpoint derivation. One P1 issue exists: the token refresh URL is derived incorrectly for the standard production workflow, causing silent failures after the access token expires. All other findings are P2 (style/maintenance). The P1 should be fixed before the feature is shipped to users who will rely on long-lived sessions. internal/api/client.go (DeriveAuthBaseURL call with scheme-prefixed endpoint) and internal/oauth/oauth.go (DeriveAuthBaseURL function — needs to handle scheme-prefixed api.* URLs). Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant LoginCmd as rootly login
participant Browser
participant AuthServer as app.rootly.com
participant CallbackServer as localhost:19797
participant TokenFile as ~/.rootly-cli/config.yaml
User->>LoginCmd: rootly login
LoginCmd->>LoginCmd: GenerateState() + GenerateVerifier()
LoginCmd->>CallbackServer: Start HTTP listener
LoginCmd->>Browser: Open authURL (S256 challenge)
Browser->>AuthServer: GET /oauth/authorize?code_challenge=...
AuthServer->>Browser: Redirect to localhost:19797/callback?code=...
Browser->>CallbackServer: GET /callback?code=X&state=Y
CallbackServer->>CallbackServer: Validate state
CallbackServer->>LoginCmd: Send code via channel
LoginCmd->>AuthServer: POST /oauth/token (code + verifier)
AuthServer-->>LoginCmd: access_token + refresh_token
LoginCmd->>TokenFile: SaveOAuth2Token (0600)
LoginCmd->>User: Login successful!
Note over User,TokenFile: Subsequent API calls
participant APIClient as API Client
participant PTS as persistingTokenSource
participant RTS as ReuseTokenSource
participant APIServer as api.rootly.com
User->>APIClient: rootly incidents list
APIClient->>PTS: Token()
PTS->>RTS: Token() [cached if valid]
RTS-->>PTS: access_token
PTS->>TokenFile: SaveOAuth2Token (every call)
PTS-->>APIClient: Bearer token
APIClient->>APIServer: GET /v1/incidents (Authorization: Bearer ...)
APIServer-->>APIClient: incidents JSON
APIClient-->>User: table output
|
| "golang.org/x/oauth2" | ||
|
|
||
| xoauth "github.com/rootlyhq/rootly-cli/internal/oauth" | ||
| ) |
There was a problem hiding this comment.
Callback port duplicated across two files
callbackPort = "19797" in this file and the port embedded in RedirectURI = "http://localhost:19797/callback" in internal/oauth/oauth.go must always match. If one is updated without the other, the OAuth callback will silently break (the redirect URI registered with the auth server won't match the listener port).
Consider exporting a constant from the oauth package:
// internal/oauth/oauth.go
const (
CallbackPort = "19797"
RedirectURI = "http://localhost:" + CallbackPort + "/callback"
)// internal/cmd/auth/login.go
listener, err := lc.Listen(ctx, "tcp", "localhost:"+xoauth.CallbackPort)- P1: DeriveAuthBaseURL now strips scheme before applying api.→app. mapping, fixing token refresh endpoint mismatch in production - P2: Deduplicate callback port — single CallbackPort constant in oauth package, used by both oauth.go and login.go - P2: Only persist tokens on actual refresh (compare access_token), fix misleading comment about save frequency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
The Rootly monolith no longer supports magic client IDs. On first login, POST /oauth/register to obtain a client_id dynamically, cache it in ~/.rootly-cli/config.yaml, and re-register automatically if the authorize endpoint returns 404 (deleted app).
The monolith no longer uses app.rootly.com — strip the "api." prefix without prepending "app." for production and staging hosts.
Summary
rootly loginandrootly logoutcommands for browser-based OAuth2 authentication using Authorization Code + PKCE flowclient_id=rootly-cliso no configuration is needed — just runrootly login~/.rootly-cli/tokens.yaml(mode 0600) with auto-refresh viagolang.org/x/oauth2transportinternal/oauth/package wrapsgolang.org/x/oauth2with persisting token source, PKCE helpers, and User-Agent transportTest plan
go test ./...— 447 tests pass (31 new acrossinternal/oauth/andinternal/cmd/auth/)rootly login --api-host=localhost:22166completes browser flow and stores tokensrootly incidents list --api-host=localhost:22166/apiworks with OAuth tokensrootly logoutclears tokens