feat: add admin client api key management

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-12 01:58:08 +08:00
co-authored by Codex
parent 2ed0c9d74f
commit e9fdea08d2
15 changed files with 695 additions and 33 deletions
+36 -1
View File
@@ -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 <plaintext>`.
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.
+3 -3
View File
@@ -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. |
@@ -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.
+2 -1
View File
@@ -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))
+22
View File
@@ -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()
+151
View File
@@ -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"),
}
}
+2
View File
@@ -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>;
+1
View File
@@ -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;
+177
View File
@@ -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,
+19 -8
View File
@@ -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>) {
+4 -1
View File
@@ -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);
+32
View File
@@ -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);
+188
View File
@@ -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()
}
+46 -16
View File
@@ -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(),
)
+10 -2
View File
@@ -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(),