Skip to content

Add OAuth2 PKCE login/logout to setup screen#25

Draft
kwent wants to merge 13 commits intomasterfrom
features/oauth2-pkce-login
Draft

Add OAuth2 PKCE login/logout to setup screen#25
kwent wants to merge 13 commits intomasterfrom
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 browser-based login as the default authentication method alongside the existing API-key flow, including a new internal/oauth/ package, an auto-refresh HTTP transport, config storage for tokens, a redesigned setup screen with an auth-method selector, and an animated first-run wizard.

Key changes:

  • New internal/oauth/ package: PKCE config, state generation, token load/save/clear in ~/.rootly-tui/config.yaml, auto-refresh transport with 401-retry and token persistence
  • Setup screen redesigned: auth-method toggle (OAuth2 default / API Key), first-run wizard with animated ASCII logo, Logout button when OAuth is active
  • API client updated: setAuthHeaders/doRequest helpers unify OAuth and API-key paths; Content-Type fixed to application/vnd.api+json for raw HTTP calls
  • Config extended with five OAuth token fields; IsValid() now accepts OAuth-only configs (no API key required)
  • 774 tests passing, new tests for token roundtrip, transport behaviour, DeriveAuthBaseURL, and config validation

Minor findings (all P2, non-blocking):

  • CHANGELOG.md lists golang.org/x/oauth2 v0.36.0 but go.mod/go.sum pin v0.35.0
  • handleOAuthExpired in app.go manually clears OAuth fields but omits OAuthExpiresAtConfig.ClearOAuthTokens() (added in this PR) already handles all five fields and should be reused
  • isLocalHost has a comment claiming it matches "any host with a non-standard port that looks like a dev server" but only checks localhost and 127.0.0.1
  • The local OAuth callback http.Server has no ReadHeaderTimeout

Confidence Score: 5/5

Safe to merge — the OAuth flow is additive, existing API-key configs continue to work unchanged, and all findings are non-blocking P2 style/polish items.

All four findings are P2 (documentation inconsistency, minor code duplication, misleading comment, missing HTTP timeout on a short-lived localhost server). No correctness, data-integrity, or security defects were found. Test coverage is good with 774 tests passing.

internal/app/app.go — handleOAuthExpired should reuse ClearOAuthTokens() to avoid leaving a stale OAuthExpiresAt in the config file.

Important Files Changed

Filename Overview
internal/oauth/oauth.go New OAuth2/PKCE config, state generation, code exchange, and DeriveAuthBaseURL helper — well-tested; one misleading comment on isLocalHost.
internal/oauth/tokens.go Token load/save/clear backed by the shared config file; conversion helpers between TokenData and oauth2.Token are straightforward.
internal/oauth/transport.go Auto-refresh HTTP transport with mutex-guarded proactive expiry check, 401-retry, token persistence, and user-agent injection — correctly implemented and well-tested.
internal/app/app.go OAuth session-expiry handling (handleOAuthExpired) manually clears token fields but misses OAuthExpiresAt and duplicates logic already in ClearOAuthTokens().
internal/views/setup.go Setup screen redesigned with auth-method selector, first-run wizard, and OAuth PKCE callback server; callback HTTP server lacks read/write timeouts.
internal/api/client.go OAuth transport wired into API client; setAuthHeaders/doRequest helpers cleanly unify the two auth paths and fix the previously hard-coded Content-Type.
internal/config/config.go Adds five OAuth token fields to Config, HasOAuthTokens/ClearOAuthTokens helpers, and updates IsValid() to accept OAuth-only configs — backward compatible.
internal/views/welcome.go New animated ASCII logo with gradient + shimmer effect for the first-run wizard; pre-computed styles avoid per-frame allocations.
CHANGELOG.md Version listed for golang.org/x/oauth2 (v0.36.0) doesn't match what go.mod actually pins (v0.35.0).

Sequence Diagram

sequenceDiagram
    participant User
    participant TUI as rootly-tui
    participant Browser
    participant Callback as Local Callback :19798
    participant AuthSrv as Rootly Auth Server
    participant Config as config.yaml

    User->>TUI: Press Enter on Login button
    TUI->>TUI: Generate state and code verifier
    TUI->>Callback: Start HTTP server on :19798
    TUI->>Browser: Open authorization URL
    Browser->>AuthSrv: GET /oauth/authorize
    AuthSrv-->>Browser: Redirect to localhost:19798/callback
    Browser->>Callback: GET /callback with code and state params
    Callback->>Callback: Validate state for CSRF protection
    Callback-->>Browser: HTML success page
    Callback->>TUI: Send auth code via channel
    TUI->>AuthSrv: POST /oauth/token to exchange code
    AuthSrv-->>TUI: Access and refresh credentials
    TUI->>Config: Persist credentials and endpoint
    TUI->>User: Navigate to main screen

    Note over TUI,AuthSrv: On subsequent API calls
    TUI->>AuthSrv: Request with Bearer header
    alt Server returns 401
        AuthSrv-->>TUI: 401 Unauthorized
        TUI->>AuthSrv: POST /oauth/token refresh grant
        AuthSrv-->>TUI: New credentials
        TUI->>Config: Persist refreshed credentials
        TUI->>AuthSrv: Retry with new Bearer header
    end
Loading

Comments Outside Diff (2)

  1. internal/oauth/oauth.go, line 599-607 (link)

    P2 isLocalHost comment doesn't match its actual behaviour

    The comment says "or any host with a non-standard port that looks like a dev server" but the function only checks for the two literals "localhost" and "127.0.0.1" — a non-standard port on, say, 10.0.0.1 would not be treated as local. The comment should be updated to reflect the real logic:

  2. internal/views/setup.go, line 1796-1798 (link)

    P2 OAuth callback server has no read/write timeouts

    The http.Server created for the local OAuth callback has no ReadHeaderTimeout, ReadTimeout, or WriteTimeout. While this server is localhost-only and already guarded by the 5-minute outer timer, adding at least a ReadHeaderTimeout is a good practice to prevent a misbehaving browser from holding the goroutine open indefinitely.

    srv := &http.Server{
        Handler:           mux,
        ReadHeaderTimeout: 30 * time.Second,
    }

Reviews (1): Last reviewed commit: "fix: redirect to setup screen when OAuth..." | Re-trigger Greptile

Comment thread CHANGELOG.md
- Raw HTTP requests now use `application/vnd.api+json` content type (was `application/json`)
- Forward `WindowSizeMsg` to setup screen for proper centering

### Dependencies
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CHANGELOG version doesn't match go.mod

The CHANGELOG lists golang.org/x/oauth2 v0.36.0 but go.mod and go.sum both use v0.35.0. Minor documentation inconsistency.

Suggested change
### Dependencies
- Add `golang.org/x/oauth2` v0.35.0

Comment thread internal/app/app.go
Comment on lines +407 to +413
case views.OAuthLoginResultMsg:
var cmd tea.Cmd
m.setup, cmd = m.setup.Update(msg)
return m, cmd

case views.OAuthLogoutResultMsg:
m.setup, _ = m.setup.Update(msg)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 handleOAuthExpired doesn't clear OAuthExpiresAt and duplicates ClearOAuthTokens

The manual field-clearing here omits OAuthExpiresAt, leaving a stale timestamp in the saved config file. The Config.ClearOAuthTokens() method (added in this very PR) handles all five fields correctly and should be used here instead:

if m.cfg != nil {
    m.cfg.ClearOAuthTokens()
    _ = config.Save(m.cfg)
}

This also avoids the duplication between the two clearing paths.

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

1 participant