From 5393a67f6b1b1d1d2903461ae58ef0e89387907d Mon Sep 17 00:00:00 2001 From: Zyy Date: Fri, 12 Jun 2026 16:10:48 +0800 Subject: [PATCH] feat: add local client API key import export Co-authored-by: Codex --- docs/api.md | 86 +++++ docs/implementation-status.md | 4 +- .../2026-06-12-openai-gpt-codex-parity.md | 5 +- src/app.rs | 12 +- src/auth/api_key_repository.rs | 28 +- src/http/admin.rs | 334 ++++++++++++++++++ tests/admin_api_keys_route_test.rs | 152 ++++++++ 7 files changed, 610 insertions(+), 11 deletions(-) diff --git a/docs/api.md b/docs/api.md index 714d96633..461fc7b08 100644 --- a/docs/api.md +++ b/docs/api.md @@ -238,6 +238,92 @@ Success response: } ``` +### `GET /admin/api-keys/export` + +Exports Rust-local client API key metadata for backup or migration. The export never includes plaintext keys, key hashes, or the raw auth pepper. Because `cpr_` keys are stored only as peppered HMAC hashes, imports rotate keys instead of preserving the original plaintext. + +Optional query: + +```http +GET /admin/api-keys/export?ids=key_01,key_02 +``` + +Success response: + +```json +{ + "code": 200, + "message": "OK", + "data": { + "sourceFormat": "rustLocalClientApiKeys", + "rotationRequired": true, + "apiKeys": [ + { + "id": "key_01", + "name": "cursor", + "label": "automation", + "prefix": "cpr_xxxxxxxx", + "enabled": true, + "createdAt": "2026-06-11T12:00:00Z", + "lastUsedAt": null + } + ] + }, + "requestId": "req_01" +} +``` + +### `POST /admin/api-keys/import` + +Imports Rust-local client API key metadata exported by this service. Import creates new local `cpr_` keys using the target instance's pepper and returns the new plaintext values only in this response. Clients must replace the old keys with these rotated plaintext values. + +Request: + +```json +{ + "sourceFormat": "rustLocalClientApiKeys", + "rotationRequired": true, + "apiKeys": [ + { + "id": "key_01", + "name": "cursor", + "label": "automation", + "prefix": "cpr_xxxxxxxx", + "enabled": true + } + ] +} +``` + +Success response: + +```json +{ + "code": 200, + "message": "OK", + "data": { + "imported": 1, + "skipped": 0, + "rotated": true, + "keys": [ + { + "sourceId": "key_01", + "sourcePrefix": "cpr_xxxxxxxx", + "id": "key_02", + "name": "cursor", + "label": "automation", + "prefix": "cpr_yyyyyyyy", + "enabled": true, + "createdAt": "2026-06-11T12:05:00Z", + "lastUsedAt": null, + "plaintext": "cpr_new_full_key_returned_once" + } + ] + }, + "requestId": "req_01" +} +``` + ### `GET /admin/accounts` Returns stored Codex accounts with cursor pagination. Access tokens and refresh tokens are never decrypted or returned. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index dbeac8495..a07a4335e 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -8,7 +8,7 @@ Update this file before each feature commit. | API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. | | Configuration | Completed | `9ed8579` + `e0680bf` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, `Arc` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. | | SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. | -| Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_api_keys_route_test`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext is not exportable after HMAC storage. | +| Admin auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; `cargo test --test admin_api_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. | | Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext and can load/create the runtime master key file used by repositories. | | 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. | @@ -16,7 +16,7 @@ Update this file before each feature commit. | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` | `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`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, and `cargo test --test account_pool_scheduling_test`; current Task 7 targeted `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, 401-triggered refresh and single retry for `/v1/responses`, 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 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, and shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, and Chat Completions. Still missing durable release-time slot state, WebSocket/history fallback policy, and durable quota cooldown persistence. | | Account repository | Completed | `14fe706` + `db6cb98` + `72438c6` | `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` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, full export reads, status/label updates, account deletion, 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` | `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`; 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`; current Task 7 targeted `cargo test --test admin_accounts_route_test` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/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/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 streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, 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/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` now 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; 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/device account login flows, settings mutation/persistence, diagnostics, live Chat streaming transform, and WebSocket/history fallback policy. | +| 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` | `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`; 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`; current Task 7 targeted `cargo test --test admin_accounts_route_test`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/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 streaming for `previous_response_id`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, 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/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` now 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/device account login flows, settings mutation/persistence, diagnostics, live Chat streaming transform, and WebSocket/history fallback policy. | | 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-12-openai-gpt-codex-parity.md b/docs/superpowers/plans/2026-06-12-openai-gpt-codex-parity.md index fb9614538..ae79cfec5 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 @@ -27,7 +27,7 @@ This audit is the correction point against the TypeScript reference at `/home/zy | Manual account creation / `16938e6` + current correction | `POST /auth/accounts` accepts only `token` and/or `refreshToken`, runs `AccountImportService.importOne`, exchanges refresh-token-only imports, derives account identity/profile from JWT claims, updates an existing `chatgpt_account_id` + `chatgpt_user_id` entry, and never returns tokens. | `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects missing/invalid/expired JWTs and tokens without `https://api.openai.com/auth.chatgpt_account_id`, derives email/accountId/userId/planType/expiresAt from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only imports with `AppState`'s OpenAI refresher, preserves or rotates refresh tokens, encrypts secrets, and synchronizes the runtime account pool. It does not yet cache quota from a verification probe. | Corrected scoped parity. | Keep. Finish quota fetch/health/login variants separately without adding proxy pools or non-OpenAI provider support. | | Local client API key status/delete / `8af350d` | `/auth/api-keys` manages third-party provider key pools such as OpenAI, Anthropic, Gemini, OpenRouter/custom, plus model bindings and capabilities. | `/admin/api-keys/*` manages local `cpr_` client auth keys used only for this Rust service's `/v1/*` authorization. | Rust local extension, not TS parity. | Keep only as local admin utility if accepted. Do not count as provider API-key parity and do not add non-OpenAI provider pools. | | 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. | Intentional scope cut. | Do not port TS provider key import/export. Document that local client keys cannot be safely exported after creation. | +| 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`, admin quota fetch, health check, per-account refresh, and reset-usage for imported accounts. Rust still lacks OAuth PKCE login-start/code-relay/callback, device login/poll, and logout/auth status equivalents. | Partial OpenAI/Codex parity. | Continue with login/status variants without adding 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. | @@ -157,6 +157,9 @@ Immediate priority after this audit: - [x] Write failing tests for local client API key label and batch-delete mutations. - [x] Implement local client API key label and batch-delete mutations without third-party provider key pools. - [x] Run: `cargo test --test admin_api_keys_route_test` +- [x] Write failing tests for local client API key metadata export and rotation import. +- [x] Implement local client API key metadata export/import without plaintext export, hash export, pepper export, or third-party provider key pools. +- [x] Run: `cargo test --test admin_api_keys_route_test admin_api_keys_export_should_return_metadata_without_secret_material`; `cargo test --test admin_api_keys_route_test admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once` - [x] Write failing tests for account Cookie get/set/delete routes. - [x] Implement encrypted account Cookie get/set/delete routes. - [x] Run: `cargo test --test admin_accounts_route_test` diff --git a/src/app.rs b/src/app.rs index 06ac24f97..6ee9811af 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,11 +10,11 @@ use crate::{ admin::{ account_quota, accounts, api_keys, 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, get_account_cookies, - health_check_accounts, import_accounts, 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, + 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, @@ -77,6 +77,8 @@ pub fn build_router(state: AppState) -> Router { .route("/admin/accounts/import", post(import_accounts)) .route("/admin/accounts/import-cli", post(import_cli_auth)) .route("/admin/api-keys", get(api_keys).post(create_api_key)) + .route("/admin/api-keys/export", get(export_api_keys)) + .route("/admin/api-keys/import", post(import_api_keys)) .route("/admin/api-keys/batch-delete", post(batch_delete_api_keys)) .route("/admin/api-keys/{key_id}", delete(delete_api_key)) .route( diff --git a/src/auth/api_key_repository.rs b/src/auth/api_key_repository.rs index 2faaf6dc4..91b10be84 100644 --- a/src/auth/api_key_repository.rs +++ b/src/auth/api_key_repository.rs @@ -45,25 +45,38 @@ impl ClientApiKeyRepository { &self, name: &str, generated: &GeneratedClientApiKey, + ) -> ClientApiKeyRepositoryResult { + self.insert_generated_with_metadata(name, None, true, generated) + .await + } + + pub async fn insert_generated_with_metadata( + &self, + name: &str, + label: Option<&str>, + enabled: bool, + generated: &GeneratedClientApiKey, ) -> ClientApiKeyRepositoryResult { let id = format!("key_{}", Uuid::new_v4().simple()); let now = Utc::now().to_rfc3339(); sqlx::query( - "insert into client_api_keys (id, name, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, 1, ?, null)", + "insert into client_api_keys (id, name, label, prefix, key_hash, enabled, created_at, last_used_at) values (?, ?, ?, ?, ?, ?, ?, null)", ) .bind(&id) .bind(name) + .bind(label) .bind(&generated.prefix) .bind(&generated.key_hash) + .bind(if enabled { 1_i64 } else { 0_i64 }) .bind(&now) .execute(&self.pool) .await?; Ok(StoredClientApiKey { id, name: name.to_string(), - label: None, + label: label.map(ToString::to_string), prefix: generated.prefix.clone(), - enabled: true, + enabled, created_at: now, last_used_at: None, }) @@ -123,6 +136,15 @@ impl ClientApiKeyRepository { Ok(row.map(|row| key_from_row(&row))) } + pub async fn list_all(&self) -> ClientApiKeyRepositoryResult> { + let rows = sqlx::query( + "select id, name, label, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|row| key_from_row(&row)).collect()) + } + pub async fn verify_and_touch( &self, plaintext: &str, diff --git a/src/http/admin.rs b/src/http/admin.rs index b11036f60..90f625898 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -166,6 +166,12 @@ pub struct ApiKeysQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeyExportQuery { + pub ids: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UsageStatsQuery { @@ -476,6 +482,59 @@ pub struct DeleteClientApiKeyData { pub deleted: bool, } +#[derive(Debug, Clone)] +struct ClientApiKeyImportEntry { + pub source_id: Option, + pub source_prefix: Option, + pub name: String, + pub label: Option, + pub enabled: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApiKeyExportData { + pub source_format: &'static str, + pub rotation_required: bool, + pub api_keys: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApiKeyExportEntry { + pub id: String, + pub name: String, + pub label: Option, + pub prefix: String, + pub enabled: bool, + pub created_at: String, + pub last_used_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApiKeyImportData { + pub imported: u32, + pub skipped: u32, + pub rotated: bool, + pub keys: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedClientApiKeyData { + pub source_id: Option, + pub source_prefix: Option, + pub id: String, + pub name: String, + pub label: Option, + pub prefix: String, + pub enabled: bool, + pub created_at: String, + pub last_used_at: Option, + pub plaintext: String, +} + impl From for ClientApiKeyData { fn from(key: StoredClientApiKey) -> Self { Self { @@ -490,6 +549,20 @@ impl From for ClientApiKeyData { } } +impl From for ClientApiKeyExportEntry { + fn from(key: StoredClientApiKey) -> Self { + Self { + id: key.id, + name: key.name, + label: key.label, + prefix: key.prefix, + enabled: key.enabled, + created_at: key.created_at, + last_used_at: key.last_used_at, + } + } +} + impl CreatedClientApiKeyData { fn new(key: StoredClientApiKey, plaintext: String) -> Self { Self { @@ -505,6 +578,28 @@ impl CreatedClientApiKeyData { } } +impl ImportedClientApiKeyData { + fn new( + key: StoredClientApiKey, + plaintext: String, + source_id: Option, + source_prefix: Option, + ) -> Self { + Self { + source_id, + source_prefix, + id: key.id, + name: key.name, + label: key.label, + prefix: key.prefix, + enabled: key.enabled, + created_at: key.created_at, + last_used_at: key.last_used_at, + plaintext, + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AdminAccountData { @@ -2468,6 +2563,192 @@ pub async fn api_keys( } } +pub async fn export_api_keys( + 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; + } + let Some(repo) = state.client_api_key_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "API key repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + + let ids = account_export_ids(query.ids.as_deref()); + let keys = if ids.is_empty() { + match repo.list_all().await { + Ok(keys) => keys, + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to export API keys", (), request_id), + ) + .into_response(); + } + } + } else { + let mut keys = Vec::with_capacity(ids.len()); + for id in ids { + match repo.get(&id).await { + Ok(Some(key)) => keys.push(key), + Ok(None) => {} + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to export API keys", (), request_id), + ) + .into_response(); + } + } + } + keys + }; + + // 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。 + let data = ClientApiKeyExportData { + source_format: "rustLocalClientApiKeys", + rotation_required: true, + api_keys: keys + .into_iter() + .map(ClientApiKeyExportEntry::from) + .collect(), + }; + AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data, request_id)).into_response() +} + +pub async fn import_api_keys( + 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 Some(repo) = state.client_api_key_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "API key repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + let Some(hasher) = state.api_key_hasher() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "API key hasher is not initialized", (), request_id), + ) + .into_response(); + }; + + let entries = parse_client_api_key_import_payload(&payload); + if entries.is_empty() { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "No importable API keys found", (), request_id), + ) + .into_response(); + } + + let mut imported = 0u32; + let mut skipped = 0u32; + let mut keys = Vec::with_capacity(entries.len()); + for entry in entries { + let name = entry.name.trim(); + if name.is_empty() { + skipped += 1; + continue; + } + if entry + .label + .as_ref() + .is_some_and(|label| label.chars().count() > 64) + { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new( + 40001, + "API key label must be 64 characters or fewer", + (), + request_id, + ), + ) + .into_response(); + } + + let generated = hasher.generate_client_api_key(name); + let plaintext = generated.plaintext.clone(); + let source_id = entry.source_id; + let source_prefix = entry.source_prefix; + match repo + .insert_generated_with_metadata(name, entry.label.as_deref(), entry.enabled, &generated) + .await + { + Ok(key) => { + imported += 1; + keys.push(ImportedClientApiKeyData::new( + key, + plaintext, + source_id, + source_prefix, + )); + } + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to import API key", (), request_id), + ) + .into_response(); + } + } + } + + AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + ClientApiKeyImportData { + imported, + skipped, + rotated: true, + keys, + }, + request_id, + ), + ) + .into_response() +} + pub async fn create_api_key( State(state): State, Extension(request_id): Extension, @@ -3728,6 +4009,59 @@ fn account_export_ids(value: Option<&str>) -> Vec { .collect() } +fn parse_client_api_key_import_payload(payload: &Value) -> Vec { + let payload = payload + .get("data") + .filter(|data| data.get("apiKeys").is_some() || data.get("keys").is_some()) + .unwrap_or(payload); + + if let Some(keys) = payload.get("apiKeys").and_then(Value::as_array) { + return keys + .iter() + .filter_map(client_api_key_import_entry_from_value) + .collect(); + } + if let Some(keys) = payload.get("keys").and_then(Value::as_array) { + return keys + .iter() + .filter_map(client_api_key_import_entry_from_value) + .collect(); + } + if let Some(keys) = payload.as_array() { + return keys + .iter() + .filter_map(client_api_key_import_entry_from_value) + .collect(); + } + + client_api_key_import_entry_from_value(payload) + .into_iter() + .collect() +} + +fn client_api_key_import_entry_from_value(value: &Value) -> Option { + value.as_object()?; + let name = first_string(value, &[&["name"]])?; + // 安全边界:导入即使收到 plaintext/keyHash,也只按元数据轮换生成新的本地 cpr_ key。 + Some(ClientApiKeyImportEntry { + source_id: first_string(value, &[&["id"], &["sourceId"]]), + source_prefix: first_string(value, &[&["prefix"], &["sourcePrefix"]]), + name, + label: first_string(value, &[&["label"]]), + enabled: client_api_key_import_enabled(value), + }) +} + +fn client_api_key_import_enabled(value: &Value) -> bool { + if let Some(enabled) = value.get("enabled").and_then(Value::as_bool) { + return enabled; + } + !first_string(value, &[&["status"]]) + .unwrap_or_else(|| "active".to_string()) + .trim() + .eq_ignore_ascii_case("disabled") +} + fn parse_account_export_format(value: Option<&str>) -> Result { match value.unwrap_or("native") { "" | "native" | "full" => Ok(AccountExportFormat::Native), diff --git a/tests/admin_api_keys_route_test.rs b/tests/admin_api_keys_route_test.rs index 0fa0d01a7..c19e7feaf 100644 --- a/tests/admin_api_keys_route_test.rs +++ b/tests/admin_api_keys_route_test.rs @@ -477,6 +477,158 @@ async fn create_admin_api_key( ) } +#[tokio::test] +async fn admin_api_keys_export_should_return_metadata_without_secret_material() { + let (app, _dir) = admin_api_keys_test_app("admin-api-key-export.sqlite").await; + let (key_id, plaintext) = create_admin_api_key(&app, "session_1", "export-key").await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/admin/api-keys/export?ids={key_id}")) + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key_export") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["requestId"], "req_api_key_export"); + assert_eq!(body["data"]["sourceFormat"], "rustLocalClientApiKeys"); + assert_eq!(body["data"]["rotationRequired"], true); + assert_eq!(body["data"]["apiKeys"][0]["id"], key_id); + assert_eq!(body["data"]["apiKeys"][0]["name"], "export-key"); + assert!(body["data"]["apiKeys"][0]["prefix"] + .as_str() + .unwrap() + .starts_with("cpr_")); + assert!(body["data"]["apiKeys"][0].get("plaintext").is_none()); + assert!(body["data"]["apiKeys"][0].get("keyHash").is_none()); + assert!(body["data"].get("pepper").is_none()); + assert!(!body.to_string().contains(&plaintext)); +} + +#[tokio::test] +async fn admin_api_keys_import_should_rotate_exported_metadata_and_return_new_plaintext_once() { + let source_dir = tempfile::tempdir().unwrap(); + let source_db = source_dir.path().join("admin-api-key-export-source.sqlite"); + let source_url = format!("sqlite://{}", source_db.display()); + let source_pool = connect_sqlite(&source_url).await.unwrap(); + seed_admin_session(&source_pool, "session_1").await; + let source_app = build_router(AppState::with_pool_secret_and_api_key_hasher( + test_config(source_url), + source_pool, + SecretBox::new([51u8; 32]), + ApiKeyHasher::new([52u8; 32]), + )); + let (source_key_id, source_plaintext) = + create_admin_api_key(&source_app, "session_1", "rotated-key").await; + + let export_response = source_app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/api-keys/export") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(export_response.status(), StatusCode::OK); + let export_body = response_json(export_response).await; + + let target_dir = tempfile::tempdir().unwrap(); + let target_db = target_dir.path().join("admin-api-key-import-target.sqlite"); + let target_url = format!("sqlite://{}", target_db.display()); + let target_pool = connect_sqlite(&target_url).await.unwrap(); + seed_admin_session(&target_pool, "session_1").await; + let target_app = build_router(AppState::with_pool_secret_and_api_key_hasher( + test_config(target_url), + target_pool, + SecretBox::new([61u8; 32]), + ApiKeyHasher::new([62u8; 32]), + )); + + let import_response = target_app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api-keys/import") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key_import") + .body(Body::from(export_body["data"].to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(import_response.status(), StatusCode::OK); + let import_body = response_json(import_response).await; + assert_eq!(import_body["requestId"], "req_api_key_import"); + assert_eq!(import_body["data"]["imported"], 1); + assert_eq!(import_body["data"]["skipped"], 0); + assert_eq!(import_body["data"]["rotated"], true); + assert_eq!(import_body["data"]["keys"][0]["sourceId"], source_key_id); + assert_eq!(import_body["data"]["keys"][0]["name"], "rotated-key"); + assert!(import_body["data"]["keys"][0].get("keyHash").is_none()); + let rotated_plaintext = import_body["data"]["keys"][0]["plaintext"] + .as_str() + .unwrap() + .to_string(); + assert!(rotated_plaintext.starts_with("cpr_")); + assert_ne!(rotated_plaintext, source_plaintext); + + let list_response = target_app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/api-keys?limit=10") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + let list_body = response_json(list_response).await; + assert!(list_body["data"][0].get("plaintext").is_none()); + + let old_rejected = target_app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {source_plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(old_rejected.status(), StatusCode::UNAUTHORIZED); + + let new_accepted = target_app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {rotated_plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(new_accepted.status(), StatusCode::OK); +} + async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) { let now = Utc::now().to_rfc3339(); sqlx::query(