Add OAuth2 PKCE login/logout to setup screen#25
Conversation
Why: Enable browser-based OAuth2 authentication as an alternative to API keys, providing a more secure and user-friendly login experience that works with Rootly's magic client_id auto-provisioning. - Add internal/oauth/ package (oauth.go, tokens.go, transport.go) with: - OAuth2 config with PKCE (client_id=rootly-tui, port 19798) - Token storage consolidated in ~/.rootly-tui/config.yaml - Auto-refresh transport that persists refreshed tokens to disk - DeriveAuthBaseURL with path stripping and http:// for localhost - Redesign setup screen with auth method selector (OAuth2 default, API Key) - Add animated first-run welcome screen with ASCII logo typing effect, gradient coloring, shimmer sweep, and subtitle fade-in - First-run wizard: single Login button, auto-saves and proceeds to main screen - Returning users: full two-panel setup with Login/Save/Logout buttons - Update API client to use Bearer token via OAuth transport when available - Fix Content-Type to application/vnd.api+json for all raw HTTP requests - Auto-derive /api path for local dev OAuth endpoints - Add comprehensive tests for oauth package, config, and setup views 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
- Fix doSavePreferences wiping OAuth tokens (data loss bug) - persistingTokenSource: only save to disk when token actually changes - Pre-compute lipgloss styles for welcome animation (avoid per-char alloc) - Stop welcome animation ticks after shimmer cycle completes - Pass pre-loaded tokens to NewHTTPClientWithTokens (avoid double disk read) - Remove dead IsValidForAPI() method - Remove extra blank line (goimports) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
v0.36.0 requires Go 1.25 which CI doesn't have yet. Co-Authored-By: Claude <noreply@anthropic.com>
- Replace persistingTokenSource with retryOn401Transport that force-refreshes tokens when the server returns 401 (handles revoked/invalid tokens) - Remove hardcoded api.rootly.com → app.rootly.com mapping in DeriveAuthBaseURL; derive auth URL purely from the configured endpoint - Add debug logging to OAuth transport for troubleshooting token issues
…oked Instead of showing a cryptic token refresh error, detect ErrTokenRefreshFailed, clear stale tokens from config, and redirect the user to the setup screen with a friendly "Session expired" message.
Greptile SummaryThis PR adds OAuth2 PKCE authentication as an alternative to API-key auth, introducing a new
Confidence Score: 4/5Safe to merge with one known defect: 401-triggered retries will silently fail for non-GET requests because the request body is consumed before the retry attempt. The transport retry-on-401 path clones the original request after its body has already been read, meaning any mutation hitting a revoked-token 401 will be retried with an empty body. The TUI is primarily read-only today, but the SDK client is configured with the OAuth transport and could exercise this path if mutation methods are called. All other changes are correct and well-tested. internal/oauth/transport.go — the retry-on-401 body consumption and the partial token debug log both need attention.
|
| Filename | Overview |
|---|---|
| internal/oauth/transport.go | Auto-refresh OAuth transport with retry-on-401; contains a request body consumption bug for non-GET retries and partial token exposure in debug logging |
| internal/oauth/oauth.go | New OAuth2 PKCE helpers: config, dynamic client registration, state/verifier generation, DeriveAuthBaseURL/DeriveAPIBaseURL — well-structured and tested |
| internal/oauth/tokens.go | Token load/save/clear logic consolidated in config.yaml; all five OAuth fields cleared correctly via ClearOAuthTokens() |
| internal/api/client.go | Client updated to use OAuth HTTP client or API-key auth; Content-Type fixed to vnd.api+json for raw HTTP requests |
| internal/app/app.go | OAuth message routing and expired-token redirect added; handleOAuthExpired correctly calls ClearOAuthTokens() clearing all five fields |
| internal/config/config.go | Six new OAuth fields added; IsValid() updated; ClearOAuthTokens/HasOAuthTokens helpers are correct and complete |
| internal/views/setup.go | Auth method selector, OAuth login/logout flow, first-run wizard; navigation skip logic for API key field in OAuth mode is correct |
| internal/views/welcome.go | New animated ASCII welcome screen with gradient and shimmer; pre-computed style slices avoid per-character allocations |
Comments Outside Diff (1)
-
internal/oauth/transport.go, line 1288-1290 (link)Retry on 401 silently sends an empty body for POST/PUT/DELETE requests
req.Clone()creates a shallow copy with the sameBodyreader. By the time the 401 retry path is reached,t.base.RoundTrip(req2)has already consumedreq.Body, soreq3.Bodyis an exhausted reader. Any mutation that gets a 401 (e.g., token revoked mid-session) will be retried with an empty body, producing a silent 400/422 from the server instead of a proper error. The fix is to checkreq.GetBodyand use it to recreate the body forreq3.Prompt To Fix With AI
This is a comment left during a code review. Path: internal/oauth/transport.go Line: 1288-1290 Comment: **Retry on 401 silently sends an empty body for POST/PUT/DELETE requests** `req.Clone()` creates a shallow copy with the same `Body` reader. By the time the 401 retry path is reached, `t.base.RoundTrip(req2)` has already consumed `req.Body`, so `req3.Body` is an exhausted reader. Any mutation that gets a 401 (e.g., token revoked mid-session) will be retried with an empty body, producing a silent 400/422 from the server instead of a proper error. The fix is to check `req.GetBody` and use it to recreate the body for `req3`. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
internal/oauth/transport.go:1288-1290
**Retry on 401 silently sends an empty body for POST/PUT/DELETE requests**
`req.Clone()` creates a shallow copy with the same `Body` reader. By the time the 401 retry path is reached, `t.base.RoundTrip(req2)` has already consumed `req.Body`, so `req3.Body` is an exhausted reader. Any mutation that gets a 401 (e.g., token revoked mid-session) will be retried with an empty body, producing a silent 400/422 from the server instead of a proper error. The fix is to check `req.GetBody` and use it to recreate the body for `req3`.
### Issue 2 of 2
internal/oauth/transport.go:79-81
The first 30 characters of the `Authorization` header (i.e., `Bearer ` + the first ~23 chars of the access token) are written to the debug log. Even in debug mode, tokens should not appear in log output — use a redacted placeholder instead.
```suggestion
req2 := req.Clone(req.Context())
tok.SetAuthHeader(req2)
debug.Logger.Debug("OAuth request", "auth_header", "Bearer [REDACTED]")
```
Reviews (3): Last reviewed commit: "fix: address Greptile review findings" | Re-trigger Greptile
- CHANGELOG: correct oauth2 version to v0.35.0 (matches go.mod) - handleOAuthExpired: use ClearOAuthTokens() to clear all 5 fields - isLocalHost: fix misleading comment to match actual behavior - OAuth callback server: add ReadHeaderTimeout for safety
The Rootly monolith no longer supports magic client IDs. On first login, the TUI now registers dynamically via POST /oauth/register, caches the client_id in config, and reuses it on subsequent logins. Stale client IDs are cleared on invalid_client errors so the next attempt re-registers.
This comment has been minimized.
This comment has been minimized.
- Parse scope from POST /oauth/register response and cache in config - Use server-provided scopes for authorize URL instead of hardcoded list - Split URL derivation: DeriveAPIBaseURL for server-to-server calls (POST /oauth/register), DeriveAuthBaseURL strips api. prefix for browser-facing URLs (api.rootly.com → rootly.com) - Localhost and non-api. hosts (staging.rootly.com) unchanged
- Remove CSRF state values from error HTML to prevent information disclosure - Clear client registration on logout to prevent stale client_id when switching endpoints
Why
Enable browser-based OAuth2 authentication as an alternative to API keys. This provides a more secure and user-friendly login experience using Rootly's magic
client_id=rootly-tuiauto-provisioning, matching the flow used byrootly-cli.What
New
internal/oauth/packageclient_id=rootly-tui, callback port 19798), state generation, code exchange,DeriveAuthBaseURL(strips paths, useshttp://for localhost)~/.rootly-tui/config.yaml(no separate tokens file)persistingTokenSource(saves refreshed tokens to disk) anduserAgentTransportSetup screen redesign
:19798→ PKCE exchange → stores tokenss): full two-panel layout with Login/Save/Logout + PreferencesAPI client changes
Authorization: Bearer <access_token>via OAuth transport whenuse_oauth: true/apipath for local dev OAuth endpointsContent-Typetoapplication/vnd.api+jsonfor all raw HTTP requestsConfig changes
use_oauth,oauth_access_token,oauth_refresh_token,oauth_token_type,oauth_expires_atfieldsIsValid()accepts OAuth (no API key required whenuse_oauth: true)Tests
Rollback/Revert Plan
Revert the commit. OAuth is additive — existing API key configs continue to work unchanged.
Demo/Screenshots