Skip to main content

aep_core/
protocol.rs

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        // The claim document is a member of the request, so its issue paths are re-rooted under
39        // `$.claims` to name the member the caller has to correct.
40        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
273/// One AEP error code: `error-code = lc-token *( "_" lc-token )`.
274fn 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    // `error-code = lc-token *( "_" lc-token )` with `lc-token = LCALPHA *( LCALPHA / DIGIT )`,
294    // so a code carrying an uppercase letter is not the registered syntax even when `type` agrees
295    // with it.
296    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}