Skip to main content

authplane_sdk/
verified_claims.rs

1use std::collections::BTreeMap;
2
3use serde_json::Value;
4use thiserror::Error;
5
6use crate::dpop::VerifiedDpopProof;
7
8#[derive(Debug, Clone)]
9pub struct VerifiedClaims {
10    pub sub: String,
11    pub client_id: String,
12    pub scopes: Vec<String>,
13    pub issuer: String,
14    pub audience: Vec<String>,
15    pub expires_at: i64,
16    pub issued_at: i64,
17    pub jti: String,
18    pub kid: String,
19    pub agent_id: String,
20    pub agent_chain: Vec<String>,
21    pub not_before: i64,
22    pub raw: BTreeMap<String, Value>,
23    /// The verified DPoP proof, if the token was DPoP-bound and
24    /// `verify_with_context` was used. `None` for bearer tokens.
25    pub dpop_proof: Option<VerifiedDpopProof>,
26}
27
28/// Manual `PartialEq` that excludes `dpop_proof`: the verified proof is
29/// attached to the claims for the caller's use, but two sets of claims are
30/// equal on their claim values, not on which request carried them.
31impl PartialEq for VerifiedClaims {
32    fn eq(&self, other: &Self) -> bool {
33        self.sub == other.sub
34            && self.client_id == other.client_id
35            && self.scopes == other.scopes
36            && self.issuer == other.issuer
37            && self.audience == other.audience
38            && self.expires_at == other.expires_at
39            && self.issued_at == other.issued_at
40            && self.jti == other.jti
41            && self.kid == other.kid
42            && self.agent_id == other.agent_id
43            && self.agent_chain == other.agent_chain
44            && self.not_before == other.not_before
45            && self.raw == other.raw
46    }
47}
48
49impl VerifiedClaims {
50    pub fn has_scope(&self, scope: &str) -> bool {
51        self.scopes.iter().any(|candidate| candidate == scope)
52    }
53
54    pub fn require_scope(&self, scope: &str) -> Result<(), VerifierError> {
55        if self.has_scope(scope) {
56            return Ok(());
57        }
58
59        Err(VerifierError::InsufficientScope {
60            required: scope.to_string(),
61            available: self.scopes.clone(),
62        })
63    }
64
65    pub fn has_claim(&self, key: &str, expected: Option<&Value>) -> bool {
66        match self.raw.get(key) {
67            Some(value) => expected.is_none_or(|candidate| candidate == value),
68            None => false,
69        }
70    }
71
72    /// RFC 8693 §4.1 — the `act` (actor) claim, if present.
73    ///
74    /// Returns the nested actor claim object describing who is acting on
75    /// behalf of the subject.
76    pub fn act(&self) -> Option<&Value> {
77        self.raw.get("act")
78    }
79
80    /// RFC 8693 §4.4 — the `may_act` claim, if present.
81    ///
82    /// Returns the authorization claim describing who is allowed to act
83    /// on behalf of the subject.
84    #[deprecated(note = "authserver 0.2.0 no longer issues may_act; removed in the next minor")]
85    pub fn may_act(&self) -> Option<&Value> {
86        self.raw.get("may_act")
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Error)]
91#[non_exhaustive]
92pub enum VerifierError {
93    #[error("access token is missing")]
94    TokenMissing,
95    #[error("token has expired")]
96    TokenExpired,
97    #[error("token signature verification failed: {message}")]
98    InvalidSignature { message: String },
99    #[error("token claims validation failed: {message}")]
100    InvalidClaims { message: String },
101    #[error("failed to fetch or use metadata: {message}")]
102    MetadataUnavailable { message: String },
103    #[error("failed to fetch or use JWKS: {message}")]
104    JwksUnavailable { message: String },
105    /// RFC 7662 §2.2 — introspection answered `active: false` for a token
106    /// that had already passed local JWT verification.
107    ///
108    /// RFC 7662 defines `active: false` broadly and authserver does not
109    /// say why, so the token may be revoked — or the AS may not recognise
110    /// this resource server as the token's owner. Since authserver 0.1.2
111    /// only the issuing client or a runtime-client of the Resource named
112    /// in `aud` gets a real answer; any other caller, including a public
113    /// (secret-less) client, gets `active: false` for every token. If every
114    /// token is rejected with this error, register the resource server's
115    /// client on the Resource:
116    /// `authserver admin resource runtime-client add --client-id <rs-client-id> --slug <resource-slug>`.
117    ///
118    /// The Display is deliberately bare: `www_authenticate*` copies
119    /// `error.to_string()` into `error_description` and the mcp adapter copies
120    /// it into the 401 body, so anything said here reaches an unauthenticated
121    /// caller. The operator guidance above stays in the docs.
122    #[error("token is not active")]
123    TokenRevoked,
124    #[error("token missing required scope {required:?}; available scopes: {available:?}")]
125    InsufficientScope {
126        required: String,
127        available: Vec<String>,
128    },
129    /// RFC 9449 §7 — the `verify_with_context` entrypoint received a
130    /// DPoP-bound access token (one with `cnf.jkt`) but the request
131    /// context carried no DPoP proof. Maps to the catalog's
132    /// `error_category = "dpop_proof_missing"` bucket.
133    ///
134    /// A context that was never supplied is a different failure and is
135    /// reported as [`Self::DpopBindingMismatch`]: `verify` takes no
136    /// request context by construction, so "the caller passed one and it
137    /// held no proof" is a claim only this entrypoint can make.
138    #[error("DPoP-bound access token rejected: no DPoP proof supplied in request context")]
139    DpopProofMissing,
140    /// RFC 9449 §11.1 — the proof's `jti` had already been observed by
141    /// the configured replay store.
142    #[error("DPoP proof replay detected (duplicate jti)")]
143    DpopReplayDetected,
144    /// RFC 9449 §4.3 #1 — the request carried more than one `DPoP` header,
145    /// so there is no way to know which proof binds the request. Unlike the
146    /// other DPoP failures this maps to `error="invalid_dpop_proof"`
147    /// (RFC 9449 §7.1) rather than the generic `invalid_token`.
148    #[error("multiple DPoP headers received; exactly one required (RFC 9449 section 4.3)")]
149    DpopMultipleProofs,
150    #[error("DPoP binding mismatch: {message}")]
151    DpopBindingMismatch { message: String },
152    /// RFC 9449 §6 — the resource has NOT opted into inbound DPoP
153    /// (`ResourceOptions::inbound_dpop` is `None`), but the request carried
154    /// a DPoP signal (a `cnf.jkt`-bound access token or a `DPoP` proof
155    /// header). The verifier rejects rather than silently downgrading to
156    /// bearer or applying ad-hoc defaults never advertised in PRM.
157    #[error(
158        "DPoP-bound request rejected: resource is not configured for inbound DPoP. \
159         Set ResourceOptions::inbound_dpop to enable."
160    )]
161    DpopNotSupported,
162}
163
164impl VerifierError {
165    /// `true` for any DPoP-specific variant. Currently `DpopProofMissing`,
166    /// `DpopReplayDetected`, `DpopMultipleProofs`, `DpopBindingMismatch`,
167    /// and `DpopNotSupported`.
168    ///
169    /// Membership only — does NOT decide the `WWW-Authenticate` scheme.
170    /// `DpopNotSupported` is a DPoP-flavoured error but the spec-correct
171    /// retry scheme is `Bearer` (see [`Self::www_authenticate_scheme_is_dpop`]).
172    pub fn is_dpop(&self) -> bool {
173        matches!(
174            self,
175            VerifierError::DpopProofMissing
176                | VerifierError::DpopReplayDetected
177                | VerifierError::DpopMultipleProofs
178                | VerifierError::DpopBindingMismatch { .. }
179                | VerifierError::DpopNotSupported
180        )
181    }
182
183    /// `true` when the spec-correct `WWW-Authenticate` challenge for this
184    /// error uses the `DPoP` scheme (RFC 9449 §7.1) rather than the default
185    /// `Bearer` (RFC 6750 §3).
186    ///
187    /// All DPoP-bound failures map to `DPoP` **except** [`Self::DpopNotSupported`],
188    /// which is the carve-out: the client presented a DPoP signal against a
189    /// resource that has not opted into DPoP, so there is no `DPoP` retry
190    /// path on this resource — the correct challenge tells the client to
191    /// retry as `Bearer`. Conformance fixtures assert this scheme
192    /// byte-for-byte.
193    pub fn www_authenticate_scheme_is_dpop(&self) -> bool {
194        matches!(
195            self,
196            VerifierError::DpopProofMissing
197                | VerifierError::DpopReplayDetected
198                | VerifierError::DpopMultipleProofs
199                | VerifierError::DpopBindingMismatch { .. }
200        )
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::{VerifiedClaims, VerifierError};
207    use serde_json::json;
208    use std::collections::BTreeMap;
209
210    fn sample_claims() -> VerifiedClaims {
211        let mut raw = BTreeMap::new();
212        raw.insert("sub".to_string(), json!("user-123"));
213        raw.insert("tenant".to_string(), json!("acme"));
214        VerifiedClaims {
215            sub: "user-123".to_string(),
216            client_id: "client-123".to_string(),
217            scopes: vec!["tools/read".to_string(), "tools/write".to_string()],
218            issuer: "https://auth.example.com".to_string(),
219            audience: vec!["https://api.example.com".to_string()],
220            expires_at: 4_102_444_800,
221            issued_at: 1_700_000_000,
222            jti: "jti-1".to_string(),
223            kid: "kid-1".to_string(),
224            agent_id: "agent-1".to_string(),
225            agent_chain: vec!["agent-0".to_string(), "agent-1".to_string()],
226            not_before: 1_700_000_000,
227            raw,
228            dpop_proof: None,
229        }
230    }
231
232    #[test]
233    fn has_scope_returns_true_when_present() {
234        let claims = sample_claims();
235        assert!(claims.has_scope("tools/read"));
236    }
237
238    #[test]
239    fn has_scope_returns_false_when_absent() {
240        let claims = sample_claims();
241        assert!(!claims.has_scope("tools/delete"));
242    }
243
244    #[test]
245    fn require_scope_succeeds_when_present() {
246        let claims = sample_claims();
247        assert!(claims.require_scope("tools/write").is_ok());
248    }
249
250    #[test]
251    fn require_scope_fails_when_missing() {
252        let claims = sample_claims();
253        let error = claims
254            .require_scope("tools/admin")
255            .expect_err("missing scope must fail");
256        let VerifierError::InsufficientScope {
257            required,
258            available,
259        } = error
260        else {
261            panic!("expected insufficient scope")
262        };
263        assert_eq!(required, "tools/admin");
264        assert_eq!(available, vec!["tools/read", "tools/write"]);
265    }
266
267    #[test]
268    fn has_claim_true_without_expected_when_key_exists() {
269        let claims = sample_claims();
270        assert!(claims.has_claim("tenant", None));
271    }
272
273    #[test]
274    fn has_claim_true_with_expected_when_value_matches() {
275        let claims = sample_claims();
276        assert!(claims.has_claim("tenant", Some(&json!("acme"))));
277    }
278
279    #[test]
280    fn has_claim_false_when_value_mismatch() {
281        let claims = sample_claims();
282        assert!(!claims.has_claim("tenant", Some(&json!("other"))));
283    }
284
285    #[test]
286    fn has_claim_false_when_key_missing() {
287        let claims = sample_claims();
288        assert!(!claims.has_claim("missing", None));
289    }
290
291    /// `is_dpop` is part of the public API — downstream consumers use it
292    /// to bucket DPoP-flavoured failures. Pin its membership exhaustively
293    /// so a new `VerifierError` variant added in the future does not
294    /// silently miss the predicate.
295    #[test]
296    fn is_dpop_covers_every_dpop_variant_and_nothing_else() {
297        // DPoP-flavoured: must return true.
298        assert!(VerifierError::DpopProofMissing.is_dpop());
299        assert!(VerifierError::DpopReplayDetected.is_dpop());
300        assert!(VerifierError::DpopMultipleProofs.is_dpop());
301        assert!(
302            VerifierError::DpopBindingMismatch {
303                message: "x".to_string()
304            }
305            .is_dpop()
306        );
307        assert!(VerifierError::DpopNotSupported.is_dpop());
308
309        // Non-DPoP: must return false.
310        assert!(!VerifierError::TokenMissing.is_dpop());
311        assert!(!VerifierError::TokenExpired.is_dpop());
312        assert!(
313            !VerifierError::InvalidSignature {
314                message: "x".to_string()
315            }
316            .is_dpop()
317        );
318        assert!(
319            !VerifierError::InvalidClaims {
320                message: "x".to_string()
321            }
322            .is_dpop()
323        );
324        assert!(
325            !VerifierError::MetadataUnavailable {
326                message: "x".to_string()
327            }
328            .is_dpop()
329        );
330        assert!(
331            !VerifierError::JwksUnavailable {
332                message: "x".to_string()
333            }
334            .is_dpop()
335        );
336        assert!(!VerifierError::TokenRevoked.is_dpop());
337        assert!(
338            !VerifierError::InsufficientScope {
339                required: "x".to_string(),
340                available: vec![]
341            }
342            .is_dpop()
343        );
344    }
345
346    /// `www_authenticate*` copies `error.to_string()` into `error_description`
347    /// and the mcp adapter copies it into the 401 body, so this Display reaches
348    /// unauthenticated callers. It must not describe the deployment.
349    #[test]
350    fn token_revoked_display_carries_no_deployment_detail() {
351        let rendered = VerifierError::TokenRevoked.to_string();
352        assert_eq!(rendered, "token is not active");
353        for leaked in [
354            "introspection",
355            "runtime-client",
356            "issuing client",
357            "active=false",
358        ] {
359            assert!(
360                !rendered.contains(leaked),
361                "TokenRevoked Display leaked {leaked:?} onto the wire"
362            );
363        }
364    }
365}