feat(msg-bus): add DPoP support, fix floow from client, correct deletion

This commit is contained in:
Edouard Vanbelle
2026-09-11 03:05:45 +02:00
parent 821f76b471
commit 75a123ae6c
15 changed files with 1025 additions and 118 deletions
+1
View File
@@ -23,6 +23,7 @@ pub mod opaque_auth_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod rt_ticket_handler;
pub mod rt_ws;
pub mod search_handler;
pub mod share_handler;
@@ -0,0 +1,80 @@
//! Ticket issuance for browser WebSocket authentication.
//!
//! `POST /api/rt/ticket` — issues a one-shot 30 s ticket for the
//! authenticated caller. Runs under the full `/api/*` middleware
//! stack (auth + DPoP), so the caller proves possession of the
//! session AND (when the session is DPoP-bound) the DPoP key on the
//! same request. The ticket then substitutes for that proof on the
//! next WS upgrade.
//!
//! See `src/infrastructure/services/rt_ticket_store.rs` for the
//! store semantics and `docs/plan/message-bus.md § F` for the
//! architectural context.
use std::sync::Arc;
use axum::{Json, extract::State};
use serde::Serialize;
use crate::common::di::AppState;
use crate::infrastructure::services::rt_ticket_store::{SUBPROTOCOL_PREFIX, TICKET_TTL};
use crate::interfaces::middleware::auth::CurrentUserId;
/// Response body for `POST /api/rt/ticket`. Deliberately minimal —
/// callers only need the token string; the TTL is echoed so the FE
/// doesn't hard-code the 30 s constant on its side.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RtTicketResponse {
/// Opaque single-use token. Present in the WS upgrade as
/// `Sec-WebSocket-Protocol: oxi.ticket.<ticket>` (the prefix is
/// baked in by both sides — see [`SUBPROTOCOL_PREFIX`]).
pub ticket: String,
/// Seconds until this ticket expires server-side. Consumers should
/// open the WS immediately; a 30 s bound leaves generous headroom
/// for the handshake without letting a captured ticket live long.
pub expires_in_seconds: u64,
/// Full `Sec-WebSocket-Protocol` value the client MUST pass on the
/// upgrade. Included pre-assembled so a FE bug can't emit the
/// wrong prefix and blow the handshake in a way that looks like a
/// server-side denial.
pub subprotocol: String,
}
/// Issue a fresh ticket for the authenticated caller. Idempotent from
/// the caller's perspective — each call mints a new token — but
/// each ticket is single-use once redeemed by the WS handler.
///
/// No rate limiting today: even a mildly abusive client would just
/// fill the ticket store with entries that reap in 30 s. If ever
/// necessary, add a per-caller_id token bucket alongside the auth
/// middleware limits.
#[utoipa::path(
post,
path = "/api/rt/ticket",
tag = "message-bus",
responses(
(status = 200, description = "Ticket issued", body = RtTicketResponse),
(status = 401, description = "Unauthenticated"),
),
security(("bearerAuth" = []))
)]
pub async fn issue_rt_ticket(
CurrentUserId(caller_id): CurrentUserId,
State(state): State<Arc<AppState>>,
) -> Json<RtTicketResponse> {
let ticket = state.rt_ticket_store.issue(caller_id);
let ticket_str = ticket.to_string();
tracing::debug!(
target: "oxicloud::message_bus",
event = "message_bus.ticket_issued",
caller_id = %caller_id,
"🎫 rt.ticket issued",
);
Json(RtTicketResponse {
subprotocol: format!("{SUBPROTOCOL_PREFIX}{ticket_str}"),
ticket: ticket_str,
expires_in_seconds: TICKET_TTL.as_secs(),
})
}
+124 -15
View File
@@ -18,13 +18,28 @@
//!
//! # Auth
//!
//! Route sits under `protected_api` (see `src/interfaces/api/routes.rs`)
//! so `auth_middleware` runs first. Cookie AND `Authorization: Bearer`
//! paths both produce a `CurrentUserId` extension the handler extracts.
//! Browser-side subprotocol bearer (`Sec-WebSocket-Protocol:
//! authorization.bearer.<jwt>`) is a Phase-A follow-up — the MVP relies
//! on the Authorization header, which programmatic clients (the
//! `rt-hurl-helper` smoke test) set directly.
//! Route is mounted at `/api/rt/ws` OUTSIDE the standard
//! `auth_middleware` + `require_dpop_layer` stack — a browser can't
//! attach a `DPoP:` header to `new WebSocket()` (RFC 6455 gives us
//! only `Sec-WebSocket-Protocol`), and the standard chain would 401
//! on every DPoP-bound session. This handler self-authenticates
//! from two accepted sources:
//!
//! 1. **Ticket subprotocol** (`Sec-WebSocket-Protocol:
//! oxi.ticket.<uuid>`) — the primary path for browser clients.
//! The FE first `POST /api/rt/ticket` under the full middleware
//! chain (auth + DPoP proofed), receives an opaque one-shot
//! token, and passes it here. Verified by redeeming through
//! [`AppState::rt_ticket_store`]. See
//! `docs/plan/message-bus.md § F`.
//! 2. **Bearer token** (`Authorization: Bearer <jwt>`) — the
//! programmatic-client path used by `rt-hurl-helper` in api-test.
//! Verified against `AuthServices::token_service`. DPoP-bound
//! tokens are rejected on this path to preserve the substrate's
//! proof-of-possession invariant.
//!
//! Neither → 401. Order matters: ticket first (short-lived, tied to
//! a proofed HTTP round-trip), bearer second.
//!
//! # Limits
//!
@@ -40,7 +55,8 @@ use std::time::Duration;
use axum::body::Bytes;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -49,13 +65,14 @@ use tokio::task::JoinHandle;
use tokio::time::MissedTickBehavior;
use uuid::Uuid;
use crate::application::ports::auth_ports::TokenServicePort;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::message_bus_ports::{
AuthzCheck, BusResource, MessageBus, MessageBusEvent, ParseTopicErr, Topic, error_code,
};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::interfaces::middleware::auth::CurrentUserId;
use crate::infrastructure::services::rt_ticket_store::SUBPROTOCOL_PREFIX;
/// Max simultaneous subscriptions on a single WS session. Beyond this the
/// server responds `-32005 sub_limit` and the client is expected to
@@ -151,20 +168,112 @@ struct RpcNotification<'a> {
// Handler entrypoint
// ════════════════════════════════════════════════════════════════════════════
/// `GET /api/rt/ws` — WS upgrade handler. Sits under `protected_api` so
/// [`CurrentUserId`] resolves against a valid session before we reach
/// `on_upgrade`.
/// `GET /api/rt/ws` — WS upgrade handler. Mounted outside the standard
/// `/api/*` middleware stack; self-authenticates via ticket
/// subprotocol OR bearer token (see the module doc).
///
/// Returns whatever `WebSocketUpgrade::on_upgrade` produces (an HTTP 101
/// Switching Protocols with the WebSocket handshake headers).
/// Returns 101 Switching Protocols on success; 401 with an audit
/// entry on any auth failure. The response is deliberately terse —
/// browsers surface the status code via the `close` event's code
/// field (1006 on a rejected upgrade), so a longer body wouldn't
/// reach the FE anyway.
pub async fn rt_ws_handler(
ws: WebSocketUpgrade,
CurrentUserId(caller_id): CurrentUserId,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
let auth = match authenticate_upgrade(&headers, &state).await {
Ok(auth) => auth,
Err(reason) => {
tracing::info!(
target: "audit",
event = "message_bus.upgrade_rejected",
reason = %reason,
"👮🏻‍♂️ WS upgrade rejected",
);
return (StatusCode::UNAUTHORIZED, "ws_auth_failed").into_response();
}
};
let caller_id = auth.caller_id;
// If the caller reached us via the ticket path, echo the exact
// subprotocol they sent back on the 101 response — RFC 6455 §4.2.2
// requires this or the client fails the connection.
let ws = match auth.accepted_subprotocol {
Some(sub) => ws.protocols([sub]),
None => ws,
};
ws.on_upgrade(move |socket| handle_session(socket, caller_id, state))
}
/// Successful upgrade credentials — the resolved caller and (when the
/// ticket path was used) the subprotocol to echo on the 101 response.
struct UpgradeAuth {
caller_id: Uuid,
accepted_subprotocol: Option<String>,
}
/// Extract `Sec-WebSocket-Protocol` and match a ticket subprotocol
/// first; fall back to `Authorization: Bearer`. Returns a stable
/// `reason` key on failure so the audit log stays filterable.
async fn authenticate_upgrade(
headers: &HeaderMap,
state: &Arc<AppState>,
) -> Result<UpgradeAuth, &'static str> {
if let Some(ticket_sub) = extract_ticket_subprotocol(headers) {
// Redeem parses the UUID; a malformed subprotocol is a
// structural failure ("bad_ticket_format"), an unknown-or-
// expired UUID is a redemption failure ("ticket_invalid").
let Some(ticket_str) = ticket_sub.strip_prefix(SUBPROTOCOL_PREFIX) else {
return Err("bad_ticket_format");
};
let Ok(ticket_uuid) = Uuid::parse_str(ticket_str) else {
return Err("bad_ticket_uuid");
};
let Some(caller_id) = state.rt_ticket_store.redeem(ticket_uuid) else {
return Err("ticket_invalid");
};
return Ok(UpgradeAuth {
caller_id,
accepted_subprotocol: Some(ticket_sub),
});
}
if let Some(bearer) = extract_bearer(headers) {
let Some(auth_service) = state.auth_service.as_ref() else {
return Err("auth_service_unavailable");
};
let claims = auth_service
.token_service
.validate_token(bearer)
.map_err(|_| "bearer_invalid")?;
if claims.sub_id.is_nil() {
return Err("bearer_bad_subject");
}
return Ok(UpgradeAuth {
caller_id: claims.sub_id,
accepted_subprotocol: None,
});
}
Err("no_credentials")
}
/// Find the first subprotocol value that looks like a ticket. Browsers
/// send `Sec-WebSocket-Protocol` as a comma-separated list per RFC 6455.
fn extract_ticket_subprotocol(headers: &HeaderMap) -> Option<String> {
let raw = headers.get("sec-websocket-protocol")?.to_str().ok()?;
raw.split(',')
.map(str::trim)
.find(|s| s.starts_with(SUBPROTOCOL_PREFIX))
.map(|s| s.to_string())
}
/// Extract `Authorization: Bearer <token>` if present. Returns the raw
/// token string (never empty).
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
let value = headers.get("authorization")?.to_str().ok()?;
let token = value.strip_prefix("Bearer ")?.trim();
(!token.is_empty()).then_some(token)
}
// ════════════════════════════════════════════════════════════════════════════
// Session loop
// ════════════════════════════════════════════════════════════════════════════
+15 -7
View File
@@ -674,16 +674,24 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.with_state(app_state.clone());
router = router.nest("/users", users_router);
// Message bus WebSocket. Auth (session cookie or bearer JWT) via
// the same `auth_middleware` the rest of `/api/*` gets; the handler
// extracts `CurrentUserId` from the extension the middleware
// installs. See `docs/plan/message-bus.md` and the module doc on
// `rt_ws` for the JSON-RPC 2.0 wire.
// Message bus — ticket issuance (`POST /api/rt/ticket`). Stays in
// the protected router (auth + DPoP), so the caller proves session
// + DPoP-key possession before a ticket is minted. See
// `handlers/rt_ticket_handler.rs` and `docs/plan/message-bus.md § F`.
router = router.route(
"/rt/ws",
get(crate::interfaces::api::handlers::rt_ws::rt_ws_handler).with_state(app_state.clone()),
"/rt/ticket",
post(crate::interfaces::api::handlers::rt_ticket_handler::issue_rt_ticket)
.with_state(app_state.clone()),
);
// The WS upgrade (`GET /api/rt/ws`) is registered OUTSIDE the
// protected-api middleware stack — a browser cannot attach a
// `DPoP:` header to `new WebSocket()`, so the standard stack
// 401s on every DPoP-bound session. See the `rt_ws` module doc
// for the self-auth logic (ticket subprotocol or bearer token).
// Registration happens in `main.rs` where the outer router owns
// the middleware layering.
// Collector for any unknown `/api/*` path. Without this, an
// unmatched API URL falls through Axum's matcher to the
// ServeDir fallback and is logged under `http::web` — wrong