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        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}