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.
486 lines
18 KiB
Rust
486 lines
18 KiB
Rust
//! Shared identity-auth axum middlewares ([ADR-004], [ADR-073]).
|
|
//!
|
|
//! Two middleware shapes, one resolution backend
|
|
//! (`IdentityProvider::resolve_from_token()`), both stashing the
|
|
//! resolved `Option<Identity>` in request extensions:
|
|
//!
|
|
//! - **[`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).
|
|
//! - 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).
|
|
//!
|
|
//! 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;
|
|
|
|
use alkcall::core::auth::{AuthToken, Identity, IdentityProvider};
|
|
use axum::extract::{FromRequestParts, Request, State};
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::middleware::Next;
|
|
use axum::response::Response;
|
|
use http::request::Parts;
|
|
|
|
/// 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. 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)`
|
|
/// around both HTTP routes and a nested service.
|
|
pub async fn bearer_auth_middleware(
|
|
State(identity_provider): State<Arc<dyn IdentityProvider>>,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let identity = extract_bearer_identity(&request, identity_provider.as_ref());
|
|
request.extensions_mut().insert(identity);
|
|
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`
|
|
/// decides whether to reject). Returns `None` if the token is present
|
|
/// but resolution fails (treat as unauthenticated, not as an error —
|
|
/// matches the call protocol's per-request identity resolution behavior).
|
|
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 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?.into_bytes(),
|
|
};
|
|
identity_provider.resolve_from_token(&token)
|
|
}
|
|
|
|
/// Axum extractor: the resolved bearer identity (or `None` if
|
|
/// unauthenticated). Read from request extensions (stashed by
|
|
/// `bearer_auth_middleware`).
|
|
#[derive(Clone, Debug)]
|
|
pub struct ResolvedIdentity(pub Option<Identity>);
|
|
|
|
impl<S> FromRequestParts<S> for ResolvedIdentity
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = Infallible;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
let identity = parts
|
|
.extensions
|
|
.get::<Option<Identity>>()
|
|
.cloned()
|
|
.flatten();
|
|
Ok(ResolvedIdentity(identity))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::body::Body;
|
|
use axum::http::{Request as AxumRequest, StatusCode};
|
|
use axum::middleware::from_fn_with_state;
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use std::collections::HashMap;
|
|
use tower::ServiceExt;
|
|
|
|
fn sample_identity() -> Identity {
|
|
Identity {
|
|
id: "worker-a".to_string(),
|
|
scopes: vec!["relay:connect".to_string()],
|
|
resources: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
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>,
|
|
}
|
|
|
|
impl IdentityProvider for StaticProvider {
|
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
|
None
|
|
}
|
|
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
|
self.identity.clone()
|
|
}
|
|
}
|
|
|
|
fn provider(identity: Option<Identity>) -> Arc<dyn IdentityProvider> {
|
|
Arc::new(StaticProvider { identity })
|
|
}
|
|
|
|
fn request_with_authorization(value: Option<&str>) -> Request {
|
|
let mut builder = AxumRequest::builder();
|
|
if let Some(v) = value {
|
|
builder = builder.header(AUTHORIZATION, v);
|
|
}
|
|
builder.body(Body::empty()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn extract_returns_some_for_valid_bearer_when_provider_resolves() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("Bearer alk_testsecret"));
|
|
let identity = extract_bearer_identity(&req, idp.as_ref());
|
|
assert!(identity.is_some());
|
|
assert_eq!(identity.unwrap().id, "worker-a");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_returns_none_for_missing_authorization_header() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(None);
|
|
let identity = extract_bearer_identity(&req, idp.as_ref());
|
|
assert!(identity.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_returns_none_for_malformed_authorization_header() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("not-a-bearer-scheme"));
|
|
let identity = extract_bearer_identity(&req, idp.as_ref());
|
|
assert!(identity.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_returns_none_for_basic_auth_bearer_only() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("Basic dXNlcjpwYXNz"));
|
|
let identity = extract_bearer_identity(&req, idp.as_ref());
|
|
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);
|
|
let req = request_with_authorization(Some("Bearer alk_unknown"));
|
|
let identity = extract_bearer_identity(&req, idp.as_ref());
|
|
assert!(identity.is_none());
|
|
}
|
|
|
|
async fn run_middleware(idp: Arc<dyn IdentityProvider>, request: Request) -> Response {
|
|
let app: Router<()> = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|req: Request| async move {
|
|
let identity = req
|
|
.extensions()
|
|
.get::<Option<Identity>>()
|
|
.cloned()
|
|
.flatten();
|
|
if let Some(id) = identity {
|
|
(StatusCode::OK, id.id)
|
|
} else {
|
|
(StatusCode::OK, "none".to_string())
|
|
}
|
|
}),
|
|
)
|
|
.layer(from_fn_with_state(idp, bearer_auth_middleware));
|
|
|
|
app.oneshot(request).await.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn middleware_stashes_some_identity_for_valid_bearer() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("Bearer alk_testsecret"));
|
|
let response = run_middleware(idp, req).await;
|
|
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 middleware_stashes_none_when_no_authorization_header() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(None);
|
|
let response = run_middleware(idp, req).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], b"none");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn middleware_stashes_none_for_malformed_authorization() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("garbage"));
|
|
let response = run_middleware(idp, req).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], b"none");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn middleware_stashes_none_for_basic_auth() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let req = request_with_authorization(Some("Basic dXNlcjpwYXNz"));
|
|
let response = run_middleware(idp, req).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(&bytes[..], b"none");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn middleware_stashes_none_when_resolution_fails() {
|
|
let idp = provider(None);
|
|
let req = request_with_authorization(Some("Bearer alk_unknown"));
|
|
let response = run_middleware(idp, req).await;
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
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()));
|
|
let app: Router<()> = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
|
|
match identity {
|
|
Some(id) => (StatusCode::OK, id.id),
|
|
None => (StatusCode::OK, "none".to_string()),
|
|
}
|
|
}),
|
|
)
|
|
.layer(from_fn_with_state(idp, bearer_auth_middleware));
|
|
|
|
let req = request_with_authorization(Some("Bearer alk_testsecret"));
|
|
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_none() {
|
|
let idp = provider(Some(sample_identity()));
|
|
let app: Router<()> = Router::new()
|
|
.route(
|
|
"/",
|
|
get(|ResolvedIdentity(identity): ResolvedIdentity| async move {
|
|
match identity {
|
|
Some(id) => (StatusCode::OK, id.id),
|
|
None => (StatusCode::OK, "none".to_string()),
|
|
}
|
|
}),
|
|
)
|
|
.layer(from_fn_with_state(idp, bearer_auth_middleware));
|
|
|
|
let req = request_with_authorization(None);
|
|
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"none");
|
|
}
|
|
}
|