Skip to content

feat(oauth): add OpenRouter PKCE OAuth flow - #2355

Merged
TimeToBuildBob merged 2 commits into
masterfrom
feat-account-oauth
May 8, 2026
Merged

TimeToBuildBob merged 2 commits into
masterfrom
feat-account-oauth

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Member

Summary

Adds a gptme.oauth.openrouter module that runs OpenRouter's dynamic OAuth/PKCE flow:

  1. Generate PKCE verifier + S256 challenge
  2. Open browser to https://openrouter.ai/auth?...
  3. Listen for callback on http://127.0.0.1:3000/callback
  4. Exchange the code at https://openrouter.ai/api/v1/auth/keys
  5. Receive a durable sk-or-... API key

OpenRouter's flow needs no client_id registration on our side and returns a permanent API key (not a JWT), so the result is suitable for both interactive setup and headless automation.

Why this exists

Foundation for the redesigned /account setup openrouter flow that #2313 originally asked for. Closed PR #2353 went the "swap raw API keys" route, which Erik pushed back on ("kinda meh and pointless") — the more ambitious shape is OAuth-first onboarding so users go from pip install gptme to a working agent without ever pasting a key.

This PR ships only the OAuth module so the surface stays small and reviewable. The /account command wiring will land in a follow-up.

Design notes

  • Standalone module, zero coupling to the existing llm_openai_subscription.py flow (which is JWT-based and OpenAI-specific). No risk of regressing OpenAI subscription auth.
  • authenticate(open_browser=...) accepts an injectable browser hook so the full happy path is testable without a real browser.
  • CSRF state validation matches the existing OpenAI subscription handler's approach.
  • Error surface is collapsed into one OAuthError — port-busy, callback errors, token-exchange failures, and malformed responses all raise the same exception type with a descriptive message.
  • Port 3000 is fixed by OpenRouter (not configurable on their side); the module surfaces a clear error if it's busy.

Test plan

  • poetry run pytest tests/test_oauth_openrouter.py -v — 10 passed
  • poetry run ruff check gptme/oauth/ tests/test_oauth_openrouter.py — All checks passed
  • poetry run ruff format gptme/oauth/ tests/test_oauth_openrouter.py — clean
  • poetry run mypy gptme/oauth/openrouter.py — Success: no issues found

Tests cover:

  • PKCE S256 challenge derivation matches urlsafe_b64encode(sha256(verifier))
  • PKCE generation is non-deterministic (5 distinct pairs in 5 calls)
  • Port-busy detection (is_port_available)
  • authenticate() raises OAuthError when port busy
  • Full happy-path flow with mocked browser + mocked token exchange (verifier/challenge round-trip is verified)
  • CSRF state mismatch is rejected
  • Provider-side error=... callbacks surface the description verbatim
  • Token-exchange HTTP failure raises OAuthError
  • Token-exchange response without a sk-or-... key raises OAuthError

Follow-up

A separate PR will wire this module into a /account setup openrouter command and store the issued key in the gptme credential config. That PR depends on this one but stays out of scope here so review can focus on the OAuth flow itself.

Refs: #2313 (original ask), #2353 (closed predecessor PR — different approach).

New ``gptme.oauth.openrouter`` module that implements OpenRouter's
dynamic OAuth/PKCE flow:

  1. Generate PKCE verifier + S256 challenge
  2. Open browser to https://openrouter.ai/auth?...
  3. Listen for callback on http://127.0.0.1:3000/callback
  4. Exchange code at https://openrouter.ai/api/v1/auth/keys
  5. Receive a durable ``sk-or-...`` API key

OpenRouter's OAuth flow needs no client_id registration on our side and
returns a permanent API key (not a JWT), so the result is well suited for
both interactive setup and headless automation.

This is foundation work for the redesigned ``/account setup openrouter``
command (#2313, follow-up to closed #2353). Shipped as a standalone
module with no UI integration so the surface stays minimal and testable.

Tests cover:
- PKCE verifier/S256 challenge derivation
- Port availability check
- Full happy-path OAuth flow with mocked browser + token exchange
- CSRF state mismatch rejection
- Provider-side error surfacing
- Token exchange HTTP failure
- Malformed/missing-key response

10 tests, all passing.
@greptile-apps

greptile-apps Bot commented May 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces gptme/oauth/openrouter.py, a self-contained module that runs the OpenRouter dynamic PKCE OAuth flow and returns a durable sk-or-... API key. It is the foundation for a future /account setup openrouter command.

  • _make_callback_handler(state_holder) uses a per-flow closure instead of class variables, eliminating the shared-state concurrency bug; html.escape() is applied to any provider-supplied error description before rendering it; HTTPServer construction is wrapped in try/except OSError to cover the TOCTOU window between is_port_available() and socket bind; all user-facing output uses logger.* rather than print().
  • tests/test_oauth_openrouter.py covers PKCE derivation, port availability, the full happy-path round-trip with verifier/challenge validation, CSRF state mismatch rejection, provider error surfacing, HTTP failures on token exchange, and malformed token responses — 10 tests in total.

Confidence Score: 5/5

Safe to merge — all issues flagged in the previous review round have been fully addressed in this revision.

The module is new, self-contained, and introduces no regressions to existing auth paths. The handler factory isolates per-flow state, HTML injection is prevented with html.escape(), the TOCTOU bind race is caught with try/except OSError, and output goes through the logging hierarchy. The test suite exercises every error path. No remaining correctness or security issues were found.

No files require special attention.

Important Files Changed

Filename Overview
gptme/oauth/init.py New package init with docstring only; no code concerns.
gptme/oauth/openrouter.py Full PKCE OAuth flow implementation; all previously flagged issues (class-level state, XSS, TOCTOU race, print() calls) have been addressed in this revision.
tests/test_oauth_openrouter.py 10 tests covering PKCE derivation, port detection, happy path, CSRF rejection, provider error surfacing, token exchange failures, and malformed response; coverage is thorough.

Sequence Diagram

sequenceDiagram
    participant User
    participant Authenticate as authenticate()
    participant Browser
    participant CallbackServer as HTTPServer 127.0.0.1:3000
    participant OpenRouter

    Authenticate->>Authenticate: generate_pkce() verifier + S256 challenge
    Authenticate->>Authenticate: token_urlsafe(16) CSRF state
    Authenticate->>Authenticate: _make_callback_handler per-flow closure
    Authenticate->>CallbackServer: start serve_forever on daemon thread
    Authenticate->>Browser: open_browser(auth_url with challenge + state)
    Browser->>OpenRouter: "GET /auth?callback_url=...&code_challenge=...&state=..."
    User->>OpenRouter: Sign in
    OpenRouter->>CallbackServer: "GET /callback?code=...&state=..."
    CallbackServer->>CallbackServer: Validate CSRF state
    CallbackServer->>Authenticate: "state_holder authorization_code = code"
    Authenticate->>Authenticate: poll loop exits (0.2s interval)
    Authenticate->>Authenticate: server.shutdown() + server_close()
    Authenticate->>OpenRouter: POST /api/v1/auth/keys with code and code_verifier
    OpenRouter-->>Authenticate: key sk-or-...
    Authenticate-->>User: return durable API key
Loading

Reviews (2): Last reviewed commit: "fix(oauth): address Greptile review find..." | Re-trigger Greptile

Comment thread gptme/oauth/openrouter.py Outdated
Comment thread gptme/oauth/openrouter.py Outdated
Comment thread gptme/oauth/openrouter.py Outdated
Comment thread gptme/oauth/openrouter.py Outdated
@codecov

codecov Bot commented May 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 34.23423% with 73 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
gptme/oauth/openrouter.py 34.23% 73 Missing ⚠️

📢 Thoughts on this report? Let us know!

- Shared class-level state → closure-based handler factory
  (_make_callback_handler) so concurrent authenticate() calls on
  different ports cannot corrupt each other's CSRF state

- HTML injection → html.escape() on error_description before
  interpolating into the callback response page

- TOCTOU → wrap HTTPServer() construction in try/except OSError
  so races between is_port_available() and bind() raise OAuthError
  with a clear message instead of a bare OSError

- print() → logger.info/warning throughout; library code should
  not write to stdout directly
@TimeToBuildBob
TimeToBuildBob merged commit c55a0fd into master May 8, 2026
14 checks passed
@TimeToBuildBob
TimeToBuildBob deleted the feat-account-oauth branch May 8, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant