1use std::sync::OnceLock;
2
3use regex::Regex;
4use time::{OffsetDateTime, format_description::well_known::Rfc3339};
5use url::Url;
6
7use crate::{
8 ApiKeyGrantResponse, AssertionOperation, BasicGrantResponse, BuiltInGrantResponse,
9 ClientAssertionClaims, EnrollRequest, EnrollResponse, ErrorCode, GrantRequest, GrantType,
10 IdempotencyMetadata, MAX_ASSERTION_LIFETIME, OAuthBearerGrantResponse,
11 OpenApiAepSecurityScheme, ParseError, ProblemDetails, RevokeRequest, RevokeResponse,
12 StatusResponse, StringBoolean, ValidationError, ValidationIssue,
13 claims::validate_claim_values,
14 openapi::is_loopback_host,
15 validation::{
16 issue, parse_and_validate, require_non_empty, result, validate_non_empty_strings,
17 },
18};
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct ClientAssertionValidationOptions {
22 pub allow_insecure_loopback: bool,
23}
24
25pub fn parse_enroll_request(data: &[u8]) -> Result<EnrollRequest, ParseError> {
26 parse_and_validate(data, "Enroll request", validate_enroll_request)
27}
28
29pub fn validate_enroll_request(value: &EnrollRequest) -> Result<(), ValidationError> {
30 let mut issues = Vec::new();
31 require_non_empty(&value.agent_did, "$.agent_did", &mut issues);
32 if value.idempotency_key.as_deref() == Some("") {
33 issues.push(issue("$.idempotency_key", "Expected a non-empty string."));
34 }
35 if let Some(claims) = &value.claims
36 && let Err(error) = validate_claim_values(claims)
37 {
38 issues.extend(error.issues);
39 }
40 result("Enroll request", issues)
41}
42
43pub fn parse_enroll_response(data: &[u8]) -> Result<EnrollResponse, ParseError> {
44 parse_and_validate(data, "Enroll response", validate_enroll_response)
45}
46
47pub fn validate_enroll_response(value: &EnrollResponse) -> Result<(), ValidationError> {
48 let mut issues = Vec::new();
49 validate_optional_non_empty_unique(
50 value.verification_pending.as_deref(),
51 "$.verification_pending",
52 &mut issues,
53 );
54 validate_optional_non_empty_unique(
55 value.requirements_pending.as_deref(),
56 "$.requirements_pending",
57 &mut issues,
58 );
59 result("Enroll response", issues)
60}
61
62pub fn parse_status_response(data: &[u8]) -> Result<StatusResponse, ParseError> {
63 parse_and_validate(data, "Status response", validate_status_response)
64}
65
66pub fn validate_status_response(value: &StatusResponse) -> Result<(), ValidationError> {
67 let mut issues = Vec::new();
68 validate_optional_non_empty_unique(
69 value.verification_pending.as_deref(),
70 "$.verification_pending",
71 &mut issues,
72 );
73 validate_optional_non_empty_unique(
74 value.requirements_pending.as_deref(),
75 "$.requirements_pending",
76 &mut issues,
77 );
78 if value
79 .since
80 .as_deref()
81 .is_some_and(|since| !is_rfc3339(since))
82 {
83 issues.push(issue("$.since", "Expected an RFC 3339 date-time."));
84 }
85 result("Status response", issues)
86}
87
88pub fn parse_grant_request(data: &[u8]) -> Result<GrantRequest, ParseError> {
89 parse_and_validate(data, "Grant request", validate_grant_request)
90}
91
92pub fn validate_grant_request(value: &GrantRequest) -> Result<(), ValidationError> {
93 let mut issues = Vec::new();
94 require_non_empty(value.grant_type.as_str(), "$.grant_type", &mut issues);
95 validate_strings(&value.requested_scopes, "$.requested_scopes", &mut issues);
96 result("Grant request", issues)
97}
98
99pub fn parse_revoke_request(data: &[u8]) -> Result<RevokeRequest, ParseError> {
100 parse_and_validate(data, "Revoke request", validate_revoke_request)
101}
102
103pub fn validate_revoke_request(value: &RevokeRequest) -> Result<(), ValidationError> {
104 let mut issues = Vec::new();
105 let has_all = value.all_grant_types.is_some();
106 let has_credential = value.credential_id.is_some();
107 let has_grant = value.grant_type.is_some();
108 if value.all_grant_types == Some(StringBoolean::False) {
109 issues.push(issue("$.all_grant_types", "Expected true."));
110 }
111 if value.credential_id.as_deref() == Some("") {
112 issues.push(issue("$.credential_id", "Expected a non-empty string."));
113 }
114 if has_all == has_grant || (has_all && has_credential) {
115 issues.push(issue(
116 "$",
117 "Expected grant_type, grant_type with credential_id, or all_grant_types.",
118 ));
119 }
120 result("Revoke request", issues)
121}
122
123pub fn parse_revoke_response(data: &[u8]) -> Result<RevokeResponse, ParseError> {
124 parse_and_validate(data, "Revoke response", validate_revoke_response)
125}
126
127pub fn validate_revoke_response(_value: &RevokeResponse) -> Result<(), ValidationError> {
128 Ok(())
129}
130
131pub fn parse_idempotency_metadata(data: &[u8]) -> Result<IdempotencyMetadata, ParseError> {
132 parse_and_validate(data, "Idempotency metadata", validate_idempotency_metadata)
133}
134
135pub fn validate_idempotency_metadata(value: &IdempotencyMetadata) -> Result<(), ValidationError> {
136 let mut issues = Vec::new();
137 require_non_empty(&value.idempotency_key, "$.idempotency_key", &mut issues);
138 if value.agent_did.as_deref() == Some("") {
139 issues.push(issue("$.agent_did", "Expected a non-empty string."));
140 }
141 validate_body_hash(
142 value.first_body_hash.as_deref(),
143 "$.first_body_hash",
144 &mut issues,
145 );
146 validate_body_hash(
147 value.second_body_hash.as_deref(),
148 "$.second_body_hash",
149 &mut issues,
150 );
151 result("Idempotency metadata", issues)
152}
153
154pub fn parse_openapi_aep_security_scheme(
155 data: &[u8],
156) -> Result<OpenApiAepSecurityScheme, ParseError> {
157 parse_and_validate(
158 data,
159 "OpenAPI AEP security scheme",
160 validate_openapi_aep_security_scheme,
161 )
162}
163
164pub fn validate_openapi_aep_security_scheme(
165 value: &OpenApiAepSecurityScheme,
166) -> Result<(), ValidationError> {
167 let issues = if advertisement_pattern().is_match(value.authentication_method.as_str()) {
168 Vec::new()
169 } else {
170 vec![issue(
171 "$.x-aep-authentication-method",
172 "Expected a lowercase authentication-method identifier.",
173 )]
174 };
175 result("OpenAPI AEP security scheme", issues)
176}
177
178pub fn parse_client_assertion_claims(data: &[u8]) -> Result<ClientAssertionClaims, ParseError> {
179 parse_client_assertion_claims_with_options(data, ClientAssertionValidationOptions::default())
180}
181
182pub fn parse_client_assertion_claims_with_options(
183 data: &[u8],
184 options: ClientAssertionValidationOptions,
185) -> Result<ClientAssertionClaims, ParseError> {
186 parse_and_validate(data, "client assertion claims", |value| {
187 validate_client_assertion_claims_with_options(value, options)
188 })
189}
190
191pub fn validate_client_assertion_claims(
192 value: &ClientAssertionClaims,
193) -> Result<(), ValidationError> {
194 validate_client_assertion_claims_with_options(
195 value,
196 ClientAssertionValidationOptions::default(),
197 )
198}
199
200pub fn validate_client_assertion_claims_with_options(
201 value: &ClientAssertionClaims,
202 options: ClientAssertionValidationOptions,
203) -> Result<(), ValidationError> {
204 let mut issues = Vec::new();
205 require_non_empty(&value.iss, "$.iss", &mut issues);
206 require_non_empty(&value.sub, "$.sub", &mut issues);
207 if !value.iss.is_empty() && !value.sub.is_empty() && value.iss != value.sub {
208 issues.push(issue("$.sub", "Expected sub to equal iss."));
209 }
210 require_non_empty(&value.aud, "$.aud", &mut issues);
211 require_non_empty(&value.jti, "$.jti", &mut issues);
212 if value.op == AssertionOperation::Authenticate {
213 if value.resource.as_deref().is_none_or(|resource| {
214 !is_protected_resource_uri(resource, options.allow_insecure_loopback)
215 }) {
216 issues.push(issue(
217 "$.resource",
218 "Expected an HTTPS protected-resource URI without a fragment.",
219 ));
220 }
221 } else if value.resource.is_some() {
222 issues.push(issue(
223 "$.resource",
224 "resource is only valid for authenticate.",
225 ));
226 }
227 if value.exp <= value.iat {
228 issues.push(issue("$.exp", "Expected exp after iat."));
229 } else if value.exp.saturating_sub(value.iat) > MAX_ASSERTION_LIFETIME.as_secs() as i64 {
230 issues.push(issue(
231 "$.exp",
232 "Expected an assertion lifetime no greater than 300 seconds.",
233 ));
234 }
235 result("client assertion claims", issues)
236}
237
238pub fn new_problem_details(
239 code: ErrorCode,
240 title: impl Into<String>,
241 status: i64,
242) -> ProblemDetails {
243 ProblemDetails {
244 problem_type: format!("urn:aep:error:{}", code.as_str()),
245 title: title.into(),
246 status,
247 detail: None,
248 instance: None,
249 code,
250 owner_action_required: None,
251 requirements_pending: None,
252 verification_pending: None,
253 additional: Default::default(),
254 }
255}
256
257pub fn parse_problem_details(data: &[u8]) -> Result<ProblemDetails, ParseError> {
258 parse_and_validate(data, "Problem Details", validate_problem_details)
259}
260
261pub fn validate_problem_details(value: &ProblemDetails) -> Result<(), ValidationError> {
262 let mut issues = Vec::new();
263 if value.problem_type != format!("urn:aep:error:{}", value.code.as_str()) {
264 issues.push(issue("$.type", "Expected an AEP error URN matching code."));
265 }
266 require_non_empty(&value.title, "$.title", &mut issues);
267 if value.status == 0 {
268 issues.push(issue("$.status", "Expected an integer HTTP status."));
269 }
270 require_non_empty(value.code.as_str(), "$.code", &mut issues);
271 if value.owner_action_required == Some(StringBoolean::False) {
272 issues.push(issue("$.owner_action_required", "Expected true."));
273 }
274 validate_optional_non_empty_unique(
275 value.verification_pending.as_deref(),
276 "$.verification_pending",
277 &mut issues,
278 );
279 validate_optional_non_empty_unique(
280 value.requirements_pending.as_deref(),
281 "$.requirements_pending",
282 &mut issues,
283 );
284 if value.code == ErrorCode::NotRecognized
285 && (value.owner_action_required.is_some()
286 || value.verification_pending.is_some()
287 || value.requirements_pending.is_some())
288 {
289 issues.push(issue(
290 "$",
291 "not_recognized must not expose pending or owner-action metadata.",
292 ));
293 }
294 result("Problem Details", issues)
295}
296
297pub fn parse_built_in_grant_response(
298 grant_type: &GrantType,
299 data: &[u8],
300) -> Result<BuiltInGrantResponse, ParseError> {
301 match grant_type {
302 GrantType::OAuthBearer => {
303 parse_oauth_bearer_grant_response(data).map(BuiltInGrantResponse::OAuthBearer)
304 }
305 GrantType::ApiKey => parse_api_key_grant_response(data).map(BuiltInGrantResponse::ApiKey),
306 GrantType::Basic => parse_basic_grant_response(data).map(BuiltInGrantResponse::Basic),
307 GrantType::Other(_) => Err(ParseError::Validation(ValidationError {
308 document_type: "Grant response".to_owned(),
309 issues: vec![issue("$.grant_type", "Expected a built-in AEP grant type.")],
310 })),
311 }
312}
313
314pub fn validate_built_in_grant_response(
315 grant_type: &GrantType,
316 value: &BuiltInGrantResponse,
317) -> Result<(), ValidationError> {
318 match (grant_type, value) {
319 (GrantType::OAuthBearer, BuiltInGrantResponse::OAuthBearer(value)) => {
320 validate_oauth_bearer_grant_response(value)
321 }
322 (GrantType::ApiKey, BuiltInGrantResponse::ApiKey(value)) => {
323 validate_api_key_grant_response(value)
324 }
325 (GrantType::Basic, BuiltInGrantResponse::Basic(value)) => {
326 validate_basic_grant_response(value)
327 }
328 _ => result(
329 "Grant response",
330 vec![issue(
331 "$.grant_type",
332 "Expected the selected built-in AEP grant type.",
333 )],
334 ),
335 }
336}
337
338pub fn parse_oauth_bearer_grant_response(
339 data: &[u8],
340) -> Result<OAuthBearerGrantResponse, ParseError> {
341 parse_and_validate(
342 data,
343 "OAuth Bearer Grant response",
344 validate_oauth_bearer_grant_response,
345 )
346}
347
348pub fn validate_oauth_bearer_grant_response(
349 value: &OAuthBearerGrantResponse,
350) -> Result<(), ValidationError> {
351 let mut issues = Vec::new();
352 require_non_empty(&value.access_token, "$.access_token", &mut issues);
353 require_credential_fields(
354 &value.credential_id,
355 &value.expires_at,
356 &value.scopes,
357 &mut issues,
358 );
359 if value.token_type != "Bearer" {
360 issues.push(issue("$.token_type", "Expected Bearer."));
361 }
362 result("OAuth Bearer Grant response", issues)
363}
364
365pub fn parse_api_key_grant_response(data: &[u8]) -> Result<ApiKeyGrantResponse, ParseError> {
366 parse_and_validate(
367 data,
368 "API-key Grant response",
369 validate_api_key_grant_response,
370 )
371}
372
373pub fn validate_api_key_grant_response(value: &ApiKeyGrantResponse) -> Result<(), ValidationError> {
374 let mut issues = Vec::new();
375 require_non_empty(&value.api_key, "$.api_key", &mut issues);
376 require_non_empty(&value.header, "$.header", &mut issues);
377 if !value.api_key.is_empty() && !valid_api_key_value(&value.api_key) {
378 issues.push(issue(
379 "$.api_key",
380 "Expected an unambiguous HTTP field value.",
381 ));
382 }
383 if !value.header.is_empty() && !is_http_field_name(&value.header) {
384 issues.push(issue("$.header", "Expected an HTTP field name."));
385 }
386 require_credential_fields(
387 &value.credential_id,
388 &value.expires_at,
389 &value.scopes,
390 &mut issues,
391 );
392 result("API-key Grant response", issues)
393}
394
395pub fn parse_basic_grant_response(data: &[u8]) -> Result<BasicGrantResponse, ParseError> {
396 parse_and_validate(data, "Basic Grant response", validate_basic_grant_response)
397}
398
399pub fn validate_basic_grant_response(value: &BasicGrantResponse) -> Result<(), ValidationError> {
400 let mut issues = Vec::new();
401 require_non_empty(&value.password, "$.password", &mut issues);
402 require_non_empty(&value.username, "$.username", &mut issues);
403 if !value.username.is_empty()
404 && (value.username.contains(':') || contains_control_character(&value.username))
405 {
406 issues.push(issue(
407 "$.username",
408 "Expected an RFC 7617 username without a colon or control character.",
409 ));
410 }
411 if !value.password.is_empty() && contains_control_character(&value.password) {
412 issues.push(issue(
413 "$.password",
414 "Expected a value without control characters.",
415 ));
416 }
417 if value.realm.as_deref() == Some("") {
418 issues.push(issue("$.realm", "Expected a non-empty string."));
419 }
420 require_credential_fields(
421 &value.credential_id,
422 &value.expires_at,
423 &value.scopes,
424 &mut issues,
425 );
426 result("Basic Grant response", issues)
427}
428
429pub fn is_http_field_name(value: &str) -> bool {
430 !value.is_empty()
431 && value.bytes().all(|byte| {
432 byte.is_ascii_alphanumeric()
433 || matches!(
434 byte,
435 b'!' | b'#'
436 | b'$'
437 | b'%'
438 | b'&'
439 | b'\''
440 | b'*'
441 | b'+'
442 | b'-'
443 | b'.'
444 | b'^'
445 | b'_'
446 | b'`'
447 | b'|'
448 | b'~'
449 )
450 })
451}
452
453fn require_credential_fields(
454 credential_id: &str,
455 expires_at: &str,
456 scopes: &[String],
457 issues: &mut Vec<ValidationIssue>,
458) {
459 require_non_empty(credential_id, "$.credential_id", issues);
460 require_non_empty(expires_at, "$.expires_at", issues);
461 if !expires_at.is_empty() && !is_rfc3339(expires_at) {
462 issues.push(issue("$.expires_at", "Expected an RFC 3339 date-time."));
463 }
464 validate_strings(scopes, "$.scopes", issues);
465}
466
467fn validate_optional_non_empty_unique(
468 values: Option<&[String]>,
469 path: &str,
470 issues: &mut Vec<ValidationIssue>,
471) {
472 if let Some(values) = values {
473 validate_non_empty_strings(values, path, true, issues);
474 }
475}
476
477fn validate_strings(values: &[String], path: &str, issues: &mut Vec<ValidationIssue>) {
478 for (index, value) in values.iter().enumerate() {
479 if value.is_empty() {
480 issues.push(issue(format!("{path}[{index}]"), "Expected a string."));
481 }
482 }
483}
484
485fn validate_body_hash(value: Option<&str>, path: &str, issues: &mut Vec<ValidationIssue>) {
486 if value.is_some_and(|value| !body_hash_pattern().is_match(value)) {
487 issues.push(issue(path, "Expected a lowercase SHA-256 body hash."));
488 }
489}
490
491fn is_rfc3339(value: &str) -> bool {
492 OffsetDateTime::parse(value, &Rfc3339).is_ok()
493}
494
495fn is_protected_resource_uri(value: &str, allow_insecure_loopback: bool) -> bool {
496 let Ok(url) = Url::parse(value) else {
497 return false;
498 };
499 if url.host_str().is_none() || url.fragment().is_some() {
500 return false;
501 }
502 url.scheme() == "https"
503 || (allow_insecure_loopback
504 && url.scheme() == "http"
505 && url.host_str().is_some_and(is_loopback_host))
506}
507
508fn valid_api_key_value(value: &str) -> bool {
509 !value.is_empty()
510 && value.bytes().all(|byte| {
511 (0x21..=0x7e).contains(&byte) && !matches!(byte, b'"' | b',' | b';' | b'\\')
512 })
513}
514
515fn contains_control_character(value: &str) -> bool {
516 value.chars().any(char::is_control)
517}
518
519fn body_hash_pattern() -> &'static Regex {
520 static PATTERN: OnceLock<Regex> = OnceLock::new();
521 PATTERN.get_or_init(|| Regex::new(r"^sha256:[0-9a-f]{64}$").expect("valid body hash pattern"))
522}
523
524fn advertisement_pattern() -> &'static Regex {
525 static PATTERN: OnceLock<Regex> = OnceLock::new();
526 PATTERN.get_or_init(|| {
527 Regex::new(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").expect("valid advertisement pattern")
528 })
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use serde_json::to_string;
535
536 #[test]
537 fn validates_core_messages() {
538 parse_enroll_request(br#"{"agent_did":"did:web:agent.example","idempotency_key":"key-1"}"#)
539 .expect("enroll request");
540 parse_revoke_request(br#"{"all_grant_types":"true"}"#).expect("revoke request");
541 parse_revoke_response(br#"{}"#).expect("revoke response");
542 assert!(
543 parse_enroll_request(br#"{"agent_did":"did:web:agent.example","claims":null}"#)
544 .is_err()
545 );
546 assert!(
547 parse_status_response(br#"{"status":"pending","verification_pending":[]}"#).is_err()
548 );
549 assert!(parse_revoke_response(br#"{"unexpected":true}"#).is_err());
550 }
551
552 #[test]
553 fn validates_built_in_credentials() {
554 let response = parse_built_in_grant_response(
555 &GrantType::OAuthBearer,
556 br#"{"access_token":"token","credential_id":"id","expires_at":"2027-01-01T00:00:00Z","scopes":null,"token_type":"Bearer"}"#,
557 )
558 .expect("OAuth response");
559 assert_eq!(response.grant_type(), GrantType::OAuthBearer);
560 assert!(parse_api_key_grant_response(
561 br#"{"api_key":"unsafe value","credential_id":"id","expires_at":"2027-01-01T00:00:00Z","header":"X-API-Key"}"#,
562 )
563 .is_err());
564 }
565
566 #[test]
567 fn protects_recognition_failure_metadata() {
568 let mut problem = new_problem_details(ErrorCode::NotRecognized, "Not recognized", 401);
569 problem.requirements_pending = Some(vec!["contact.email".to_owned()]);
570 assert!(validate_problem_details(&problem).is_err());
571
572 problem.requirements_pending = None;
573 problem.owner_action_required = Some(StringBoolean::True);
574 assert!(validate_problem_details(&problem).is_err());
575 }
576
577 #[test]
578 fn requires_problem_type_to_match_code() {
579 let mut problem = new_problem_details(ErrorCode::NotRecognized, "Not recognized", 401);
580 validate_problem_details(&problem).expect("matching Problem Details");
581 assert!(
582 parse_problem_details(
583 br#"{"type":"urn:aep:error:not_recognized","status":401,"code":"not_recognized"}"#,
584 )
585 .is_err()
586 );
587 assert!(
588 parse_problem_details(
589 br#"{"type":"urn:aep:error:not_recognized","title":null,"status":401,"code":"not_recognized"}"#,
590 )
591 .is_err()
592 );
593 problem.problem_type = "urn:aep:error:invalid_request".to_owned();
594 assert!(validate_problem_details(&problem).is_err());
595 }
596
597 #[test]
598 fn validates_enroll_and_status_responses() {
599 let enroll = parse_enroll_response(
600 br#"{"status":"pending","owner_action_required":"false","verification_pending":["email"]}"#,
601 )
602 .expect("Enroll response");
603 assert_eq!(
604 to_string(&enroll).expect("serialized response"),
605 r#"{"status":"pending","verification_pending":["email"]}"#
606 );
607 let existing = parse_enroll_response(br#"{"status":"suspended"}"#)
608 .expect("existing enrollment lifecycle response");
609 assert_eq!(existing.status, crate::AgentStatus::Suspended);
610 parse_status_response(br#"{"status":"active","since":"2026-08-29T12:00:00Z"}"#)
611 .expect("Status response");
612 assert!(
613 parse_status_response(
614 br#"{"status":"pending","requirements_pending":["email","email"]}"#,
615 )
616 .is_err()
617 );
618 assert!(parse_status_response(br#"{"status":"active","since":"not-a-date"}"#).is_err());
619 }
620
621 #[test]
622 fn validates_revoke_selectors() {
623 parse_revoke_request(br#"{"grant_type":"oauth-bearer","credential_id":"credential-1"}"#)
624 .expect("targeted Revoke");
625 for invalid in [
626 r#"{}"#,
627 r#"{"credential_id":"credential-1"}"#,
628 r#"{"all_grant_types":"true","grant_type":"oauth-bearer"}"#,
629 r#"{"all_grant_types":"false"}"#,
630 ] {
631 assert!(
632 parse_revoke_request(invalid.as_bytes()).is_err(),
633 "accepted {invalid}"
634 );
635 }
636 }
637
638 #[test]
639 fn validates_metadata_and_openapi_security() {
640 let hash = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
641 parse_idempotency_metadata(
642 format!(r#"{{"idempotency_key":"key-1","first_body_hash":"{hash}"}}"#).as_bytes(),
643 )
644 .expect("idempotency metadata");
645 assert!(
646 parse_idempotency_metadata(
647 br#"{"idempotency_key":"key-1","first_body_hash":"invalid"}"#,
648 )
649 .is_err()
650 );
651 parse_openapi_aep_security_scheme(br#"{"x-aep-authentication-method":"oauth-bearer"}"#)
652 .expect("OpenAPI security scheme");
653 assert!(
654 parse_openapi_aep_security_scheme(
655 br#"{"x-aep-authentication-method":"OAuth Bearer"}"#,
656 )
657 .is_err()
658 );
659 }
660
661 #[test]
662 fn validates_assertion_claim_relationships() {
663 let mut claims = ClientAssertionClaims {
664 aud: "did:web:service.example".to_owned(),
665 exp: 61,
666 iat: 1,
667 iss: "did:web:agent.example".to_owned(),
668 jti: "jti".to_owned(),
669 op: AssertionOperation::Status,
670 resource: None,
671 sub: "did:web:agent.example".to_owned(),
672 additional: Default::default(),
673 };
674 validate_client_assertion_claims(&claims).expect("valid assertion claims");
675 claims.op = AssertionOperation::Authenticate;
676 assert!(validate_client_assertion_claims(&claims).is_err());
677 claims.resource = Some("https://service.example/private".to_owned());
678 validate_client_assertion_claims(&claims).expect("protected-resource assertion");
679 claims.exp = 302;
680 assert!(validate_client_assertion_claims(&claims).is_err());
681 }
682
683 #[test]
684 fn validates_each_built_in_credential_shape() {
685 parse_api_key_grant_response(
686 br#"{"api_key":"secret","credential_id":"id","expires_at":"2027-01-01T00:00:00Z","header":"X-API-Key"}"#,
687 )
688 .expect("API-key response");
689 parse_basic_grant_response(
690 br#"{"credential_id":"id","expires_at":"2027-01-01T00:00:00Z","password":"secret","username":"agent"}"#,
691 )
692 .expect("Basic response");
693 assert!(
694 parse_basic_grant_response(
695 br#"{"credential_id":"id","expires_at":"2027-01-01T00:00:00Z","password":"secret","username":"agent:name"}"#,
696 )
697 .is_err()
698 );
699 let wrong = BuiltInGrantResponse::Basic(BasicGrantResponse {
700 credential_id: "id".to_owned(),
701 expires_at: "2027-01-01T00:00:00Z".to_owned(),
702 password: "secret".to_owned(),
703 realm: None,
704 scopes: Vec::new(),
705 username: "agent".to_owned(),
706 additional: Default::default(),
707 });
708 assert!(validate_built_in_grant_response(&GrantType::ApiKey, &wrong).is_err());
709 }
710
711 #[test]
712 fn redacts_credentials_from_debug_output() {
713 let credentials = [
714 parse_built_in_grant_response(
715 &GrantType::OAuthBearer,
716 br#"{"access_token":"oauth-secret","credential_id":"id","expires_at":"2027-01-01T00:00:00Z","token_type":"Bearer"}"#,
717 )
718 .expect("OAuth response"),
719 parse_built_in_grant_response(
720 &GrantType::ApiKey,
721 br#"{"api_key":"api-secret","credential_id":"id","expires_at":"2027-01-01T00:00:00Z","header":"X-API-Key"}"#,
722 )
723 .expect("API-key response"),
724 parse_built_in_grant_response(
725 &GrantType::Basic,
726 br#"{"credential_id":"id","expires_at":"2027-01-01T00:00:00Z","password":"basic-secret","username":"agent"}"#,
727 )
728 .expect("Basic response"),
729 ];
730 let authorization = crate::ProtectedResourceAuthorization {
731 carrier: crate::AuthorizationCarrier::Standard,
732 scheme: crate::CredentialScheme::Bearer,
733 credentials: "authorization-secret".to_owned(),
734 };
735 for output in credentials
736 .iter()
737 .map(|credential| format!("{credential:?}"))
738 .chain([format!("{authorization:?}")])
739 {
740 assert!(!output.contains("secret"));
741 assert!(output.contains("[REDACTED]"));
742 }
743
744 let claims = crate::ClaimValues {
745 contact_email: Some("claims-secret@example.com".to_owned()),
746 ..crate::ClaimValues::default()
747 };
748 let output = format!("{claims:?}");
749 assert!(!output.contains("claims-secret"));
750 assert!(output.contains("[REDACTED]"));
751
752 let address = crate::ContactAddressPrimary {
753 line1: "address-secret".to_owned(),
754 country: "US".to_owned(),
755 ..crate::ContactAddressPrimary::default()
756 };
757 let output = format!("{address:?}");
758 assert!(!output.contains("address-secret"));
759 assert!(output.contains("[REDACTED]"));
760 }
761}