authplane_sdk/
verified_claims.rs1use 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 pub dpop_proof: Option<VerifiedDpopProof>,
26}
27
28impl 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 pub fn act(&self) -> Option<&Value> {
77 self.raw.get("act")
78 }
79
80 #[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 #[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 #[error("DPoP-bound access token rejected: no DPoP proof supplied in request context")]
139 DpopProofMissing,
140 #[error("DPoP proof replay detected (duplicate jti)")]
143 DpopReplayDetected,
144 #[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 #[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 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 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 #[test]
296 fn is_dpop_covers_every_dpop_variant_and_nothing_else() {
297 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 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 #[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}