summaryrefslogtreecommitdiff
path: root/docs/oauth-provider-auth.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/oauth-provider-auth.md')
-rw-r--r--docs/oauth-provider-auth.md495
1 files changed, 495 insertions, 0 deletions
diff --git a/docs/oauth-provider-auth.md b/docs/oauth-provider-auth.md
new file mode 100644
index 0000000..9ae63a7
--- /dev/null
+++ b/docs/oauth-provider-auth.md
@@ -0,0 +1,495 @@
+# OAuth provider auth
+
+Goal: make provider authentication a core, reusable capability instead of
+embedding auth details directly in each provider entry. A provider should name
+the auth session it uses; core resolves that auth session into request-ready
+credentials before a provider stream opens.
+
+This covers today's static API-key providers, GitHub Copilot subscription
+login, and OpenAI Codex/ChatGPT subscription login with one configuration
+shape:
+
+```toml
+[providers.openai]
+style = "openai_chat"
+base_url = "https://api.openai.com/v1"
+auth = "openai_api"
+
+[auth.openai_api]
+type = "api_key"
+key_env_var = "OPENAI_API_KEY"
+```
+
+Providers no longer carry `api_key` or `api_key_env_var` directly. All
+providers use `auth = "<name>"`; all credentials live under `[auth.<name>]`.
+OAuth is just one family of auth configuration types in that namespace.
+
+---
+
+## Why config-only core auth
+
+The earlier Copilot design put most auth behavior in Lua: HTTP requests, token
+storage, refresh-before-turn, config mutation, and retry-after-401. With named
+`[auth.<name>]` blocks, that split no longer buys enough. Auth can be entirely
+described in config and executed by core.
+
+Core should own:
+
+- loading and validating named auth blocks
+- API-key resolution from literal config or environment
+- OAuth login flows
+- token persistence under `$PANTO_HOME`
+- refresh-before-turn with an expiry margin
+- refresh-after-401/403 with one retry
+- final request-header injection
+- dynamic `base_url` updates when an auth exchange returns one
+
+Lua is not required for API-key, GitHub Copilot, or OpenAI Codex auth
+correctness. Extensions may still package optional provider presets or UI
+polish, but the flagship auth stories should work from config alone.
+
+---
+
+## OAuth flow choices
+
+`oauth_pkce` means OAuth 2.0 Authorization Code flow with PKCE.
+
+PKCE is "Proof Key for Code Exchange." The client creates a one-time secret
+called a `code_verifier`, sends only a hashed `code_challenge` in the browser
+authorization URL, then later proves it owns the original verifier when it
+exchanges the returned authorization code for tokens. This protects native
+apps and CLIs that cannot safely keep a client secret.
+
+For pantograph, `oauth_pkce` is the browser-login flow:
+
+1. Start a short-lived localhost callback server.
+2. Generate PKCE verifier/challenge and state.
+3. Open or print an authorization URL.
+4. Receive the callback with `code` and `state`.
+5. Exchange the code + verifier at the token endpoint.
+6. Persist returned tokens and refresh them later.
+
+This is different from `oauth_device`, which prints a URL and user code, then
+polls until the browser-side authorization completes. Device flow is better for
+headless or remote terminals because it needs no local callback server.
+
+For the first implementation, prefer `oauth_device` if it can cover both
+flagship user stories:
+
+1. It avoids callback URLs, so it works naturally through SSH and remote
+ servers where `localhost` is ambiguous.
+2. It avoids implementing a local Zig web server in the first pass.
+3. GitHub officially positions device flow for headless apps and CLIs.
+4. OpenAI Codex already supports device-code login for headless environments.
+
+Keep `oauth_pkce` in the design as a known later extension, not as a first-pass
+requirement, unless Codex subscription auth proves device flow is unavailable
+for the accounts we need to support.
+
+---
+
+## Configuration model
+
+### API key auth
+
+```toml
+[auth.openai_api]
+type = "api_key"
+key_env_var = "OPENAI_API_KEY"
+```
+
+or:
+
+```toml
+[auth.local_proxy_key]
+type = "api_key"
+key = "sk-..."
+```
+
+Rules:
+
+- `key` wins over `key_env_var` if both are present.
+- if `key_env_var` is set but absent from the environment, the auth session is
+ unresolved
+- a provider with unresolved auth may remain visible/selectable, but the first
+ request should fail with a clear auth error unless the auth type can launch an
+ interactive login
+- literal `key` is supported for completeness, but env vars remain preferred
+
+### OAuth device auth
+
+GitHub Copilot:
+
+```toml
+[auth.github_copilot]
+type = "oauth_device"
+dialect = "token"
+client_id = "Iv1.b507a08c87ecfe98"
+device_code_url = "https://github.com/login/device/code"
+token_url = "https://github.com/login/oauth/access_token"
+scope = "read:user"
+token_request_format = "form" # or "json"; default TBD
+```
+
+OpenAI Codex:
+
+```toml
+[auth.openai_codex]
+type = "oauth_device"
+dialect = "codex"
+client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
+issuer = "https://auth.openai.com"
+device_code_url = "https://auth.openai.com/api/accounts/deviceauth/usercode"
+device_poll_url = "https://auth.openai.com/api/accounts/deviceauth/token"
+verification_url = "https://auth.openai.com/codex/device"
+token_url = "https://auth.openai.com/oauth/token"
+```
+
+Core owns requesting the device code, presenting `verification_uri` and
+`user_code`, polling on `interval`, and storing the durable OAuth token.
+`dialect` selects the device-flow completion shape:
+
+- `token` — standard OAuth device flow. The poll endpoint returns the OAuth
+ token response directly, including `access_token` and optional
+ `refresh_token` / `id_token`.
+- `codex` — OpenAI Codex device flow. The poll endpoint returns an
+ authorization code plus PKCE verifier data; core exchanges those at
+ `token_url` to obtain `id_token`, `access_token`, and `refresh_token`.
+
+### OAuth PKCE auth (deferred)
+
+```toml
+[auth.openai_codex]
+type = "oauth_pkce"
+client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
+issuer = "https://auth.openai.com"
+authorize_url = "https://auth.openai.com/oauth/authorize"
+token_url = "https://auth.openai.com/oauth/token"
+redirect_port = 1455
+scopes = [
+ "openid",
+ "profile",
+ "email",
+ "offline_access",
+ "api.connectors.read",
+ "api.connectors.invoke",
+]
+
+[auth.openai_codex.authorize_params]
+id_token_add_organizations = "true"
+codex_cli_simplified_flow = "true"
+```
+
+Core can later own PKCE generation, callback handling, code exchange, token
+persistence, JWT expiry parsing, and refresh-token grants. This is explicitly
+not required for the first pass if `oauth_device` covers Copilot and Codex.
+
+### Optional token exchange
+
+Some OAuth flows do not return the token that should be sent to the model API.
+GitHub Copilot is the important example: GitHub device auth returns `ghu_...`,
+then Copilot requires a second exchange to get a short-lived chat token and API
+endpoint.
+
+```toml
+[auth.github_copilot.exchange]
+method = "GET"
+url = "https://api.github.com/copilot_internal/v2/token"
+bearer = "oauth_access_token"
+token_json_path = "token"
+expires_at_json_path = "expires_at"
+base_url_json_path = "endpoints.api"
+
+[auth.github_copilot.exchange.headers]
+User-Agent = "GitHubCopilotChat/0.26.7"
+Editor-Version = "vscode/1.99.0"
+Editor-Plugin-Version = "copilot-chat/0.26.7"
+Copilot-Integration-Id = "vscode-chat"
+```
+
+The exchange result becomes the request credential used by the provider. It may
+also override the provider `base_url`.
+
+### Provider reference
+
+```toml
+[providers.copilot]
+style = "openai_chat"
+base_url = "https://api.individual.githubcopilot.com" # fallback/placeholder
+auth = "github_copilot"
+
+[providers.copilot.extra_headers]
+User-Agent = "GitHubCopilotChat/0.26.7"
+Editor-Version = "vscode/1.99.0"
+Editor-Plugin-Version = "copilot-chat/0.26.7"
+Copilot-Integration-Id = "vscode-chat"
+X-Initiator = "user"
+```
+
+```toml
+[providers.codex]
+style = "openai_chat" # pending endpoint verification
+base_url = "https://chatgpt.com/backend-api" # placeholder
+auth = "openai_codex"
+```
+
+`extra_headers` remains a provider capability because it applies to the model
+request, not necessarily to the auth endpoints. Auth exchanges can have their
+own headers.
+
+---
+
+## GitHub Copilot auth
+
+Verified against opencode and copilot-api reference implementations.
+
+Two tokens, two phases:
+
+1. **One-time browser login (OAuth device flow).** Yields a long-lived OAuth
+ token (`ghu_...`). This is the durable credential; persist it. The device
+ flow needs no redirect URI and no local HTTP server.
+2. **Per-~30-min token exchange.** GET the Copilot token endpoint with the
+ `ghu_` token as bearer; receive a short-lived API token, its expiry, and the
+ API base URL. Re-run with the same `ghu_` token to refresh.
+
+Constants:
+
+```text
+CLIENT_ID = "Iv1.b507a08c87ecfe98"
+DEVICE_CODE_URL = "https://github.com/login/device/code"
+ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
+TOKEN_URL = "https://api.github.com/copilot_internal/v2/token"
+SCOPE = "read:user"
+GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
+```
+
+Device-code request:
+
+```json
+{ "client_id": CLIENT_ID, "scope": "read:user" }
+```
+
+Poll request:
+
+```json
+{ "client_id": CLIENT_ID, "device_code": "...", "grant_type": GRANT_TYPE }
+```
+
+Copilot exchange response:
+
+```json
+{
+ "token": "...",
+ "expires_at": 1700000000,
+ "refresh_in": 1500,
+ "endpoints": {
+ "api": "https://api.individual.githubcopilot.com"
+ }
+}
+```
+
+`token` is opaque and becomes the provider bearer credential.
+`endpoints.api` becomes the runtime `base_url`.
+
+Required Copilot headers ride on the token exchange and chat requests:
+
+```text
+User-Agent: GitHubCopilotChat/<ver>
+Editor-Version: vscode/<ver>
+Editor-Plugin-Version: copilot-chat/<ver>
+Copilot-Integration-Id: vscode-chat
+```
+
+Chat requests commonly also send `X-Initiator: user` or `agent`.
+
+---
+
+## OpenAI Codex auth
+
+Official Codex documentation describes two OpenAI sign-in methods:
+
+- ChatGPT sign-in for subscription access
+- API-key sign-in for usage-based access
+
+The open-source Codex client currently implements ChatGPT sign-in with OAuth
+PKCE and also supports a device-code variant for headless environments. For
+pantograph, prefer the device-code variant first.
+
+Device-code login uses the ChatGPT auth issuer plus Codex-specific device auth
+endpoints:
+
+```text
+issuer/client auth base: https://auth.openai.com
+device user-code URL: https://auth.openai.com/api/accounts/deviceauth/usercode
+device poll URL: https://auth.openai.com/api/accounts/deviceauth/token
+browser verification: https://auth.openai.com/codex/device
+token endpoint: https://auth.openai.com/oauth/token
+client_id: app_EMoamEEZ73f0CkXaXp7hrann
+```
+
+The device poll response returns an authorization code plus PKCE verifier data;
+Codex then exchanges that code at the token endpoint. That means OpenAI's
+device flow is not identical to GitHub's, but it still has the CLI-friendly
+property we care about: no local callback URL and no local web server.
+
+Browser/PKCE login uses:
+
+```text
+issuer/client auth base: https://auth.openai.com
+token endpoint: https://auth.openai.com/oauth/token
+client_id: app_EMoamEEZ73f0CkXaXp7hrann
+default callback port: 1455
+fallback callback port: 1457
+```
+
+The token exchange returns:
+
+```json
+{
+ "id_token": "...",
+ "access_token": "...",
+ "refresh_token": "..."
+}
+```
+
+Core should persist these as one auth session. Refresh uses
+`grant_type = "refresh_token"` against the same token endpoint. The access
+token is a JWT in normal ChatGPT auth sessions, so core can refresh
+proactively when its `exp` claim is within the safety margin.
+
+Codex-backed requests use:
+
+```text
+Authorization: Bearer <access_token>
+ChatGPT-Account-ID: <account_id> # when present
+X-OpenAI-Fedramp: true # when the ID token says FedRAMP
+```
+
+Open question: the exact provider wire endpoint pantograph should call for
+ChatGPT subscription-backed Codex use. The auth machinery is clear; the model
+request shape still needs verification before we commit to treating this as a
+plain `openai_chat` provider.
+
+---
+
+## Core implementation plan
+
+### C1. Parse named auth blocks
+
+Add `Config.auths: []AuthConfig` and make `Provider.auth` required for
+networked providers. Move `api_key` / `api_key_env_var` out of providers and
+into `[auth.<name>]`.
+
+Compatibility path: accept provider-level `api_key` and `api_key_env_var` for
+one release by synthesizing hidden auth entries, then warn.
+
+### C2. Resolve provider auth before each request
+
+The active provider config should be built from:
+
+- provider transport fields (`style`, `base_url`, `extra_headers`)
+- selected model alias
+- resolved auth session (`api_key`, OAuth access token, exchanged token, extra
+ auth-derived headers, dynamic base URL)
+
+This replaces the old "drop providers whose env var is absent" behavior. A
+provider can survive config resolution even if its auth is not currently
+resolved.
+
+### C3. Core HTTP client
+
+Core auth needs HTTPS for OAuth and token exchange. Implement a small
+request/response HTTP helper backed by Zig `std.http.Client`. Because this is
+core Zig auth, it does not need the Lua coroutine surface from the old design.
+If Lua still needs HTTP later, expose a separate `panto.http` wrapper.
+
+### C4. Token storage
+
+Store under `$PANTO_HOME/auth/<name>.json` initially:
+
+```json
+{
+ "type": "oauth_device",
+ "access_token": "...",
+ "refresh_token": "...",
+ "id_token": "...",
+ "expires_at": 1700000000,
+ "exchange": {
+ "token": "...",
+ "expires_at": 1700000000,
+ "base_url": "https://api.individual.githubcopilot.com"
+ }
+}
+```
+
+Only fields relevant to the auth type are present. Treat these files like
+passwords. A future credential-store backend can keep the same logical API.
+
+### C5. Refresh lifecycle
+
+Before opening a provider stream:
+
+1. Load the named auth session.
+2. If absent and interactive login is possible, run login.
+3. If token is within the refresh margin, refresh.
+4. If an exchange is configured and stale, run the exchange.
+5. Build request auth headers and dynamic provider config.
+
+On 401/403:
+
+1. Force one refresh/exchange.
+2. Retry the same turn once.
+3. Surface the original provider error if it still fails.
+
+### C6. Extra headers
+
+Add generic `extra_headers` to `OpenAIChatConfig` and
+`AnthropicMessagesConfig`, then thread config TOML through libpanto.
+
+Auth-derived headers and provider `extra_headers` should merge deterministically.
+Provider request-specific defaults should not leak into OAuth endpoints unless
+configured under `[auth.<name>.exchange.headers]`.
+
+### C7. No Lua dependency
+
+Built-in auth should not depend on Lua. Optional Lua surfaces can come later if
+they are useful:
+
+- auth status query
+- command to trigger login/logout
+- UI overrides for device-code prompts
+- provider packages that install config/model defaults
+
+None of these are required for Copilot or Codex auth correctness.
+
+---
+
+## Tests
+
+- API-key auth: literal key, env var present, env var absent, provider survives
+ with clear unresolved-auth error.
+- Config migration: provider-level legacy key fields synthesize auth entries.
+- OAuth device: device-code response parsing, polling pending/success/error,
+ persisted token shape.
+- OAuth device: GitHub-style direct device-token polling.
+- OAuth device: OpenAI Codex-style device polling followed by authorization-code
+ exchange.
+- OAuth PKCE later: authorize URL construction, state validation, code
+ exchange, token persistence.
+- JWT expiry parsing: refresh inside safety margin, no-op when fresh.
+- Token exchange: Copilot `token`, `expires_at`, and `endpoints.api` mapping.
+- Request headers: bearer auth, `ChatGPT-Account-ID`, FedRAMP header,
+ provider `extra_headers`, exchange-only headers.
+- 401 retry: refresh/exchange once and retry the same turn.
+
+## Open questions
+
+- Exact TOML names: `key_env_var` vs. current `api_key_env_var`; this doc uses
+ `key_env_var` because the `[auth]` section already establishes the domain.
+- Whether `auth = "<name>"` should be mandatory for no-auth local providers or
+ whether `auth = null` / omitted means unauthenticated.
+- How much of OpenAI Codex device auth to model generically versus as an
+ OpenAI-specific device-flow dialect.
+- The correct Codex subscription-backed model request endpoint and wire API.
+- Whether to add OS keychain storage in the first pass or after file-backed
+ auth works end to end.