diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 28f5ac481..7da122d21 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -13,10 +13,10 @@ Update this file before each feature commit. | Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` + `ee0ed90` + `fdb9374` + `5bf012c` | `cargo test event_logs_are_cursor_paginated`; `cargo test admin_logs_are_cursor_paginated_and_include_request_id`; `cargo test rotating_writer_splits_files_when_size_limit_is_reached`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page` camelCase serialization, admin `/admin/logs` query API, admin `/admin/usage-stats` and `/admin/usage-stats/summary`, event log repository, runtime tracing initialization, HTTP trace layer, date/size-based file rotation, v1 response lifecycle events with request/account/route/model/status/latency/non-secret metadata, and non-secret `account.refresh` events for upstream-401 refresh failures. | | TLS headers and fingerprint | Completed | `1bdd08c` | `cargo test codex_headers_include_desktop_identity_and_turn_state`; `cargo tree \| rg 'reqwest\|rustls'`; full `fmt/test/clippy` before commit | Adds Codex Desktop fingerprint model, exact identity/request headers, and pinned reqwest/rustls client builder. | | Cookie persistence | Completed | `4710926` + `5d3cc6e` + `203270f` | `cargo test cookie_jar_captures_and_replays_account_scoped_cookies`; `cargo test cookie_repository_encrypts_and_replays_account_scoped_cookies`; full `fmt/test/clippy` before `203270f` | Adds account-scoped in-memory Cookie jar and encrypted SQLite Cookie repository; Cookies are replayed only for the same account/domain. Admin-pasted browser Cookie headers are stored encrypted and can be cleared per account. | -| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` + `93cfee3` + `6d18d2c` + `a32a53c` + current device login pass | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test codex_websocket_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket fallback route tests; Task 7 targeted `cargo test --test admin_accounts_route_test`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; current device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, OpenAI OAuth device-code polling into the shared validated account import path, 401-triggered refresh and single retry, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process manual/import/CLI/device import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, admin label/status/delete/runtime refresh synchronization, admin reset-usage last-used clearing, runtime pool clearing on logout, shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, Chat Completions, and non-history WebSocket handshakes before request body send. `previous_response_id` WebSocket requests remain account-affine on classified failures. Still missing durable release-time slot state and durable quota cooldown persistence because the current account schema has no dedicated cooldown column. | +| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` + `93cfee3` + `6d18d2c` + `a32a53c` + `1d6c301` + PKCE auth-flow phase | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test codex_websocket_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket fallback route tests; Task 7 targeted `cargo test --test admin_accounts_route_test`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, OpenAI OAuth device-code polling into the shared validated account import path, OpenAI OAuth PKCE authorization-code exchange into the same path, 401-triggered refresh and single retry, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, capacity summary, settings-backed pool construction, same-process manual/import/CLI/device/PKCE import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, admin label/status/delete/runtime refresh synchronization, admin reset-usage last-used clearing, runtime pool clearing on logout, shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, Chat Completions, and non-history WebSocket handshakes before request body send. `previous_response_id` WebSocket requests remain account-affine on classified failures. Still missing durable release-time slot state and durable quota cooldown persistence because the current account schema has no dedicated cooldown column. | | Account repository | Completed | `14fe706` + `db6cb98` + `72438c6` + `869b6f8` + `93cfee3` + `a32a53c` | `cargo test --test account_repository_test`; `cargo test --test admin_accounts_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `72438c6`; auth status/logout targeted `cargo test --test admin_accounts_route_test` | Adds encrypted SQLite account token storage, decrypting reads, metadata-only reads for auth status, cursor pagination, full export reads, status/label updates, account deletion, account-wide delete for logout, release-time usage counter persistence, refresh-token-preserving token updates, ChatGPT account/user identity lookup, JWT-claim metadata/token updates that preserve existing labels, quota JSON caching, and per-account local usage counter reset. | | Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` + `869b6f8` + `e1f35e0` + `93cfee3` + `bfe561d` + `cf5ff0b` + `6d18d2c` + `a32a53c` + current device login pass | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; targeted HTTP SSE error tests before `bfe561d`; targeted local API key import/export tests before `cf5ff0b`; WebSocket fallback targeted route tests; auth status/logout targeted `cargo test --test admin_accounts_route_test`; current device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/auth/status`, `/admin/auth/logout`, `/admin/auth/device-login`, `/admin/auth/device-poll/{device_code}`, `/admin/accounts`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/health-check`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/refresh`, `/admin/accounts/{id}/reset-usage`, `/admin/accounts/{id}/quota`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/accounts/import-cli`, `/admin/api-keys`, `/admin/api-keys/export`, `/admin/api-keys/import`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses for `previous_response_id`, explicit non-history WebSocket Responses via `use_websocket`/`useWebSocket`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, WebSocket setup fallback after classified handshake failures when no `previous_response_id` is present, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/auth/status` returns sanitized authenticated state, first account metadata, and pool summary without decrypting or returning tokens. `/admin/auth/logout` clears all account rows and the runtime pool while relying on SQLite foreign-key cascades for usage/cookies. `/admin/auth/device-login` starts the OpenAI OAuth device-code flow, and `/admin/auth/device-poll/{device_code}` handles pending/slow_down states or imports returned tokens through the same JWT-claim validated path without returning secrets. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, `/refresh`, `/reset-usage`, `/quota`, delete, batch-delete, batch-status, and `/admin/accounts/health-check` mutate or inspect SQLite plus runtime account pool without returning secrets; health/quota call Codex backend usage endpoints through imported account tokens and classify invalid/quota/banned/Cloudflare responses into account state or cooldown. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; `/admin/api-keys/export` returns metadata only without plaintext/keyHash/pepper, and `/admin/api-keys/import` rotates metadata into new local keys with plaintext returned once. These are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing OAuth PKCE account login flows, settings mutation/persistence, diagnostics, and live Chat streaming transform. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` + `869b6f8` + `e1f35e0` + `93cfee3` + `bfe561d` + `cf5ff0b` + `6d18d2c` + `a32a53c` + `1d6c301` + PKCE auth-flow phase | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; targeted HTTP SSE error tests before `bfe561d`; targeted local API key import/export tests before `cf5ff0b`; WebSocket fallback targeted route tests; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/auth/status`, `/admin/auth/logout`, `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, `/auth/callback`, `/admin/auth/device-login`, `/admin/auth/device-poll/{device_code}`, `/admin/accounts`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/health-check`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/refresh`, `/admin/accounts/{id}/reset-usage`, `/admin/accounts/{id}/quota`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/accounts/import-cli`, `/admin/api-keys`, `/admin/api-keys/export`, `/admin/api-keys/import`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses for `previous_response_id`, explicit non-history WebSocket Responses via `use_websocket`/`useWebSocket`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, WebSocket setup fallback after classified handshake failures when no `previous_response_id` is present, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/auth/status` returns sanitized authenticated state, first account metadata, and pool summary without decrypting or returning tokens. `/admin/auth/logout` clears all account rows and the runtime pool while relying on SQLite foreign-key cascades for usage/cookies. `/admin/auth/login-start` creates a short-lived in-memory PKCE session and returns the OpenAI authorization URL; `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path exchange returned authorization codes and import tokens through the same JWT-claim validated path. `/admin/auth/device-login` starts the OpenAI OAuth device-code flow, and `/admin/auth/device-poll/{device_code}` handles pending/slow_down states or imports returned tokens without returning secrets. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, `/refresh`, `/reset-usage`, `/quota`, delete, batch-delete, batch-status, and `/admin/accounts/health-check` mutate or inspect SQLite plus runtime account pool without returning secrets; health/quota call Codex backend usage endpoints through imported account tokens and classify invalid/quota/banned/Cloudflare responses into account state or cooldown. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; `/admin/api-keys/export` returns metadata only without plaintext/keyHash/pepper, and `/admin/api-keys/import` rotates metadata into new local keys with plaintext returned once. These are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing settings mutation/persistence, diagnostics, and live Chat streaming transform. | | Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. | | HTTP auth boundary | Completed | `cec4607` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | | Model catalog | Partial | `6341336` + `1738f24` + `f75db60` + `4b19846` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; `cargo test --test storage_schema_test`; `cargo test --test routes_responses_test model_catalog_route_returns_cached_backend_models`; `cargo test --test admin_models_route_test`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846` | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing including `none` and `minimal`, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, debug summary, SQLite-backed backend model snapshots by plan, cached backend catalog merge, model-to-plan allowlist derivation, and an admin refresh route that probes Codex model endpoints with one imported account per plan and syncs the runtime account-pool allowlist. Still missing full model metadata parity. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index f20a06ba5..efca34be3 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2981,7 +2981,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [x] **HTTP auth boundary:** restore a real `src/http/auth.rs` module instead of scattered header checks. It validates `Bearer cpr_...` client API keys for `/v1/*`, validates admin session cookies for `/admin/*`, rejects cross-use in both directions, and returns the documented OpenAI/admin response families. - [x] **Account SQLite repository:** implement `src/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, batch-safe mutations, metadata-only auth status reads, and account-wide delete for logout. Do not add TypeScript JSON migration or legacy import shims. - [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned. Encrypted import, manual token add, CLI import, paginated sanitized list, native/Sub2API export, Sub2API import marking, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, reset usage, admin auth status, and admin logout are complete. Quota warnings remain. -- [ ] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. CLI auth import, validated manual token import, device login/poll, admin-session-gated auth status, and logout are complete. OAuth PKCE remains. Keep this separate from admin password login and client API key creation. +- [x] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. CLI auth import, validated manual token import, device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated auth status, and logout are complete. Keep this separate from admin password login and client API key creation. - [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access. - [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Read-only `/admin/settings` is complete; persistent mutation and config-store writeback remain. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings. - [ ] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. Keep production-local gating for sensitive debug endpoints. @@ -2999,7 +2999,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/ - [x] **OpenAI Chat Completions route wiring:** Rust mounts `/v1/chat/completions` to a dedicated Chat handler that parses Chat `messages`, translates to Codex Responses requests, and returns OpenAI Chat completion JSON or SSE chunks. - [ ] **Responses request semantics:** Rust now retains `service_tier` normalization (`fast` -> `priority` upstream), `prompt_cache_key`, `client_metadata`, `include`, `text.format`, `tool_choice`, `parallel_tool_calls`, Codex context headers, session/window/thread metadata, and reasoning include defaults. Remaining parity gaps include tuple-schema reconversion edge cases, sanitized reasoning replay behavior, and real-client verification for installation/fingerprint metadata. - [ ] **Responses stream/error parity:** Rust reconstructs non-stream output from `response.output_item.done` and `response.output_text.delta`, maps non-stream SSE `error` and `response.failed` to OpenAI-compatible upstream errors, and keeps streaming passthrough while recording terminal failure lifecycle logs. Remaining gaps include upstream premature-close classification, empty-response retry, stream close `response.failed` injection, tuple reconversion, reasoning replay metadata, function-call metadata, image-generation usage, and detailed OpenAI-compatible error mapping. -- [ ] **Account login flows:** Rust has admin login, native/Sub2API account import, Codex CLI auth import, validated manual token import, OpenAI OAuth device login/poll, admin-session-gated account logout, and `/auth/status`-equivalent `/admin/auth/status`. Still missing account-login flows for OAuth PKCE login-start/code-relay/callback. These must be rebuilt as admin/session-gated account-login endpoints, not by treating local client API keys as passwords. +- [x] **Account login flows:** Rust has admin login, native/Sub2API account import, Codex CLI auth import, validated manual token import, OpenAI OAuth device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated account logout, and `/auth/status`-equivalent `/admin/auth/status`. These are rebuilt as admin/session-gated account-login endpoints, not by treating local client API keys as passwords. - [ ] **Admin account management:** Rust has paginated sanitized list, native/Sub2API import, native/Sub2API export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, auth status, and logout. Missing quota warnings, persistence-health reporting, and durable quota-window restore. - [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs. - [ ] **Settings:** Rust exposes read-only `/admin/settings` for retained fields. Missing admin-session-gated mutation/writeback with validation for default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention. Do not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. diff --git a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md index 247325970..28f729e79 100644 --- a/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md +++ b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md @@ -8,7 +8,7 @@ **Tech Stack:** Rust, Axum, Reqwest/rustls, serde, sqlx/SQLite, wiremock, tokio, futures, optional WebSocket transport dependency. -**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `6d18d2c` completes Task 5 policy for WebSocket-backed Responses: handshake upstream errors are classified before request body send, non-history WebSocket requests may fallback accounts, successful handshakes capture rate-limit headers, and `previous_response_id` requests remain account-affine for streaming and non-streaming clients. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and Sub2API account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens. Commit `869b6f8` narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Commit `e1f35e0` adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Commit `93cfee3` adds admin health-check, per-account refresh, reset-usage, and quota routes for imported Codex accounts. Commit `bfe561d` maps HTTP SSE terminal failures to OpenAI-compatible upstream errors for non-streaming clients while preserving streaming passthrough. Commit `cf5ff0b` adds Rust-local client API key metadata export/import with target-local key rotation. Commit `a32a53c` adds admin-session-gated `/admin/auth/status` and `/admin/auth/logout`, returning sanitized account/pool state and clearing SQLite accounts plus the runtime pool without token exposure. The current device login pass adds admin-session-gated `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` backed by the OpenAI OAuth device-code flow and shared validated account import path. Remaining major work continues with OAuth PKCE login-start/code-relay/callback. +**Checkpoint 2026-06-12:** Commit `b9c30ba` completed Tasks 1-3 for the in-scope OpenAI GPT/Codex path: Chat Completions route/translation/output, Responses request field parity, default Responses streaming, Codex context headers, and local-only WebSocket flag serialization cleanup. Commit `350ffbe` completed Task 4: `previous_response_id` requests use WebSocket `response.create` and return SSE-compatible output. Commit `3ecd5b6` partially completed Task 5 for non-streaming `/v1/responses`: 429/402/403 account-state classification and fallback retry across imported accounts. Commit `3960a35` extends the same fallback path to HTTP SSE Responses setup and Chat Completions. Commit `6d18d2c` completes Task 5 policy for WebSocket-backed Responses: handshake upstream errors are classified before request body send, non-history WebSocket requests may fallback accounts, successful handshakes capture rate-limit headers, and `previous_response_id` requests remain account-affine for streaming and non-streaming clients. Commit `1738f24` adds Task 6 cache groundwork: persisted backend model snapshots and cached `/v1/models*` reads. Commit `0b6cc7e` adds Task 7 import compatibility for Sub2API OpenAI OAuth exports and marks the detected source format while ignoring proxy-only fields. Commit `f75db60` adds `/admin/refresh-models` backed by imported accounts. Commit `4b19846` syncs refreshed model-plan allowlists into the runtime account pool. Commit `db6cb98` adds admin account label/status/delete mutations with runtime pool synchronization. Commit `16eb4ed` adds batch delete/status mutations. Commit `8af350d` adds local client API key status/delete mutations. Commit `203270f` adds encrypted account Cookie get/set/delete. Commit `960bbb2` adds local client API key label and batch-delete mutations. Commit `72438c6` adds native and Sub2API account export. Commit `16938e6` adds manual account creation for Codex OAuth tokens. Commit `869b6f8` narrows `POST /admin/accounts` to TS-like `token`/`refreshToken` import semantics for OpenAI GPT/Codex accounts. Commit `e1f35e0` adds deterministic Codex CLI `auth.json` import through the same validated JWT-claim path. Commit `93cfee3` adds admin health-check, per-account refresh, reset-usage, and quota routes for imported Codex accounts. Commit `bfe561d` maps HTTP SSE terminal failures to OpenAI-compatible upstream errors for non-streaming clients while preserving streaming passthrough. Commit `cf5ff0b` adds Rust-local client API key metadata export/import with target-local key rotation. Commit `a32a53c` adds admin-session-gated `/admin/auth/status` and `/admin/auth/logout`, returning sanitized account/pool state and clearing SQLite accounts plus the runtime pool without token exposure. Commit `1d6c301` adds admin-session-gated `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` backed by the OpenAI OAuth device-code flow and shared validated account import path. The PKCE auth-flow phase adds `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path, with in-memory PKCE sessions, OpenAI authorization-code exchange, and shared validated account import. **Scoped HTTP SSE error update:** Non-WebSocket `/v1/responses` HTTP SSE collection now detects upstream `event: error` and `event: response.failed` terminal frames. Non-streaming clients receive a `502` OpenAI-compatible `upstream_error` body with the upstream message, while streaming clients keep passthrough SSE frames and record the lifecycle event as an upstream SSE failure. @@ -29,12 +29,12 @@ This audit is the correction point against the TypeScript reference at `/home/zy | Local client API key label/batch-delete / `960bbb2` | TS label/batch-delete applies to third-party provider key pool entries. | Rust label/batch-delete applies to local HMAC-hashed `cpr_` client keys. | Rust local extension, possible drift if strict parity is required. | Reclassify in docs as local admin utility. Remove only if strict original parity excludes local client-key management beyond create/list/status/delete. | | API key export/import variants | TS can export/import third-party provider keys for reimport. | Rust local `cpr_` key plaintext is shown only at creation and then HMAC-hashed; provider key pools are not implemented. Local `/admin/api-keys/export` exports only metadata, and `/admin/api-keys/import` rotates metadata into new target-local `cpr_` keys with plaintext returned once. | Rust local extension, intentional provider scope cut. | Keep local metadata export/import as client auth utility only. Do not port TS provider key import/export, provider/model binding, proxy pools, or non-OpenAI provider behavior. | | Model refresh and plan allowlist sync / `f75db60`, `4b19846` | TS has provider/key model catalog behavior mixed with account/provider management. | Rust probes Codex backend models via imported accounts and syncs plan allowlists into the account pool. | Partial parity, scoped Rust adaptation. | Keep; avoid third-party provider catalog behavior. Finish full Codex model metadata parity later if needed. | -| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, OpenAI OAuth device login/poll, admin quota fetch, health check, per-account refresh, reset-usage, sanitized admin auth status, and admin logout for imported accounts. Rust still lacks OAuth PKCE login-start/code-relay/callback. | Partial OpenAI/Codex parity. | Continue with OAuth PKCE login variants without adding proxy pools or non-OpenAI providers. | +| OAuth/device/CLI account login/import variants | Original scope includes ChatGPT/Codex OAuth login, device login, CLI token import, manual validated token import, logout/status, quota, and health checks. | Rust now supports validated manual `token`/`refreshToken` import, deterministic admin CLI import from Codex `auth.json`, OpenAI OAuth device login/poll, admin-session-gated PKCE login-start/code-relay/callback, admin quota fetch, health check, per-account refresh, reset-usage, sanitized admin auth status, and admin logout for imported accounts. | Scoped OpenAI/Codex parity complete for account-login operations. | Keep admin-session-gated Rust routes and do not add proxy pools or non-OpenAI providers. | | Proxy/provider metadata and routes | TS project includes per-account proxy assignment, proxy health, and non-OpenAI provider routes. | Rust imports ignore proxy fields and has no outbound proxy/provider pools. | Intentional scope cut. | Keep omitted. Do not add proxy routing, proxy UI/API, Anthropic/Gemini/Ollama/custom provider support, or Electron/frontend flows. | Immediate priority after this audit: -1. Finish in-scope account operation parity: OAuth PKCE login-start/code-relay/callback. +1. Finish remaining in-scope account operation gaps such as quota warnings and durable quota cooldown persistence. 2. Run final verification for the completed Task 5 fallback paths, including WebSocket history affinity. 3. Keep local client API key utilities documented as Rust-local auth, not TypeScript provider-key parity. @@ -188,9 +188,9 @@ Immediate priority after this audit: - [x] Write failing tests for OpenAI OAuth device login/poll. - [x] Implement `/admin/auth/device-login` and `/admin/auth/device-poll/{device_code}` with the OpenAI device-code grant and shared validated account import path. - [x] Run: `cargo test --test admin_accounts_route_test admin_auth_device`; `cargo test --test oauth_refresh_test`; `cargo test --test admin_accounts_route_test` -- [ ] Write failing tests for OAuth PKCE login-start/code-relay/callback. -- [ ] Implement remaining OAuth PKCE scoped operations needed to operate Codex-backed OpenAI GPT routes. -- [ ] Run: `cargo test --test admin_accounts_route_test` +- [x] Write failing tests for OAuth PKCE login-start/code-relay/callback. +- [x] Implement `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path with in-memory PKCE sessions and shared validated account import. +- [x] Run: `cargo test --test admin_accounts_route_test admin_auth_`; `cargo test --test oauth_refresh_test`; `cargo test --test admin_accounts_route_test` ### Task 8: Final Verification And Docs diff --git a/src/app.rs b/src/app.rs index d7ece3f2d..523353925 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,14 +8,15 @@ use tower_http::trace::TraceLayer; use crate::{ http::{ admin::{ - account_quota, accounts, api_keys, auth_device_login, auth_device_poll, auth_logout, - auth_status, batch_delete_accounts, batch_delete_api_keys, batch_update_account_status, - create_account, create_api_key, delete_account, delete_account_cookies, delete_api_key, - export_accounts, export_api_keys, get_account_cookies, health_check_accounts, - import_accounts, import_api_keys, import_cli_auth, login, logs, refresh_account, - refresh_models, reset_account_usage, set_account_cookies, settings, - update_account_label, update_account_status, update_api_key_label, - update_api_key_status, usage_stats, usage_stats_summary, + account_quota, accounts, api_keys, auth_callback, auth_code_relay, auth_device_login, + auth_device_poll, auth_login_start, auth_logout, auth_status, batch_delete_accounts, + batch_delete_api_keys, batch_update_account_status, create_account, create_api_key, + delete_account, delete_account_cookies, delete_api_key, export_accounts, + export_api_keys, get_account_cookies, health_check_accounts, import_accounts, + import_api_keys, import_cli_auth, login, logs, refresh_account, refresh_models, + reset_account_usage, set_account_cookies, settings, update_account_label, + update_account_status, update_api_key_label, update_api_key_status, usage_stats, + usage_stats_summary, }, health::health, middleware::attach_request_id, @@ -40,6 +41,11 @@ pub fn build_router(state: AppState) -> Router { .route("/admin/login", post(login)) .route("/admin/auth/status", get(auth_status)) .route("/admin/auth/logout", post(auth_logout)) + .route("/admin/auth/login-start", post(auth_login_start)) + .route("/admin/auth/code-relay", post(auth_code_relay)) + .route("/admin/auth/callback", get(auth_callback)) + // OpenAI OAuth 客户端注册的是 /auth/callback;handler 内仍要求管理员会话。 + .route("/auth/callback", get(auth_callback)) .route("/admin/auth/device-login", post(auth_device_login)) .route( "/admin/auth/device-poll/{device_code}", diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index 676711228..a2964e61d 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -1,6 +1,12 @@ +use std::collections::BTreeMap; + use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Duration, Utc}; +use rand::Rng; use reqwest::{Client, StatusCode}; use serde::Deserialize; +use sha2::{Digest, Sha256}; use thiserror::Error; use crate::auth::{ @@ -12,6 +18,8 @@ const CODEX_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; const OPENAI_OAUTH_DEVICE_CODE_ENDPOINT: &str = "https://auth.openai.com/oauth/device/code"; const OPENAI_OAUTH_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token"; +const OAUTH_CALLBACK_PORT: u16 = 1455; +const OAUTH_CALLBACK_PATH: &str = "/auth/callback"; #[derive(Debug, Clone)] pub struct OAuthConfig { @@ -66,11 +74,115 @@ impl OAuthError { #[async_trait] pub trait OAuthClient: Send + Sync + 'static { + async fn exchange_code( + &self, + code: &str, + code_verifier: &str, + redirect_uri: &str, + ) -> Result; + async fn request_device_code(&self) -> Result; async fn poll_device_token(&self, device_code: &str) -> Result; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PkceLogin { + pub auth_url: String, + pub state: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PkceSession { + pub code_verifier: String, + pub redirect_uri: String, + pub return_host: String, +} + +#[derive(Debug, Clone)] +struct PendingPkceSession { + session: PkceSession, + created_at: DateTime, + exchanging: bool, +} + +#[derive(Debug)] +pub struct PkceSessionStore { + pending: BTreeMap, + completed: BTreeMap>, + ttl: Duration, +} + +impl Default for PkceSessionStore { + fn default() -> Self { + Self { + pending: BTreeMap::new(), + completed: BTreeMap::new(), + ttl: Duration::minutes(5), + } + } +} + +impl PkceSessionStore { + pub fn start_login(&mut self, return_host: &str, config: &OAuthConfig) -> PkceLogin { + self.cleanup(Utc::now()); + let (code_verifier, code_challenge) = generate_pkce_pair(); + let state = random_state(); + let redirect_uri = format!("http://localhost:{OAUTH_CALLBACK_PORT}{OAUTH_CALLBACK_PATH}"); + let auth_url = build_auth_url(config, &redirect_uri, &state, &code_challenge); + self.pending.insert( + state.clone(), + PendingPkceSession { + session: PkceSession { + code_verifier, + redirect_uri, + return_host: return_host.to_string(), + }, + created_at: Utc::now(), + exchanging: false, + }, + ); + PkceLogin { auth_url, state } + } + + pub fn try_acquire(&mut self, state: &str) -> Option { + self.cleanup(Utc::now()); + let pending = self.pending.get_mut(state)?; + if pending.exchanging { + return None; + } + pending.exchanging = true; + Some(pending.session.clone()) + } + + pub fn release(&mut self, state: &str) { + if let Some(pending) = self.pending.get_mut(state) { + pending.exchanging = false; + } + } + + pub fn complete(&mut self, state: &str) { + self.pending.remove(state); + self.completed.insert(state.to_string(), Utc::now()); + } + + pub fn is_completed_or_exchanging(&mut self, state: &str) -> bool { + self.cleanup(Utc::now()); + self.completed.contains_key(state) + || self + .pending + .get(state) + .is_some_and(|pending| pending.exchanging) + } + + fn cleanup(&mut self, now: DateTime) { + self.pending + .retain(|_, pending| now - pending.created_at <= self.ttl); + self.completed + .retain(|_, completed_at| now - *completed_at <= self.ttl); + } +} + #[derive(Clone)] pub struct OpenAiOAuthRefresher { client: Client, @@ -101,6 +213,35 @@ struct OAuthErrorResponse { #[async_trait] impl OAuthClient for OpenAiOAuthRefresher { + async fn exchange_code( + &self, + code: &str, + code_verifier: &str, + redirect_uri: &str, + ) -> Result { + let response = self + .client + .post(&self.config.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", self.config.client_id.as_str()), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", code_verifier), + ]) + .send() + .await + .map_err(|_| OAuthError::Transport)?; + let status = response.status(); + let body = response.text().await.map_err(|_| OAuthError::Transport)?; + if !status.is_success() { + return Err(OAuthError::Rejected(format!( + "Token exchange failed ({status}): {body}" + ))); + } + parse_token_pair(&body).map_err(|_| OAuthError::Transport) + } + async fn request_device_code(&self) -> Result { let response = self .client @@ -195,6 +336,60 @@ fn classify_device_poll_failure(status: StatusCode, body: &str) -> OAuthError { } } +fn generate_pkce_pair() -> (String, String) { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + let code_verifier = URL_SAFE_NO_PAD.encode(bytes); + let digest = Sha256::digest(code_verifier.as_bytes()); + let code_challenge = URL_SAFE_NO_PAD.encode(digest); + (code_verifier, code_challenge) +} + +fn random_state() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn build_auth_url( + config: &OAuthConfig, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> String { + let params = [ + ("response_type", "code"), + ("client_id", config.client_id.as_str()), + ("redirect_uri", redirect_uri), + ("scope", "openid profile email offline_access"), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ("state", state), + ("originator", "codex_cli_rs"), + ]; + let query = params + .iter() + .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value))) + .collect::>() + .join("&"); + format!("{}?{query}", config.auth_endpoint) +} + +fn percent_encode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(char::from(byte)); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + fn classify_refresh_failure(status: StatusCode, body: &str) -> RefreshFailure { let lower = body.to_ascii_lowercase(); if lower.contains("quota") { diff --git a/src/http/admin.rs b/src/http/admin.rs index bb9e36771..628854f97 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -4,7 +4,7 @@ use axum::{ body::Bytes, extract::{Path, Query, State}, http::{ - header::{HeaderValue, SET_COOKIE}, + header::{HeaderValue, HOST, LOCATION, SET_COOKIE}, HeaderMap, StatusCode, }, response::{IntoResponse, Response}, @@ -33,7 +33,7 @@ use crate::{ auth::{ admin_session::verify_admin_password, cli_import::{default_codex_home, read_cli_auth_from_home}, - oauth::{DeviceCode, OAuthError}, + oauth::{DeviceCode, OAuthClient, OAuthConfig, OAuthError, PkceSession}, refresh::RefreshFailure, token::TokenPair, }, @@ -264,6 +264,34 @@ pub struct AdminAuthDevicePollData { pub code: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAuthLoginStartData { + pub auth_url: String, + pub state: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAuthCodeRelayRequest { + pub callback_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAuthCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAuthCodeRelayData { + pub success: bool, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AdminSettingsData { @@ -1149,6 +1177,196 @@ pub async fn auth_device_poll( } } +pub async fn auth_login_start( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, +) -> Response { + let request_id = request_id.as_str().to_string(); + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + if let Err(response) = require_admin_session(pool, &headers, &request_id).await { + return response; + } + let return_host = request_host(&headers, state.config()); + let login = state + .oauth_sessions() + .lock() + .await + .start_login(&return_host, &OAuthConfig::codex_default()); + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + AdminAuthLoginStartData { + auth_url: login.auth_url, + state: login.state, + }, + request_id, + ), + ) + .into_response() +} + +pub async fn auth_code_relay( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Response { + let request_id = request_id.as_str().to_string(); + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + if let Err(response) = require_admin_session(pool, &headers, &request_id).await { + return response; + } + + let Ok((code, oauth_state)) = parse_callback_url(&payload.callback_url) else { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "Invalid OAuth callback URL", (), request_id), + ) + .into_response(); + }; + let Some(repo) = state.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + let Some(oauth_client) = state.oauth_client() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "OAuth client is not initialized", (), request_id), + ) + .into_response(); + }; + + let session = match acquire_pkce_session(&state, &oauth_state, &request_id).await { + Ok(Some(session)) => session, + Ok(None) => { + return AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AdminAuthCodeRelayData { success: true }, request_id), + ) + .into_response(); + } + Err(response) => return response, + }; + match exchange_pkce_session( + &state, + &repo, + oauth_client.as_ref(), + &oauth_state, + &code, + session, + &request_id, + ) + .await + { + Ok(_) => AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(AdminAuthCodeRelayData { success: true }, request_id), + ) + .into_response(), + Err(response) => response, + } +} + +pub async fn auth_callback( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let request_id = request_id.as_str().to_string(); + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + if let Err(response) = require_admin_session(pool, &headers, &request_id).await { + return response; + } + if query.error.is_some() { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new( + 40001, + query + .error_description + .unwrap_or_else(|| "OAuth error".to_string()), + (), + request_id, + ), + ) + .into_response(); + } + let (Some(code), Some(oauth_state)) = (query.code.as_deref(), query.state.as_deref()) else { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "Missing OAuth code or state", (), request_id), + ) + .into_response(); + }; + let Some(repo) = state.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + let Some(oauth_client) = state.oauth_client() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "OAuth client is not initialized", (), request_id), + ) + .into_response(); + }; + let session = match acquire_pkce_session(&state, oauth_state, &request_id).await { + Ok(Some(session)) => session, + Ok(None) => return redirect_to_host(&request_host(&headers, state.config()), request_id), + Err(response) => return response, + }; + let return_host = session.return_host.clone(); + match exchange_pkce_session( + &state, + &repo, + oauth_client.as_ref(), + oauth_state, + code, + session, + &request_id, + ) + .await + { + Ok(_) => redirect_to_host(&return_host, request_id), + Err(response) => response, + } +} + pub async fn logs( State(state): State, Extension(request_id): Extension, @@ -4206,6 +4424,122 @@ fn oauth_error_response(error: OAuthError, request_id: String, message: &'static .into_response() } +fn request_host(headers: &HeaderMap, config: &AppConfig) -> String { + headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("localhost:{}", config.server.port)) +} + +fn parse_callback_url(callback_url: &str) -> Result<(String, String), ()> { + let callback_url = callback_url.trim(); + if callback_url.is_empty() { + return Err(()); + } + let url = Url::parse(callback_url).map_err(|_| ())?; + if url.query_pairs().any(|(key, _)| key == "error") { + return Err(()); + } + let code = url + .query_pairs() + .find_map(|(key, value)| (key == "code").then(|| value.into_owned())) + .filter(|value| !value.trim().is_empty()) + .ok_or(())?; + let state = url + .query_pairs() + .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) + .filter(|value| !value.trim().is_empty()) + .ok_or(())?; + Ok((code, state)) +} + +async fn acquire_pkce_session( + state: &AppState, + oauth_state: &str, + request_id: &str, +) -> Result, Response> { + let sessions = state.oauth_sessions(); + let mut sessions = sessions.lock().await; + if let Some(session) = sessions.try_acquire(oauth_state) { + return Ok(Some(session)); + } + if sessions.is_completed_or_exchanging(oauth_state) { + return Ok(None); + } + Err(AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new( + 40001, + "Invalid or expired OAuth session", + (), + request_id.to_string(), + ), + ) + .into_response()) +} + +async fn exchange_pkce_session( + state: &AppState, + repo: &crate::accounts::repository::AccountRepository, + oauth_client: &dyn OAuthClient, + oauth_state: &str, + code: &str, + session: PkceSession, + request_id: &str, +) -> Result { + let tokens = match oauth_client + .exchange_code(code, &session.code_verifier, &session.redirect_uri) + .await + { + Ok(tokens) => tokens, + Err(error) => { + state.oauth_sessions().lock().await.release(oauth_state); + return Err(oauth_error_response( + error, + request_id.to_string(), + "Token exchange failed", + )); + } + }; + match store_validated_account_import( + state, + repo, + Some(tokens.access_token), + tokens.refresh_token, + ) + .await + { + Ok(_) => { + state.oauth_sessions().lock().await.complete(oauth_state); + Ok(session.return_host) + } + Err(error) => { + state.oauth_sessions().lock().await.complete(oauth_state); + Err(validated_account_import_error_response( + error, + request_id.to_string(), + )) + } + } +} + +fn redirect_to_host(return_host: &str, request_id: String) -> Response { + let location = format!("http://{return_host}/"); + let Ok(location) = HeaderValue::from_str(&location) else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Invalid OAuth return host", (), request_id), + ) + .into_response(); + }; + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert(LOCATION, location); + response +} + fn manual_account_claims( token: &str, now: DateTime, diff --git a/src/state.rs b/src/state.rs index 4fe8fb05d..b0c5e9517 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,7 +8,9 @@ use crate::accounts::{ repository::{AccountRepository, AccountRepositoryResult, AccountUsageRepository}, }; use crate::auth::{ - api_key::ApiKeyHasher, api_key_repository::ClientApiKeyRepository, oauth::OAuthClient, + api_key::ApiKeyHasher, + api_key_repository::ClientApiKeyRepository, + oauth::{OAuthClient, PkceSessionStore}, refresh::TokenRefresher, }; use crate::config::AppConfig; @@ -30,6 +32,7 @@ pub struct AppServices { pub api_key_hasher: Option, pub token_refresher: Option>, pub oauth_client: Option>, + pub oauth_sessions: Arc>, pub account_pool: Arc>, } @@ -45,6 +48,7 @@ impl AppState { api_key_hasher: None, token_refresher: None, oauth_client: None, + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -61,6 +65,7 @@ impl AppState { api_key_hasher: None, token_refresher: None, oauth_client: None, + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -81,6 +86,7 @@ impl AppState { api_key_hasher: None, token_refresher: None, oauth_client: None, + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -102,6 +108,7 @@ impl AppState { api_key_hasher: Some(api_key_hasher), token_refresher: None, oauth_client: None, + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -127,6 +134,7 @@ impl AppState { api_key_hasher: Some(api_key_hasher), token_refresher: Some(Arc::new(token_refresher)), oauth_client: None, + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -155,6 +163,7 @@ impl AppState { api_key_hasher: Some(api_key_hasher), token_refresher: Some(token_refresher), oauth_client: Some(oauth_client), + oauth_sessions: Arc::new(Mutex::new(PkceSessionStore::default())), account_pool, }), } @@ -214,6 +223,10 @@ impl AppState { self.services.oauth_client.clone() } + pub fn oauth_sessions(&self) -> Arc> { + self.services.oauth_sessions.clone() + } + pub async fn reload_account_pool_from_repository(&self) -> AccountRepositoryResult { let Some(repo) = self.account_repository() else { return Ok(0); diff --git a/tests/admin_accounts_route_test.rs b/tests/admin_accounts_route_test.rs index aaa18fd5a..d47a3a708 100644 --- a/tests/admin_accounts_route_test.rs +++ b/tests/admin_accounts_route_test.rs @@ -52,7 +52,16 @@ struct FailingTokenRefresher { struct StaticOAuthClient { device_response: Result, poll_response: Result, + exchange_response: Result, poll_calls: Arc>>, + exchange_calls: Arc>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExchangeCall { + code: String, + code_verifier: String, + redirect_uri: String, } #[async_trait] @@ -79,6 +88,20 @@ impl TokenRefresher for StaticOAuthClient { #[async_trait] impl OAuthClient for StaticOAuthClient { + async fn exchange_code( + &self, + code: &str, + code_verifier: &str, + redirect_uri: &str, + ) -> Result { + self.exchange_calls.lock().await.push(ExchangeCall { + code: code.to_string(), + code_verifier: code_verifier.to_string(), + redirect_uri: redirect_uri.to_string(), + }); + self.exchange_response.clone() + } + async fn request_device_code(&self) -> Result { self.device_response.clone() } @@ -371,7 +394,9 @@ async fn admin_auth_device_login_should_return_openai_device_code() { interval: 5, }), poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: Arc::new(Mutex::new(Vec::new())), }; let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_client("admin-auth-device-login.sqlite", 38, client) @@ -408,7 +433,9 @@ async fn admin_auth_device_poll_should_return_pending_without_importing_account( let client = StaticOAuthClient { device_response: Err(OAuthError::Rejected("not used".to_string())), poll_response: Err(OAuthError::SlowDown), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), poll_calls: poll_calls.clone(), + exchange_calls: Arc::new(Mutex::new(Vec::new())), }; let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_client("admin-auth-device-pending.sqlite", 39, client) @@ -456,7 +483,9 @@ async fn admin_auth_device_poll_should_import_tokens_without_returning_secrets() access_token: token.clone(), refresh_token: Some("device-refresh-secret".to_string()), }), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), poll_calls: poll_calls.clone(), + exchange_calls: Arc::new(Mutex::new(Vec::new())), }; let (app, state, pool, _dir) = admin_accounts_test_app_with_oauth_client("admin-auth-device-success.sqlite", 40, client) @@ -513,6 +542,251 @@ async fn admin_auth_device_poll_should_import_tokens_without_returning_secrets() assert_eq!(acquired.email.as_deref(), Some("device@example.com")); } +#[tokio::test] +async fn admin_auth_login_start_should_require_admin_session_cookie() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-auth-login-start-auth.sqlite", 41).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/login-start") + .header("x-request-id", "req_login_start_no_session") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = response_json(response).await; + assert_eq!(body["code"], 40101); + assert_eq!(body["requestId"], "req_login_start_no_session"); +} + +#[tokio::test] +async fn admin_auth_login_start_should_return_pkce_auth_url_and_state() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-auth-login-start.sqlite", 42).await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "127.0.0.1:8080") + .header("x-request-id", "req_login_start") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + let state = body["data"]["state"].as_str().unwrap(); + let auth_url = body["data"]["authUrl"].as_str().unwrap(); + assert_eq!(state.len(), 32); + assert!(auth_url.starts_with("https://auth.openai.com/oauth/authorize?")); + assert!(auth_url.contains("response_type=code")); + assert!(auth_url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann")); + assert!(auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); + assert!(auth_url.contains("scope=openid%20profile%20email%20offline_access")); + assert!(auth_url.contains("code_challenge_method=S256")); + assert!(auth_url.contains("originator=codex_cli_rs")); + assert!(auth_url.contains(&format!("state={state}"))); +} + +#[tokio::test] +async fn admin_auth_code_relay_should_exchange_code_and_import_account() { + let token = test_jwt( + Some("pkce-account"), + Some("pkce-user"), + Some("pkce@example.com"), + Some("plus"), + 3600, + ); + let exchange_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Ok(TokenPair { + access_token: token.clone(), + refresh_token: Some("pkce-refresh-secret".to_string()), + }), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: exchange_calls.clone(), + }; + let (app, state, pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-code-relay.sqlite", 43, client).await; + let start = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "127.0.0.1:8080") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let start_body = response_json(start).await; + let state_value = start_body["data"]["state"].as_str().unwrap(); + let callback_url = + format!("http://localhost:1455/auth/callback?code=oauth-code&state={state_value}"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/code-relay") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_code_relay") + .body(Body::from( + json!({ "callbackUrl": callback_url }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["success"], true); + assert!(body["data"].get("accessToken").is_none()); + assert!(body["data"].get("refreshToken").is_none()); + let calls = exchange_calls.lock().await; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].code, "oauth-code"); + assert!(!calls[0].code_verifier.is_empty()); + assert_eq!(calls[0].redirect_uri, "http://localhost:1455/auth/callback"); + drop(calls); + let stored = AccountRepository::new(pool.clone(), SecretBox::new([43; 32])) + .find_by_chatgpt_identity("pkce-account", Some("pkce-user")) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.email.as_deref(), Some("pkce@example.com")); + assert_eq!( + stored.refresh_token.unwrap().expose_secret(), + "pkce-refresh-secret" + ); + let acquired = state + .account_pool() + .lock() + .await + .acquire("gpt-5.5") + .unwrap(); + assert_eq!(acquired.account_id.as_deref(), Some("pkce-account")); +} + +#[tokio::test] +async fn admin_auth_code_relay_should_reject_invalid_callback_url() { + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Err(OAuthError::Rejected("not used".to_string())), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: Arc::new(Mutex::new(Vec::new())), + }; + let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_client( + "admin-auth-code-relay-invalid.sqlite", + 44, + client, + ) + .await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/code-relay") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_code_relay_invalid") + .body(Body::from( + json!({ "callbackUrl": "not a url" }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response_json(response).await; + assert_eq!(body["code"], 40001); +} + +#[tokio::test] +async fn admin_auth_callback_should_exchange_code_and_redirect_to_return_host() { + let token = test_jwt( + Some("callback-account"), + Some("callback-user"), + Some("callback@example.com"), + Some("plus"), + 3600, + ); + let exchange_calls = Arc::new(Mutex::new(Vec::new())); + let client = StaticOAuthClient { + device_response: Err(OAuthError::Rejected("not used".to_string())), + poll_response: Err(OAuthError::AuthorizationPending), + exchange_response: Ok(TokenPair { + access_token: token, + refresh_token: Some("callback-refresh-secret".to_string()), + }), + poll_calls: Arc::new(Mutex::new(Vec::new())), + exchange_calls: exchange_calls.clone(), + }; + let (app, _state, pool, _dir) = + admin_accounts_test_app_with_oauth_client("admin-auth-callback.sqlite", 45, client).await; + let start = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/auth/login-start") + .header("cookie", "cpr_admin_session=session_1") + .header("host", "codex.local:1455") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let start_body = response_json(start).await; + let state_value = start_body["data"]["state"].as_str().unwrap(); + let callback_path = format!("/auth/callback?code=callback-code&state={state_value}"); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(callback_path) + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "http://codex.local:1455/" + ); + assert_eq!(exchange_calls.lock().await[0].code, "callback-code"); + let stored = AccountRepository::new(pool.clone(), SecretBox::new([45; 32])) + .find_by_chatgpt_identity("callback-account", Some("callback-user")) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.email.as_deref(), Some("callback@example.com")); +} + #[tokio::test] async fn admin_accounts_import_should_store_tokens_encrypted_and_list_sanitized_accounts() { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/oauth_refresh_test.rs b/tests/oauth_refresh_test.rs index 08cc5c0d9..4d7e07513 100644 --- a/tests/oauth_refresh_test.rs +++ b/tests/oauth_refresh_test.rs @@ -68,6 +68,39 @@ async fn openai_oauth_client_should_request_device_code_with_form_body() { assert!(body.contains("scope=openid+profile+email+offline_access")); } +#[tokio::test] +async fn openai_oauth_client_should_exchange_authorization_code_with_pkce_verifier() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "pkce-access", + "refresh_token": "pkce-refresh" + }))) + .mount(&server) + .await; + let client = test_oauth_client(&server); + + let tokens = client + .exchange_code( + "oauth-code", + "pkce-verifier", + "http://localhost:1455/auth/callback", + ) + .await + .unwrap(); + + assert_eq!(tokens.access_token, "pkce-access"); + assert_eq!(tokens.refresh_token.as_deref(), Some("pkce-refresh")); + let requests = server.received_requests().await.unwrap(); + let body = String::from_utf8(requests[0].body.clone()).unwrap(); + assert!(body.contains("grant_type=authorization_code")); + assert!(body.contains("client_id=codex-client")); + assert!(body.contains("code=oauth-code")); + assert!(body.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); + assert!(body.contains("code_verifier=pkce-verifier")); +} + #[tokio::test] async fn openai_oauth_client_should_map_device_poll_pending_errors() { let server = MockServer::start().await;