From e9fdea08d249a6306e4676ec46b18fa24b6e3e0b Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 12 Jun 2026 01:58:08 +0800 Subject: [PATCH] feat: add admin client api key management Co-authored-by: Codex --- docs/api.md | 37 +++- docs/implementation-status.md | 6 +- .../plans/2026-06-11-codex-proxy-rs.md | 3 +- src/app.rs | 3 +- src/auth/api_key.rs | 22 ++ src/auth/api_key_repository.rs | 151 ++++++++++++++ src/auth/error.rs | 2 + src/auth/mod.rs | 1 + src/http/admin.rs | 177 +++++++++++++++++ src/http/v1.rs | 27 ++- src/main.rs | 5 +- src/state.rs | 32 +++ tests/admin_api_keys_route_test.rs | 188 ++++++++++++++++++ tests/routes_responses_test.rs | 62 ++++-- tests/v1_upstream_route_test.rs | 12 +- 15 files changed, 695 insertions(+), 33 deletions(-) create mode 100644 src/auth/api_key_repository.rs create mode 100644 tests/admin_api_keys_route_test.rs diff --git a/docs/api.md b/docs/api.md index fa4894d67..d4024547d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ ## `/v1/*` -These endpoints are OpenAI-compatible and are authenticated only by client API keys using `Authorization: Bearer cpr_...`. +These endpoints are OpenAI-compatible and are authenticated only by enabled local client API keys created through `/admin/api-keys` and sent as `Authorization: Bearer cpr_...`. Responses include an `X-Request-Id` header for tracing, but the body stays OpenAI-compatible and does not include a custom `requestId` field. Error body: @@ -122,6 +122,41 @@ Success response: } ``` +### `GET /admin/api-keys` + +Returns local client API keys with cursor pagination. The plaintext key and hash are never returned by list endpoints. + +### `POST /admin/api-keys` + +Creates a local client API key for `/v1/*`. The plaintext value is returned only in this response; store it client-side and use it as `Authorization: Bearer `. + +Request: + +```json +{ + "name": "cursor" +} +``` + +Success response: + +```json +{ + "code": 200, + "message": "OK", + "data": { + "id": "key_01", + "name": "cursor", + "prefix": "cpr_xxxxxxxx", + "enabled": true, + "createdAt": "2026-06-11T12:00:00Z", + "lastUsedAt": null, + "plaintext": "cpr_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 60a310262..65155ff73 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<AppServices>` 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 | Completed | `24d5799` + `4e0d1e5` | `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`; full `fmt/test/clippy` before commit | 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 auth and client API keys | Partial | `24d5799` + `4e0d1e5` + `pending` | `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 commit | 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, and `/v1/*` now verifies enabled keys against SQLite instead of accepting any `cpr_` string. Still missing delete/batch-delete/label/status/export/import key management. | | 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:<nonce>:<ciphertext>` without plaintext and can load/create the runtime master key file used by repositories. | | Logging and pagination | Completed | `eb0341e` + `fc16c48` + `b0c10f2` | `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`; full `fmt/test/clippy` before commit | Adds cursor pagination, `Page<T>` camelCase serialization, admin `/admin/logs` query API, event log repository, runtime tracing initialization, HTTP trace layer, and date/size-based file rotation. | | 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,9 +16,9 @@ Update this file before each feature commit. | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` | `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 v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, 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 import-to-pool sync, and startup pool snapshots from SQLite with usage-based last-used metadata. Still missing durable release-time slot state, upstream retry/fallback wiring, and admin account CRUD. | | Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. | | Translation | Completed | `eff182e` | `cargo test chat_completion_translates_to_codex_response_request`; full `fmt/test/clippy` before commit | Adds OpenAI Chat request types, Codex Responses request type, translation, and OpenAI-compatible error body helper. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` | `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 v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/v1/responses` HTTP SSE collection through imported Codex accounts, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, admin API-key management routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, streaming passthrough, and WebSocket Responses routes. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `pending` | `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 admin_api_keys_route_test`; `cargo test --test v1_upstream_route_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/accounts`, `/admin/accounts/import`, `/admin/api-keys`, `/v1/responses` HTTP SSE collection through imported Codex accounts, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Still missing OAuth/device/CLI account login flows, admin account mutation/health/quota/Cookie routes, full admin API-key mutation/export/import routes, settings mutation/persistence, diagnostics, usage stats, backend model refresh, model cache persistence, streaming passthrough, and WebSocket Responses routes. | | 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` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_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 `Bearer cpr_...`, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | +| HTTP auth boundary | Completed | `cec4607` + `pending` | `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` | `cargo test --test model_catalog_test`; `cargo test model_catalog_route_returns_codex_metadata_without_alias_entries`; full `fmt/test/clippy` before commit | Adds a native static Codex model catalog with `gpt-5.5` default, configured aliases, reasoning/service-tier suffix parsing, OpenAI-compatible `/v1/models`, extended catalog/detail/info routes, and debug summary. Still missing backend Codex model fetching per plan, cache persistence, `/admin/refresh-models`, plan allowlist integration from fetched models, and full model metadata parity. | | Upstream lifecycle | Partial | `88cad87` + `225fbff` + `c2cf7a6` + `636fa79` | `cargo test responses_route_rejects_non_codex_provider_models`; `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test --test codex_sse_usage_test`; `cargo test --test codex_client_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; full `fmt/test/clippy` before commit | `/v1` enforces client API key shape, rejects non-Codex provider models through the model catalog, returns OpenAI-compatible errors, includes reusable SSE/usage helpers, sends Codex Desktop HTTP SSE requests through imported accounts, replays/captures encrypted Cookies, collects `response.completed`, records token usage, and restores the account pool from SQLite on startup. Still missing rate-limit header capture, retry/fallback across accounts, 401 refresh integration, 429 quota cache, 402/403 classification, streaming passthrough, and previous-response/session affinity. | | Codex WebSocket boundary | Completed | `cc0516d` | `cargo test --test codex_websocket_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds `src/codex/websocket.rs` with tested transport classification for HTTP SSE versus WebSocket-only requests. `previous_response_id` and explicit WebSocket requests now fail through a typed boundary instead of being silently downgraded; real WebSocket transport remains an upstream lifecycle follow-up. | 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 0ebb3d0e6..e8bcd6e28 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2965,6 +2965,7 @@ git commit -m "docs: document codex-proxy-rs scope" - [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes. - [x] Add the first native Codex upstream lifecycle slice. Imported accounts are synchronized into the process account pool, `/v1/responses` acquires an account, sends Codex Desktop HTTP SSE requests with encrypted Cookie replay/capture, collects `response.completed`, records usage, and releases the slot. Startup pool snapshots, streaming passthrough, retry/fallback, 401 refresh, rate-limit capture, and WebSocket remain separate tasks. - [x] Restore the process account pool from SQLite on startup. The snapshot decrypts account tokens through `SecretBox`, carries usage `last_used_at` into scheduling, and lets previously imported accounts survive service restarts. +- [x] Split local client API key auth from admin login. `/admin/api-keys` can create/list local `cpr_` keys, plaintext is returned only once, list responses never expose plaintext/hash, and `/v1/*` verifies enabled keys from SQLite instead of accepting any `cpr_` string. ## 2026-06-11 Original Project Gap Audit @@ -2976,7 +2977,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav - [ ] **Account SQLite repository:** implement `src/accounts/repository.rs` for encrypted access/refresh tokens, account metadata, quota JSON, usage counters, labels, status transitions, pagination, and batch-safe mutations. 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 and paginated sanitized list are complete; export, mutations, health, quota, Cookie CRUD, and 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. Keep this separate from admin password login and client API key creation. -- [ ] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access. +- [ ] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list 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. - [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and `/admin/usage-stats/summary` plus paginated or bounded history queries. Keep log file rotation and SQLite event pagination. diff --git a/src/app.rs b/src/app.rs index da9233586..a2ca09a3e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use tower_http::trace::TraceLayer; use crate::{ http::{ - admin::{accounts, import_accounts, login, logs, settings}, + admin::{accounts, api_keys, create_api_key, import_accounts, login, logs, settings}, health::health, middleware::attach_request_id, v1::{debug_models, model_catalog, model_detail, model_info, models, responses}, @@ -30,6 +30,7 @@ pub fn build_router(state: AppState) -> Router { .route("/admin/settings", get(settings)) .route("/admin/accounts", get(accounts)) .route("/admin/accounts/import", post(import_accounts)) + .route("/admin/api-keys", get(api_keys).post(create_api_key)) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(from_fn(attach_request_id)) diff --git a/src/auth/api_key.rs b/src/auth/api_key.rs index dbc58cbeb..c3cea3e2a 100644 --- a/src/auth/api_key.rs +++ b/src/auth/api_key.rs @@ -1,3 +1,5 @@ +use std::{fs, path::Path}; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, KeyInit, Mac}; use rand::Rng; @@ -24,6 +26,26 @@ impl ApiKeyHasher { Self { pepper } } + pub fn load_or_create(path: impl AsRef<Path>) -> AuthResult<Self> { + let path = path.as_ref(); + if path.exists() { + let encoded = fs::read_to_string(path)?; + let pepper = URL_SAFE_NO_PAD.decode(encoded.trim())?; + return Self::try_from_slice(&pepper); + } + + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + let mut pepper = [0u8; 32]; + rand::rng().fill_bytes(&mut pepper); + fs::write(path, URL_SAFE_NO_PAD.encode(pepper))?; + Ok(Self::new(pepper)) + } + pub fn try_from_slice(pepper: &[u8]) -> AuthResult<Self> { let pepper: [u8; 32] = pepper .try_into() diff --git a/src/auth/api_key_repository.rs b/src/auth/api_key_repository.rs new file mode 100644 index 000000000..12e6ff98c --- /dev/null +++ b/src/auth/api_key_repository.rs @@ -0,0 +1,151 @@ +use chrono::Utc; +use sqlx::{Row, SqlitePool}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + auth::api_key::{ApiKeyHasher, GeneratedClientApiKey}, + pagination::{decode_cursor, encode_cursor, Page}, +}; + +#[derive(Debug, Error)] +pub enum ClientApiKeyRepositoryError { + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + #[error("api key verification error: {0}")] + Auth(#[from] crate::auth::error::AuthError), + #[error("invalid pagination cursor")] + InvalidCursor, +} + +pub type ClientApiKeyRepositoryResult<T> = Result<T, ClientApiKeyRepositoryError>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredClientApiKey { + pub id: String, + pub name: String, + pub prefix: String, + pub enabled: bool, + pub created_at: String, + pub last_used_at: Option<String>, +} + +#[derive(Clone)] +pub struct ClientApiKeyRepository { + pool: SqlitePool, +} + +impl ClientApiKeyRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn insert_generated( + &self, + name: &str, + generated: &GeneratedClientApiKey, + ) -> ClientApiKeyRepositoryResult<StoredClientApiKey> { + 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)", + ) + .bind(&id) + .bind(name) + .bind(&generated.prefix) + .bind(&generated.key_hash) + .bind(&now) + .execute(&self.pool) + .await?; + Ok(StoredClientApiKey { + id, + name: name.to_string(), + prefix: generated.prefix.clone(), + enabled: true, + created_at: now, + last_used_at: None, + }) + } + + pub async fn list( + &self, + cursor: Option<String>, + limit: u32, + ) -> ClientApiKeyRepositoryResult<Page<StoredClientApiKey>> { + let fetch_limit = i64::from(limit) + 1; + let rows = if let Some(cursor) = cursor { + let (created_at, id) = + decode_cursor(&cursor).ok_or(ClientApiKeyRepositoryError::InvalidCursor)?; + sqlx::query( + "select id, name, prefix, enabled, created_at, last_used_at from client_api_keys where created_at < ? or (created_at = ? and id < ?) order by created_at desc, id desc limit ?", + ) + .bind(&created_at) + .bind(created_at) + .bind(id) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query( + "select id, name, prefix, enabled, created_at, last_used_at from client_api_keys order by created_at desc, id desc limit ?", + ) + .bind(fetch_limit) + .fetch_all(&self.pool) + .await? + }; + + let has_next = rows.len() > limit as usize; + let take_count = rows.len().min(limit as usize); + let items = rows + .into_iter() + .take(take_count) + .map(|row| key_from_row(&row)) + .collect::<Vec<_>>(); + let next_cursor = if has_next { + items + .last() + .map(|key| encode_cursor(&key.created_at, &key.id)) + } else { + None + }; + Ok(Page { items, next_cursor }) + } + + pub async fn verify_and_touch( + &self, + plaintext: &str, + hasher: &ApiKeyHasher, + ) -> ClientApiKeyRepositoryResult<bool> { + let prefix = plaintext.chars().take(12).collect::<String>(); + let rows = sqlx::query( + "select id, key_hash from client_api_keys where prefix = ? and enabled = 1", + ) + .bind(prefix) + .fetch_all(&self.pool) + .await?; + for row in rows { + let key_hash = row.get::<String, _>("key_hash"); + if hasher.verify_client_api_key(plaintext, &key_hash)? { + let id = row.get::<String, _>("id"); + sqlx::query("update client_api_keys set last_used_at = ? where id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(id) + .execute(&self.pool) + .await?; + return Ok(true); + } + } + Ok(false) + } +} + +fn key_from_row(row: &sqlx::sqlite::SqliteRow) -> StoredClientApiKey { + StoredClientApiKey { + id: row.get("id"), + name: row.get("name"), + prefix: row.get("prefix"), + enabled: row.get::<i64, _>("enabled") != 0, + created_at: row.get("created_at"), + last_used_at: row.get("last_used_at"), + } +} diff --git a/src/auth/error.rs b/src/auth/error.rs index 9d2aff6a6..ca6752538 100644 --- a/src/auth/error.rs +++ b/src/auth/error.rs @@ -8,6 +8,8 @@ pub enum AuthError { ApiKeyEncoding(#[from] base64::DecodeError), #[error("invalid api key pepper length")] InvalidPepperLength, + #[error("api key pepper file io error: {0}")] + PepperIo(#[from] std::io::Error), } pub type AuthResult<T> = Result<T, AuthError>; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 0d78319a4..caf1b9c7a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,5 +1,6 @@ pub mod admin_session; pub mod api_key; +pub mod api_key_repository; pub mod error; pub mod oauth; pub mod refresh; diff --git a/src/http/admin.rs b/src/http/admin.rs index 34e31a8e3..b4c880e7a 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -19,6 +19,7 @@ use crate::{ repository::{NewAccount, StoredAccountMetadata}, }, auth::admin_session::verify_admin_password, + auth::api_key_repository::StoredClientApiKey, config::{AppConfig, QuotaWarningThresholds}, http::{auth::admin_session_id, middleware::RequestId}, pagination::{clamp_limit, Page}, @@ -130,6 +131,13 @@ pub struct AccountsQuery { pub limit: Option<u32>, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeysQuery { + pub cursor: Option<String>, + pub limit: Option<u32>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LoginRequest { @@ -192,6 +200,62 @@ pub struct AccountImportData { pub skipped: u32, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateApiKeyRequest { + pub name: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApiKeyData { + pub id: String, + pub name: String, + pub prefix: String, + pub enabled: bool, + pub created_at: String, + pub last_used_at: Option<String>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatedClientApiKeyData { + pub id: String, + pub name: String, + pub prefix: String, + pub enabled: bool, + pub created_at: String, + pub last_used_at: Option<String>, + pub plaintext: String, +} + +impl From<StoredClientApiKey> for ClientApiKeyData { + fn from(key: StoredClientApiKey) -> Self { + Self { + id: key.id, + name: key.name, + 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 { + id: key.id, + name: key.name, + 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 { @@ -594,6 +658,119 @@ pub async fn import_accounts( .into_response() } +pub async fn api_keys( + State(state): State<AppState>, + Extension(request_id): Extension<RequestId>, + headers: HeaderMap, + Query(query): Query<ApiKeysQuery>, +) -> 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 limit = clamp_limit(query.limit.unwrap_or(50)); + match repo.list(query.cursor, limit).await { + Ok(page) => { + let Page { items, next_cursor } = page; + let page = Page { + items: items.into_iter().map(ClientApiKeyData::from).collect(), + next_cursor, + }; + AdminResponse::new( + StatusCode::OK, + AdminPageEnvelope::ok(page, limit, request_id), + ) + .into_response() + } + Err(_) => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to list API keys", (), request_id), + ) + .into_response(), + } +} + +pub async fn create_api_key( + State(state): State<AppState>, + Extension(request_id): Extension<RequestId>, + headers: HeaderMap, + Json(payload): Json<CreateApiKeyRequest>, +) -> 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 name = payload.name.trim(); + if name.is_empty() { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, "API key name is required", (), request_id), + ) + .into_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 generated = hasher.generate_client_api_key(name); + let plaintext = generated.plaintext.clone(); + match repo.insert_generated(name, &generated).await { + Ok(key) => AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(CreatedClientApiKeyData::new(key, plaintext), request_id), + ) + .into_response(), + Err(_) => AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to create API key", (), request_id), + ) + .into_response(), + } +} + #[derive(Debug)] struct AdminUserRow { id: String, diff --git a/src/http/v1.rs b/src/http/v1.rs index ea0ba1526..dc711b41b 100644 --- a/src/http/v1.rs +++ b/src/http/v1.rs @@ -42,7 +42,7 @@ pub async fn responses( headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -253,7 +253,7 @@ fn u64_to_i64_saturating(value: u64) -> i64 { } pub async fn models(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -273,7 +273,7 @@ pub async fn models(State(state): State<AppState>, headers: HeaderMap) -> impl I } pub async fn model_catalog(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -286,7 +286,7 @@ pub async fn model_detail( headers: HeaderMap, Path(model_id): Path<String>, ) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -302,7 +302,7 @@ pub async fn model_info( headers: HeaderMap, Path(model_id): Path<String>, ) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -314,7 +314,7 @@ pub async fn model_info( } pub async fn debug_models(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse { - if !has_client_api_key(&headers) { + if !authorize_client_api_key(&state, &headers).await { return missing_client_api_key_response(); } @@ -322,8 +322,19 @@ pub async fn debug_models(State(state): State<AppState>, headers: HeaderMap) -> (StatusCode::OK, Json(json!(catalog.debug()))) } -fn has_client_api_key(headers: &HeaderMap) -> bool { - client_api_key(headers).is_some() +async fn authorize_client_api_key(state: &AppState, headers: &HeaderMap) -> bool { + let Some(api_key) = client_api_key(headers) else { + return false; + }; + let Some(repo) = state.client_api_key_repository() else { + return false; + }; + let Some(hasher) = state.api_key_hasher().cloned() else { + return false; + }; + repo.verify_and_touch(api_key.as_str(), &hasher) + .await + .unwrap_or(false) } fn missing_client_api_key_response() -> (StatusCode, Json<Value>) { diff --git a/src/main.rs b/src/main.rs index e6ee3633d..2b01a48f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use codex_proxy_rs::{ app::build_router, + auth::api_key::ApiKeyHasher, config::AppConfig, crypto::SecretBox, logs::rotation::{init_tracing, RotationConfig}, @@ -19,8 +20,10 @@ async fn main() -> anyhow::Result<()> { let host = config.server.host.clone(); let port = config.server.port; let secret_box = SecretBox::load_or_create(&config.security.master_key_file)?; + let api_key_hasher = ApiKeyHasher::load_or_create(&config.security.api_key_pepper_file)?; let pool = connect_sqlite(&config.database.url).await?; - let state = AppState::with_pool_and_secret_box(config, pool, secret_box); + let state = + AppState::with_pool_secret_and_api_key_hasher(config, pool, secret_box, api_key_hasher); let restored_accounts = state.reload_account_pool_from_repository().await?; tracing::info!(restored_accounts, "account pool restored from sqlite"); let app = build_router(state); diff --git a/src/state.rs b/src/state.rs index 04eac125c..4ad42b3b6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,6 +7,7 @@ use crate::accounts::{ pool::{AccountPool, AccountPoolOptions, RotationStrategy}, repository::{AccountRepository, AccountRepositoryResult}, }; +use crate::auth::{api_key::ApiKeyHasher, api_key_repository::ClientApiKeyRepository}; use crate::config::AppConfig; use crate::cookies::repository::CookieRepository; use crate::crypto::SecretBox; @@ -22,6 +23,7 @@ pub struct AppServices { pub db: Option<SqlitePool>, pub event_logs: Option<EventLogRepository>, pub secret_box: Option<SecretBox>, + pub api_key_hasher: Option<ApiKeyHasher>, pub account_pool: Arc<Mutex<AccountPool>>, } @@ -34,6 +36,7 @@ impl AppState { db: None, event_logs: None, secret_box: None, + api_key_hasher: None, account_pool, }), } @@ -47,6 +50,7 @@ impl AppState { db: Some(pool.clone()), event_logs: Some(EventLogRepository::new(pool)), secret_box: None, + api_key_hasher: None, account_pool, }), } @@ -64,6 +68,26 @@ impl AppState { db: Some(pool.clone()), event_logs: Some(EventLogRepository::new(pool)), secret_box: Some(secret_box), + api_key_hasher: None, + account_pool, + }), + } + } + + pub fn with_pool_secret_and_api_key_hasher( + config: AppConfig, + pool: SqlitePool, + secret_box: SecretBox, + api_key_hasher: ApiKeyHasher, + ) -> Self { + let account_pool = account_pool_from_config(&config); + Self { + services: Arc::new(AppServices { + config, + db: Some(pool.clone()), + event_logs: Some(EventLogRepository::new(pool)), + secret_box: Some(secret_box), + api_key_hasher: Some(api_key_hasher), account_pool, }), } @@ -99,6 +123,14 @@ impl AppState { self.services.account_pool.clone() } + pub fn client_api_key_repository(&self) -> Option<ClientApiKeyRepository> { + Some(ClientApiKeyRepository::new(self.db()?.clone())) + } + + pub fn api_key_hasher(&self) -> Option<&ApiKeyHasher> { + self.services.api_key_hasher.as_ref() + } + pub async fn reload_account_pool_from_repository(&self) -> AccountRepositoryResult<usize> { let Some(repo) = self.account_repository() else { return Ok(0); diff --git a/tests/admin_api_keys_route_test.rs b/tests/admin_api_keys_route_test.rs new file mode 100644 index 000000000..aa3102d85 --- /dev/null +++ b/tests/admin_api_keys_route_test.rs @@ -0,0 +1,188 @@ +use std::collections::BTreeMap; + +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use chrono::Utc; +use serde_json::Value; +use tower::ServiceExt; + +use codex_proxy_rs::{ + app::build_router, + auth::api_key::ApiKeyHasher, + config::{ + AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, + QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig, + UsageStatsConfig, + }, + crypto::SecretBox, + state::AppState, + storage::db::connect_sqlite, +}; + +fn test_config(database_url: String) -> AppConfig { + AppConfig { + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 0, + }, + api: ApiConfig { + base_url: "https://chatgpt.com/backend-api".to_string(), + }, + model: ModelConfig { + default_model: "gpt-5.5".to_string(), + default_reasoning_effort: None, + service_tier: None, + aliases: BTreeMap::new(), + }, + auth: AuthConfig { + refresh_margin_seconds: 300, + refresh_enabled: true, + refresh_concurrency: 2, + max_concurrent_per_account: 3, + request_interval_ms: 50, + rotation_strategy: "least_used".to_string(), + tier_priority: Vec::new(), + }, + quota: QuotaConfig { + refresh_interval_minutes: 5, + warning_thresholds: QuotaWarningThresholds { + primary: vec![80, 90], + secondary: vec![80, 90], + }, + skip_exhausted: true, + }, + usage_stats: UsageStatsConfig { + history_retention_days: None, + }, + database: DatabaseConfig { url: database_url }, + security: SecurityConfig { + master_key_file: "data/master.key".to_string(), + api_key_pepper_file: "data/api-key-pepper.key".to_string(), + }, + tls: TlsConfig { + force_http11: false, + }, + admin: AdminConfig { + session_ttl_minutes: 1440, + }, + logging: LoggingConfig { + directory: "logs".to_string(), + max_file_bytes: 10_485_760, + retention_days: 14, + enabled: false, + capacity: 2_000, + capture_body: false, + }, + } +} + +#[tokio::test] +async fn admin_api_keys_should_create_list_and_authorize_v1_requests() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("admin-api-keys.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + seed_admin_session(&pool, "session_1").await; + let app = build_router(AppState::with_pool_secret_and_api_key_hasher( + test_config(url), + pool, + SecretBox::new([41u8; 32]), + ApiKeyHasher::new([42u8; 32]), + )); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", "Bearer cpr_not_stored") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + + let create_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/api-keys") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_api_key") + .body(Body::from(r#"{"name":"cursor"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::OK); + let body = response_json(create_response).await; + let plaintext = body["data"]["plaintext"].as_str().unwrap().to_string(); + assert!(plaintext.starts_with("cpr_")); + assert_eq!(body["requestId"], "req_api_key"); + + let list_response = 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 body = response_json(list_response).await; + assert_eq!(body["data"][0]["name"], "cursor"); + assert!(body["data"][0].get("plaintext").is_none()); + assert!(body["data"][0].get("keyHash").is_none()); + + let models_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {plaintext}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(models_response.status(), StatusCode::OK); +} + +async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)", + ) + .bind("admin_1") + .bind("hash") + .bind(&now) + .bind(&now) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)", + ) + .bind(session_id) + .bind("admin_1") + .bind("2999-01-01T00:00:00Z") + .bind(now) + .execute(pool) + .await + .unwrap(); +} + +async fn response_json(response: axum::response::Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} diff --git a/tests/routes_responses_test.rs b/tests/routes_responses_test.rs index 129c8c14b..26fbd49fb 100644 --- a/tests/routes_responses_test.rs +++ b/tests/routes_responses_test.rs @@ -3,18 +3,22 @@ use std::collections::BTreeMap; use axum::{ body::{to_bytes, Body}, http::{Request, StatusCode}, + Router, }; use serde_json::{json, Value}; use tower::ServiceExt; use codex_proxy_rs::{ app::build_router, + auth::{api_key::ApiKeyHasher, api_key_repository::ClientApiKeyRepository}, config::{ AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig, UsageStatsConfig, }, + crypto::SecretBox, state::AppState, + storage::db::connect_sqlite, }; fn test_config() -> AppConfig { @@ -78,6 +82,32 @@ fn test_config() -> AppConfig { } } +fn test_config_with_database(database_url: String) -> AppConfig { + let mut config = test_config(); + config.database.url = database_url; + config +} + +async fn test_app_with_client_api_key() -> (Router, String, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("routes-responses.sqlite"); + let url = format!("sqlite://{}", db.display()); + let pool = connect_sqlite(&url).await.unwrap(); + let hasher = ApiKeyHasher::new([52u8; 32]); + let generated = hasher.generate_client_api_key("test"); + ClientApiKeyRepository::new(pool.clone()) + .insert_generated("test", &generated) + .await + .unwrap(); + let app = build_router(AppState::with_pool_secret_and_api_key_hasher( + test_config_with_database(url), + pool, + SecretBox::new([53u8; 32]), + hasher, + )); + (app, generated.plaintext, dir) +} + #[tokio::test] async fn v1_requires_client_api_key_not_admin_cookie() { let app = build_router(AppState::new(test_config())); @@ -96,13 +126,13 @@ async fn v1_requires_client_api_key_not_admin_cookie() { #[tokio::test] async fn responses_route_rejects_non_codex_provider_models() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("POST") .uri("/v1/responses") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .header("content-type", "application/json") .body(Body::from(r#"{"model":"claude-3","input":[]}"#)) .unwrap(), @@ -114,13 +144,13 @@ async fn responses_route_rejects_non_codex_provider_models() { #[tokio::test] async fn v1_response_has_request_id_header_without_admin_body_field() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("POST") .uri("/v1/responses") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .header("x-request-id", "req_client") .body(Body::from("{}")) .unwrap(), @@ -139,13 +169,13 @@ async fn v1_response_has_request_id_header_without_admin_body_field() { #[tokio::test] async fn models_route_returns_openai_compatible_codex_model_list() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) @@ -163,13 +193,13 @@ async fn models_route_returns_openai_compatible_codex_model_list() { #[tokio::test] async fn model_catalog_route_returns_codex_metadata_without_alias_entries() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models/catalog") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) @@ -191,13 +221,13 @@ async fn model_catalog_route_returns_codex_metadata_without_alias_entries() { #[tokio::test] async fn model_detail_route_returns_openai_model_for_known_model() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models/gpt-5.5") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) @@ -213,13 +243,13 @@ async fn model_detail_route_returns_openai_model_for_known_model() { #[tokio::test] async fn model_detail_route_rejects_unknown_model_with_openai_error() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models/not-a-codex-model") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) @@ -234,13 +264,13 @@ async fn model_detail_route_rejects_unknown_model_with_openai_error() { #[tokio::test] async fn model_info_route_returns_extended_catalog_entry() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/v1/models/gpt-5.5/info") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) @@ -256,13 +286,13 @@ async fn model_info_route_returns_extended_catalog_entry() { #[tokio::test] async fn debug_models_route_returns_model_store_summary() { - let app = build_router(AppState::new(test_config())); + let (app, api_key, _dir) = test_app_with_client_api_key().await; let response = app .oneshot( Request::builder() .method("GET") .uri("/debug/models") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {api_key}")) .body(Body::empty()) .unwrap(), ) diff --git a/tests/v1_upstream_route_test.rs b/tests/v1_upstream_route_test.rs index 0def4ccc8..7c9020e0f 100644 --- a/tests/v1_upstream_route_test.rs +++ b/tests/v1_upstream_route_test.rs @@ -14,6 +14,7 @@ use wiremock::{ use codex_proxy_rs::{ app::build_router, + auth::{api_key::ApiKeyHasher, api_key_repository::ClientApiKeyRepository}, config::{ AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig, QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig, @@ -116,10 +117,17 @@ async fn v1_responses_should_use_imported_account_and_record_usage() { let pool = connect_sqlite(&url).await.unwrap(); seed_admin_session(&pool, "session_1").await; let secret_box = SecretBox::new([21u8; 32]); - let app = build_router(AppState::with_pool_and_secret_box( + let hasher = ApiKeyHasher::new([22u8; 32]); + let generated = hasher.generate_client_api_key("test"); + ClientApiKeyRepository::new(pool.clone()) + .insert_generated("test", &generated) + .await + .unwrap(); + let app = build_router(AppState::with_pool_secret_and_api_key_hasher( test_config(url, server.uri()), pool.clone(), secret_box.clone(), + hasher, )); let import_response = app @@ -153,7 +161,7 @@ async fn v1_responses_should_use_imported_account_and_record_usage() { Request::builder() .method("POST") .uri("/v1/responses") - .header("authorization", "Bearer cpr_test") + .header("authorization", format!("Bearer {}", generated.plaintext)) .header("content-type", "application/json") .body(Body::from(r#"{"model":"gpt-5.5","input":[]}"#)) .unwrap(),