Skip to content

Add OAuth2 PKCE login/logout to setup screen#25

Merged
kwent merged 14 commits into
masterfrom
features/oauth2-pkce-login
May 15, 2026
Merged

Add OAuth2 PKCE login/logout to setup screen#25
kwent merged 14 commits into
masterfrom
features/oauth2-pkce-login

Conversation

@kwent
Copy link
Copy Markdown
Member

@kwent kwent commented Mar 28, 2026

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-tui auto-provisioning, matching the flow used by rootly-cli.

What

New internal/oauth/ package

  • oauth.go — OAuth2 config with PKCE (client_id=rootly-tui, callback port 19798), state generation, code exchange, DeriveAuthBaseURL (strips paths, uses http:// for localhost)
  • tokens.go — Token load/save/clear consolidated in ~/.rootly-tui/config.yaml (no separate tokens file)
  • transport.go — Auto-refresh HTTP transport with persistingTokenSource (saves refreshed tokens to disk) and userAgentTransport

Setup screen redesign

  • Auth method selector at top of connection panel: OAuth2 (default) or API Key
  • OAuth flow: opens browser → local callback server on :19798 → PKCE exchange → stores tokens
  • Logout button appears when OAuth is active and logged in
  • First-run wizard: animated ASCII "ROOTLY" logo with gradient + shimmer, single Login button, auto-saves and proceeds to main screen
  • Returning users (via s): full two-panel layout with Login/Save/Logout + Preferences

API client changes

  • Uses Authorization: Bearer <access_token> via OAuth transport when use_oauth: true
  • Falls back to API key auth when OAuth not configured
  • Auto-derives /api path for local dev OAuth endpoints
  • Fixed Content-Type to application/vnd.api+json for all raw HTTP requests

Config changes

  • Added use_oauth, oauth_access_token, oauth_refresh_token, oauth_token_type, oauth_expires_at fields
  • IsValid() accepts OAuth (no API key required when use_oauth: true)

Tests

  • 774 total tests passing, 0 lint issues
  • New tests: token roundtrip, clear, expiry buffer, OAuth2 conversion, transport (user-agent, persisting token source), DeriveAuthBaseURL variants, config OAuth validation

Rollback/Revert Plan

Revert the commit. OAuth is additive — existing API key configs continue to work unchanged.

Demo/Screenshots

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>
@kwent kwent added the enhancement New feature or request label Mar 28, 2026
kwent and others added 5 commits March 27, 2026 18:38
- 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.
@kwent
Copy link
Copy Markdown
Member Author

kwent commented Mar 28, 2026

@greptileai

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 28, 2026

Greptile Summary

This PR adds OAuth2 PKCE authentication as an alternative to API-key auth, introducing a new internal/oauth/ package (config, token management, auto-refresh transport), a redesigned setup screen with an auth-method selector and first-run wizard, and corresponding API client changes. Existing API-key configs are unaffected.

  • internal/oauth/transport.goretryOn401Transport clones the original *http.Request for the forced-refresh retry, but req.Body is already consumed by the first round-trip attempt; any non-GET request that encounters a 401 will be retried with an empty body, silently producing a downstream 400/422.
  • internal/oauth/transport.go — The debug log writes the first 30 chars of the Authorization header; even in debug mode, partial tokens should not appear in log output.
  • All other changes — config fields, token persistence, first-run wizard, navigation logic — look correct and are well-tested.

Confidence Score: 4/5

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

Security Review

  • Partial token exposure in debug logs (internal/oauth/transport.go): debug.Logger.Debug writes the first 30 characters of the Authorization header before every OAuth request. If debug logging is captured, partial tokens are exposed.
  • No other security issues identified: PKCE verifier is correctly generated, state is 32-byte random base64, callback validates state before accepting the code, and tokens are stored only in the user's local config file.

Important Files Changed

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)

  1. internal/oauth/transport.go, line 1288-1290 (link)

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

    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

Comment thread CHANGELOG.md
Comment thread internal/app/app.go
kwent added 2 commits March 28, 2026 09:45
- 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.
@blacksmith-sh

This comment has been minimized.

kwent added 5 commits March 28, 2026 10:45
- 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
@kwent kwent marked this pull request as ready for review May 13, 2026 02:09
- Remove CSRF state values from error HTML to prevent information disclosure
- Clear client registration on logout to prevent stale client_id when
  switching endpoints
@kwent
Copy link
Copy Markdown
Member Author

kwent commented May 13, 2026

@greptileai

@kwent kwent merged commit 0b65413 into master May 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants