Skip to main content

boatramp_server/
auth.rs

1//! Control-plane authorization for the publishing/management API.
2//!
3//! Every `/api/*` route is gated by the **COSE/CWT + Cedar** authorizer: the
4//! request maps to a required [`Right`] (action × resource), the bearer token
5//! (a `COSE_Sign1` CWT, RFC 8392/9052) is verified against the root public key,
6//! checked against the KV revocation store, then decided by the Cedar policy
7//! generated from the RBAC model. There are no legacy single-secret or opaque-KV
8//! tokens — COSE is the one credential model (the OIDC→token exchange lives at
9//! `/api/auth/exchange`). If no root key is configured, auth is **disabled**
10//! (every request allowed — development only); public serving is never gated.
11
12use boatramp_core::time::now_unix;
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15
16use axum::body::Body;
17use axum::extract::{Request, State};
18use axum::http::{header, HeaderName, StatusCode};
19use axum::middleware::Next;
20use axum::response::{IntoResponse, Response};
21
22use boatramp_core::authz::{self, AuthzPolicy, Right};
23use boatramp_core::cedar::CompiledCedar;
24use boatramp_core::cose::{self, PopClaims, TokenError, TokenPublicKey, POP_MAX_BODY_HASH_BYTES};
25use boatramp_core::kv::KvStore;
26
27/// The header carrying a per-request proof-of-possession (base64url `COSE_Sign1`),
28/// signed by the token's holder (`cnf`) key. Lower-case per HTTP/2 conventions.
29const POP_HEADER: HeaderName = HeaderName::from_static("boatramp-pop");
30
31use authz::ROOT_ANCHOR_PREFIX;
32
33/// The classification of a bearer presented to a session-channel gate
34/// ([`Auth::classify_channel_bearer`]).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ChannelBearer {
37    /// A valid, unrevoked, plain (non-`cnf`) bearer — admit the channel.
38    Valid,
39    /// A valid, unrevoked, but holder-bound (`cnf`) token — reject: a channel can't
40    /// carry a per-request PoP proof for its downstream in-process calls.
41    HolderBound,
42    /// Missing, malformed, unverifiable, expired, or revoked.
43    Invalid,
44}
45
46/// Control-plane auth configuration: the token trust anchor (root public key)
47/// plus the KV that holds the RBAC policy (`authz/policy`) and revocation markers
48/// (`authz/revoked/<id>`). `None` ⇒ auth disabled (development).
49#[derive(Clone, Default)]
50pub struct Auth {
51    inner: Option<Arc<AuthInner>>,
52}
53
54struct AuthInner {
55    public: TokenPublicKey,
56    kv: Arc<dyn KvStore>,
57    /// The fleet's canonical public origin (a PoP proof's required `aud`). `None`
58    /// ⇒ a holder-bound token cannot be verified here (fails closed).
59    pop_origin: Option<String>,
60    /// Require **every** token to be holder-bound (`cnf`) and PoP-proven. A `cnf`
61    /// token *always* requires a proof regardless of this knob; when `true`, a
62    /// plain (non-`cnf`) token is additionally rejected.
63    require_pop: bool,
64    /// Node-local replay guard for PoP proof `jti`s (window-bounded).
65    replay: PopReplayCache,
66}
67
68/// A node-local, window-bounded replay guard for PoP proof `jti`s. Bounds
69/// **same-node** proof replay within the freshness window; there is deliberately
70/// **no** cross-node cache (boatramp's `KvStore` has no atomic CAS outside Raft, so
71/// a correct shared cache would cost a consensus round-trip per request). A
72/// captured proof can therefore be replayed on a *different* node within the
73/// ~`POP_WINDOW_SECS` window — an accepted, documented trade-off, bounded further
74/// by the tight window + `ath` token binding + `cti` revocation.
75#[derive(Clone, Default)]
76struct PopReplayCache {
77    seen: Arc<Mutex<HashMap<String, u64>>>,
78}
79
80impl PopReplayCache {
81    fn new() -> Self {
82        Self {
83            seen: Arc::new(Mutex::new(HashMap::new())),
84        }
85    }
86
87    /// Record `jti` as seen at `now`; returns `false` if it was already seen within
88    /// its validity window (a replay), `true` if fresh. Prunes expired entries on
89    /// each call so the map stays bounded by the in-flight proof count.
90    fn check_and_insert(&self, jti: &str, now: u64) -> bool {
91        let ttl = cose::POP_WINDOW_SECS + cose::POP_SKEW_SECS;
92        let expiry = now.saturating_add(ttl);
93        let mut seen = self.seen.lock().expect("pop replay cache mutex poisoned");
94        seen.retain(|_, exp| *exp > now);
95        if seen.contains_key(jti) {
96            return false;
97        }
98        seen.insert(jti.to_string(), expiry);
99        true
100    }
101}
102
103impl Auth {
104    /// No authentication (development default).
105    pub fn disabled() -> Self {
106        Self::default()
107    }
108
109    /// Enable token auth: verify tokens against the root public key `public`, and
110    /// read the RBAC policy + revocation markers from `kv` (front it with the
111    /// shared `CachedKv` so policy reads are cheap and ride cache invalidation).
112    pub fn with_key(public: TokenPublicKey, kv: Arc<dyn KvStore>) -> Self {
113        Self {
114            inner: Some(Arc::new(AuthInner {
115                public,
116                kv,
117                pop_origin: None,
118                require_pop: false,
119                replay: PopReplayCache::new(),
120            })),
121        }
122    }
123
124    /// Configure per-request proof-of-possession enforcement (DPoP): the fleet's
125    /// canonical origin a proof must bind (`pop_origin`, the proof `aud`) and
126    /// whether **every** token must be holder-bound (`require_pop`). A no-op when
127    /// auth is disabled. A holder-bound (`cnf`) token always requires a valid proof
128    /// regardless of `require_pop`.
129    pub fn with_pop(self, pop_origin: Option<String>, require_pop: bool) -> Self {
130        match self.inner {
131            Some(inner) => Self {
132                inner: Some(Arc::new(AuthInner {
133                    public: inner.public.clone(),
134                    kv: inner.kv.clone(),
135                    pop_origin,
136                    require_pop,
137                    replay: PopReplayCache::new(),
138                })),
139            },
140            None => Self { inner: None },
141        }
142    }
143
144    /// Whether no authentication is configured.
145    pub fn is_disabled(&self) -> bool {
146        self.inner.is_none()
147    }
148
149    /// The root public key (verification trust anchor), when auth is enabled —
150    /// so a self-service handler like `whoami` can verify the presented token.
151    pub fn public_key(&self) -> Option<TokenPublicKey> {
152        self.inner.as_ref().map(|i| i.public.clone())
153    }
154
155    /// The "any valid token" gate (protected previews): a token that is
156    /// authentic, unexpired, and not revoked — no RBAC right required. Returns
157    /// `false` when auth is disabled (no tokens exist to present).
158    pub async fn verify_bearer(&self, bearer: &str) -> bool {
159        let Some(inner) = &self.inner else {
160            return false;
161        };
162        let Ok(verified) = inner.verify_credential_any(bearer, now_unix()).await else {
163            return false;
164        };
165        !inner.is_revoked(&verified.cti).await
166    }
167
168    /// Classify a bearer for a session-channel gate (the HTTP `/mcp` endpoint): a
169    /// channel authenticates once, then re-authorizes each operation per call, so it
170    /// needs a valid **plain** bearer. A holder-bound (`cnf`) token can't produce a
171    /// per-request PoP proof for the in-process calls, so it's reported distinctly
172    /// (the gate rejects it with a clear message rather than letting every tool call
173    /// fail an opaque PoP check).
174    pub async fn classify_channel_bearer(&self, bearer: &str) -> ChannelBearer {
175        let Some(inner) = &self.inner else {
176            return ChannelBearer::Invalid;
177        };
178        let Ok(verified) = inner.verify_credential_any(bearer, now_unix()).await else {
179            return ChannelBearer::Invalid;
180        };
181        if inner.is_revoked(&verified.cti).await {
182            return ChannelBearer::Invalid;
183        }
184        if verified.leaf_cnf.is_some() {
185            return ChannelBearer::HolderBound;
186        }
187        ChannelBearer::Valid
188    }
189
190    /// Verify a mesh **join token** against the primary root, then — on failure —
191    /// the replicated rotation anchor set, returning its single-use `jti`. Because
192    /// the anchor set is operator-managed (`auth rotate-root`), a cluster can mint
193    /// join tokens with a **distinct mesh-admission key** trusted alongside (not
194    /// instead of) the admin-token root — narrowing the admission blast radius
195    /// (F8) without a separate signer config or imposing KMS. `Err` when auth is
196    /// disabled or the token verifies under no trusted anchor.
197    pub async fn verify_join_token(&self, token: &str, now: u64) -> Result<String, TokenError> {
198        let Some(inner) = self.inner.as_ref() else {
199            return Err(TokenError::Invalid("auth disabled".into()));
200        };
201        match cose::verify_join(token, &inner.public, now) {
202            Ok(jti) => Ok(jti),
203            Err(primary_err) => {
204                for anchor in inner.rotation_anchors().await {
205                    if let Ok(jti) = cose::verify_join(token, &anchor, now) {
206                        return Ok(jti);
207                    }
208                }
209                Err(primary_err)
210            }
211        }
212    }
213
214    /// Like [`verify_bearer`](Self::verify_bearer), but returns the token's
215    /// granted roles on success. `whoami` uses this so it reports an identity
216    /// only for a token that is authentic, **unexpired, and unrevoked** — not for
217    /// any signature-valid blob. `None` when auth is disabled or any
218    /// check fails.
219    pub async fn verify_bearer_roles(&self, bearer: &str) -> Option<Vec<authz::GrantedRole>> {
220        let inner = self.inner.as_ref()?;
221        let verified = inner.verify_credential_any(bearer, now_unix()).await.ok()?;
222        if inner.is_revoked(&verified.cti).await {
223            return None;
224        }
225        Some(verified.roles)
226    }
227
228    /// Authorize an API request, or reject it. Callers guard on
229    /// [`Auth::is_disabled`] first (a disabled auth allows everything). `pop_proof`
230    /// is the presented `Boatramp-PoP` header (if any); `body_hash` is the hex
231    /// SHA-256 of the (buffered) request body for a write, or `None`.
232    async fn authorize(
233        &self,
234        bearer: &str,
235        method: &str,
236        path: &str,
237        pop_proof: Option<&str>,
238        body_hash: Option<String>,
239    ) -> Result<(), Reject> {
240        let inner = self
241            .inner
242            .as_ref()
243            .expect("authorize called on disabled auth");
244        // Endpoints not gated by a right (the OIDC→token exchange) authenticate
245        // by other means; the router still requires *some* bearer to reach here.
246        let Some(required) = Right::required(method, path) else {
247            return Ok(());
248        };
249        let now = now_unix();
250        let verified = inner
251            .verify_credential_any(bearer, now)
252            .await
253            .map_err(Reject::from_token_err)?;
254        if inner.is_revoked(&verified.cti).await {
255            return Err(Reject::forbidden("token revoked\n"));
256        }
257        // Proof-of-possession (DPoP): a holder-bound (`cnf`) credential MUST carry a
258        // valid per-request proof — always, regardless of the posture knob (RFC 9449:
259        // a `cnf` token is presented with a proof or not at all). Never silently
260        // accept it as a plain bearer (the anti-downgrade invariant). The
261        // `require_pop` knob additionally forbids a non-`cnf` token fleet-wide.
262        match &verified.leaf_cnf {
263            Some(leaf_cnf) => {
264                inner.verify_pop(leaf_cnf, pop_proof, method, path, bearer, body_hash, now)?;
265            }
266            None if inner.require_pop => {
267                return Err(Reject::unauthorized(
268                    "proof-of-possession required: present a holder-bound (cnf) token\n",
269                ));
270            }
271            None => {}
272        }
273        // Delegation caveats can only *subtract* from the root's authority: enforce
274        // them before consulting the RBAC policy.
275        if !verified.caveats.allows(&required, now) {
276            return Err(Reject::forbidden(
277                "token not authorized for this resource\n",
278            ));
279        }
280        let (policy, compiled) = inner.policy().await;
281        // Normalize legacy grants (a pre-0.2.0 `publisher:blog` reads as the `default`
282        // project) so a site name minted before the project re-keying still authorizes
283        // its now project-qualified route.
284        let roles = policy.normalize_grants(&verified.roles);
285        if compiled.authorize(&roles, &required) {
286            Ok(())
287        } else {
288            Err(Reject::forbidden(
289                "token not authorized for this resource\n",
290            ))
291        }
292    }
293}
294
295impl AuthInner {
296    /// Whether the token's revocation id (`cti`) is marked revoked in the KV.
297    async fn is_revoked(&self, cti: &str) -> bool {
298        matches!(self.kv.get(&authz::revoked_key(cti)).await, Ok(Some(_)))
299    }
300
301    /// Verify a credential against the primary anchor, then — only on failure —
302    /// the replicated **rotation anchor set** (`auth/root/*`). This makes a
303    /// `auth rotate-root` make-before-break: both the old and new root keys are
304    /// trusted during the overlap, so no node ever rejects a valid token. The
305    /// replicated set is consulted only when the primary key doesn't verify (the
306    /// rare overlap case), so the common path stays a single in-memory check.
307    async fn verify_credential_any(
308        &self,
309        bearer: &str,
310        now: u64,
311    ) -> Result<cose::VerifiedChain, TokenError> {
312        match cose::verify_credential(bearer, &self.public, now) {
313            Ok(v) => Ok(v),
314            Err(primary_err) => {
315                for anchor in self.rotation_anchors().await {
316                    if let Ok(v) = cose::verify_credential(bearer, &anchor, now) {
317                        return Ok(v);
318                    }
319                }
320                Err(primary_err)
321            }
322        }
323    }
324
325    /// The replicated rotation anchors — extra root public keys added by
326    /// `auth rotate-root` (`auth/root/{es256:hex}`), trusted alongside the primary.
327    async fn rotation_anchors(&self) -> Vec<TokenPublicKey> {
328        self.kv
329            .list_prefix(ROOT_ANCHOR_PREFIX)
330            .await
331            .unwrap_or_default()
332            .iter()
333            .filter_map(|k| k.strip_prefix(ROOT_ANCHOR_PREFIX))
334            .filter_map(|hex| TokenPublicKey::from_hex(hex).ok())
335            .collect()
336    }
337
338    /// Require + verify a per-request PoP proof for a holder-bound credential.
339    /// Binds the proof to `htm` (method) + `htp` (canonicalized path) + a
340    /// **config-set** `aud` (never a forwarded header) + `ath` (the presented
341    /// token) + `bh` (the body hash on writes), verified against the credential's
342    /// terminal (`leaf`) `cnf` — then a node-local replay check on the proof `jti`.
343    #[allow(clippy::too_many_arguments)]
344    fn verify_pop(
345        &self,
346        leaf_cnf: &str,
347        proof: Option<&str>,
348        method: &str,
349        path: &str,
350        bearer: &str,
351        body_hash: Option<String>,
352        now: u64,
353    ) -> Result<(), Reject> {
354        let Some(proof) = proof else {
355            return Err(Reject::unauthorized(
356                "missing proof-of-possession (Boatramp-PoP header)\n",
357            ));
358        };
359        // The origin a proof must bind is operator config, never a request header.
360        // A `cnf` token is unusable against a server that hasn't set `pop_origin`
361        // (its proof cannot be verified) — fail closed, and say so in the log.
362        let Some(aud) = self.pop_origin.clone() else {
363            tracing::warn!(
364                "a holder-bound (cnf) token was presented but `pop_origin` is not \
365                 configured; rejecting — set [serve] pop_origin in boatramp.cfg"
366            );
367            return Err(Reject::unauthorized(
368                "proof-of-possession not configured on this server\n",
369            ));
370        };
371        let holder = TokenPublicKey::from_hex(leaf_cnf)
372            .map_err(|_| Reject::unauthorized("invalid holder key\n"))?;
373        let expected = PopClaims {
374            htm: method.to_string(),
375            htp: cose::canon_pop_path(path),
376            aud,
377            ath: cose::pop_sha256_hex(bearer.as_bytes()),
378            bh: body_hash,
379        };
380        let jti = cose::verify_pop(proof, &holder, now, &expected).map_err(|err| match err {
381            TokenError::Expired => Reject::unauthorized("proof-of-possession expired\n"),
382            _ => Reject::unauthorized("invalid proof-of-possession\n"),
383        })?;
384        if !self.replay.check_and_insert(&jti, now) {
385            return Err(Reject::unauthorized("proof-of-possession replayed\n"));
386        }
387        Ok(())
388    }
389
390    /// Load + compile the RBAC policy from `authz/policy` into a Cedar authorizer,
391    /// falling back to the built-in default when absent, unreadable, or
392    /// uncompilable (a malformed stored policy must never brick the control plane
393    /// — it is logged and the default used). Returns the raw [`AuthzPolicy`] too, so
394    /// the caller can [`normalize_grants`](AuthzPolicy::normalize_grants) (legacy
395    /// bare-site grants → the `default` project) with the same policy the authorizer
396    /// compiled from.
397    async fn policy(&self) -> (AuthzPolicy, CompiledCedar) {
398        let stored = match self.kv.get(authz::POLICY_KEY).await {
399            Ok(Some(bytes)) => match serde_json::from_slice::<AuthzPolicy>(&bytes) {
400                Ok(p) => Some(p),
401                Err(err) => {
402                    tracing::warn!(%err, "authz/policy is malformed; using the default policy");
403                    None
404                }
405            },
406            Ok(None) => None,
407            Err(err) => {
408                tracing::warn!(%err, "could not read authz/policy; using the default policy");
409                None
410            }
411        };
412        let policy = stored.unwrap_or_else(AuthzPolicy::default_policy);
413        match CompiledCedar::compile(&policy) {
414            Ok(c) => (policy, c),
415            Err(err) => {
416                tracing::warn!(%err, "authz/policy failed to compile; using the default policy");
417                let default = AuthzPolicy::default_policy();
418                let compiled =
419                    CompiledCedar::compile(&default).expect("the default policy always compiles");
420                (default, compiled)
421            }
422        }
423    }
424}
425
426/// A rejected request: the HTTP status + a short body.
427struct Reject {
428    status: StatusCode,
429    body: &'static str,
430}
431
432impl Reject {
433    fn unauthorized(body: &'static str) -> Self {
434        Self {
435            status: StatusCode::UNAUTHORIZED,
436            body,
437        }
438    }
439    fn forbidden(body: &'static str) -> Self {
440        Self {
441            status: StatusCode::FORBIDDEN,
442            body,
443        }
444    }
445    /// Map a token verification failure to a response. An *expired* token is a 401
446    /// so a client re-authenticates (re-exchanges); any other verification failure
447    /// (bad signature, malformed, wrong algorithm) is also a 401.
448    fn from_token_err(err: TokenError) -> Self {
449        match err {
450            TokenError::Expired => Self::unauthorized("token expired\n"),
451            _ => Self::unauthorized("invalid token\n"),
452        }
453    }
454}
455
456/// Axum middleware enforcing control-plane auth on the routes it wraps.
457pub async fn require_auth(State(auth): State<Auth>, request: Request, next: Next) -> Response {
458    if auth.is_disabled() {
459        return next.run(request).await;
460    }
461    let Some(bearer) = bearer_token(&request) else {
462        return (StatusCode::UNAUTHORIZED, "missing bearer token\n").into_response();
463    };
464    let method = request.method().as_str().to_owned();
465    // Authorize (and PoP-bind) the path the client actually sent: for a project-scoped
466    // request the `project_scope` layer rewrote the URI to its global form but stashed
467    // the original `/api/projects/<proj>/…` path here, so `Right::required` still sees
468    // the project-qualified path and enforces the project-scoped right.
469    let path = match request.extensions().get::<crate::OriginalPath>() {
470        Some(original) => original.0.clone(),
471        None => request.uri().path().to_owned(),
472    };
473    let pop_proof = pop_header(&request);
474
475    // On a write, bind the request body into the PoP proof: buffer it (up to a
476    // bound) so a hash can be committed to, then hand the buffered bytes
477    // downstream. Larger/streamed bodies (blob uploads) pass through unbuffered and
478    // are not body-bound. The hash is computed unconditionally for small write
479    // bodies; `authorize` only consults it when the token is holder-bound.
480    let is_write = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE");
481    let (request, body_hash) = if is_write {
482        match buffer_body_for_pop(request).await {
483            Ok(pair) => pair,
484            Err(response) => return response,
485        }
486    } else {
487        (request, None)
488    };
489
490    match auth
491        .authorize(&bearer, &method, &path, pop_proof.as_deref(), body_hash)
492        .await
493    {
494        Ok(()) => next.run(request).await,
495        Err(reject) => (reject.status, reject.body).into_response(),
496    }
497}
498
499/// Buffer a write request's body (when its declared length fits the PoP hash
500/// bound) so the auth layer can bind its hash, reconstructing the request from the
501/// buffered bytes. Bodies with no `Content-Length` or one over the bound stream
502/// through untouched and are not body-bound (documented gap). Returns the
503/// (possibly reconstructed) request and the body hash (`None` for an empty or
504/// unbuffered body).
505async fn buffer_body_for_pop(request: Request) -> Result<(Request, Option<String>), Response> {
506    let within_bound = request
507        .headers()
508        .get(header::CONTENT_LENGTH)
509        .and_then(|v| v.to_str().ok())
510        .and_then(|v| v.parse::<usize>().ok())
511        .is_some_and(|len| len <= POP_MAX_BODY_HASH_BYTES);
512    if !within_bound {
513        return Ok((request, None));
514    }
515    let (parts, body) = request.into_parts();
516    let bytes = match axum::body::to_bytes(body, POP_MAX_BODY_HASH_BYTES).await {
517        Ok(bytes) => bytes,
518        // A body that exceeds the bound despite its declared length (or a broken
519        // stream) — reject rather than silently drop the body binding.
520        Err(_) => {
521            return Err((StatusCode::BAD_REQUEST, "could not read request body\n").into_response())
522        }
523    };
524    let body_hash = if bytes.is_empty() {
525        None
526    } else {
527        Some(cose::pop_sha256_hex(&bytes))
528    };
529    Ok((Request::from_parts(parts, Body::from(bytes)), body_hash))
530}
531
532fn bearer_token(request: &Request) -> Option<String> {
533    let value = request
534        .headers()
535        .get(header::AUTHORIZATION)?
536        .to_str()
537        .ok()?;
538    value.strip_prefix("Bearer ").map(str::to_string)
539}
540
541/// The presented per-request PoP proof (the `Boatramp-PoP` header), if any.
542fn pop_header(request: &Request) -> Option<String> {
543    request
544        .headers()
545        .get(&POP_HEADER)?
546        .to_str()
547        .ok()
548        .map(str::to_string)
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use boatramp_core::authz::GrantedRole;
555    use boatramp_core::cose::{Claims, LocalSigner, Signer, TokenAlg};
556    use boatramp_core::kv::MemoryKv;
557
558    const ORIGIN: &str = "https://cp.example.com";
559    // A gated GET path (System·Read) an `admin` token is authorized for — reaches
560    // the full pipeline (unlike the ungated `/api/auth/exchange`).
561    const PATH: &str = "/api/sites";
562
563    fn holder() -> LocalSigner {
564        LocalSigner::generate(TokenAlg::Es256)
565    }
566
567    fn admin_claims(now: u64) -> Claims {
568        Claims {
569            roles: vec![GrantedRole::global("admin")],
570            kind: "role".into(),
571            ttl_secs: Some(3600),
572            now_unix: now,
573        }
574    }
575
576    /// An `Auth` over a fresh in-memory KV (default policy) with `pop_origin`
577    /// configured to [`ORIGIN`] and the given `require_pop`.
578    fn auth_with(root: &LocalSigner, require_pop: bool) -> Auth {
579        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
580        Auth::with_key(root.public_key(), kv).with_pop(Some(ORIGIN.to_string()), require_pop)
581    }
582
583    /// Mint a PoP proof for `token` bound to the given facts.
584    async fn proof(
585        holder: &LocalSigner,
586        token: &str,
587        htm: &str,
588        path: &str,
589        aud: &str,
590        bh: Option<String>,
591        now: u64,
592    ) -> String {
593        cose::mint_pop(
594            &PopClaims {
595                htm: htm.to_string(),
596                htp: cose::canon_pop_path(path),
597                aud: aud.to_string(),
598                ath: cose::pop_sha256_hex(token.as_bytes()),
599                bh,
600            },
601            holder,
602            now,
603        )
604        .await
605        .unwrap()
606    }
607
608    /// Make-before-break root rotation: a token signed by a **new** root verifies
609    /// only once that key is trusted as a rotation anchor (`auth/root/*`), while
610    /// the **primary** root's tokens keep verifying throughout — so there is no
611    /// window where a valid token is rejected. Retiring the anchor reverses it.
612    #[tokio::test]
613    async fn rotation_anchor_is_make_before_break() {
614        use boatramp_core::kv::KvStore;
615        let primary = holder();
616        let new_root = holder();
617        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
618        let auth = Auth::with_key(primary.public_key(), kv.clone());
619        let now = now_unix();
620
621        let new_token = cose::mint(&admin_claims(now), &new_root).await.unwrap();
622        // Before rotation: only the primary is trusted, so the new key's token fails.
623        assert!(!auth.verify_bearer(&new_token).await);
624
625        // Trust the new key as a rotation anchor (make-before-break).
626        let anchor = authz::root_anchor_key(&new_root.public_key().to_hex());
627        kv.put(&anchor, Vec::new()).await.unwrap();
628        assert!(
629            auth.verify_bearer(&new_token).await,
630            "new-root token now verifies"
631        );
632        // The primary's tokens verify the whole time.
633        let primary_token = cose::mint(&admin_claims(now), &primary).await.unwrap();
634        assert!(auth.verify_bearer(&primary_token).await);
635
636        // Retire the old/new anchor → its tokens stop verifying again.
637        kv.delete(&anchor).await.unwrap();
638        assert!(!auth.verify_bearer(&new_token).await);
639        assert!(
640            auth.verify_bearer(&primary_token).await,
641            "primary still valid"
642        );
643    }
644
645    #[tokio::test]
646    async fn plain_bearer_is_authorized_without_a_proof() {
647        let root = holder();
648        let auth = auth_with(&root, false);
649        let now = now_unix();
650        let token = cose::mint(&admin_claims(now), &root).await.unwrap();
651        // A non-holder-bound token needs no proof when `require_pop` is off.
652        assert!(auth
653            .authorize(&token, "GET", PATH, None, None)
654            .await
655            .is_ok());
656    }
657
658    #[tokio::test]
659    async fn cnf_token_requires_a_valid_proof() {
660        let root = holder();
661        let h = holder();
662        let auth = auth_with(&root, false);
663        let now = now_unix();
664        let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
665            .await
666            .unwrap();
667
668        // No proof → rejected (no silent bearer downgrade), with a 401.
669        let rej = auth
670            .authorize(&token, "GET", PATH, None, None)
671            .await
672            .unwrap_err();
673        assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
674
675        // A valid proof (bound to the request + config origin + this token) → ok.
676        let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
677        assert!(auth
678            .authorize(&token, "GET", PATH, Some(&p), None)
679            .await
680            .is_ok());
681    }
682
683    #[tokio::test]
684    async fn proof_bound_to_the_wrong_facts_is_rejected() {
685        let root = holder();
686        let h = holder();
687        let auth = auth_with(&root, false);
688        let now = now_unix();
689        let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
690            .await
691            .unwrap();
692        let body = cose::pop_sha256_hex(b"the-real-body");
693
694        // Wrong method: proof says PUT, request is GET.
695        let wrong_method = proof(&h, &token, "PUT", PATH, ORIGIN, None, now).await;
696        assert!(auth
697            .authorize(&token, "GET", PATH, Some(&wrong_method), None)
698            .await
699            .is_err());
700
701        // Wrong path.
702        let wrong_path = proof(&h, &token, "GET", "/api/certs", ORIGIN, None, now).await;
703        assert!(auth
704            .authorize(&token, "GET", PATH, Some(&wrong_path), None)
705            .await
706            .is_err());
707
708        // Wrong origin (a captured proof relayed to a different fleet).
709        let wrong_aud = proof(
710            &h,
711            &token,
712            "GET",
713            PATH,
714            "https://evil.example.com",
715            None,
716            now,
717        )
718        .await;
719        assert!(auth
720            .authorize(&token, "GET", PATH, Some(&wrong_aud), None)
721            .await
722            .is_err());
723
724        // Wrong token (proof paired with a different access token's `ath`).
725        let other = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
726            .await
727            .unwrap();
728        let wrong_ath = proof(&h, &other, "GET", PATH, ORIGIN, None, now).await;
729        assert!(auth
730            .authorize(&token, "GET", PATH, Some(&wrong_ath), None)
731            .await
732            .is_err());
733
734        // Wrong body: proof binds one body, the request carries another.
735        let bound = proof(&h, &token, "PUT", PATH, ORIGIN, Some(body.clone()), now).await;
736        let tampered = cose::pop_sha256_hex(b"a-different-body");
737        assert!(auth
738            .authorize(&token, "PUT", PATH, Some(&bound), Some(tampered))
739            .await
740            .is_err());
741        // ...but the matching body authorizes.
742        assert!(auth
743            .authorize(&token, "PUT", PATH, Some(&bound), Some(body))
744            .await
745            .is_ok());
746    }
747
748    #[tokio::test]
749    async fn require_pop_forbids_a_plain_bearer_fleetwide() {
750        let root = holder();
751        let h = holder();
752        let auth = auth_with(&root, true); // require_pop on
753        let now = now_unix();
754
755        // A plain (non-cnf) token is rejected fleet-wide.
756        let plain = cose::mint(&admin_claims(now), &root).await.unwrap();
757        let rej = auth
758            .authorize(&plain, "GET", PATH, None, None)
759            .await
760            .unwrap_err();
761        assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
762
763        // A holder-bound token with a valid proof still works.
764        let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
765            .await
766            .unwrap();
767        let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
768        assert!(auth
769            .authorize(&token, "GET", PATH, Some(&p), None)
770            .await
771            .is_ok());
772    }
773
774    #[tokio::test]
775    async fn aud_comes_from_config_never_a_request_header() {
776        // The proof binds the server's *configured* origin; a proof bound to any
777        // other value (what a spoofed `X-Forwarded-Host` might inject) fails —
778        // `authorize` never reads a request header for `aud`, so this is structural.
779        let root = holder();
780        let h = holder();
781        let now = now_unix();
782        let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
783            .await
784            .unwrap();
785
786        let auth = auth_with(&root, false); // pop_origin = ORIGIN
787        let good = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
788        assert!(auth
789            .authorize(&token, "GET", PATH, Some(&good), None)
790            .await
791            .is_ok());
792
793        // With no origin configured, a `cnf` token can't be verified → fail closed.
794        let unset: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
795        let auth_unset = Auth::with_key(root.public_key(), unset).with_pop(None, false);
796        assert!(auth_unset
797            .authorize(&token, "GET", PATH, Some(&good), None)
798            .await
799            .is_err());
800    }
801
802    #[tokio::test]
803    async fn proof_verifies_against_the_leaf_cnf_not_the_root() {
804        // root (cnf = h1) → delegation block signed by h1 (cnf = h2). The proof must
805        // be signed by the *leaf* holder (h2); the intermediate/root holder (h1) is
806        // rejected even though it signed a chain block.
807        let root = holder();
808        let h1 = holder();
809        let h2 = holder();
810        let now = now_unix();
811        let base = cose::mint_delegatable(&admin_claims(now), &h1.public_key(), &root)
812            .await
813            .unwrap();
814        let chain = cose::attenuate(&base, &h1, &Default::default(), Some(&h2.public_key()), now)
815            .await
816            .unwrap();
817
818        let auth = auth_with(&root, false);
819        // Leaf holder (h2) → authorized.
820        let leaf_proof = proof(&h2, &chain, "GET", PATH, ORIGIN, None, now).await;
821        assert!(auth
822            .authorize(&chain, "GET", PATH, Some(&leaf_proof), None)
823            .await
824            .is_ok());
825        // Intermediate holder (h1) → rejected.
826        let stale_proof = proof(&h1, &chain, "GET", PATH, ORIGIN, None, now).await;
827        assert!(auth
828            .authorize(&chain, "GET", PATH, Some(&stale_proof), None)
829            .await
830            .is_err());
831    }
832
833    #[tokio::test]
834    async fn a_proof_cannot_be_replayed_on_the_same_node() {
835        let root = holder();
836        let h = holder();
837        let auth = auth_with(&root, false);
838        let now = now_unix();
839        let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
840            .await
841            .unwrap();
842        let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
843
844        // First use succeeds; the same proof (same `jti`) is then a replay → 401.
845        assert!(auth
846            .authorize(&token, "GET", PATH, Some(&p), None)
847            .await
848            .is_ok());
849        let rej = auth
850            .authorize(&token, "GET", PATH, Some(&p), None)
851            .await
852            .unwrap_err();
853        assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
854    }
855
856    #[tokio::test]
857    async fn buffer_body_hashes_small_writes_and_streams_large_ones() {
858        // A small declared body is buffered, hashed, and reconstructed intact.
859        let payload = b"{\"config\":true}".to_vec();
860        let req = Request::builder()
861            .method("PUT")
862            .uri(PATH)
863            .header(header::CONTENT_LENGTH, payload.len())
864            .body(Body::from(payload.clone()))
865            .unwrap();
866        let (rebuilt, hash) = buffer_body_for_pop(req).await.unwrap();
867        assert_eq!(hash, Some(cose::pop_sha256_hex(&payload)));
868        let echoed = axum::body::to_bytes(rebuilt.into_body(), usize::MAX)
869            .await
870            .unwrap();
871        assert_eq!(echoed.as_ref(), payload.as_slice());
872
873        // A body whose declared length exceeds the bound is not buffered/hashed.
874        let big = Request::builder()
875            .method("PUT")
876            .uri(PATH)
877            .header(header::CONTENT_LENGTH, POP_MAX_BODY_HASH_BYTES + 1)
878            .body(Body::empty())
879            .unwrap();
880        let (_, hash) = buffer_body_for_pop(big).await.unwrap();
881        assert_eq!(hash, None);
882
883        // An empty write body binds no hash.
884        let empty = Request::builder()
885            .method("DELETE")
886            .uri(PATH)
887            .header(header::CONTENT_LENGTH, 0)
888            .body(Body::empty())
889            .unwrap();
890        let (_, hash) = buffer_body_for_pop(empty).await.unwrap();
891        assert_eq!(hash, None);
892    }
893}