From cec460762c43c0f88243b9deba1bd0de4b76a0a4 Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 12 Jun 2026 00:07:28 +0800 Subject: [PATCH] feat: add http auth boundary Co-authored-by: Codex --- docs/implementation-status.md | 1 + .../plans/2026-06-11-codex-proxy-rs.md | 1 + src/http/admin.rs | 8 +--- src/http/auth.rs | 23 +++++++++++ src/http/v1.rs | 8 +--- tests/http_auth_test.rs | 38 +++++++++++++++++++ 6 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 tests/http_auth_test.rs diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 6d4015121..c5548759b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -18,6 +18,7 @@ Update this file before each feature commit. | 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` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1` unauthorized OpenAI error body, global request-id response header, and binary listener. Still missing admin auth middleware extraction, account/auth/API-key management routes, settings routes, diagnostics, usage stats, model catalog/detail/refresh routes, and additional 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 | pending | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_logs_route_test`; full `fmt/test/clippy` before commit | 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. | | Upstream lifecycle | Partial | `88cad87` + `225fbff` | `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`; full `fmt/test/clippy` before commit | `/v1` currently enforces client API key shape, rejects non-Codex provider models, returns OpenAI-compatible errors, and includes reusable SSE/usage helpers. It does not yet acquire accounts, forward to Codex, replay/capture Cookies, stream/collect upstream responses, capture rate-limit headers, retry across accounts, record usage, or preserve previous-response/session affinity. | | Fingerprint updates | Partial | `dd26d31` + `e32220a` | `cargo test update_manifest_updates_app_version_and_build_number`; `cargo test fingerprint_updater_fetches_manifest_and_persists_history`; full `fmt/test/clippy` before commit | Parses a JSON update manifest and persists app version/build number history to SQLite. Still missing original appcast XML polling, update-state persistence, runtime header fingerprint selection from the latest stored fingerprint, and optional local Codex.app Chromium extraction. | | Runtime docs and packaging | Completed | `33b63f3` | `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; `cargo run`; `curl -s -i http://127.0.0.1:8080/health` | README scope, `.env.example`, Dockerfile, compose file, and health smoke test are complete. | 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 a49932482..84a8678e8 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -2956,6 +2956,7 @@ git commit -m "docs: document codex-proxy-rs scope" - [x] Replace the refresh policy shell with a native refresh scheduler. It refreshes before expiry, refreshes immediately after an upstream 401 trigger, preserves the existing refresh token when the refresh response omits one, maps refresh failures to account statuses, and enforces configured concurrency with a semaphore. - [x] Fill the previously empty `src/codex/sse.rs` and `src/codex/usage.rs` modules with tested SSE frame parsing/encoding and token usage extraction helpers. - [x] Add a tested SQLite account repository foundation. It encrypts access/refresh tokens, decrypts account reads, cursor-pages accounts, updates status/labels without rewriting token ciphertext, persists usage counters, and preserves the existing refresh token when token refresh responses omit a new one. +- [x] Add a real HTTP auth boundary. `src/http/auth.rs` parses client API keys and admin session cookies as separate credential types, `/v1/*` uses only `Bearer cpr_...`, and admin session parsing ignores client API keys. ## 2026-06-11 Original Project Gap Audit diff --git a/src/http/admin.rs b/src/http/admin.rs index b1bed11b5..2bbdac8c5 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::{ auth::admin_session::verify_admin_password, - http::middleware::RequestId, + http::{auth::admin_session_id, middleware::RequestId}, pagination::{clamp_limit, Page}, state::AppState, }; @@ -348,9 +348,5 @@ async fn validate_admin_session( } fn admin_session_cookie(headers: &HeaderMap) -> Option<&str> { - let cookie = headers.get("cookie")?.to_str().ok()?; - cookie.split(';').map(str::trim).find_map(|part| { - let (name, value) = part.split_once('=')?; - (name == "cpr_admin_session" && !value.is_empty()).then_some(value) - }) + admin_session_id(headers) } diff --git a/src/http/auth.rs b/src/http/auth.rs index 8b1378917..6eb213df1 100644 --- a/src/http/auth.rs +++ b/src/http/auth.rs @@ -1 +1,24 @@ +use axum::http::HeaderMap; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientApiKey<'a>(&'a str); + +impl<'a> ClientApiKey<'a> { + pub fn as_str(self) -> &'a str { + self.0 + } +} + +pub fn client_api_key(headers: &HeaderMap) -> Option> { + let auth = headers.get("authorization")?.to_str().ok()?.trim(); + let token = auth.strip_prefix("Bearer ")?.trim(); + token.starts_with("cpr_").then_some(ClientApiKey(token)) +} + +pub fn admin_session_id(headers: &HeaderMap) -> Option<&str> { + let cookie = headers.get("cookie")?.to_str().ok()?; + cookie.split(';').map(str::trim).find_map(|part| { + let (name, value) = part.split_once('=')?; + (name == "cpr_admin_session" && !value.is_empty()).then_some(value) + }) +} diff --git a/src/http/v1.rs b/src/http/v1.rs index bda01adda..b0e40759c 100644 --- a/src/http/v1.rs +++ b/src/http/v1.rs @@ -7,7 +7,7 @@ use axum::{ use serde::Deserialize; use serde_json::json; -use crate::translation::codex_to_openai::openai_error; +use crate::{http::auth::client_api_key, translation::codex_to_openai::openai_error}; #[derive(Deserialize)] struct ResponsesBody { @@ -67,9 +67,5 @@ pub async fn models(headers: HeaderMap) -> impl IntoResponse { } fn has_client_api_key(headers: &HeaderMap) -> bool { - let auth = headers - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - auth.starts_with("Bearer cpr_") + client_api_key(headers).is_some() } diff --git a/tests/http_auth_test.rs b/tests/http_auth_test.rs new file mode 100644 index 000000000..caaacf7cb --- /dev/null +++ b/tests/http_auth_test.rs @@ -0,0 +1,38 @@ +use axum::http::{HeaderMap, HeaderValue}; + +use codex_proxy_rs::http::auth::{admin_session_id, client_api_key}; + +#[test] +fn client_api_key_should_accept_only_cpr_bearer_tokens() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer cpr_local_client_key"), + ); + assert_eq!( + client_api_key(&headers).unwrap().as_str(), + "cpr_local_client_key" + ); + + headers.insert( + "authorization", + HeaderValue::from_static("Bearer sk-official-openai-key"), + ); + assert!(client_api_key(&headers).is_none()); +} + +#[test] +fn admin_session_id_should_ignore_client_api_key_and_extract_only_cookie() { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static("Bearer cpr_local_client_key"), + ); + assert!(admin_session_id(&headers).is_none()); + + headers.insert( + "cookie", + HeaderValue::from_static("theme=dark; cpr_admin_session=sess_123; other=1"), + ); + assert_eq!(admin_session_id(&headers), Some("sess_123")); +}