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