From c2d17042d687dd421a9f17b323e695abe45fffa3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 29 Oct 2026 11:17:50 +0000 Subject: [PATCH 1/4] fix(auth): an empty credential header counts as offered and the 401 names it `credential_offered` documented that a present-but-unusable credential header counts as offered, but treated an empty or whitespace-only `x-aisix-api-key`, `Authorization` or `x-api-key` as absent. With MCP anonymous access enabled, such a request was served as the anonymous principal instead of being rejected. Any present credential header now counts as offered, so anonymous MCP entry answers it with 401. `ProxyError::MissingAuth` always said "missing or malformed Authorization header", even when the header that failed was `x-aisix-api-key` or `x-api-key`. It now carries the failing header's name. Status, error type/code and the auth challenge are unchanged; a request with no credential header at all keeps the old text. `gateway_key` passthrough routes read their credential with their own helper, which let an empty `Authorization` fall through to `x-api-key`. An empty `Authorization` is now a 401 there too. --- crates/aisix-proxy/src/attempt.rs | 4 +- crates/aisix-proxy/src/auth.rs | 129 +++++++++++------- crates/aisix-proxy/src/error.rs | 20 +-- crates/aisix-proxy/src/mcp.rs | 21 ++- crates/aisix-proxy/src/passthrough_route.rs | 91 +++++++++++- crates/aisix-proxy/src/realtime.rs | 2 +- .../cases/mcp-anonymous-access-e2e.test.ts | 86 +++++++++++- 7 files changed, 275 insertions(+), 78 deletions(-) diff --git a/crates/aisix-proxy/src/attempt.rs b/crates/aisix-proxy/src/attempt.rs index 0d41cd468..2b1106f4b 100644 --- a/crates/aisix-proxy/src/attempt.rs +++ b/crates/aisix-proxy/src/attempt.rs @@ -512,7 +512,7 @@ pub(crate) const GUARDRAIL_BLOCK_CLASS: &str = "content_filter"; /// upstream wrote. pub(crate) fn gateway_error_message(err: &ProxyError) -> String { let gateway_text = match err { - ProxyError::MissingAuth + ProxyError::MissingAuth(_) | ProxyError::MissingRouteAuthHeader(_) | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired @@ -574,7 +574,7 @@ pub(crate) fn attempt_reached_upstream(err: &ProxyError) -> bool { match err { ProxyError::Bridge(be) => be.reached_upstream(), ProxyError::ContentFiltered { .. } => true, - ProxyError::MissingAuth + ProxyError::MissingAuth(_) | ProxyError::MissingRouteAuthHeader(_) | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired diff --git a/crates/aisix-proxy/src/auth.rs b/crates/aisix-proxy/src/auth.rs index 8be607c4f..99ec39787 100644 --- a/crates/aisix-proxy/src/auth.rs +++ b/crates/aisix-proxy/src/auth.rs @@ -476,6 +476,11 @@ fn deny_key( pub(crate) const GATEWAY_KEY_HEADER: &str = aisix_core::forwarded_headers::GATEWAY_CREDENTIAL_HEADER; +/// How a `MissingAuth` names `Authorization` — also the header it names +/// when the request carries no credential header at all. +pub(crate) const AUTHORIZATION_HEADER: &str = "Authorization"; +pub(crate) const X_API_KEY_HEADER: &str = "x-api-key"; + /// Whether the request OFFERS an inbound gateway credential at all. /// /// Deliberately wider than [`header_credential`]: a present-but-unusable @@ -488,15 +493,10 @@ pub(crate) fn credential_offered(parts: &Parts) -> bool { [ GATEWAY_KEY_HEADER, axum::http::header::AUTHORIZATION.as_str(), - "x-api-key", + X_API_KEY_HEADER, ] .iter() - .any(|name| { - parts - .headers - .get(*name) - .is_some_and(|v| v.to_str().map_or(true, |s| !s.trim().is_empty())) - }) + .any(|name| parts.headers.contains_key(*name)) } /// Whether the caller's gateway credential came from [`GATEWAY_KEY_HEADER`] @@ -509,8 +509,9 @@ pub(crate) fn admitted_via_gateway_key_header(headers: &axum::http::HeaderMap) - /// The caller's gateway credential, read from the request headers by /// precedence: [`GATEWAY_KEY_HEADER`] when the header is present, else /// `Authorization: Bearer`, else `x-api-key`. The first header present -/// decides — an empty or malformed one is `MissingAuth`, never a -/// fall-through to the next. `None` when none of the three is present. +/// decides — an empty or malformed one is `MissingAuth` naming that +/// header, never a fall-through to the next. `None` when none of the three +/// is present. pub(crate) fn header_credential( headers: &axum::http::HeaderMap, ) -> Option> { @@ -519,14 +520,16 @@ pub(crate) fn header_credential( } if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { let Ok(s) = auth.to_str() else { - return Some(Err(ProxyError::MissingAuth)); + return Some(Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER))); }; return Some(match s.strip_prefix("Bearer ").map(str::trim) { Some(rest) if !rest.is_empty() => Ok(rest.to_string()), - _ => Err(ProxyError::MissingAuth), + _ => Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)), }); } - headers.get("x-api-key").map(raw_credential) + headers + .get(X_API_KEY_HEADER) + .map(|raw| raw_credential(raw, X_API_KEY_HEADER)) } /// The credential in [`GATEWAY_KEY_HEADER`]: the key itself, or the key @@ -534,28 +537,34 @@ pub(crate) fn header_credential( pub(crate) fn gateway_key_header_credential( raw: &axum::http::HeaderValue, ) -> Result { - let value = raw_credential(raw)?; + let value = raw_credential(raw, GATEWAY_KEY_HEADER)?; match value .strip_prefix("Bearer ") .or_else(|| value.strip_prefix("bearer ")) .map(str::trim) { - Some("") => Err(ProxyError::MissingAuth), + Some("") => Err(ProxyError::MissingAuth(GATEWAY_KEY_HEADER)), Some(key) => Ok(key.to_string()), None => Ok(value), } } -fn raw_credential(raw: &axum::http::HeaderValue) -> Result { - let s = raw.to_str().map_err(|_| ProxyError::MissingAuth)?.trim(); +fn raw_credential( + raw: &axum::http::HeaderValue, + header: &'static str, +) -> Result { + let s = raw + .to_str() + .map_err(|_| ProxyError::MissingAuth(header))? + .trim(); if s.is_empty() { - return Err(ProxyError::MissingAuth); + return Err(ProxyError::MissingAuth(header)); } Ok(s.to_string()) } fn extract_bearer(parts: &Parts) -> Result { - header_credential(&parts.headers).unwrap_or(Err(ProxyError::MissingAuth)) + header_credential(&parts.headers).unwrap_or(Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER))) } #[cfg(test)] @@ -784,6 +793,14 @@ mod tests { req.into_parts().0 } + fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (name, value) in pairs { + h.insert(*name, HeaderValue::from_static(value)); + } + h + } + #[test] fn extract_bearer_happy_path() { let mut h = HeaderMap::new(); @@ -805,13 +822,6 @@ mod tests { #[test] fn the_gateway_key_header_wins_and_never_falls_through() { - let headers = |pairs: &[(&'static str, &'static str)]| { - let mut h = HeaderMap::new(); - for (name, value) in pairs { - h.insert(*name, HeaderValue::from_static(value)); - } - h - }; let both = headers(&[ ("x-aisix-api-key", "sk-gateway"), ("authorization", "Bearer upstream-token"), @@ -830,7 +840,7 @@ mod tests { ]); assert!(matches!( extract_bearer(&parts_with(empty.clone())), - Err(ProxyError::MissingAuth) + Err(ProxyError::MissingAuth(_)) )); assert!(credential_offered(&parts_with(headers(&[( "x-aisix-api-key", @@ -849,7 +859,7 @@ mod tests { let parts = parts_with(HeaderMap::new()); assert!(matches!( extract_bearer(&parts), - Err(ProxyError::MissingAuth) + Err(ProxyError::MissingAuth(_)) )); } @@ -863,7 +873,7 @@ mod tests { let parts = parts_with(h); assert!(matches!( extract_bearer(&parts), - Err(ProxyError::MissingAuth) + Err(ProxyError::MissingAuth(_)) )); } @@ -871,32 +881,49 @@ mod tests { fn credential_offered_covers_unusable_credentials() { // Absent → not offered: the anonymous path may serve these. assert!(!credential_offered(&parts_with(HeaderMap::new()))); - let mut empty = HeaderMap::new(); - empty.insert( - axum::http::header::AUTHORIZATION, - HeaderValue::from_static(" "), - ); - assert!(!credential_offered(&parts_with(empty))); - - // Present but unusable → offered: must fail, never downgrade. - for (name, value) in [ - (axum::http::header::AUTHORIZATION.as_str(), "Bearer sk-abc"), - (axum::http::header::AUTHORIZATION.as_str(), "Bearer "), - ( - axum::http::header::AUTHORIZATION.as_str(), - "Basic dXNlcjpwdw==", - ), - ("x-api-key", "sk-abc"), + + // Present but unusable → offered: must fail, never downgrade. An + // empty or whitespace-only value is one of those. + for name in [ + GATEWAY_KEY_HEADER, + axum::http::header::AUTHORIZATION.as_str(), + X_API_KEY_HEADER, ] { - let mut h = HeaderMap::new(); - h.insert(name, HeaderValue::from_str(value).unwrap()); - assert!( - credential_offered(&parts_with(h)), - "{name}: {value:?} must count as an offered credential" - ); + for value in ["", " ", "Bearer ", "Basic dXNlcjpwdw==", "sk-abc"] { + let mut h = HeaderMap::new(); + h.insert(name, HeaderValue::from_str(value).unwrap()); + assert!( + credential_offered(&parts_with(h)), + "{name}: {value:?} must count as an offered credential" + ); + } } } + #[test] + fn missing_auth_names_the_header_that_failed() { + let message = |pairs: &[(&'static str, &'static str)]| { + extract_bearer(&parts_with(headers(pairs))) + .unwrap_err() + .to_string() + }; + let authorization = "missing or malformed Authorization header"; + assert_eq!(message(&[]), authorization); + assert_eq!(message(&[("authorization", "")]), authorization); + assert_eq!(message(&[("authorization", "Basic abc")]), authorization); + assert_eq!( + message(&[ + ("x-aisix-api-key", ""), + ("authorization", "Bearer sk-valid") + ]), + "missing or malformed x-aisix-api-key header" + ); + assert_eq!( + message(&[("x-api-key", " ")]), + "missing or malformed x-api-key header" + ); + } + #[test] fn extract_bearer_rejects_empty_bearer() { let mut h = HeaderMap::new(); @@ -907,7 +934,7 @@ mod tests { let parts = parts_with(h); assert!(matches!( extract_bearer(&parts), - Err(ProxyError::MissingAuth) + Err(ProxyError::MissingAuth(_)) )); } } diff --git a/crates/aisix-proxy/src/error.rs b/crates/aisix-proxy/src/error.rs index 4b3a33371..98480bea8 100644 --- a/crates/aisix-proxy/src/error.rs +++ b/crates/aisix-proxy/src/error.rs @@ -163,8 +163,10 @@ impl ErrorEnvelope { #[derive(Debug, thiserror::Error)] pub enum ProxyError { - #[error("missing or malformed Authorization header")] - MissingAuth, + /// No usable gateway credential. Carries the credential header that + /// failed, so the message points at the header to fix. + #[error("missing or malformed {0} header")] + MissingAuth(&'static str), /// A `header_key` passthrough route saw no gateway key in its configured /// header. Named separately from [`ProxyError::MissingAuth`] because on /// these routes `Authorization` deliberately carries the caller's own @@ -458,7 +460,7 @@ impl ProxyError { pub fn status(&self) -> StatusCode { match self { ProxyError::MissingRouteAuthHeader(_) - | ProxyError::MissingAuth + | ProxyError::MissingAuth(_) | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled @@ -503,7 +505,7 @@ impl ProxyError { pub fn kind(&self) -> &'static str { match self { ProxyError::MissingRouteAuthHeader(_) - | ProxyError::MissingAuth + | ProxyError::MissingAuth(_) | ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled @@ -784,7 +786,7 @@ impl ProxyError { /// fault, not a credential judgment. fn auth_challenge(&self) -> Option { match self { - ProxyError::MissingAuth => Some(AuthChallenge::MissingCredentials), + ProxyError::MissingAuth(_) => Some(AuthChallenge::MissingCredentials), ProxyError::InvalidApiKey | ProxyError::ApiKeyExpired | ProxyError::ApiKeyDisabled @@ -1186,7 +1188,7 @@ mod tests { #[test] fn missing_auth_maps_to_401_invalid_api_key() { - let e = ProxyError::MissingAuth; + let e = ProxyError::MissingAuth("Authorization"); assert_eq!(e.status(), StatusCode::UNAUTHORIZED); assert_eq!(e.kind(), "invalid_api_key"); } @@ -1213,7 +1215,7 @@ mod tests { #[test] fn auth_challenge_classification_covers_exactly_the_curable_failures() { assert!(matches!( - ProxyError::MissingAuth.auth_challenge(), + ProxyError::MissingAuth("Authorization").auth_challenge(), Some(AuthChallenge::MissingCredentials) )); for e in [ @@ -1265,7 +1267,7 @@ mod tests { #[tokio::test] async fn into_response_attaches_the_challenge_extension() { - let resp = ProxyError::MissingAuth.into_response(); + let resp = ProxyError::MissingAuth("Authorization").into_response(); assert!(matches!( resp.extensions().get::(), Some(AuthChallenge::MissingCredentials) @@ -1482,7 +1484,7 @@ mod tests { #[tokio::test] async fn anthropic_envelope_401_maps_to_authentication_error() { - let err = ProxyError::MissingAuth; + let err = ProxyError::MissingAuth("Authorization"); let resp = err.into_anthropic_response(); assert_anthropic_envelope(resp, StatusCode::UNAUTHORIZED, "authentication_error").await; } diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index 37d17b4a4..057177cd0 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -2504,12 +2504,21 @@ mod tests { let router = router_with(snapshot_with_anonymous( serde_json::json!({ "servers": ["docs"], "aggregate_entry": true }), )); - for bad in ["Bearer sk-not-a-real-key", "Basic dXNlcjpwdw==", "Bearer "] { + let mut offered: Vec<(&str, &str)> = + ["Bearer sk-not-a-real-key", "Basic dXNlcjpwdw==", "Bearer "] + .into_iter() + .map(|bad| ("authorization", bad)) + .collect(); + // An empty credential header is an offered one too, whichever slot. + for name in ["x-aisix-api-key", "authorization", "x-api-key"] { + offered.push((name, "")); + offered.push((name, " ")); + } + for (name, bad) in offered { let mut request = from_ip(initialize_request(None), "10.1.2.3"); - request.headers_mut().insert( - axum::http::header::AUTHORIZATION, - axum::http::HeaderValue::from_str(bad).unwrap(), - ); + request + .headers_mut() + .insert(name, axum::http::HeaderValue::from_str(bad).unwrap()); let response = router .clone() .oneshot(request) @@ -2518,7 +2527,7 @@ mod tests { assert_eq!( response.status(), StatusCode::UNAUTHORIZED, - "{bad:?} must be rejected, not served anonymously" + "{name}: {bad:?} must be rejected, not served anonymously" ); } } diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index 8ca7b851b..6087a7e31 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -1294,9 +1294,7 @@ async fn authenticate( // one read (and an empty one is not a fall-through). let token = match headers.get(crate::auth::GATEWAY_KEY_HEADER) { Some(raw) => crate::auth::gateway_key_header_credential(raw)?, - None => bearer_of(headers.get(header::AUTHORIZATION)) - .or_else(|| raw_of(headers.get("x-api-key"))) - .ok_or(ProxyError::MissingAuth)?, + None => legacy_gateway_credential(headers)?, }; crate::auth::authenticate_token(state, &token, ctx).await } @@ -1340,6 +1338,23 @@ async fn authenticate( } } +/// `Authorization: Bearer`, else `x-api-key`. An empty `Authorization` is an +/// offered credential and never falls through to `x-api-key`. +fn legacy_gateway_credential(headers: &HeaderMap) -> Result { + use crate::auth::{AUTHORIZATION_HEADER, X_API_KEY_HEADER}; + let authorization = headers.get(header::AUTHORIZATION); + if let Some(token) = bearer_of(authorization) { + return Ok(token); + } + if authorization.is_some_and(|v| v.as_bytes().trim_ascii().is_empty()) { + return Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)); + } + match headers.get(X_API_KEY_HEADER) { + Some(raw) => raw_of(Some(raw)).ok_or(ProxyError::MissingAuth(X_API_KEY_HEADER)), + None => Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)), + } +} + fn bearer_of(v: Option<&HeaderValue>) -> Option { let s = v?.to_str().ok()?; let token = s @@ -10760,6 +10775,76 @@ data: {"type":"error","error":{"type":"server_error","code":null,"message":"nest assert_eq!(usage(br#"{"code":"InvalidParameter","message":"x"}"#), None); } + /// An empty credential header is offered-and-invalid: it never falls + /// through to the next header, and the 401 names the header that failed. + #[tokio::test] + async fn gateway_key_route_rejects_an_empty_credential_header() { + let upstream = MockServer::builder().start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true}))) + .mount(&upstream) + .await; + let snap = AisixSnapshot::new(); + snap.provider_keys + .insert(provider_key_entry("http://unused")); + snap.apikeys.insert(apikey_entry("sk-caller", Some(&["*"]))); + snap.passthrough_routes + .insert(inject_route(&upstream.uri())); + let app = build_app(snap); + + let send = |pairs: &'static [(&'static str, &'static str)]| { + let app = app.clone(); + async move { + let mut req = Request::builder() + .method("GET") + .uri("/passthrough/openai/v1/models"); + for (name, value) in pairs { + req = req.header(*name, *value); + } + let resp = app + .oneshot(req.body(axum::body::Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status(); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(resp.into_body(), 65536).await.unwrap()) + .unwrap_or_default(); + ( + status, + body["error"]["message"].as_str().unwrap_or("").to_string(), + ) + } + }; + + assert_eq!(send(&[("x-api-key", "sk-caller")]).await.0, StatusCode::OK); + assert_eq!( + send(&[("authorization", ""), ("x-api-key", "sk-caller")]).await, + ( + StatusCode::UNAUTHORIZED, + "missing or malformed Authorization header".to_string() + ) + ); + assert_eq!( + send(&[("x-api-key", " ")]).await, + ( + StatusCode::UNAUTHORIZED, + "missing or malformed x-api-key header".to_string() + ) + ); + assert_eq!( + send(&[ + ("x-aisix-api-key", ""), + ("authorization", "Bearer sk-caller") + ]) + .await, + ( + StatusCode::UNAUTHORIZED, + "missing or malformed x-aisix-api-key header".to_string() + ) + ); + } + #[tokio::test] async fn inject_strips_caller_credentials_even_with_empty_strip_headers() { // A ProviderKey whose strip_headers is explicitly EMPTY: the diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index 2d45c029e..978877128 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -581,7 +581,7 @@ async fn authenticate( } } } - Err(ProxyError::MissingAuth) + Err(ProxyError::MissingAuth(crate::auth::AUTHORIZATION_HEADER)) } fn to_ws_scheme(url: &str) -> Result { diff --git a/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts b/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts index 2df56e3ce..79a6ff85e 100644 --- a/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts +++ b/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts @@ -27,7 +27,8 @@ import { // principal's own grant is `*`; // - an unlisted-but-registered server is indistinguishable from an unknown // one (401, never 404); -// - a credential that is offered and fails NEVER downgrades to anonymous; +// - a credential that is offered and fails NEVER downgrades to anonymous, +// and an empty credential header is an offered one; // - a valid credential keeps its own identity and wider reach on the same // entries. @@ -36,6 +37,8 @@ const ANON_PRINCIPAL_SECRET = `sk-anon-e2e-principal-${randomUUID()}`; const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); +type Credential = string | Record | undefined; + interface RpcReply { status: number; json?: { @@ -57,17 +60,22 @@ describe("mcp anonymous access e2e", () => { let seed: SeedClient; const settingsId = randomUUID(); - /** POST a JSON-RPC body; `token` omitted means no credential at all. */ + /** + * POST a JSON-RPC body. A string `token` rides `Authorization: Bearer`, an + * object is sent as the credential headers verbatim, and `undefined` means + * no credential at all. + */ const post = async ( path: string, - token: string | undefined, + token: Credential, body: unknown, ): Promise => { const headers: Record = { "content-type": "application/json", accept: "application/json, text/event-stream", }; - if (token !== undefined) headers.authorization = `Bearer ${token}`; + if (typeof token === "string") headers.authorization = `Bearer ${token}`; + else if (token) Object.assign(headers, token); const res = await fetch(`${app!.proxyUrl}${path}`, { method: "POST", headers, @@ -85,7 +93,7 @@ describe("mcp anonymous access e2e", () => { const initialize = async ( path: string, - token: string | undefined, + token: Credential, ): Promise => { const init = await post(path, token, { jsonrpc: "2.0", @@ -108,7 +116,7 @@ describe("mcp anonymous access e2e", () => { const listToolNames = async ( path: string, - token: string | undefined, + token: Credential, ): Promise<{ status: number; names?: string[] }> => { const status = await initialize(path, token); if (status !== 200) return { status }; @@ -264,6 +272,72 @@ describe("mcp anonymous access e2e", () => { } }); + test("an empty credential header is offered, never anonymous", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // A caller that sent a credential header, even an empty one, tried to + // authenticate: serving it as the anonymous principal would hand it a + // grant it did not ask for. + for (const name of ["x-aisix-api-key", "authorization", "x-api-key"]) { + for (const value of ["", " "]) { + for (const path of ["/mcp/docs", "/mcp"]) { + expect( + await initialize(path, { [name]: value }), + `${path} with ${name}: ${JSON.stringify(value)}`, + ).toBe(401); + } + } + } + }); + + test("an empty x-aisix-api-key is a 401 naming it, despite a valid Authorization", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const creds = { "x-aisix-api-key": "", authorization: `Bearer ${KEY_WILD}` }; + for (const res of [ + await fetch(`${app.proxyUrl}/mcp/docs`, { + method: "POST", + headers: { + ...creds, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }), + }), + await fetch(`${app.proxyUrl}/v1/chat/completions`, { + method: "POST", + headers: { ...creds, "content-type": "application/json" }, + body: JSON.stringify({ + model: "any", + messages: [{ role: "user", content: "hi" }], + }), + }), + ]) { + expect(res.status).toBe(401); + const body = (await res.json()) as { error?: { message?: string } }; + expect(body.error?.message).toContain("x-aisix-api-key"); + expect(body.error?.message).not.toContain("Authorization"); + } + }); + + test("a valid x-aisix-api-key authenticates as its own key", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const listed = await listToolNames("/mcp", { "x-aisix-api-key": KEY_WILD }); + expect(listed.status).toBe(200); + // The key's own reach, not the anonymous principal's capped one. + expect(listed.names).toEqual([ + "docs__echo", + "docs__reverse", + "kb__echo", + "kb__reverse", + ]); + const models = await fetch(`${app.proxyUrl}/v1/models`, { + headers: { "x-aisix-api-key": KEY_WILD }, + }); + expect(models.status).toBe(200); + }); + test("a valid credential keeps its own identity and reach", async (ctx) => { if (!etcdReachable || !app) return ctx.skip(); From d4d14f317b1017ec24639a26261e7bd148137603 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 29 Oct 2026 11:21:37 +0000 Subject: [PATCH 2/4] test(e2e): drop the whitespace variant fetch cannot send; name the Authorization label constant for what it is --- crates/aisix-proxy/src/auth.rs | 13 +++++++------ crates/aisix-proxy/src/passthrough_route.rs | 6 +++--- crates/aisix-proxy/src/realtime.rs | 2 +- .../e2e/src/cases/mcp-anonymous-access-e2e.test.ts | 14 +++++++------- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/aisix-proxy/src/auth.rs b/crates/aisix-proxy/src/auth.rs index 99ec39787..a87437c71 100644 --- a/crates/aisix-proxy/src/auth.rs +++ b/crates/aisix-proxy/src/auth.rs @@ -476,9 +476,10 @@ fn deny_key( pub(crate) const GATEWAY_KEY_HEADER: &str = aisix_core::forwarded_headers::GATEWAY_CREDENTIAL_HEADER; -/// How a `MissingAuth` names `Authorization` — also the header it names -/// when the request carries no credential header at all. -pub(crate) const AUTHORIZATION_HEADER: &str = "Authorization"; +/// How a `MissingAuth` message spells `Authorization` (display text, not +/// the lowercase wire name) — also what it names when the request carries +/// no credential header at all. +pub(crate) const AUTHORIZATION_LABEL: &str = "Authorization"; pub(crate) const X_API_KEY_HEADER: &str = "x-api-key"; /// Whether the request OFFERS an inbound gateway credential at all. @@ -520,11 +521,11 @@ pub(crate) fn header_credential( } if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { let Ok(s) = auth.to_str() else { - return Some(Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER))); + return Some(Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL))); }; return Some(match s.strip_prefix("Bearer ").map(str::trim) { Some(rest) if !rest.is_empty() => Ok(rest.to_string()), - _ => Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)), + _ => Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)), }); } headers @@ -564,7 +565,7 @@ fn raw_credential( } fn extract_bearer(parts: &Parts) -> Result { - header_credential(&parts.headers).unwrap_or(Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER))) + header_credential(&parts.headers).unwrap_or(Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL))) } #[cfg(test)] diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index 6087a7e31..a81bbf155 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -1341,17 +1341,17 @@ async fn authenticate( /// `Authorization: Bearer`, else `x-api-key`. An empty `Authorization` is an /// offered credential and never falls through to `x-api-key`. fn legacy_gateway_credential(headers: &HeaderMap) -> Result { - use crate::auth::{AUTHORIZATION_HEADER, X_API_KEY_HEADER}; + use crate::auth::{AUTHORIZATION_LABEL, X_API_KEY_HEADER}; let authorization = headers.get(header::AUTHORIZATION); if let Some(token) = bearer_of(authorization) { return Ok(token); } if authorization.is_some_and(|v| v.as_bytes().trim_ascii().is_empty()) { - return Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)); + return Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)); } match headers.get(X_API_KEY_HEADER) { Some(raw) => raw_of(Some(raw)).ok_or(ProxyError::MissingAuth(X_API_KEY_HEADER)), - None => Err(ProxyError::MissingAuth(AUTHORIZATION_HEADER)), + None => Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)), } } diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index 978877128..61167e55d 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -581,7 +581,7 @@ async fn authenticate( } } } - Err(ProxyError::MissingAuth(crate::auth::AUTHORIZATION_HEADER)) + Err(ProxyError::MissingAuth(crate::auth::AUTHORIZATION_LABEL)) } fn to_ws_scheme(url: &str) -> Result { diff --git a/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts b/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts index 79a6ff85e..9b1d499a9 100644 --- a/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts +++ b/tests/e2e/src/cases/mcp-anonymous-access-e2e.test.ts @@ -278,14 +278,14 @@ describe("mcp anonymous access e2e", () => { // A caller that sent a credential header, even an empty one, tried to // authenticate: serving it as the anonymous principal would hand it a // grant it did not ask for. + // (fetch trims header values, and HTTP drops a field's surrounding + // whitespace anyway, so a whitespace-only value arrives as this one.) for (const name of ["x-aisix-api-key", "authorization", "x-api-key"]) { - for (const value of ["", " "]) { - for (const path of ["/mcp/docs", "/mcp"]) { - expect( - await initialize(path, { [name]: value }), - `${path} with ${name}: ${JSON.stringify(value)}`, - ).toBe(401); - } + for (const path of ["/mcp/docs", "/mcp"]) { + expect( + await initialize(path, { [name]: "" }), + `${path} with an empty ${name}`, + ).toBe(401); } } }); From 6abafcfdf5b49c342ee26e939bf6c7784807c9f8 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 29 Oct 2026 11:32:13 +0000 Subject: [PATCH 3/4] fix(auth): passthrough reads the gateway credential like /v1; Bearer scheme is case-insensitive `gateway_key` passthrough routes now read the gateway credential through the same `header_credential` as `/v1/*`, `/mcp`, `/a2a` and realtime: the first credential header present decides. A present but unusable `Authorization` (`Basic ...`, `Bearer` with no token, non-ASCII bytes) is a 401 naming `Authorization`, and no longer falls through to `x-api-key`. A caller that keeps `Authorization` for something else should send the gateway key in `x-aisix-api-key`. The `Bearer` scheme name is matched case-insensitively (RFC 7235) wherever an inbound credential is read: `/v1/*`, `/mcp`, `/a2a`, realtime, passthrough (`gateway_key` and the `header_key` header) and the Admin API. `bearer ` and `BEARER ` now work everywhere; the key itself stays case-sensitive. --- crates/aisix-admin/src/auth.rs | 17 +++- crates/aisix-core/src/forwarded_headers.rs | 28 ++++++ crates/aisix-proxy/src/auth.rs | 28 ++++-- crates/aisix-proxy/src/passthrough_route.rs | 84 ++++++++-------- .../src/cases/passthrough-route-e2e.test.ts | 97 +++++++++++++++++++ 5 files changed, 203 insertions(+), 51 deletions(-) diff --git a/crates/aisix-admin/src/auth.rs b/crates/aisix-admin/src/auth.rs index 2ebfbaa72..ed3d21615 100644 --- a/crates/aisix-admin/src/auth.rs +++ b/crates/aisix-admin/src/auth.rs @@ -51,7 +51,7 @@ pub(crate) fn is_admin_authorized(headers: &axum::http::HeaderMap, admin_keys: & fn extract_bearer(headers: &axum::http::HeaderMap) -> Result { if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { let s = auth.to_str().map_err(|_| AdminError::Unauthorized)?; - if let Some(rest) = s.strip_prefix("Bearer ") { + if let Some(rest) = aisix_core::forwarded_headers::strip_bearer_scheme(s) { let rest = rest.trim(); if rest.is_empty() { return Err(AdminError::Unauthorized); @@ -95,6 +95,21 @@ mod tests { ); } + #[test] + fn extract_bearer_matches_the_scheme_case_insensitively() { + for value in ["bearer admin-secret", "BEARER admin-secret"] { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static(value), + ); + assert_eq!( + extract_bearer(&parts_with(h).headers).unwrap(), + "admin-secret" + ); + } + } + #[test] fn extract_bearer_accepts_x_api_key_fallback() { let mut h = HeaderMap::new(); diff --git a/crates/aisix-core/src/forwarded_headers.rs b/crates/aisix-core/src/forwarded_headers.rs index 6a576ed2e..1061a4a2c 100644 --- a/crates/aisix-core/src/forwarded_headers.rs +++ b/crates/aisix-core/src/forwarded_headers.rs @@ -78,6 +78,17 @@ pub const GATEWAY_HEADER_PREFIX: &str = "x-aisix-"; /// slot is refused for capture or as a request-id source. pub const GATEWAY_CREDENTIAL_HEADER: &str = "x-aisix-api-key"; +/// The credential behind a `Bearer` authentication scheme, matching the +/// scheme name case-insensitively (RFC 7235 §2.1) and the credential as +/// written. `None` when `value` does not start with `Bearer `. +pub fn strip_bearer_scheme(value: &str) -> Option<&str> { + const SCHEME: &str = "bearer "; + value + .get(..SCHEME.len()) + .filter(|scheme| scheme.eq_ignore_ascii_case(SCHEME)) + .map(|_| &value[SCHEME.len()..]) +} + /// Client headers never forwarded by a surface that REBUILDS the outbound /// message, on top of [`NON_FORWARDABLE_HEADERS`]. /// @@ -287,6 +298,23 @@ pub fn resolve_forwarded_client_headers( #[cfg(test)] mod tests { + + #[test] + fn bearer_scheme_is_case_insensitive_and_the_token_is_not() { + for v in [ + "Bearer sk-AbC", + "bearer sk-AbC", + "BEARER sk-AbC", + "bEaReR sk-AbC", + ] { + assert_eq!(strip_bearer_scheme(v), Some("sk-AbC"), "{v}"); + } + for v in ["Basic sk-AbC", "Bearer", "Bearersk-AbC", "", "Bé"] { + assert_eq!(strip_bearer_scheme(v), None, "{v}"); + } + assert_eq!(strip_bearer_scheme("Bearer "), Some("")); + } + use super::*; fn map(pairs: &[(&str, &str)]) -> HeaderMap { diff --git a/crates/aisix-proxy/src/auth.rs b/crates/aisix-proxy/src/auth.rs index a87437c71..dee88e523 100644 --- a/crates/aisix-proxy/src/auth.rs +++ b/crates/aisix-proxy/src/auth.rs @@ -523,10 +523,12 @@ pub(crate) fn header_credential( let Ok(s) = auth.to_str() else { return Some(Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL))); }; - return Some(match s.strip_prefix("Bearer ").map(str::trim) { - Some(rest) if !rest.is_empty() => Ok(rest.to_string()), - _ => Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)), - }); + return Some( + match aisix_core::forwarded_headers::strip_bearer_scheme(s).map(str::trim) { + Some(rest) if !rest.is_empty() => Ok(rest.to_string()), + _ => Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)), + }, + ); } headers .get(X_API_KEY_HEADER) @@ -539,11 +541,7 @@ pub(crate) fn gateway_key_header_credential( raw: &axum::http::HeaderValue, ) -> Result { let value = raw_credential(raw, GATEWAY_KEY_HEADER)?; - match value - .strip_prefix("Bearer ") - .or_else(|| value.strip_prefix("bearer ")) - .map(str::trim) - { + match aisix_core::forwarded_headers::strip_bearer_scheme(&value).map(str::trim) { Some("") => Err(ProxyError::MissingAuth(GATEWAY_KEY_HEADER)), Some(key) => Ok(key.to_string()), None => Ok(value), @@ -901,6 +899,18 @@ mod tests { } } + #[test] + fn the_bearer_scheme_is_case_insensitive() { + for value in ["bearer sk-abc", "BEARER sk-abc", "bEaReR sk-abc"] { + assert_eq!( + extract_bearer(&parts_with(headers(&[("authorization", value)]))).unwrap(), + "sk-abc" + ); + let key = headers(&[("x-aisix-api-key", value)]); + assert_eq!(extract_bearer(&parts_with(key)).unwrap(), "sk-abc"); + } + } + #[test] fn missing_auth_names_the_header_that_failed() { let message = |pairs: &[(&'static str, &'static str)]| { diff --git a/crates/aisix-proxy/src/passthrough_route.rs b/crates/aisix-proxy/src/passthrough_route.rs index a81bbf155..2d05cedaf 100644 --- a/crates/aisix-proxy/src/passthrough_route.rs +++ b/crates/aisix-proxy/src/passthrough_route.rs @@ -1290,12 +1290,11 @@ async fn authenticate( }; match route.auth_mode { PassthroughAuthMode::GatewayKey => { - // The dedicated gateway-key header, when present, is the only - // one read (and an empty one is not a fall-through). - let token = match headers.get(crate::auth::GATEWAY_KEY_HEADER) { - Some(raw) => crate::auth::gateway_key_header_credential(raw)?, - None => legacy_gateway_credential(headers)?, - }; + // Read exactly as `/v1/*` reads it: the first credential header + // present decides, never a fall-through to the next. + let token = crate::auth::header_credential(headers).unwrap_or(Err( + ProxyError::MissingAuth(crate::auth::AUTHORIZATION_LABEL), + ))?; crate::auth::authenticate_token(state, &token, ctx).await } PassthroughAuthMode::HeaderKey => { @@ -1303,7 +1302,12 @@ async fn authenticate( let token = headers .get(name) .and_then(|v| v.to_str().ok()) - .map(|v| v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string()) + .map(|v| { + aisix_core::forwarded_headers::strip_bearer_scheme(v) + .unwrap_or(v) + .trim() + .to_string() + }) .filter(|v| !v.is_empty()) .ok_or_else(|| ProxyError::MissingRouteAuthHeader(name.to_string()))?; crate::auth::authenticate_token(state, &token, ctx).await @@ -1338,37 +1342,6 @@ async fn authenticate( } } -/// `Authorization: Bearer`, else `x-api-key`. An empty `Authorization` is an -/// offered credential and never falls through to `x-api-key`. -fn legacy_gateway_credential(headers: &HeaderMap) -> Result { - use crate::auth::{AUTHORIZATION_LABEL, X_API_KEY_HEADER}; - let authorization = headers.get(header::AUTHORIZATION); - if let Some(token) = bearer_of(authorization) { - return Ok(token); - } - if authorization.is_some_and(|v| v.as_bytes().trim_ascii().is_empty()) { - return Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)); - } - match headers.get(X_API_KEY_HEADER) { - Some(raw) => raw_of(Some(raw)).ok_or(ProxyError::MissingAuth(X_API_KEY_HEADER)), - None => Err(ProxyError::MissingAuth(AUTHORIZATION_LABEL)), - } -} - -fn bearer_of(v: Option<&HeaderValue>) -> Option { - let s = v?.to_str().ok()?; - let token = s - .strip_prefix("Bearer ") - .or_else(|| s.strip_prefix("bearer "))?; - let token = token.trim(); - (!token.is_empty()).then(|| token.to_string()) -} - -fn raw_of(v: Option<&HeaderValue>) -> Option { - let s = v?.to_str().ok()?.trim(); - (!s.is_empty()).then(|| s.to_string()) -} - /// Route-level source allowlist: unset means unrestricted (the schema /// forces a non-empty list for `anonymous` routes). fn source_allowed(route: &PassthroughRoute, source_ip: &str) -> bool { @@ -10775,10 +10748,11 @@ data: {"type":"error","error":{"type":"server_error","code":null,"message":"nest assert_eq!(usage(br#"{"code":"InvalidParameter","message":"x"}"#), None); } - /// An empty credential header is offered-and-invalid: it never falls - /// through to the next header, and the 401 names the header that failed. + /// A `gateway_key` route reads the credential exactly as `/v1/*` does: + /// the first credential header present decides, an empty or unusable + /// one is a 401 naming it, and the Bearer scheme is case-insensitive. #[tokio::test] - async fn gateway_key_route_rejects_an_empty_credential_header() { + async fn gateway_key_route_reads_the_credential_like_v1() { let upstream = MockServer::builder().start().await; Mock::given(wm_method("GET")) .and(wm_path("/v1/models")) @@ -10818,6 +10792,34 @@ data: {"type":"error","error":{"type":"server_error","code":null,"message":"nest }; assert_eq!(send(&[("x-api-key", "sk-caller")]).await.0, StatusCode::OK); + // Only the scheme is case-insensitive. + for pairs in [ + &[("authorization", "bearer sk-caller")], + &[("authorization", "BEARER sk-caller")], + ] { + assert_eq!(send(pairs).await.0, StatusCode::OK, "{pairs:?}"); + } + assert_eq!( + send(&[("authorization", "Bearer SK-CALLER")]).await.0, + StatusCode::UNAUTHORIZED + ); + // A present but unusable Authorization never falls through either. + for pairs in [ + &[ + ("authorization", "Basic dXNlcjpwdw=="), + ("x-api-key", "sk-caller"), + ], + &[("authorization", "Bearer "), ("x-api-key", "sk-caller")], + ] { + assert_eq!( + send(pairs).await, + ( + StatusCode::UNAUTHORIZED, + "missing or malformed Authorization header".to_string() + ), + "{pairs:?}" + ); + } assert_eq!( send(&[("authorization", ""), ("x-api-key", "sk-caller")]).await, ( diff --git a/tests/e2e/src/cases/passthrough-route-e2e.test.ts b/tests/e2e/src/cases/passthrough-route-e2e.test.ts index 8847b16fe..98d15d448 100644 --- a/tests/e2e/src/cases/passthrough-route-e2e.test.ts +++ b/tests/e2e/src/cases/passthrough-route-e2e.test.ts @@ -216,6 +216,103 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, unclaimed pat }); }); + test("gateway_key credential reads like /v1: case-insensitive Bearer, no fall-through", async (ctx) => { + if (!etcdReachable || !app || !seed) { + ctx.skip(); + return; + } + + const upstream = await startOpenAiUpstream({ + nonStreamBody: { + id: "chatcmpl-ptr-cred", + object: "chat.completion", + created: 0, + model: "gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + }); + upstreams.push(upstream); + const pk = await seed.createProviderKey({ + display_name: "ptr-cred-pk", + secret: "sk-mock", + api_base: `${upstream.baseUrl}/v1`, + }); + await seed.createModel({ + display_name: "ptr-cred-model", + provider: "openai", + model_name: "gpt-4o-mini", + provider_key_id: pk.id, + }); + await seed.createPassthroughRoute({ + name: "ptr-cred-tunnel", + path_prefix: "/passthrough/ptr-cred", + target_url: `${upstream.baseUrl}/v1`, + provider_key_id: pk.id, + }); + + const chat = (path: string, headers: Record) => + fetch(`${app!.proxyUrl}${path}`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + model: "ptr-cred-model", + messages: [{ role: "user", content: "hi" }], + }), + }); + const ROUTE = "/passthrough/ptr-cred/chat/completions"; + const V1 = "/v1/chat/completions"; + + await waitConfigPropagation(async () => { + const [r, v] = await Promise.all([ + chat(ROUTE, { authorization: `Bearer ${CALLER_PLAINTEXT}` }), + chat(V1, { authorization: `Bearer ${CALLER_PLAINTEXT}` }), + ]); + await Promise.all([r.text(), v.text()]); + return r.status === 200 && v.status === 200; + }); + + // RFC 7235: the auth scheme name is case-insensitive; the key is not. + for (const path of [ROUTE, V1]) { + for (const scheme of ["bearer", "BEARER"]) { + const res = await chat(path, { authorization: `${scheme} ${CALLER_PLAINTEXT}` }); + expect(res.status, `${path} with ${scheme}`).toBe(200); + await res.text(); + } + const upper = await chat(path, { + authorization: `Bearer ${CALLER_PLAINTEXT.toUpperCase()}`, + }); + expect(upper.status, `${path} with an upper-cased key`).toBe(401); + await upper.text(); + } + + // A present but unusable Authorization is a 401 naming it, never a + // fall-through to a valid x-api-key — on the route as on /v1. + for (const path of [ROUTE, V1]) { + for (const authorization of ["Basic dXNlcjpwdw==", "Bearer"]) { + const res = await chat(path, { authorization, "x-api-key": CALLER_PLAINTEXT }); + expect(res.status, `${path} with ${authorization}`).toBe(401); + const body = (await res.json()) as { error?: { message?: string } }; + expect(body.error?.message).toBe("missing or malformed Authorization header"); + } + } + + // The migration path: the gateway key in x-aisix-api-key leaves + // Authorization to whatever the caller needs it for. + const migrated = await chat(ROUTE, { + "x-aisix-api-key": CALLER_PLAINTEXT, + authorization: "Basic dXNlcjpwdw==", + }); + expect(migrated.status).toBe(200); + await migrated.text(); + }); + test("anthropic inject shape: x-api-key + anthropic-version, no Bearer", async (ctx) => { if (!etcdReachable || !app || !seed) { ctx.skip(); From c774727ed75d3e9b6bc095b38896e4be7b780237 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 29 Oct 2026 11:45:22 +0000 Subject: [PATCH 4/4] test(e2e): gate the passthrough credential case on a key seeded last, not on the behaviour under test --- .../src/cases/passthrough-route-e2e.test.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/e2e/src/cases/passthrough-route-e2e.test.ts b/tests/e2e/src/cases/passthrough-route-e2e.test.ts index 98d15d448..cc2a5dfa6 100644 --- a/tests/e2e/src/cases/passthrough-route-e2e.test.ts +++ b/tests/e2e/src/cases/passthrough-route-e2e.test.ts @@ -48,6 +48,8 @@ const CALLER_PLAINTEXT = "sk-ptr-e2e-caller"; const CALLER_KEY_HASH = createHash("sha256") .update(CALLER_PLAINTEXT) .digest("hex"); +const CRED_PLAINTEXT = "sk-ptr-e2e-cred"; +const CRED_KEY_HASH = createHash("sha256").update(CRED_PLAINTEXT).digest("hex"); const STREAM_LIMITED_PLAINTEXT = "sk-ptr-e2e-stream-limit"; const STREAM_LIMITED_KEY_HASH = createHash("sha256") .update(STREAM_LIMITED_PLAINTEXT) @@ -256,6 +258,14 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, unclaimed pat target_url: `${upstream.baseUrl}/v1`, provider_key_id: pk.id, }); + // Seeded last: its authenticating implies the model and route landed. + await seed.createApiKey({ + key_hash: CRED_KEY_HASH, + allowed_models: ["*"], + allowed_routes: ["*"], + }); + const probe = new ProxyClient(app.proxyUrl, CRED_PLAINTEXT); + await waitConfigPropagation(async () => (await probe.listModels()).status === 200); const chat = (path: string, headers: Record) => fetch(`${app!.proxyUrl}${path}`, { @@ -269,24 +279,15 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, unclaimed pat const ROUTE = "/passthrough/ptr-cred/chat/completions"; const V1 = "/v1/chat/completions"; - await waitConfigPropagation(async () => { - const [r, v] = await Promise.all([ - chat(ROUTE, { authorization: `Bearer ${CALLER_PLAINTEXT}` }), - chat(V1, { authorization: `Bearer ${CALLER_PLAINTEXT}` }), - ]); - await Promise.all([r.text(), v.text()]); - return r.status === 200 && v.status === 200; - }); - // RFC 7235: the auth scheme name is case-insensitive; the key is not. for (const path of [ROUTE, V1]) { - for (const scheme of ["bearer", "BEARER"]) { - const res = await chat(path, { authorization: `${scheme} ${CALLER_PLAINTEXT}` }); + for (const scheme of ["Bearer", "bearer", "BEARER"]) { + const res = await chat(path, { authorization: `${scheme} ${CRED_PLAINTEXT}` }); expect(res.status, `${path} with ${scheme}`).toBe(200); await res.text(); } const upper = await chat(path, { - authorization: `Bearer ${CALLER_PLAINTEXT.toUpperCase()}`, + authorization: `Bearer ${CRED_PLAINTEXT.toUpperCase()}`, }); expect(upper.status, `${path} with an upper-cased key`).toBe(401); await upper.text(); @@ -296,7 +297,7 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, unclaimed pat // fall-through to a valid x-api-key — on the route as on /v1. for (const path of [ROUTE, V1]) { for (const authorization of ["Basic dXNlcjpwdw==", "Bearer"]) { - const res = await chat(path, { authorization, "x-api-key": CALLER_PLAINTEXT }); + const res = await chat(path, { authorization, "x-api-key": CRED_PLAINTEXT }); expect(res.status, `${path} with ${authorization}`).toBe(401); const body = (await res.json()) as { error?: { message?: string } }; expect(body.error?.message).toBe("missing or malformed Authorization header"); @@ -306,7 +307,7 @@ describe("passthrough-route e2e: explicit routes, BYO credentials, unclaimed pat // The migration path: the gateway key in x-aisix-api-key leaves // Authorization to whatever the caller needs it for. const migrated = await chat(ROUTE, { - "x-aisix-api-key": CALLER_PLAINTEXT, + "x-aisix-api-key": CRED_PLAINTEXT, authorization: "Basic dXNlcjpwdw==", }); expect(migrated.status).toBe(200);