oauth-as 0.9.3

An embeddable OAuth 2.1 Authorization Server library: spec-mirroring types (RFC 6749, RFC 8628, RFC 7636), a full device-authorization-grant state machine, and a storage trait the host implements. Deliberately host-agnostic with a tiny dependency set; nothing is allocated until the host constructs an AuthorizationServer, so an embedding host pays zero memory until its config enables the feature.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson

//! RFC 9449 DPoP: sender-constrained access tokens.
//!
//! # What this buys, and it is the largest security change in this crate
//!
//! Without it every token this server issues is a BEARER token (RFC 6750 section 1: possession of
//! the string is the whole of the authorization). A token that leaks, into a log, a proxy, a crash
//! dump, a compromised resource server, is a token the finder can spend. DPoP binds the token to a
//! key the client holds: the client signs a fresh proof for every request, the token carries the
//! RFC 7638 thumbprint of that key in `cnf.jkt` (section 6), and a resource server that checks the
//! binding refuses a token presented by anyone who cannot sign for the key. A leaked DPoP-bound
//! token is worth nothing without the private key, which never leaves the client.
//!
//! # What this module does and does not do
//!
//! It validates a PROOF (section 4.3) and produces a thumbprint. It does not speak HTTP: the host
//! (or the optional `http` feature) reads the `DPoP` request header and hands the string in.
//!
//! Three things RFC 9449 defines that are NOT implemented here, called out rather than left to be
//! discovered:
//!
//! - The `ath` CLAIM (section 4.3 step 11), which binds a proof to the specific access token it is
//!   presented with. This function verifies a proof against a method and a URI; it takes no access
//!   token, so it cannot check `ath` and does not. That is the right scope for an AS — at the
//!   token endpoint there is no access token yet — but it MATTERS for the resource-server use
//!   [`verify_proof`] documents below, because without it a proof is bound to a key and a request
//!   line but not to a token: an RS built only on this accepts token A presented with a proof
//!   carrying no `ath`, or one whose `ath` hashes a different token B, as long as both were issued
//!   to the same key. An RS MUST check `ath` itself, against the token it is about to accept.
//!   This omission was undocumented until the 0.9.1 audit; the gap was in the docs, not the code.
//!
//! - SERVER-PROVIDED NONCES (section 8), the `DPoP-Nonce` response header and the `use_dpop_nonce`
//!   error. They need the AS to mint, remember and return a nonce on a REJECTED request, which is a
//!   response-header seam this crate's error type does not have and a second piece of server state.
//!   Their purpose is to stop a client pre-generating proofs long in advance; the `iat` window here
//!   bounds that to [`MAX_PROOF_AGE`] instead, which is the weaker but non-optional half of the
//!   same defence.
//! - The `dpop_jkt` AUTHORIZATION REQUEST parameter (section 10), which binds an authorization code
//!   to a key at the authorization endpoint so a stolen code cannot be redeemed by another client.
//!   It belongs with the authorization request rather than here.
//!
//! # Single use is the point, again
//!
//! A proof is not a secret: it travels in a header on every request, so anything on the path sees
//! them constantly. Its whole value is in what a captured one cannot be used for, which is `htm`,
//! `htu`, `iat` and `jti` (section 4.3). [`verify_proof`] is PURE and returns the `jti` with the
//! deadline it must be remembered to; claiming it is [`crate::store::Storage::claim_replay_id`],
//! which `AuthorizationServer` calls on the same request. A verifier that checks the signature and
//! skips the `jti` has left the replay window wide open for exactly [`MAX_PROOF_AGE`].

use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use crate::jwt::{CompactJws, Es256Verifier, PublicJwk};

/// RFC 9449 section 4.2: the `typ` a DPoP proof MUST carry.
pub const DPOP_PROOF_TYP: &str = "dpop+jwt";

/// RFC 9449 section 5: the `token_type` a DPoP-bound token is issued with, and the HTTP
/// authentication scheme a client presents it under.
pub const DPOP_TOKEN_TYPE: &str = "DPoP";

/// RFC 9449 section 4: the request header a proof travels in.
pub const DPOP_HEADER: &str = "DPoP";

/// The JWS algorithms this server accepts on a proof, and exactly what it advertises as
/// `dpop_signing_alg_values_supported` (RFC 9449 section 5.1).
///
/// One entry, and the list must stay honest: RFC 9449 section 4.2 requires an ASYMMETRIC algorithm,
/// and this crate implements ES256 and nothing else (see `Cargo.toml` on why `p256` and not a JOSE
/// framework). Advertising an algorithm the verifier will refuse is worse than advertising fewer,
/// because a client that picks it has no way to find out except by failing.
pub const DPOP_SIGNING_ALG_VALUES_SUPPORTED: &[&str] = &["ES256"];

/// How old a proof's `iat` may be (RFC 9449 section 4.3 (10): "within an acceptable window").
///
/// Five minutes. Two things are bought with the same number, which is why it is one constant: it
/// bounds how long a captured proof is worth replaying, and it bounds the replay cache, because a
/// proof older than this is refused on time alone and its `jti` no longer has to be remembered. RFC
/// 9449 section 11.1 notes that a server wanting a tighter bound should use the section 8 nonce
/// mechanism rather than shrinking this to the point where ordinary clock skew breaks clients.
pub const MAX_PROOF_AGE: Duration = Duration::from_secs(300);

/// How far a client's clock may be AHEAD of this server's before `iat` is refused. Granted in that
/// direction only.
///
/// The SAME constant `client-assertion` publishes for RFC 7523 assertions, re-exported rather than
/// duplicated: one value defined in `src/skew.rs`, which is where the reasoning for the number is.
/// It used to be a second `pub const` here whose doc comment said "for the same reason as in
/// `crate::client_assertion`", which is a comment admitting two numbers had to be kept equal by
/// hand.
pub use crate::skew::CLOCK_SKEW_LEEWAY;

/// The largest DPoP proof [`verify_proof`] will look at, in bytes, checked BEFORE it is parsed.
///
/// WHY THIS CRATE NEEDS ITS OWN. Every other credential this server checks arrives in a request
/// BODY, and the body is bounded (`MAX_BODY_BYTES` in `src/http.rs`). A proof arrives in a request
/// HEADER (RFC 9449 section 4), so that bound never applied to it, and the string went straight
/// into [`crate::jwt::CompactJws::parse`], which base64-decodes it and runs two JSON parses over
/// the result. The client is not authenticated at that point: the work is done for anybody who can
/// open a connection.
///
/// WHY 4 KiB, from what a proof actually contains. RFC 9449 section 4.2 fixes the shape: a header
/// with `typ`, `alg` and an embedded public JWK, which for the ES256 this crate implements is two
/// 43-character base64url P-256 coordinates plus `kty` and `crv`; a payload with `htm`, `htu`,
/// `iat` and `jti`, plus at most the section 8 `nonce` and the section 4.3 `ath`; and a 64-byte
/// signature. Base64url encoded that is a little over 500 bytes in practice, and 4096 leaves room
/// for a long `htu`, a verbose `kid`, and claims a future revision of the RFC may add, while still
/// refusing a megabyte of header before any of it is decoded. A proof this cap refuses is not a
/// proof any conforming client sends.
///
/// DEFENCE IN DEPTH, stated honestly: a host's HTTP server almost always caps total header size as
/// well (8 KiB per header line is a common default), so in most deployments this is the second line
/// and not the only one. It is here because this library never sees the socket and so cannot rely
/// on a limit it did not set, and because [`verify_proof`] is public: a host may hand it a string
/// from anywhere.
pub const MAX_PROOF_BYTES: usize = 4096;

/// The longest `jti` [`verify_proof`] will accept on a proof, in bytes.
///
/// WHY A SECOND CAP, when [`MAX_PROOF_BYTES`] already bounds the whole string. That one bounds the
/// WORK; this one bounds what is RETAINED, and they are different quantities. Everything else
/// `verify_proof` reads is dropped when it returns, but the `jti` is the one value the caller is
/// obliged to WRITE DOWN: `AuthorizationServer` puts it through
/// [`crate::store::Storage::claim_replay_id`] with a deadline of `iat + MAX_PROOF_AGE`, which with
/// the [`CLOCK_SKEW_LEEWAY`] a client may claim ahead is up to six minutes from now. Under a 4096
/// byte proof cap alone, a single request affords a `jti` of nearly 3 KiB, held in the host's store
/// three hundred times longer than the request that produced it took to refuse.
///
/// This is the ONE path in this crate on which a caller who has authenticated as nobody causes a
/// durable store write. The RFC 7523 sibling in `client-assertion` also claims a `jti`, but it
/// reaches that claim only after the assertion has verified under a REGISTERED client's key, so the
/// writes it can be made to perform are bounded by credentials a host issued. A DPoP proof verifies
/// under a key the caller generated for that request, which is no bound at all.
///
/// WHY 128, from what a `jti` is for. RFC 9449 section 4.2 asks only that the value be unique, and
/// section 11.1 explains why: it is what single use is enforced on. A UUID is 36 characters, a
/// base64url 128-bit random is 22, and the crate's own tests mint them shorter than that. 128 bytes
/// is several times what any conforming client needs and still small enough that the retained set is
/// bounded by request rate rather than by a length an attacker chose. A `jti` this cap refuses is
/// not a `jti` any conforming client sends.
pub const MAX_JTI_BYTES: usize = 128;

/// Why a DPoP proof was refused.
///
/// RFC 9449 section 5 makes `invalid_dpop_proof` the token-endpoint answer for all of these; the
/// distinction here is for the host's audit channel, not for the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DpopFailure {
    /// Not a compact JWS at all, or longer than [`MAX_PROOF_BYTES`], which is refused on size
    /// before anything is decoded, or carrying a `jti` longer than [`MAX_JTI_BYTES`]. All three are
    /// the same answer to the client, because all three mean this server will not read, or will not
    /// remember, the string it was handed.
    Malformed,
    /// The `typ` header is not `dpop+jwt`, so this JWT is something else.
    NotAProof,
    /// The `alg` is not one this server accepts for a proof.
    UnsupportedAlgorithm,
    /// The `jwk` header is missing, malformed, or carries a private key parameter.
    BadProofKey,
    /// The signature did not verify under the proof's own advertised key.
    BadSignature,
    /// `htm` is absent or is not this request's method.
    WrongMethod,
    /// `htu` is absent or is not this request's URI.
    WrongUri,
    /// `iat` is absent, too old, or in the future.
    StaleProof,
    /// `jti` is absent or empty, so single use cannot be enforced.
    MissingJti,
    /// This `jti` has been seen before within the proof's acceptance window.
    Replayed,
    /// A proof arrived where this server cannot bind the token it would issue, so it was refused
    /// without being read at all.
    ///
    /// RFC 8693 token exchange is the case that exists today: the exchange has nowhere to record a
    /// `cnf.jkt`, so honouring the proof would hand a client that asked for a sender-constrained
    /// token a bearer token and say nothing. The refusal is correct; what this variant fixes is the
    /// REPORT. It was [`DpopFailure::Malformed`], whose `Display` is "the DPoP proof is not a well
    /// formed JWT", about a string this server never parsed: that sends an operator to the client's
    /// author over a JWS that is probably perfectly good, when the answer is a capability of the
    /// server the operator controls. The same mistake, the same way round, is what
    /// `AssertionFailure::ReplayCheckUnavailable` was added for one file over.
    NotAcceptedHere,
}

impl fmt::Display for DpopFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            DpopFailure::Malformed => "the DPoP proof is not a well formed JWT",
            DpopFailure::NotAProof => "the DPoP proof typ is not dpop+jwt",
            DpopFailure::UnsupportedAlgorithm => "the DPoP proof alg is not supported",
            DpopFailure::BadProofKey => "the DPoP proof jwk is missing or is not a public key",
            DpopFailure::BadSignature => "the DPoP proof signature did not verify",
            DpopFailure::WrongMethod => "the DPoP proof htm is not this request's method",
            DpopFailure::WrongUri => "the DPoP proof htu is not this request's URI",
            DpopFailure::StaleProof => "the DPoP proof iat is missing or outside the window",
            DpopFailure::MissingJti => "the DPoP proof carries no jti",
            DpopFailure::Replayed => "the DPoP proof jti has already been used",
            DpopFailure::NotAcceptedHere => {
                "this server cannot bind a token on this request, so the DPoP proof was refused"
            }
        })
    }
}

impl std::error::Error for DpopFailure {}

/// What a verified proof leaves the caller holding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedProof {
    /// The RFC 7638 thumbprint of the proof key: the `cnf.jkt` (RFC 9449 section 6.1) the issued
    /// token is bound to, and the value a resource server compares against.
    pub jkt: String,
    /// The `jti` the caller MUST claim as single use before issuing anything.
    pub jti: String,
    /// When the caller may stop remembering this `jti`: the last instant at which this exact proof
    /// would still pass the `iat` check. Remembering for less would leave a window in which a
    /// captured proof is forgotten by the cache but still accepted by the clock, which is precisely
    /// where a replay would be aimed.
    pub replay_until: SystemTime,
}

/// The `htu` of a request URI: everything before the query and the fragment.
///
/// RFC 9449 section 4.3 (7) compares `htu` against the request URI "ignoring any query and fragment
/// parts", so both sides go through this. Doing it to BOTH matters: a client that included its
/// query string in `htu` is conformant, and so is one that did not.
pub fn htu_of(url: &str) -> &str {
    let end = url.find(['?', '#']).unwrap_or(url.len());
    &url[..end]
}

/// Verify one RFC 9449 section 4.3 DPoP proof.
///
/// `htm` is the request's method and `htu` its URI (the query and fragment are stripped here, so a
/// caller may pass either form). `now` is the server's clock.
///
/// The key comes from the proof itself, which looks like a violation of the rule stated in
/// `src/jwt.rs` that the verifier chooses the key, and is not. A proof only ever proves possession
/// of the key it advertises; it establishes no identity on its own. What makes it mean something is
/// the BINDING: the token issued alongside it carries that key's thumbprint in `cnf.jkt`, so a
/// later request has to prove possession of the same key. Section 4.3 (6) is what closes the gap,
/// by requiring the signature to verify under the proof's OWN key: an attacker who captures a proof
/// cannot substitute their key without invalidating the signature, and cannot re-sign without the
/// private half.
///
/// `verifier` is the ES256 backend, which after 0.9.0 is the host's to choose: enable `jwt-p256`
/// for [`crate::jwt::P256Verifier`], or pass your own. It is a PARAMETER rather than something
/// this function reaches for, because there is no "none" that could be safe here: a caller with no
/// verifier has nothing to pass and must refuse the request instead, which is what
/// `AuthorizationServer` does.
///
/// PUBLIC because [`VerifiedProof`] and [`DpopFailure`] are, and a type with no reachable producer
/// is a type a consumer can read about and never obtain. It is also the function a host needs
/// directly: RFC 9449 section 7 has the RESOURCE server check a proof on every request, and a host
/// whose resource server is in the same tree as its AS should not have to reimplement section 4.3
/// to do it. Nothing is retained here, so claiming the returned `jti` (see
/// [`crate::store::Storage::claim_replay_id`]) remains the caller's obligation either way.
///
/// A RESOURCE SERVER HAS ONE MORE CHECK TO MAKE, and this function cannot make it: section 4.3
/// step 11 requires that a proof presented WITH an access token carry an `ath` equal to the
/// base64url SHA-256 of that token. There is no access token in this signature, so `ath` is not
/// verified here. Checking it is the caller's obligation, exactly as claiming the `jti` is, and
/// skipping it leaves a proof bound to a key and a request line but not to a token. See the
/// module docs.
pub fn verify_proof(
    verifier: &dyn Es256Verifier,
    proof: &str,
    htm: &str,
    htu: &str,
    now: SystemTime,
) -> Result<VerifiedProof, DpopFailure> {
    // (0) SIZE, before the parse and therefore before any base64 decoding or JSON parsing happens.
    // See [`MAX_PROOF_BYTES`]: this is the only bound on a credential that arrives in a header, and
    // the request carrying it has authenticated nobody yet.
    if proof.len() > MAX_PROOF_BYTES {
        return Err(DpopFailure::Malformed);
    }

    let jws = CompactJws::parse(proof).map_err(|_| DpopFailure::Malformed)?;

    // (0b) RFC 7515 s4.1.11 `crit`, on the same parser every other JWS verifier here uses. A proof
    // whose header names an extension this server does not implement is INVALID, not merely
    // unusual. See `CompactJws::reject_unknown_crit`.
    if jws.reject_unknown_crit().is_err() {
        return Err(DpopFailure::Malformed);
    }

    // (3) `typ` is `dpop+jwt`, and compared exactly. Case folding it would accept `DPOP+JWT`, which
    // no conforming client sends and which only widens what a captured JWT can be presented as; the
    // media type here is a fixed string, not a header field name.
    if jws.header_str("typ") != Some(DPOP_PROOF_TYP) {
        return Err(DpopFailure::NotAProof);
    }

    // (4) and (5): an asymmetric `alg` this server implements. Checked BEFORE the key is read, so
    // no attacker-chosen algorithm ever reaches a verification routine. A symmetric `alg` is
    // refused here rather than failing later on key type, because the reason it is wrong is
    // structural: a MAC is verified with a key both parties hold, which proves possession of
    // nothing this server does not already have, and would turn proof-of-possession back into a
    // bearer scheme.
    match jws.header_str("alg") {
        Some(alg) if DPOP_SIGNING_ALG_VALUES_SUPPORTED.contains(&alg) => {}
        _ => return Err(DpopFailure::UnsupportedAlgorithm),
    }

    // (4) The proof key, which MUST be a public key and MUST NOT carry a private one. `from_json`
    // is where that is enforced, for every JWK this crate parses; see `PRIVATE_JWK_MEMBERS`.
    let jwk = jws.header.get("jwk").ok_or(DpopFailure::BadProofKey)?;
    let jwk = PublicJwk::from_json(jwk).map_err(|_| DpopFailure::BadProofKey)?;

    // (6) The signature, under the proof's own key, over the bytes that arrived.
    if !verifier.verify(&jwk, jws.signing_input.as_bytes(), &jws.signature) {
        return Err(DpopFailure::BadSignature);
    }

    // (7) The proof is bound to THIS request. Without these two a proof captured anywhere the
    // client sends one, above all at a resource server, could be spent at the token endpoint.
    // RFC 9110 s9.1 makes the method case sensitive, so this comparison is too.
    if jws.claim_str("htm") != Some(htm) {
        return Err(DpopFailure::WrongMethod);
    }
    match jws.claim_str("htu") {
        Some(claimed) if htu_of(claimed) == htu_of(htu) => {}
        _ => return Err(DpopFailure::WrongUri),
    }

    // (10) `iat` within an acceptable window, in both directions. A proof from the future would
    // otherwise extend its own life past the window by however far ahead the client dares to
    // claim, which is the one bound this check exists to impose.
    //
    // The age bound is EXCLUSIVE, and the one character is load bearing. `replay_until` below is
    // `iat + MAX_PROOF_AGE`, and `Storage::sweep_expired` drops a claimed `jti` when `now < exp`
    // fails, so at `now == iat + MAX_PROOF_AGE` the `jti` is already forgotten. An inclusive check
    // here would still ACCEPT the proof at that instant, against an empty replay cache: exactly
    // one free replay of a captured proof, which is the whole thing the `jti` exists to refuse.
    // The two predicates have to agree at the boundary, and the sweep's is the exclusive one.
    // `client-assertion` has never had this gap because its acceptance was exclusive already.
    // Every one of these additions is CHECKED, and the reason is that `iat` is a `u64` lifted
    // straight out of attacker-written JSON on a request that has authenticated nobody. Plain `+`
    // on a `SystemTime` PANICS on overflow, and this crate is a library: the panic unwinds into the
    // host's request handler, from a proof header reading `{"iat": 18446744073709551615}`. Mapping
    // an unrepresentable instant to `StaleProof` is also correct on the merits rather than merely
    // defensive: section 4.3 (10) wants `iat` inside an acceptable window, and a time that cannot
    // be represented is outside every window a server could choose.
    let iat = jws.claim_time("iat").ok_or(DpopFailure::StaleProof)?;
    let issued_at = UNIX_EPOCH
        .checked_add(Duration::from_secs(iat))
        .ok_or(DpopFailure::StaleProof)?;
    let replay_until = issued_at
        .checked_add(MAX_PROOF_AGE)
        .ok_or(DpopFailure::StaleProof)?;
    let horizon = now
        .checked_add(CLOCK_SKEW_LEEWAY)
        .ok_or(DpopFailure::StaleProof)?;
    if issued_at > horizon || replay_until <= now {
        return Err(DpopFailure::StaleProof);
    }

    // (2) The `jti`, which single use is enforced on. Same argument as RFC 7523's: a proof that
    // cannot be remembered is one that can be sent again, so it is refused rather than accepted
    // with the check quietly skipped.
    let jti = jws.claim_str("jti").unwrap_or_default();
    if jti.is_empty() {
        return Err(DpopFailure::MissingJti);
    }
    // And bounded, because this is the one value leaving this function that the caller has to
    // REMEMBER: see [`MAX_JTI_BYTES`] for why the proof-wide cap does not cover it. `Malformed`
    // rather than `MissingJti`, because there is nothing missing: the proof is one this server will
    // not read on size, which is what `Malformed` already says.
    if jti.len() > MAX_JTI_BYTES {
        return Err(DpopFailure::Malformed);
    }

    Ok(VerifiedProof {
        jkt: jwk.thumbprint(),
        jti: jti.to_string(),
        replay_until,
    })
}

// The unit tests need a key that can SIGN, so they need `jwt-p256`, the built-in ES256 backend.
// `jwt` alone carries the `Es256Signer`/`Es256Verifier` seam and no curve arithmetic at all, and a
// test that cannot produce a signature cannot test a verifier.
#[cfg(all(test, feature = "jwt-p256"))]
#[path = "tests/dpop.rs"]
mod tests;