Compare commits
3 Commits
35a7a37ba6
...
539a812c12
| Author | SHA1 | Date | |
|---|---|---|---|
| 539a812c12 | |||
| ccbff3c7f8 | |||
| 36f74dd31b |
@@ -13,4 +13,6 @@ pub mod server;
|
||||
pub mod websocket;
|
||||
|
||||
pub use gateway::GatewayDispatch;
|
||||
pub use server::{DecoyConfig, HttpAdapter};
|
||||
pub use server::{
|
||||
bearer_auth_middleware, extract_bearer_identity, DecoyConfig, HttpAdapter, ResolvedIdentity,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::Router;
|
||||
@@ -28,6 +29,7 @@ use alknet_call::registry::registration::OperationRegistry;
|
||||
use alknet_core::auth::{AuthContext, IdentityProvider};
|
||||
use alknet_core::types::{Connection, HandlerError, ProtocolHandler, StreamError};
|
||||
|
||||
use super::auth::bearer_auth_middleware;
|
||||
use crate::server::decoy::decoy_fallback;
|
||||
use crate::server::healthz::healthz;
|
||||
|
||||
@@ -132,15 +134,17 @@ impl HttpAdapter {
|
||||
}
|
||||
|
||||
fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
let auth_state = Arc::clone(&state.identity_provider);
|
||||
let default: Router<RouterState> = Router::new()
|
||||
.route("/search", any(not_implemented))
|
||||
.route("/schema", any(not_implemented))
|
||||
.route("/call", any(not_implemented))
|
||||
.route("/batch", any(not_implemented))
|
||||
.route("/subscribe", any(not_implemented))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/openapi.json", get(not_implemented))
|
||||
.route("/mcp", post(not_implemented))
|
||||
.route_layer(from_fn_with_state(auth_state.clone(), bearer_auth_middleware))
|
||||
.route("/healthz", get(healthz))
|
||||
.fallback(decoy_fallback);
|
||||
|
||||
let with_extras = match extra_routes {
|
||||
|
||||
309
crates/alknet-http/src/server/auth.rs
Normal file
309
crates/alknet-http/src/server/auth.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
//! Shared Bearer auth axum middleware.
|
||||
//!
|
||||
//! 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` rmcp service (research §4.4). See
|
||||
//! `docs/architecture/crates/http/http-server.md` §"Auth" and
|
||||
//! [ADR-004](../../../docs/architecture/decisions/004-auth-as-shared-core.md).
|
||||
//!
|
||||
//! Resolution semantics:
|
||||
//! - 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
|
||||
//! unauthenticated, matching the `CallAdapter`'s per-request identity
|
||||
//! resolution behavior).
|
||||
//!
|
||||
//! This middleware resolves identity and stashes it; it does NOT enforce
|
||||
//! `AccessControl` (the route handlers / `GatewayDispatch::invoke()` do)
|
||||
//! or map `CallError` codes to HTTP status (the error-mapping task does).
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{FromRequestParts, Request, State};
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use http::request::Parts;
|
||||
|
||||
use alknet_core::auth::{AuthToken, Identity, IdentityProvider};
|
||||
|
||||
/// Axum middleware that 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` rmcp service (research §4.4).
|
||||
///
|
||||
/// 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 rmcp 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
|
||||
}
|
||||
|
||||
/// 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 `CallAdapter`'s per-request identity resolution behavior).
|
||||
pub fn extract_bearer_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 token = AuthToken {
|
||||
raw: token_str.as_bytes().to_vec(),
|
||||
};
|
||||
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 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 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 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");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! HTTP server: `HttpAdapter`, axum-over-QUIC, gateway routes, `/healthz`,
|
||||
//! decoy, and custom routes.
|
||||
//! decoy, custom routes, and shared Bearer auth middleware.
|
||||
//!
|
||||
//! Implements `alknet_core::types::ProtocolHandler` for the standard HTTP
|
||||
//! ALPNs (`h2`, `http/1.1`) with WebSocket upgrade for browser
|
||||
@@ -7,9 +7,11 @@
|
||||
//! `docs/architecture/crates/http/http-server.md`.
|
||||
|
||||
pub mod adapter;
|
||||
pub mod auth;
|
||||
pub mod decoy;
|
||||
pub mod healthz;
|
||||
|
||||
pub use adapter::{DecoyConfig, HttpAdapter};
|
||||
pub use auth::{bearer_auth_middleware, extract_bearer_identity, ResolvedIdentity};
|
||||
pub use decoy::decoy_fallback;
|
||||
pub use healthz::healthz;
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: http/server/bearer-auth-middleware
|
||||
name: Implement shared Bearer auth middleware (resolve_from_token, stash Identity in request extensions)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [http/server/http-adapter]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -176,4 +176,10 @@ axum middleware into `Parts.extensions` should be retrievable via
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Implemented src/server/auth.rs: bearer_auth_middleware (from_fn_with_state over
|
||||
> Arc<dyn IdentityProvider>, stashes Option<Identity> in request extensions),
|
||||
> extract_bearer_identity (Bearer-only: no/malformed/Basic/unresolvable → None,
|
||||
> not an error), ResolvedIdentity axum extractor (FromRequestParts). Wired into
|
||||
> HttpAdapter router via route_layer around gateway/openapi/mcp routes, excluding
|
||||
> /healthz. Re-exported from server/mod.rs and lib.rs. 11 unit tests. 137 total
|
||||
> tests pass. Clippy clean.
|
||||
Reference in New Issue
Block a user