From 0766f4bebeae61cde97c092698bcd4931a4214d2 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sat, 17 Oct 2026 16:09:36 +0000 Subject: [PATCH] feat(server): door-surface Basic auth tolerance (review 008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 008 resolution: - AH-1: door_auth_middleware on extras doors resolves Authorization: Basic base64(user:pass) — password field as the token (GitHub-PAT shape, git-lfs / git smart-HTTP clients) — through the same IdentityProvider::resolve_from_token backend as Bearer; no new trait method, resolve-fails -> None fallback preserved. Bearer surfaces stay Bearer-only: the gateway endpoints, /openapi.json, /healthz, the MCP nest, and the WS upgrade path never resolve Basic. The 'Bearer-only is the auth mechanism' doc line flips (ADR-073). - AH-2 ride-along: extras door route tables are static — the merged router owns the decoy fallback, a second fallback panics at construction; documented on with_extra_routes and ADR-046 §1. docs: ADR-073, ADR-046 §1/§4 amendment, ADR-004 presentation table, review 008 marked resolved; new server::* re-exports; dep:base64 behind the server feature. Verification: cargo test (449 pass, incl. door Basic resolve-through and gateway-stays-Bearer-only pins), test --all-features (528), clippy --all-targets -D warnings, fmt --check, fuzz corpus replay (21/21), cargo doc. --- CHANGELOG.md | 29 +++ Cargo.lock | 1 + Cargo.toml | 3 +- docs/architecture/README.md | 1 + .../decisions/004-auth-as-shared-core.md | 2 +- .../046-assembly-layer-custom-http-routes.md | 35 ++- .../073-door-surface-basic-auth-tolerance.md | 87 ++++++++ docs/reviews/008-http-door-consumer-review.md | 10 +- fuzz/Cargo.lock | 1 + src/server/adapter.rs | 182 ++++++++++++--- src/server/auth.rs | 210 ++++++++++++++++-- src/server/mod.rs | 5 +- 12 files changed, 511 insertions(+), 55 deletions(-) create mode 100644 docs/architecture/decisions/073-door-surface-basic-auth-tolerance.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ca904a7..8755a1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ this crate adheres to [Semantic Versioning](https://semver.org/). ## [0.8.0] — 2026-10-17 +### Added + +- **Door-surface Basic auth tolerance (review 008 AH-1, ADR-073).** + The extras-door surface now resolves `Authorization: Basic + base64(user:pass)` — the password field acts as the token and rides + the same `IdentityProvider::resolve_from_token()` backend as Bearer + (the GitHub-PAT shape `git-lfs` and git's smart-HTTP transport + authenticate with). No new trait method; the provider's grant table + governs exactly as it does for Bearer, and "unparseable / + unresolvable → None" fallback semantics are preserved. Basic is + scoped to the door surface: `bearer_auth_middleware` stays + Bearer-only on the gateway endpoints, `/openapi.json`, `/healthz`, + and the MCP nest, and the WS upgrade path is untouched — a Basic + credential contributes no identity on any path a deployment has not + explicitly mounted doors on. New public API: + `server::door_auth_middleware`, `server::extract_door_identity` + (`bearer_auth_middleware` / `extract_bearer_identity` keep their + Bearer-only semantics and their coverage). + ### Changed - **`alkcall` dependency bumped to 0.10.0** (the gateway ownership-seam @@ -25,6 +44,16 @@ this crate adheres to [Semantic Versioning](https://semver.org/). static fallback) are pinned at the HTTP surface by `call_resource_gated_*` regression tests. +### Documented + +- **Extras door route tables are static (review 008 AH-2).** One doc + line on the door-facing surface (`HttpAdapter::with_extra_routes`) + and in ADR-046: the merged router already owns the decoy fallback + and axum refuses a second, so a door attempting `fallback_layer` + panics at construction — enumerate multi-segment namespace shapes + (`{user}/{repo}/...`) as pattern routes. Not a mechanism ask; the + next door author should not rediscover the panic. + ### Fixed - `HttpAdapter::with_ws_max_sessions` never stored the freshly built diff --git a/Cargo.lock b/Cargo.lock index 581c86a..946ed5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", + "base64 0.22.1", "bytes", "futures", "http", diff --git a/Cargo.toml b/Cargo.toml index 2ac8000..e753569 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ default = ["server", "client", "h2", "http1"] # driver. `openapi` rides along because /openapi.json is part of the # default surface (ADR-042/045); `http1` rides along so the hyper # auto-builder always has at least one protocol compiled. -server = ["dep:axum", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"] +server = ["dep:axum", "dep:base64", "dep:hyper", "dep:hyper-util", "dep:uuid", "openapi", "http1"] # Consumer side: the shared outbound client host and the HTTP-backed # import adapters (`from_jsonschema`, `from_openapi` with `openapi`, # `from_mcp` with `mcp`). @@ -49,6 +49,7 @@ http1 = ["server", "dep:hyper", "hyper-util/http1", "hyper/http1"] alkcall = { version = "0.10", features = ["gateway"] } arc-swap = { version = "1", optional = true } axum = { version = "0.8", optional = true, features = ["ws"] } +base64 = { version = "0.22", optional = true } bytes = "1" futures = "0.3" http = "1" diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddfed41..6efa28d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -70,6 +70,7 @@ types — the former `alknet-core` and `alknet-call` merged). | [070](decisions/070-from-wss-consumer-adapter.md) | `from_wss` Consumer Adapter | Import a remote node's operations over WSS — same-protocol importer, channels-over-WS as transport; `wss` feature gate | | [071](decisions/071-dispatch-schema-guard.md) | Dispatch-Spine `services/schema` Op-Path Guard | `GatewayDispatch` applies the GET `/schema` visibility+ACL checks to the meta-op's inner `name` (review-002 PRJ-16); alkcall CF-004 is the complete fix, this stays as defense-in-depth (now via the promoted `alkcall::gateway` shared check — spine + guard themselves live in alkcall per ADR-048) | | [072](decisions/072-http-surface-ownership-wiring.md) | HTTP-Surface Ownership Wiring (Gateway + MCP) | alkcall 0.10's loud ownership default passes through: `HttpAdapter::with_ownership_provider` / `with_static_ownership_fallback` thread the provider into the gateway and MCP spines (single wiring in `GatewayState::dispatch`) | +| [073](decisions/073-door-surface-basic-auth-tolerance.md) | Door-Surface Basic Auth Tolerance | `door_auth_middleware` on extras doors resolves `Basic base64(user:pass)` (password field as the token) through the same `resolve_from_token` backend; gateway/MCP/WS stay Bearer-only (review 008 AH-1) | ## Relevant Open Questions diff --git a/docs/architecture/decisions/004-auth-as-shared-core.md b/docs/architecture/decisions/004-auth-as-shared-core.md index dd6fab9..35511c1 100644 --- a/docs/architecture/decisions/004-auth-as-shared-core.md +++ b/docs/architecture/decisions/004-auth-as-shared-core.md @@ -36,7 +36,7 @@ Credential presentation per handler: |---------|------------------------|-------------| | SshAdapter | SSH public key handshake | `resolve_from_fingerprint()` | | CallAdapter | AuthToken in first frame | `resolve_from_token()` | -| HttpAdapter | `Authorization: Bearer` header | `resolve_from_token()` | +| HttpAdapter | `Authorization` header — Bearer (all HTTP surfaces); Basic additionally on the extras-door surface (ADR-073, the password field as the token) | `resolve_from_token()` | | DnsAdapter | AuthToken in query labels | `resolve_from_token()` | | WebTransportAdapter | AuthToken in CONNECT headers | `resolve_from_token()` | | GitAdapter | Signed push certificate | `resolve_from_fingerprint()` | diff --git a/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md index 7bd65de..0d3be46 100644 --- a/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md +++ b/docs/architecture/decisions/046-assembly-layer-custom-http-routes.md @@ -95,6 +95,16 @@ the one-way constraint is that the assembly layer can inject routes and they coexist with the default surface. axum's `Router::merge` / `Router::nest` are the natural primitives. +**Route tables are static (review 008 AH-2).** The merged router +already owns the fallback route (the decoy) — axum refuses a second +fallback, and a door attempting `fallback_layer`/`fallback` on the +merged router panics at construction, not at request time. Door route +tables must therefore be statically enumerated: multi-segment +repository namespaces (`/{user}/{repo}/objects/batch` shape) are +pattern routes with wildcard segments, never a dynamic fallback match. +This is a recorded constraint of the router assembly, not a mechanism +ask. + ### 2. Custom routes are raw HTTP, not call-protocol operations A custom route is a raw axum handler — it receives an HTTP request and @@ -134,16 +144,21 @@ is natural (`/v1/...` doesn't collide). ### 4. Custom routes carry the same auth middleware by default; per-route opt-out is the deployment's choice -Custom routes run under the same Bearer-auth resolution as the default -surface (the `Authorization: Bearer` → `resolve_from_token` path). A -deployment that wants a custom route to be unauthenticated (a public -webhook receiver, a health endpoint with a different shape than -`/healthz`) applies axum middleware to opt that route out of auth — -the deployment owns its custom routes' middleware stack. The -`HttpAdapter` provides the identity provider and the default auth -middleware; the custom `Router` the assembly layer passes in can -layer its own middleware on top. This is standard axum composition; no -alknet-specific mechanism. +Custom routes run under the door-surface auth middleware: Bearer +resolution identically to the default surface +(`Authorization: Bearer` → `resolve_from_token`), plus — ADR-073 — +Basic tolerance (`Basic base64(user:pass)`; the password field rides +the same `resolve_from_token` backend). The default token-auth surface +(gateway endpoints, `/openapi.json`, `/healthz`, MCP) stays +Bearer-only: Basic resolution is scoped to the door surface +specifically constructed for legacy clients. A deployment that wants a +custom route to be unauthenticated (a public webhook receiver, a +health endpoint with a different shape than `/healthz`) applies axum +middleware to opt that route out of auth — the deployment owns its +custom routes' middleware stack. The `HttpAdapter` provides the +identity provider and the default middlewares; the custom `Router` the +assembly layer passes in can layer its own middleware on top. This is +standard axum composition; no alknet-specific mechanism. ### 5. Custom routes are not part of the published `to_openapi` doc diff --git a/docs/architecture/decisions/073-door-surface-basic-auth-tolerance.md b/docs/architecture/decisions/073-door-surface-basic-auth-tolerance.md new file mode 100644 index 0000000..8029da5 --- /dev/null +++ b/docs/architecture/decisions/073-door-surface-basic-auth-tolerance.md @@ -0,0 +1,87 @@ +# ADR-073: Door-Surface Basic Auth Tolerance (git / git-lfs Clients) + +## Status + +Accepted + +## Context + +Review 008 AH-1, from alkgit's LFS POC (POC-L): a real `git-lfs` +3.7.0 client authenticates the batch endpoint with **Basic** +credentials (URL userinfo — the spec-shape behavior of the official +client; config `http://user:pass@host/repo`). The shared identity +middleware was Bearer-only — the module doc said so explicitly +("Bearer-only is the auth mechanism") — so a door mounted under the +shared auth layer received the batch request with the middleware +already having folded in `None`, and the door had to re-parse the +`Authorization` header itself (Basic *or* Bearer) to get any identity. + +The middleware is shared by the gateway endpoints and `to_mcp`. Every +HTTP-door surface whose real client authenticates with Basic (LFS +today; git's smart-HTTP transport is tomorrow's `git` door) would +silently re-implement the fallback locally — the quiet-bypass shape +alkcall review 012 records for the ownership seam, one layer up in the +same stack. + +## Decision + +**Split the auth surface into token surfaces and the door surface, +and teach the door surface Basic — through the same resolution +backend, with no new `IdentityProvider` method.** + +- `bearer_auth_middleware` stays Bearer-only and continues to cover + the token-auth surfaces: the gateway endpoints, `/openapi.json`, + `/healthz`, and the `to_mcp` nest. A Basic header contributes no + identity there. `/call`, `/batch`, `/subscribe`, `/publish`, `/mcp`, + and the WS upgrade path are token surfaces — this is the "no blanket + method" constraint: a Basic credential never gains identity anywhere + the deployment has not explicitly set up for it. +- `door_auth_middleware` (new) is applied by `build_router` to the + extras-door router (`HttpAdapter::with_extra_routes`) — the surface + legacy clients authenticate on. +- Both middlewares resolve through `IdentityProvider::resolve_from_token()`. + Basic parsing: `Basic base64(user:pass)` — the password field (the + GitHub-PAT shape; the user segment is ignored) is decoded and fed as + the `AuthToken` raw bytes. A colon-less value is treated as a bare + password. No new trait method: the provider's grant table governs a + Basic credential exactly as it governs the same credential presented + as Bearer — and the same backend mechanism that grants privileges to + tokens grants them to these credentials (SOCKS5 + userinfo-style integrations can follow the same pattern later). +- Fallback semantics unchanged: no header → `None`; unparseable or + unresolvable credential → `None` (unauthenticated, not an error). + A parse-time failure never becomes a 4xx of its own. +- Doors may opt out with their own middleware stack as before + ([ADR-046](046-assembly-layer-custom-http-routes.md) §4). + +## Consequences + +**Positive:** + +- `git-lfs` / `git` doors work against a deployment whose provider + maps password-tokens through `resolve_from_token`, with zero + door-side header re-parsing. +- Scoping is structural (per-middleware), not + per-path-inspection: Basic tolerance exists only where the + assembly layer mounted the door surface. + +**Negative:** + +- A door surface deployment that does *not* want its door credentials + resolvable as Basic must now ensure the provider does not resolve + those values as tokens — or opt the door out of the shared layer. +- Scheme prefixes remain case-sensitive ("Bearer "/"Basic "), matching + the pre-existing Bearer parsing; strictly RFC-noncompliant + lowercase-scheme clients parse to `None`. + +## References + +- [Review 008](../../reviews/008-http-door-consumer-review.md) — AH-1 + evidence and disposition space +- [ADR-004](004-auth-as-shared-core.md) — the shared-core resolution + backend this rides +- [ADR-046](046-assembly-layer-custom-http-routes.md) — the extras-door + surface the door middleware covers (§4 amended; AH-2 static-route + constraint recorded there) +- alkcall review 012 — the same-round quiet-bypass analysis this + avoids repeating at the door layer \ No newline at end of file diff --git a/docs/reviews/008-http-door-consumer-review.md b/docs/reviews/008-http-door-consumer-review.md index 5843f1f..ba243da 100644 --- a/docs/reviews/008-http-door-consumer-review.md +++ b/docs/reviews/008-http-door-consumer-review.md @@ -2,7 +2,15 @@ ## Status -Open — filed 2026-10-17. Report-only consumer input from alkgit's LFS +Resolved — 2026-10-17. AH-1: door-surface Basic tolerance landed as +[ADR-073](../architecture/decisions/073-door-surface-basic-auth-tolerance.md) +(`door_auth_middleware` on extras doors; password field through the +same `resolve_from_token` backend; token surfaces stay Bearer-only). +AH-2: recorded on the door-facing surface +([ADR-046](../architecture/decisions/046-assembly-layer-custom-http-routes.md) +§1). Both ride the alkhttp 0.8.0 release (with the alkcall 0.10 +ownership wiring, ADR-072). Filed 2026-10-17 as consumer input from +alkgit's LFS POC (POC-L, 2026-10-16; findings at `/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the POC crate is `/workspace/lfs-poc`, which served a real `git-lfs` 3.7.0 diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 34d736c..8a07717 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -54,6 +54,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", + "base64 0.22.1", "bytes", "futures", "http", diff --git a/src/server/adapter.rs b/src/server/adapter.rs index ec6b97a..f7a4c69 100644 --- a/src/server/adapter.rs +++ b/src/server/adapter.rs @@ -48,7 +48,7 @@ use hyper_util::service::TowerToHyperService; use parking_lot::Mutex; use tracing::error; -use super::auth::bearer_auth_middleware; +use super::auth::{bearer_auth_middleware, door_auth_middleware}; use super::decoy::{decoy_fallback, decoy_method_not_allowed}; use super::healthz::healthz; use super::state::{DecoyConfig, RouterState}; @@ -173,9 +173,22 @@ impl HttpAdapter { self } - /// Mount assembly-provided custom routes under the bearer-auth - /// middleware (ADR-046) and rebuild the router. Reserved paths - /// (`RESERVED_PATHS`) are rejected before the merge. + /// Mount assembly-provided custom routes under the door-surface + /// auth middleware (ADR-046, ADR-073) and rebuild the router. + /// Reserved paths (`RESERVED_PATHS`) are rejected before the merge. + /// + /// The door middleware resolves Bearer and — ADR-073, review 008 + /// AH-1 — `Basic base64(user:pass)` (the password field through the + /// same `IdentityProvider::resolve_from_token()` backend), the + /// surface legacy clients (`git-lfs`, git's smart-HTTP transport) + /// authenticate on. The token-auth surface this merges onto stays + /// Bearer-only. + /// + /// Door route tables are static (review 008 AH-2): the merged + /// router already owns the decoy fallback and axum refuses a + /// second one, so a door that attempts `fallback_layer`/`fallback` + /// panics at construction — enumerate multi-segment namespace + /// shapes ({user}/{repo}/... style) as pattern routes instead. pub fn with_extra_routes(mut self, routes: Router) -> Self { // `extra_routes` is borrowed, not consumed (SRV-05): a builder // call after `with_extra_routes` must keep the custom routes in @@ -388,13 +401,29 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { // cache (SRV-09), threaded through `RouterState` below. .route("/openapi.json", get(openapi_json_handler)) .route("/healthz", get(healthz)) + // The token-auth surface's bearer layer, applied before the + // extras merge: routes registered before this call — the + // gateway endpoints, /openapi.json, /healthz — resolve + // Bearer-only (ADR-073: a Basic header contributes no identity + // on these paths; Basic is scoped to the door surface below). + .route_layer(from_fn_with_state( + Arc::clone(&auth_state), + bearer_auth_middleware, + )) .fallback(decoy_fallback) .method_not_allowed_fallback(decoy_method_not_allowed); let with_extras = match extra_routes { Some(extra) => { enforce_reserved_paths(&extra); - let extra: Router = extra.with_state(()); + // The door surface's auth layer (ADR-073): Basic-tolerant + // in addition to Bearer — exactly one resolution per door + // request (SRV-11), and the legacy-client surface this is + // scoped to. + let extra: Router = extra.with_state(()).route_layer(from_fn_with_state( + Arc::clone(&auth_state), + door_auth_middleware, + )); default.merge(extra) } None => default, @@ -407,28 +436,13 @@ fn build_router(state: RouterState, extra_routes: Option) -> Router { // surface. Idempotent for the routers the earlier call covered. let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed); - // Applied after the merges (ADR-046 §4): the bearer-auth layer wraps - // every route registered before this call (the gateway endpoints, - // /openapi.json, /healthz, and the extra routes) — axum 0.8 - // semantics are that `route_layer` wraps the routes registered - // before it, not the ones after. Routes that must not pass through - // this layer (the /mcp nest and the WS upgrade route, each with its - // own auth) are merged/registered after it — SRV-11: before the - // reorder, a route carrying an inner auth layer was also wrapped by - // this one and resolved the token twice (the SRV-10 double-resolve, - // the old comment claiming the opposite). - let with_auth = with_extras.route_layer(from_fn_with_state( - Arc::clone(&auth_state), - bearer_auth_middleware, - )); - - // Merged after the router-wide route_layer on purpose: the /mcp - // nest carries its own bearer layer (applied around the nested - // service, `from_fn_with_state` above), so registering it here keeps - // exactly one token resolution per request (SRV-11). nest_service + // Merged after the default surface's route_layer: the /mcp nest + // carries its own bearer layer (applied around the nested service, + // `from_fn_with_state` above), so registering it here keeps exactly + // one token resolution per request (SRV-11). nest_service // registers a plain Route endpoint (no MethodRouter), so the decoy // 405 fallback has no interplay with this merge. - let with_mcp = with_auth.merge(mcp_router); + let with_mcp = with_extras.merge(mcp_router); // Registered after the router-wide route_layer on purpose: axum's // route_layer applies only to earlier-registered routes, so the WS @@ -762,7 +776,7 @@ async fn openapi_json_handler( mod tests { use super::*; use crate::server::auth::ResolvedIdentity; - use alkcall::core::auth::IdentityProvider; + use alkcall::core::auth::{Identity, IdentityProvider}; use alkcall::core::types::ProtocolHandler; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1298,6 +1312,122 @@ mod tests { assert_eq!(&body[..], b"none"); } + #[tokio::test] + async fn extra_doors_resolve_basic_credentials_but_the_gateway_stays_bearer_only() { + struct PasswordProvider; + impl IdentityProvider for PasswordProvider { + fn resolve_from_fingerprint(&self, _: &str) -> Option { + None + } + fn resolve_from_token( + &self, + token: &alkcall::core::auth::AuthToken, + ) -> Option { + (String::from_utf8_lossy(&token.raw) == "lfs-token").then(|| Identity { + id: "lfs-client".to_string(), + scopes: vec![], + resources: std::collections::HashMap::new(), + }) + } + } + let idp: Arc = Arc::new(PasswordProvider); + let extra = Router::new().route( + "/v1/whoami", + get(|ResolvedIdentity(identity): ResolvedIdentity| async move { + match identity { + Some(id) => id.id, + None => "none".to_string(), + } + }), + ); + let app = build_router(router_state(Arc::clone(&idp)), Some(extra)); + + let response = get_with_bearer( + app.clone(), + "/v1/whoami", + Some("Basic dXNlcjpsZnMtdG9rZW4="), + ) + .await; + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + &body[..], + b"lfs-client", + "a Basic password resolves on the door surface" + ); + + let registry = OperationRegistry::new(); + registry + .register(alkcall::registry::registration::HandlerRegistration::new( + alkcall::registry::spec::OperationSpec::new( + "echo/run", + alkcall::registry::spec::OperationType::Query, + alkcall::registry::spec::Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + alkcall::registry::spec::AccessControl { + required_scopes: vec!["admin".to_string()], + ..Default::default() + }, + None, + ), + alkcall::registry::registration::HandlerKind::Once( + alkcall::registry::registration::make_handler(|input, ctx| async move { + alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, input) + }), + ), + alkcall::registry::registration::OperationProvenance::Local, + None, + None, + alkcall::core::types::Capabilities::new(), + )) + .unwrap(); + let gateway_state = RouterState { + registry: Arc::new(registry), + identity_provider: Arc::clone(&idp), + ownership_provider: None, + static_ownership_fallback: false, + decoy: DecoyConfig::default(), + openapi_doc: CachedOpenAPIDoc::new(&OperationRegistry::new()), + ws_sessions: Arc::new(crate::websocket::WsSessions::new()), + ws_session_slots: Arc::new(tokio::sync::Semaphore::new( + crate::websocket::DEFAULT_WS_MAX_SESSIONS, + )), + ws_idle_timeout: Some(crate::websocket::DEFAULT_WS_IDLE_TIMEOUT), + ws_openable_alpns: None, + ws_op_register_acl: alkcall::registry::spec::AccessControl::default(), + }; + let app = build_router(gateway_state, None); + + use tower::ServiceExt; + + let req = axum::http::Request::builder() + .method("POST") + .uri("/call") + .header("content-type", "application/json") + .header( + axum::http::header::AUTHORIZATION, + "Basic dXNlcjpsZnMtdG9rZW4=", + ) + .body(axum::body::Body::from( + serde_json::to_vec(&serde_json::json!({ + "operation": "echo/run", + "input": {}, + })) + .unwrap(), + )) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + assert_eq!( + response.status(), + axum::http::StatusCode::UNAUTHORIZED, + "a Basic credential contributes no identity on the token-auth gateway surface" + ); + } + #[tokio::test] async fn extra_route_with_own_layer_can_opt_out_of_default_auth() { async fn public_identity( diff --git a/src/server/auth.rs b/src/server/auth.rs index a35514f..a202849 100644 --- a/src/server/auth.rs +++ b/src/server/auth.rs @@ -1,25 +1,42 @@ -//! Shared Bearer auth axum middleware ([ADR-004]). +//! Shared identity-auth axum middlewares ([ADR-004], [ADR-073]). //! -//! Resolves the `Authorization: Bearer` header via -//! `IdentityProvider::resolve_from_token()` and stashes the resolved -//! `Option` in request extensions. Shared by the HTTP gateway -//! endpoints and the `to_mcp` service. +//! Two middleware shapes, one resolution backend +//! (`IdentityProvider::resolve_from_token()`), both stashing the +//! resolved `Option` in request extensions: //! -//! Resolution semantics: +//! - **[`bearer_auth_middleware`]** — Bearer-only. Applied to the +//! gateway endpoints, `/openapi.json`, `/healthz`, and the `to_mcp` +//! service. Token-auth surfaces (the gateway is the sole HTTP invoke +//! path; MCP's core protocol is token-based) never resolve credentials +//! presented as Basic. +//! - **[`door_auth_middleware`]** — Basic-tolerant (ADR-073). Applied to +//! the extras-door router (`HttpAdapter::with_extra_routes`), the +//! surface legacy clients authenticate on: `git-lfs`'s batch API and +//! git's smart-HTTP transport send `Basic base64(user:pass)`, with +//! the password field acting as the token (the GitHub-PAT shape). The +//! password bytes ride the same +//! `IdentityProvider::resolve_from_token()` backend as a Bearer +//! token — no separate resolution mechanism, no new trait method; +//! the provider's grant table governs exactly as it does for Bearer. +//! Basic resolution is deliberately scoped to the door surface: a +//! Basic credential is never resolved on the bearer-auth surfaces +//! unless the deployment explicitly mounts it there. +//! +//! Common resolution semantics (both middlewares): //! - No `Authorization` header → `None` (request proceeds; the route //! handler / `AccessControl` decides whether to reject). -//! - Malformed `Authorization` header (not `Bearer `) → `None` -//! (treated as no-token, not an error — Bearer-only is the auth -//! mechanism). -//! - Token present but resolution fails → `None` (treat as +//! - Unrecognized or unparseable `Authorization` header → `None` +//! (treated as no-token, not an error). +//! - Credential present but resolution fails → `None` (treat as //! unauthenticated, matching the call protocol's per-request identity //! resolution behavior). //! -//! This middleware resolves identity and stashes it; it does NOT enforce +//! These middlewares resolve identity and stash it; they do NOT enforce //! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do) //! or map `CallError` codes to HTTP status (the error mapping does). //! //! [ADR-004]: https://docs.rs/alkhttp (docs/architecture/decisions) +//! [ADR-073]: https://docs.rs/alkhttp (docs/architecture/decisions) use std::convert::Infallible; use std::sync::Arc; @@ -31,9 +48,13 @@ use axum::middleware::Next; use axum::response::Response; use http::request::Parts; -/// Axum middleware that resolves the `Authorization: Bearer` header via +/// Axum middleware for the token-auth surfaces (the gateway endpoints, +/// `/openapi.json`, `/healthz`, `to_mcp`): resolves the +/// `Authorization: Bearer` header via /// `IdentityProvider::resolve_from_token()` and stashes the resolved -/// `Option` in request extensions. +/// `Option` in request extensions. Bearer-only — a Basic +/// header contributes no identity (ADR-073 keeps Basic scoped to +/// [`door_auth_middleware`]). /// /// The state is `Arc` so the middleware can be applied /// via `middleware::from_fn_with_state(idp.clone(), bearer_auth_middleware)` @@ -48,6 +69,26 @@ pub async fn bearer_auth_middleware( next.run(request).await } +/// Axum middleware for the extras-door surface (`HttpAdapter:: +/// with_extra_routes`, ADR-073): resolves the `Authorization` header — +/// Bearer first, then Basic (the password field as the token via the +/// same `IdentityProvider::resolve_from_token()` backend) — and stashes +/// the resolved `Option` in request extensions. This is the +/// surface legacy clients (`git-lfs`, git's smart-HTTP transport) +/// authenticate on with the Basic scheme. +/// +/// The state is `Arc` (same shape as +/// [`bearer_auth_middleware`]). +pub async fn door_auth_middleware( + State(identity_provider): State>, + mut request: Request, + next: Next, +) -> Response { + let identity = extract_door_identity(&request, identity_provider.as_ref()); + request.extensions_mut().insert(identity); + next.run(request).await +} + /// Extract the `Authorization: Bearer ` header and resolve it to /// an `Option`. Returns `None` if no token is present (the /// request proceeds unauthenticated; the route handler / `AccessControl` @@ -57,11 +98,62 @@ pub async fn bearer_auth_middleware( pub fn extract_bearer_identity( request: &Request, identity_provider: &dyn IdentityProvider, +) -> Option { + resolve_token_str( + bearer_token(request.headers().get(AUTHORIZATION)), + identity_provider, + ) +} + +/// Extract the `Authorization` header with Bearer-first, Basic-tolerant +/// semantics (ADR-073): a `Basic base64(user:pass)` header resolves the +/// password field (the token; the Git-PAT shape — user, `x-access-token` +/// and similar are ignored by resolution) through the same +/// `IdentityProvider::resolve_from_token()` backend as a Bearer token. +/// A colon-less value is treated as a bare password. Unparseable or +/// unresolvable credentials resolve to `None` — the same no-token +/// fallback as [`extract_bearer_identity`]. +pub fn extract_door_identity( + request: &Request, + identity_provider: &dyn IdentityProvider, ) -> Option { let header = request.headers().get(AUTHORIZATION)?; - let token_str = header.to_str().ok()?.strip_prefix("Bearer ")?; + let credential = match bearer_token(Some(header)) { + Some(token) => token, + None => basic_password(header)?, + }; + resolve_token_str(Some(credential), identity_provider) +} + +fn bearer_token(header: Option<&http::HeaderValue>) -> Option { + header? + .to_str() + .ok()? + .strip_prefix("Bearer ") + .map(str::to_owned) +} + +fn basic_password(header: &http::HeaderValue) -> Option { + use base64::Engine as _; + + let encoded = header.to_str().ok()?.strip_prefix("Basic ")?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .ok()?; + let decoded = String::from_utf8(decoded).ok()?; + let password = match decoded.split_once(':') { + Some((_user, password)) => password, + None => &decoded, + }; + Some(password.to_owned()) +} + +fn resolve_token_str( + token_str: Option, + identity_provider: &dyn IdentityProvider, +) -> Option { let token = AuthToken { - raw: token_str.as_bytes().to_vec(), + raw: token_str?.into_bytes(), }; identity_provider.resolve_from_token(&token) } @@ -107,6 +199,19 @@ mod tests { } } + struct PasswordTokenProvider { + password: String, + } + + impl IdentityProvider for PasswordTokenProvider { + fn resolve_from_fingerprint(&self, _: &str) -> Option { + None + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + (String::from_utf8_lossy(&token.raw) == self.password).then(sample_identity) + } + } + struct StaticProvider { identity: Option, } @@ -165,6 +270,49 @@ mod tests { assert!(identity.is_none()); } + #[test] + fn door_extract_resolves_basic_password_through_the_same_token_backend() { + let idp = PasswordTokenProvider { + password: "s3cret".to_string(), + }; + let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ=")); + let identity = crate::server::auth::extract_door_identity(&req, &idp); + assert!(identity.is_some(), "Basic password resolves as the token"); + assert_eq!(identity.unwrap().id, "worker-a"); + + let req = request_with_authorization(Some("Basic OnMzY3JldA==")); + assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some()); + + let req = request_with_authorization(Some("Bearer s3cret")); + assert!(crate::server::auth::extract_door_identity(&req, &idp).is_some()); + } + + #[test] + fn door_extract_treats_colonless_basic_as_a_bare_password() { + struct BareProvider; + impl IdentityProvider for BareProvider { + fn resolve_from_fingerprint(&self, _: &str) -> Option { + None + } + fn resolve_from_token(&self, token: &AuthToken) -> Option { + (String::from_utf8_lossy(&token.raw) == "baretok").then(sample_identity) + } + } + let req = request_with_authorization(Some("Basic YmFyZXRvaw==")); + let identity = crate::server::auth::extract_door_identity(&req, &BareProvider); + assert!(identity.is_some()); + } + + #[test] + fn door_extract_returns_none_for_invalid_base64_or_failing_resolution() { + let idp = provider(None); + let req = request_with_authorization(Some("Basic !not-base64!")); + assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none()); + + let req = request_with_authorization(Some("Basic dXNlcjpwYXNz")); + assert!(crate::server::auth::extract_door_identity(&req, idp.as_ref()).is_none()); + } + #[test] fn extract_returns_none_when_token_present_but_resolution_fails() { let idp = provider(None); @@ -255,6 +403,38 @@ mod tests { assert_eq!(&bytes[..], b"none"); } + #[tokio::test] + async fn middleware_stashes_identity_for_basic_on_the_door_middleware() { + let idp: std::sync::Arc = + std::sync::Arc::new(PasswordTokenProvider { + password: "s3cret".to_string(), + }); + let app: Router<()> = Router::new() + .route( + "/", + get(|req: Request| async move { + let identity = req + .extensions() + .get::>() + .cloned() + .flatten(); + match identity { + Some(id) => (StatusCode::OK, id.id), + None => (StatusCode::OK, "none".to_string()), + } + }), + ) + .layer(from_fn_with_state(idp, door_auth_middleware)); + + let req = request_with_authorization(Some("Basic dXNlcjpzM2NyZXQ=")); + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&bytes[..], b"worker-a"); + } + #[tokio::test] async fn resolved_identity_extractor_retrieves_stashed_some() { let idp = provider(Some(sample_identity())); diff --git a/src/server/mod.rs b/src/server/mod.rs index c20fe58..9a7ef73 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -5,7 +5,10 @@ pub mod healthz; pub mod state; pub use adapter::{HttpAdapter, RESERVED_PATHS, WS_UPGRADE_PATH}; -pub use auth::{bearer_auth_middleware, extract_bearer_identity, ResolvedIdentity}; +pub use auth::{ + bearer_auth_middleware, door_auth_middleware, extract_bearer_identity, extract_door_identity, + ResolvedIdentity, +}; pub use decoy::{decoy_fallback, decoy_method_not_allowed}; pub use healthz::healthz; pub use state::DecoyConfig;