feat(server): door-surface Basic auth tolerance (review 008)

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.
This commit is contained in:
glm-5.3-flash committed 2026-10-17 16:09:36 +00:00
1 parent ad63507124
commit 0766f4bebe
12 files changed
+511 -55

No files matched your search

+156 -26
View File
@@ -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>) -> 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<RouterState> = 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<RouterState> = 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>) -> 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<Identity> {
None
}
fn resolve_from_token(
&self,
token: &alkcall::core::auth::AuthToken,
) -> Option<Identity> {
(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<dyn IdentityProvider> = 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(
+195 -15
View File
@@ -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<Identity>` 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<Identity>` 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 <token>`) → `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<Identity>` in request extensions.
/// `Option<Identity>` in request extensions. Bearer-only — a Basic
/// header contributes no identity (ADR-073 keeps Basic scoped to
/// [`door_auth_middleware`]).
///
/// The state is `Arc<dyn IdentityProvider>` 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<Identity>` 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<dyn IdentityProvider>` (same shape as
/// [`bearer_auth_middleware`]).
pub async fn door_auth_middleware(
State(identity_provider): State<Arc<dyn IdentityProvider>>,
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 <token>` header and resolve it to
/// an `Option<Identity>`. 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<Identity> {
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<Identity> {
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<String> {
header?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(str::to_owned)
}
fn basic_password(header: &http::HeaderValue) -> Option<String> {
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<String>,
identity_provider: &dyn IdentityProvider,
) -> Option<Identity> {
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<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
(String::from_utf8_lossy(&token.raw) == self.password).then(sample_identity)
}
}
struct StaticProvider {
identity: Option<Identity>,
}
@@ -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<Identity> {
None
}
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
(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<dyn IdentityProvider> =
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::<Option<Identity>>()
.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()));
+4 -1
View File
@@ -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;