CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Replace keys_have_alg with has_key_for_signing_alg in JWKS stage

RFC 7517 makes the JWK `alg` field OPTIONAL, and the atproto OAuth
profile does not override that. The old `keys_have_alg` check flagged
any per-JWK `alg` omission as a spec violation, which was neither
grounded in a spec requirement nor useful: what actually matters is
whether the JWKS contains at least one key usable with the client's
declared `token_endpoint_auth_signing_alg` for `private_key_jwt`.

The new check considers a key compatible if either its explicit `alg`
matches the declared signing alg, or (when `alg` is absent) its `crv`
structurally matches the signing alg (P-256 <-> ES256, secp256k1 <->
ES256K). Missing `token_endpoint_auth_signing_alg` on a confidential
client is now surfaced directly on this check.

The design plan is updated to reflect the refactor since we are still
in the initial implementation phase of the oauth client subcommand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

+421 -158
+23 -7
docs/design-plans/2026-04-16-test-oauth-client.md
··· 87 87 88 88 ### test-oauth-client.AC3: Static mode — JWKS validation 89 89 90 - - **test-oauth-client.AC3.1 Success:** Inline `jwks` containing valid ES256 keys with unique `kid`, `alg`, and `use=sig` passes every JWKS check. 90 + - **test-oauth-client.AC3.1 Success:** Inline `jwks` containing valid ES256 keys with unique `kid` and `use=sig` (with or without `alg`) passes every JWKS check, provided `token_endpoint_auth_signing_alg` in the metadata document is compatible with at least one key. 91 91 - **test-oauth-client.AC3.2 Success:** External `jwks_uri` returning a valid JWKS document is fetched (over the `JwksFetcher` seam) and validated identically. 92 92 - **test-oauth-client.AC3.3 Failure:** A `jwks_uri` returning a non-2xx status produces a `NetworkError` with the URL in the diagnostic. 93 93 - **test-oauth-client.AC3.4 Failure:** A JWKS containing two keys with the same `kid` produces a `SpecViolation` with both `kid`s and their offsets highlighted via miette source span. 94 - - **test-oauth-client.AC3.5 Failure:** A key without an `alg` field produces a `SpecViolation`. 94 + - **test-oauth-client.AC3.5 Failure:** A JWKS with no key compatible with the client metadata's `token_endpoint_auth_signing_alg` produces a `SpecViolation` on `has_key_for_signing_alg`. Compatibility: a key with an explicit `alg` must match `token_endpoint_auth_signing_alg` exactly; a key without `alg` is compatible only if its `kty`/`crv` structurally matches the declared signing alg (e.g., `kty=EC`, `crv=P-256` for `ES256`). Per RFC 7517 `alg` is OPTIONAL on individual JWKs, and the atproto OAuth profile does not override that — so the substantive requirement is key/algorithm compatibility, not mere presence of the `alg` field. 95 95 - **test-oauth-client.AC3.6 Failure:** A key with `use` other than `sig` (or absent) produces a `SpecViolation`. 96 96 - **test-oauth-client.AC3.7 Failure:** A key advertising a non-modern algorithm (RS1, MD5, etc.) produces a `SpecViolation`; ECDSA P-256 / ES256K and stronger algorithms pass. 97 97 - **test-oauth-client.AC3.8 Skip:** Public-client and native-client JWKS checks emit `Skipped` rows with reason `"jwks not required for {kind} clients"` rather than running. ··· 350 350 `RequestLog` against the shared `fake_as::ServerHandle`. Interactive 351 351 checks use `common::report::blocked_by` entries pointing at both 352 352 static-mode prerequisites (`metadata::scope_present`, 353 - `jwks::keys_have_alg`, etc.) and interactive prerequisites within the 353 + `jwks::has_key_for_signing_alg`, etc.) and interactive prerequisites within the 354 354 stage. 355 355 356 356 ### Fake AS endpoint surface ··· 689 689 `src/common/oauth/jws.rs` containing: 690 690 - `ParsedJwk` holding a parsed JWK's `kid`, `alg: Option<JwsAlg>` 691 691 (deliberately permissive: absent or unrecognised `alg` is not a 692 - parse-time error; the JWKS stage makes the policy call), `use`, 692 + parse-time error — per RFC 7517 `alg` is OPTIONAL, and the atproto 693 + OAuth profile does not override that; the JWKS stage makes the 694 + policy call), `crv: JwkCrv` (retained so the JWKS stage can 695 + determine structural compatibility with a declared 696 + `token_endpoint_auth_signing_alg` when a key omits `alg`), `use`, 693 697 and (for ES256 only) a constructed `jsonwebtoken::DecodingKey`. 694 698 - `parse_jwk(value: &serde_json::Value, ...) -> Result<ParsedJwk, JwsError>`. 695 699 - `parse_jwks(bytes: &[u8], ...) -> Result<Vec<ParsedJwk>, JwsError>`. ··· 827 831 - `JwksFacts { keys: Vec<ParsedJwk>, source: JwksSource }`. 828 832 - `JwksSource::{Inline, Uri(Url)}`. 829 833 - `Check` enum: `jwks_present`, `jwks_uri_fetchable` (when applicable), 830 - `jwks_is_json`, `keys_have_unique_kids`, `keys_have_alg`, 834 + `jwks_is_json`, `keys_have_unique_kids`, `has_key_for_signing_alg`, 831 835 `keys_use_signing_use`, `algs_are_modern_ec`. 832 836 - `run(facts: &MetadataFacts, options: &OauthClientOptions) -> JwksStageOutput`. 837 + - `has_key_for_signing_alg` is the substantive per-key check. It reads 838 + `token_endpoint_auth_signing_alg` from `MetadataFacts` and walks the 839 + parsed JWKS looking for at least one compatible key. A JWK is 840 + "compatible" if either (a) its explicit `alg` equals 841 + `token_endpoint_auth_signing_alg`, or (b) its `alg` is absent and its 842 + `kty`/`crv` structurally match the declared signing alg (e.g., ES256 843 + ↔ `kty=EC` + `crv=P-256`; ES256K ↔ `kty=EC` + `crv=secp256k1`). An 844 + explicit `alg` that differs from `token_endpoint_auth_signing_alg` 845 + disqualifies that key for this check, even if structurally it could 846 + have matched. This design follows RFC 7517 (where `alg` is OPTIONAL) 847 + while enforcing the atproto spec requirement that a confidential 848 + client's keys must be usable with the signing algorithm it advertises. 833 849 - Add `JwksFetcher` to `OauthClientOptions`, default to `RealJwksFetcher` 834 850 in `client.rs::run()`. 835 851 - Wire stage into `run_pipeline`. Diagnostic-code prefix ··· 842 858 - `uri_es256_happy/` 843 859 - `uri_unreachable/` (network error path) 844 860 - `duplicate_kids/` 845 - - `missing_alg/` 861 + - `signing_alg_no_compatible_key/` (signing alg ES256K but only P-256 keys in JWKS, with at least one key omitting `alg`) 846 862 - `wrong_use/` 847 863 - `weak_alg_rs1/` 848 864 - `not_required_skipped/` (public client, all checks Skipped) ··· 993 1009 - `common::report::blocked_by` calls wire interactive checks to 994 1010 static-stage check IDs (`oauth_client::metadata::scope_present`, 995 1011 `oauth_client::metadata::dpop_bound_required`, 996 - `oauth_client::jws::keys_have_alg` for confidential, etc.). 1012 + `oauth_client::jws::has_key_for_signing_alg` for confidential, etc.). 997 1013 - `tests/oauth_client_interactive.rs` extended with end-to-end scenarios 998 1014 driving `RelyingParty` against the production `fake_as` server, 999 1015 asserting that the resulting checks emit expected pass/fail patterns.
+8 -5
src/commands/test/oauth/client/pipeline.rs
··· 42 42 pub scope_present: CheckStatus, 43 43 /// Status of the dpop_bound_required check. 44 44 pub dpop_bound_required: CheckStatus, 45 - /// Status of the keys_have_alg check (for confidential clients). 46 - pub keys_have_alg: CheckStatus, 45 + /// Status of the has_key_for_signing_alg check (for confidential 46 + /// clients). Gates token-endpoint interaction: if no key in the JWKS 47 + /// matches the declared `token_endpoint_auth_signing_alg`, the 48 + /// client cannot authenticate via `private_key_jwt`. 49 + pub has_key_for_signing_alg: CheckStatus, 47 50 /// Status of the grant_types check. 48 51 pub grant_types_includes_authorization_code: CheckStatus, 49 52 /// Status of the refresh_token grant type check. ··· 152 155 let mut static_gating = StaticGating { 153 156 scope_present: CheckStatus::Pass, 154 157 dpop_bound_required: CheckStatus::Pass, 155 - keys_have_alg: CheckStatus::Pass, 158 + has_key_for_signing_alg: CheckStatus::Pass, 156 159 grant_types_includes_authorization_code: CheckStatus::Pass, 157 160 grant_types_includes_refresh_token: CheckStatus::Pass, 158 161 response_types_is_code: CheckStatus::Pass, ··· 167 170 "oauth_client::metadata::dpop_bound_required" => { 168 171 static_gating.dpop_bound_required = check.status; 169 172 } 170 - "oauth_client::jws::keys_have_alg" => { 171 - static_gating.keys_have_alg = check.status; 173 + "oauth_client::jws::has_key_for_signing_alg" => { 174 + static_gating.has_key_for_signing_alg = check.status; 172 175 } 173 176 "oauth_client::metadata::grant_types_includes_authorization_code" => { 174 177 static_gating.grant_types_includes_authorization_code = check.status;
+7 -6
src/commands/test/oauth/client/pipeline/interactive.rs
··· 153 153 154 154 // Gate table: declare which static checks block each interactive check. 155 155 // ClientReachedPar, ClientUsedPkceS256, ClientIncludedDpop depend on scope_present + dpop_bound_required. 156 - // ClientCompletedToken depends on all of the above + keys_have_alg. 156 + // ClientCompletedToken depends on all of the above + has_key_for_signing_alg. 157 157 // ClientRefreshed depends on grant_types including refresh_token. 158 158 159 159 // Compute each check's gate status independently. 160 160 let par_gates_pass = static_gating.scope_present == CheckStatus::Pass 161 161 && static_gating.dpop_bound_required == CheckStatus::Pass; 162 162 163 - let _token_gates_pass = par_gates_pass && static_gating.keys_have_alg == CheckStatus::Pass; 163 + let _token_gates_pass = 164 + par_gates_pass && static_gating.has_key_for_signing_alg == CheckStatus::Pass; 164 165 165 166 // Emit blocked_by results for checks whose gates didn't pass. 166 167 // Each check's result is based on its specific gate, not a cascading if-chain. ··· 241 242 }; 242 243 } 243 244 244 - if static_gating.keys_have_alg != CheckStatus::Pass { 245 - // PAR gates pass, but token gate (keys_have_alg) fails. 245 + if static_gating.has_key_for_signing_alg != CheckStatus::Pass { 246 + // PAR gates pass, but token gate (has_key_for_signing_alg) fails. 246 247 // ClientReachedPar, ClientUsedPkceS256, ClientIncludedDpop can pass. 247 - // ClientCompletedToken is blocked by keys_have_alg. 248 + // ClientCompletedToken is blocked by has_key_for_signing_alg. 248 249 results.push(blocked_by( 249 250 Check::ClientCompletedToken.id(), 250 251 Stage::OAUTH_CLIENT_INTERACTIVE, 251 252 Check::ClientCompletedToken.summary(), 252 - "oauth_client::jws::keys_have_alg", 253 + "oauth_client::jws::has_key_for_signing_alg", 253 254 )); 254 255 // ClientRefreshed is always skipped in Phase 7. 255 256 results.push(Check::ClientRefreshed.skipped("covered in Phase 8 flow variants"));
+100 -45
src/commands/test/oauth/client/pipeline/jwks.rs
··· 11 11 use thiserror::Error; 12 12 use url::Url; 13 13 14 - use crate::common::oauth::jws::ParsedJwk; 14 + use crate::common::oauth::jws::{JwkCrv, JwsAlg, ParsedJwk}; 15 15 use crate::common::report::{CheckResult, CheckStatus, Stage}; 16 16 17 17 /// The name used in miette NamedSource for JWKS diagnostics. ··· 119 119 JwksIsJson, 120 120 /// All keys have unique `kid` values. 121 121 KeysHaveUniqueKids, 122 - /// All keys have an `alg` field. 123 - KeysHaveAlg, 122 + /// JWKS contains at least one key compatible with the client's 123 + /// declared `token_endpoint_auth_signing_alg`. A key is compatible 124 + /// if its explicit `alg` equals the declared signing alg, or — if 125 + /// the key omits `alg` (RFC 7517 allows this) — its `crv` 126 + /// structurally matches the declared signing alg (P-256 ↔ ES256, 127 + /// secp256k1 ↔ ES256K). 128 + HasKeyForSigningAlg, 124 129 /// All keys have `use == "sig"` (or absent, which defaults to sig). 125 130 KeysUseSigningUse, 126 131 /// All algorithms are modern EC (ES256 or ES256K only). ··· 133 138 Check::JwksUriFetchable, 134 139 Check::JwksIsJson, 135 140 Check::KeysHaveUniqueKids, 136 - Check::KeysHaveAlg, 141 + Check::HasKeyForSigningAlg, 137 142 Check::KeysUseSigningUse, 138 143 Check::AlgsAreModernEc, 139 144 ]; ··· 146 151 Check::JwksUriFetchable => "oauth_client::jws::jwks_uri_fetchable", 147 152 Check::JwksIsJson => "oauth_client::jws::jwks_is_json", 148 153 Check::KeysHaveUniqueKids => "oauth_client::jws::keys_have_unique_kids", 149 - Check::KeysHaveAlg => "oauth_client::jws::keys_have_alg", 154 + Check::HasKeyForSigningAlg => "oauth_client::jws::has_key_for_signing_alg", 150 155 Check::KeysUseSigningUse => "oauth_client::jws::keys_use_signing_use", 151 156 Check::AlgsAreModernEc => "oauth_client::jws::algs_are_modern_ec", 152 157 } ··· 159 164 Check::JwksUriFetchable => "JWKS URI is fetchable", 160 165 Check::JwksIsJson => "JWKS is valid JSON", 161 166 Check::KeysHaveUniqueKids => "Keys have unique kid values", 162 - Check::KeysHaveAlg => "Keys declare alg field", 167 + Check::HasKeyForSigningAlg => { 168 + "JWKS contains a key compatible with `token_endpoint_auth_signing_alg`" 169 + } 163 170 Check::KeysUseSigningUse => "Keys use signing use", 164 171 Check::AlgsAreModernEc => "Algorithms are modern EC", 165 172 } ··· 235 242 Check::JwksUriFetchable, 236 243 Check::JwksIsJson, 237 244 Check::KeysHaveUniqueKids, 238 - Check::KeysHaveAlg, 245 + Check::HasKeyForSigningAlg, 239 246 Check::KeysUseSigningUse, 240 247 Check::AlgsAreModernEc, 241 248 ]; ··· 274 281 Check::JwksUriFetchable, 275 282 Check::JwksIsJson, 276 283 Check::KeysHaveUniqueKids, 277 - Check::KeysHaveAlg, 284 + Check::HasKeyForSigningAlg, 278 285 Check::KeysUseSigningUse, 279 286 Check::AlgsAreModernEc, 280 287 ]; ··· 363 370 for check in &[ 364 371 Check::JwksIsJson, 365 372 Check::KeysHaveUniqueKids, 366 - Check::KeysHaveAlg, 373 + Check::HasKeyForSigningAlg, 367 374 Check::KeysUseSigningUse, 368 375 Check::AlgsAreModernEc, 369 376 ] { ··· 395 402 for check in &[ 396 403 Check::JwksIsJson, 397 404 Check::KeysHaveUniqueKids, 398 - Check::KeysHaveAlg, 405 + Check::HasKeyForSigningAlg, 399 406 Check::KeysUseSigningUse, 400 407 Check::AlgsAreModernEc, 401 408 ] { ··· 438 445 // Skip remaining checks. 439 446 for check in &[ 440 447 Check::KeysHaveUniqueKids, 441 - Check::KeysHaveAlg, 448 + Check::HasKeyForSigningAlg, 442 449 Check::KeysUseSigningUse, 443 450 Check::AlgsAreModernEc, 444 451 ] { ··· 468 475 // Skip remaining checks. 469 476 for check in &[ 470 477 Check::KeysHaveUniqueKids, 471 - Check::KeysHaveAlg, 478 + Check::HasKeyForSigningAlg, 472 479 Check::KeysUseSigningUse, 473 480 Check::AlgsAreModernEc, 474 481 ] { ··· 489 496 // Parse each key and track violations. 490 497 let source_bytes: Arc<[u8]> = Arc::from(jwks_bytes); 491 498 let mut parsed_keys = Vec::new(); 492 - let mut has_key_alg_violation = false; 493 499 let mut has_key_use_violation = false; 494 500 let mut has_alg_violation = false; 495 501 let mut kid_map: std::collections::HashMap<Option<Arc<str>>, Vec<usize>> = ··· 507 513 results, 508 514 }; 509 515 } 510 - crate::common::oauth::jws::JwsError::JwkMissingField { field, .. } => { 511 - match field { 512 - "kty" | "crv" | "x" | "y" => { 513 - results.push(Check::JwksIsJson.spec_violation(Box::new(e))); 514 - return JwksStageOutput { 515 - facts: None, 516 - results, 517 - }; 518 - } 519 - "alg" => { 520 - has_key_alg_violation = true; 521 - } 522 - _ => { 523 - // Unknown field. 524 - } 525 - } 516 + crate::common::oauth::jws::JwsError::JwkMissingField { 517 + field: "kty" | "crv" | "x" | "y", 518 + .. 519 + } => { 520 + results.push(Check::JwksIsJson.spec_violation(Box::new(e))); 521 + return JwksStageOutput { 522 + facts: None, 523 + results, 524 + }; 525 + } 526 + crate::common::oauth::jws::JwsError::JwkMissingField { .. } => { 527 + // Unknown required field — ignore at this stage. 526 528 } 527 529 crate::common::oauth::jws::JwsError::JwkKtyMismatch { .. } => { 528 530 has_alg_violation = true; ··· 535 537 Ok(parsed) => { 536 538 // Track kid for uniqueness check. 537 539 kid_map.entry(parsed.kid.clone()).or_default().push(i); 538 - 539 - // Check for missing alg. 540 - if parsed.alg.is_none() && parsed.alg_raw.is_none() { 541 - has_key_alg_violation = true; 542 - } 543 540 544 541 // Check for non-sig use. 545 542 if parsed.r#use != crate::common::oauth::jws::JwkUse::Sig { ··· 600 597 } 601 598 602 599 // Emit results for per-key checks in enum order. 603 - if has_key_alg_violation { 604 - results.push( 605 - Check::KeysHaveAlg.spec_violation(Box::new(JwksViolationDiagnostic { 606 - message: "One or more keys missing required `alg` field".to_string(), 607 - code: Check::KeysHaveAlg.id(), 608 - src: None, 609 - labels: vec![], 610 - })), 611 - ); 612 - } else { 613 - results.push(Check::KeysHaveAlg.pass()); 600 + // 601 + // `has_key_for_signing_alg` is the substantive "can the client 602 + // actually authenticate with one of these keys?" check. Per 603 + // RFC 7517 a JWK's `alg` is OPTIONAL, so we do not require each key 604 + // to declare it; instead, we verify that at least one key in the 605 + // JWKS is *compatible* with the client metadata's 606 + // `token_endpoint_auth_signing_alg` — either by explicit `alg` 607 + // match, or (for keys without `alg`) by structural curve match 608 + // (ES256 ↔ P-256, ES256K ↔ secp256k1). 609 + match facts.token_endpoint_auth_signing_alg.as_deref() { 610 + None => { 611 + // Confidential clients must declare `token_endpoint_auth_signing_alg` 612 + // per the atproto OAuth profile. Without it, we cannot decide 613 + // compatibility, so we surface that directly on this check. 614 + results.push( 615 + Check::HasKeyForSigningAlg.spec_violation(Box::new(JwksViolationDiagnostic { 616 + message: 617 + "client metadata is missing `token_endpoint_auth_signing_alg`; confidential \ 618 + clients must declare it so a JWK can be selected for `private_key_jwt`" 619 + .to_string(), 620 + code: Check::HasKeyForSigningAlg.id(), 621 + src: None, 622 + labels: vec![], 623 + })), 624 + ); 625 + } 626 + Some(signing_alg) => { 627 + let compatible = parsed_keys 628 + .iter() 629 + .any(|key| jwk_is_compatible_with_signing_alg(key, signing_alg)); 630 + if compatible { 631 + results.push(Check::HasKeyForSigningAlg.pass()); 632 + } else { 633 + results.push( 634 + Check::HasKeyForSigningAlg.spec_violation(Box::new(JwksViolationDiagnostic { 635 + message: format!( 636 + "no key in the JWKS is compatible with `token_endpoint_auth_signing_alg = {signing_alg}`" 637 + ), 638 + code: Check::HasKeyForSigningAlg.id(), 639 + src: None, 640 + labels: vec![], 641 + })), 642 + ); 643 + } 644 + } 614 645 } 615 646 616 647 if has_key_use_violation { ··· 647 678 }), 648 679 results, 649 680 } 681 + } 682 + 683 + /// Returns true if `key` can be used to sign with the client's declared 684 + /// `token_endpoint_auth_signing_alg`. A key with an explicit `alg` must 685 + /// match the declared alg exactly; a key that omits `alg` (permitted by 686 + /// RFC 7517) is compatible iff its `crv` structurally matches the 687 + /// declared alg (P-256 for ES256, secp256k1 for ES256K). 688 + fn jwk_is_compatible_with_signing_alg(key: &ParsedJwk, signing_alg: &str) -> bool { 689 + if let Some(alg) = key.alg { 690 + return matches!( 691 + (alg, signing_alg), 692 + (JwsAlg::Es256, "ES256") | (JwsAlg::Es256k, "ES256K"), 693 + ); 694 + } 695 + // `alg_raw` is set when the JWK declared `alg` but it was not 696 + // ES256/ES256K — disqualifies the key regardless of crv. 697 + if key.alg_raw.is_some() { 698 + return false; 699 + } 700 + // `alg` omitted — fall back to structural crv/alg compatibility. 701 + matches!( 702 + (signing_alg, key.crv), 703 + ("ES256", JwkCrv::P256) | ("ES256K", JwkCrv::Secp256k1), 704 + ) 650 705 } 651 706 652 707 /// Generic diagnostic for JWKS violations with configurable check code.
+12
src/commands/test/oauth/client/pipeline/metadata.rs
··· 28 28 pub redirect_uris: Option<Vec<String>>, 29 29 pub dpop_bound_access_tokens: Option<bool>, 30 30 pub token_endpoint_auth_method: Option<String>, 31 + pub token_endpoint_auth_signing_alg: Option<String>, 31 32 /// Stored as raw JSON; Phase 5 parses it into JWK format. 32 33 pub jwks: Option<serde_json::Value>, 33 34 pub jwks_uri: Option<String>, ··· 276 277 pub dpop_bound: bool, 277 278 /// The parsed scope set (None for Loopback due to implicit metadata). 278 279 pub scope: Option<ScopeSet>, 280 + /// The signing algorithm the client declares for `private_key_jwt` 281 + /// authentication at the token endpoint. Captured verbatim as a 282 + /// string so the JWKS stage can compare it against each JWK (see 283 + /// `has_key_for_signing_alg`). Present only for confidential clients 284 + /// that declared it; `None` for public / native / loopback, or when 285 + /// the field is missing from the metadata document. 286 + pub token_endpoint_auth_signing_alg: Option<String>, 279 287 } 280 288 281 289 /// The kind of OAuth client. ··· 483 491 jwks_source: None, 484 492 dpop_bound: false, 485 493 scope: None, 494 + token_endpoint_auth_signing_alg: None, 486 495 }), 487 496 results, 488 497 } ··· 1018 1027 jwks_source, 1019 1028 dpop_bound, 1020 1029 scope: parsed_scope, 1030 + token_endpoint_auth_signing_alg: doc 1031 + .token_endpoint_auth_signing_alg 1032 + .clone(), 1021 1033 }) 1022 1034 } else { 1023 1035 None
+53 -15
src/common/oauth/jws.rs
··· 50 50 Other(Arc<str>), 51 51 } 52 52 53 + /// The `crv` parameter of an EC JWK, constrained to the curves recognised 54 + /// by this tool. `parse_jwk` rejects any other value as 55 + /// `JwsError::UnsupportedCurve` before this type is constructed, so 56 + /// downstream code can match exhaustively. 57 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 58 + pub enum JwkCrv { 59 + /// NIST P-256 (secp256r1, prime256v1). The curve paired with `ES256`. 60 + P256, 61 + /// SECG secp256k1. The curve paired with `ES256K` and the curve used 62 + /// by atproto DIDs. 63 + Secp256k1, 64 + } 65 + 66 + impl JwkCrv { 67 + /// Return the canonical JWK `crv` string for this curve. 68 + pub fn as_str(self) -> &'static str { 69 + match self { 70 + JwkCrv::P256 => "P-256", 71 + JwkCrv::Secp256k1 => "secp256k1", 72 + } 73 + } 74 + } 75 + 53 76 /// A parsed JWK. Holds whatever structural metadata the JWK declares — 54 77 /// but deliberately does NOT validate that the JWK is complete or 55 78 /// acceptable. Callers (the Phase 5 JWKS stage) decide whether a ··· 57 80 /// because those decisions map to specific stage-level check IDs. 58 81 /// 59 82 /// Design note: the JWS layer is deliberately permissive on `alg` 60 - /// presence and value. This keeps the per-key error → stage-level 61 - /// check mapping in exactly one place (Phase 5's `jwks::run`). 83 + /// presence and value. Per RFC 7517 `alg` is OPTIONAL on a JWK, and 84 + /// the atproto OAuth profile does not override that, so absence is 85 + /// not a parse-time failure here. This keeps the per-key error → 86 + /// stage-level check mapping in exactly one place (Phase 5's 87 + /// `jwks::run`), which compares each key to the client metadata's 88 + /// `token_endpoint_auth_signing_alg` via `has_key_for_signing_alg`. 62 89 /// 63 90 /// The case `alg = None` combined with `crv = secp256k1` is valid at 64 - /// the JWS layer (it parses successfully). Phase 5 decides whether this 65 - /// is a downstream concern (e.g., surfacing as a missing-alg violation 66 - /// or a curve-not-es256-issue violation), not a hard structural failure. 91 + /// the JWS layer (it parses successfully). Phase 5 decides whether 92 + /// this is a downstream concern (e.g., structurally incompatible with 93 + /// a declared `token_endpoint_auth_signing_alg`), not a hard 94 + /// structural failure. 67 95 #[derive(Debug, Clone)] 68 96 pub struct ParsedJwk { 69 97 pub kid: Option<Arc<str>>, 70 - /// Absent if the JWK omits `alg`. Phase 5 treats absence as a 71 - /// violation on `keys_have_alg`. 98 + /// Absent if the JWK omits `alg`. Phase 5 falls back to matching 99 + /// `crv` against `token_endpoint_auth_signing_alg` when `alg` is 100 + /// None. 72 101 pub alg: Option<JwsAlg>, 73 102 /// Raw alg string preserved verbatim when `alg` is present but not 74 103 /// in {ES256, ES256K} — Phase 5 uses it to emit a pointed 75 104 /// `algs_are_modern_ec` diagnostic citing the offending value. 76 105 pub alg_raw: Option<Arc<str>>, 106 + /// The curve declared on the JWK. Retained so the JWKS stage can 107 + /// determine structural compatibility with a declared 108 + /// `token_endpoint_auth_signing_alg` when the JWK omits `alg`. 109 + pub crv: JwkCrv, 77 110 pub r#use: JwkUse, 78 111 /// The verifier, pre-constructed from the JWK material. Only 79 112 /// present when `alg == Some(JwsAlg::Es256)` and the x/y ··· 223 256 span: crate::common::diagnostics::span_for_quoted_literal(&source, "crv"), 224 257 })?; 225 258 226 - if crv != "P-256" && crv != "secp256k1" { 227 - return Err(JwsError::UnsupportedCurve { 228 - crv: Arc::from(crv), 229 - named_source: NamedSource::new(name, source.clone()), 230 - span: crate::common::diagnostics::span_for_quoted_literal(&source, crv), 231 - }); 232 - } 259 + let jwk_crv = match crv { 260 + "P-256" => JwkCrv::P256, 261 + "secp256k1" => JwkCrv::Secp256k1, 262 + _ => { 263 + return Err(JwsError::UnsupportedCurve { 264 + crv: Arc::from(crv), 265 + named_source: NamedSource::new(name, source.clone()), 266 + span: crate::common::diagnostics::span_for_quoted_literal(&source, crv), 267 + }); 268 + } 269 + }; 233 270 234 271 // 2. Read optional `kid` (Arc<str>). 235 272 let kid = obj ··· 334 371 None 335 372 }; 336 373 337 - // 7. Return ParsedJwk { kid, alg, alg_raw, r#use, decoding_key }. 374 + // 7. Return ParsedJwk { kid, alg, alg_raw, crv, use, decoding_key }. 338 375 Ok(ParsedJwk { 339 376 kid, 340 377 alg, 341 378 alg_raw, 379 + crv: jwk_crv, 342 380 r#use, 343 381 decoding_key, 344 382 })
+10 -3
tests/fixtures/oauth_client/discovery/https_confidential_happy/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/jwks/duplicate_kids/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/jwks/inline_es256_happy/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/jwks/invalid_json/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+1
tests/fixtures/oauth_client/jwks/missing_alg/metadata.json tests/fixtures/oauth_client/jwks/signing_alg_no_compatible_key/metadata.json
··· 7 7 "scope": "atproto", 8 8 "dpop_bound_access_tokens": true, 9 9 "token_endpoint_auth_method": "private_key_jwt", 10 + "token_endpoint_auth_signing_alg": "ES256K", 10 11 "jwks": { 11 12 "keys": [ 12 13 {
+10 -3
tests/fixtures/oauth_client/jwks/uri_es256_happy/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks_uri": "https://client.example.com/jwks.json" 11 18 }
+10 -3
tests/fixtures/oauth_client/jwks/uri_unreachable/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks_uri": "https://client.example.com/jwks.json" 11 18 }
+10 -3
tests/fixtures/oauth_client/jwks/weak_alg_rs1/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/jwks/wrong_use/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "redirect_uris": ["https://client.example.com/callback"], 5 - "grant_types": ["authorization_code"], 6 - "response_types": ["code"], 4 + "redirect_uris": [ 5 + "https://client.example.com/callback" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 10 + "response_types": [ 11 + "code" 12 + ], 7 13 "scope": "atproto", 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/metadata/confidential_happy/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "response_types": ["code"], 5 - "grant_types": ["authorization_code"], 4 + "response_types": [ 5 + "code" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 6 10 "scope": "atproto", 7 - "redirect_uris": ["https://client.example.com/cb"], 11 + "redirect_uris": [ 12 + "https://client.example.com/cb" 13 + ], 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+11 -4
tests/fixtures/oauth_client/metadata/confidential_missing_jwks/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "response_types": ["code"], 5 - "grant_types": ["authorization_code"], 4 + "response_types": [ 5 + "code" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 6 10 "scope": "atproto", 7 - "redirect_uris": ["https://client.example.com/cb"], 11 + "redirect_uris": [ 12 + "https://client.example.com/cb" 13 + ], 8 14 "dpop_bound_access_tokens": true, 9 - "token_endpoint_auth_method": "private_key_jwt" 15 + "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256" 10 17 }
+10 -3
tests/fixtures/oauth_client/metadata/dpop_bound_false/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "response_types": ["code"], 5 - "grant_types": ["authorization_code"], 4 + "response_types": [ 5 + "code" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 6 10 "scope": "atproto", 7 - "redirect_uris": ["https://client.example.com/cb"], 11 + "redirect_uris": [ 12 + "https://client.example.com/cb" 13 + ], 8 14 "dpop_bound_access_tokens": false, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+10 -3
tests/fixtures/oauth_client/metadata/scope_grammar_invalid/metadata.json
··· 1 1 { 2 2 "client_id": "https://client.example.com/metadata.json", 3 3 "application_type": "web", 4 - "response_types": ["code"], 5 - "grant_types": ["authorization_code"], 4 + "response_types": [ 5 + "code" 6 + ], 7 + "grant_types": [ 8 + "authorization_code" 9 + ], 6 10 "scope": "atproto invalid-scope-token", 7 - "redirect_uris": ["https://client.example.com/cb"], 11 + "redirect_uris": [ 12 + "https://client.example.com/cb" 13 + ], 8 14 "dpop_bound_access_tokens": true, 9 15 "token_endpoint_auth_method": "private_key_jwt", 16 + "token_endpoint_auth_signing_alg": "ES256", 10 17 "jwks": { 11 18 "keys": [ 12 19 {
+3 -3
tests/oauth_client_ac_coverage.rs
··· 133 133 let static_gating = StaticGating { 134 134 scope_present: CheckStatus::Pass, 135 135 dpop_bound_required: CheckStatus::Pass, 136 - keys_have_alg: CheckStatus::Pass, 136 + has_key_for_signing_alg: CheckStatus::Pass, 137 137 grant_types_includes_authorization_code: CheckStatus::Pass, 138 138 grant_types_includes_refresh_token: CheckStatus::Pass, 139 139 response_types_is_code: CheckStatus::Pass, ··· 183 183 let static_gating = StaticGating { 184 184 scope_present: CheckStatus::Pass, 185 185 dpop_bound_required: CheckStatus::Pass, 186 - keys_have_alg: CheckStatus::Pass, 186 + has_key_for_signing_alg: CheckStatus::Pass, 187 187 grant_types_includes_authorization_code: CheckStatus::Pass, 188 188 grant_types_includes_refresh_token: CheckStatus::Pass, 189 189 response_types_is_code: CheckStatus::Pass, ··· 248 248 let static_gating = StaticGating { 249 249 scope_present: CheckStatus::Pass, 250 250 dpop_bound_required: CheckStatus::Pass, 251 - keys_have_alg: CheckStatus::Pass, 251 + has_key_for_signing_alg: CheckStatus::Pass, 252 252 grant_types_includes_authorization_code: CheckStatus::Pass, 253 253 grant_types_includes_refresh_token: CheckStatus::SpecViolation, 254 254 response_types_is_code: CheckStatus::Pass,
+5 -2
tests/oauth_client_check_id_coverage.rs
··· 79 79 jwks::Check::KeysHaveUniqueKids.id(), 80 80 "Keys have unique kid values", 81 81 )); 82 - pairs.push((jwks::Check::KeysHaveAlg.id(), "Keys declare alg field")); 82 + pairs.push(( 83 + jwks::Check::HasKeyForSigningAlg.id(), 84 + "JWKS contains a key compatible with `token_endpoint_auth_signing_alg`", 85 + )); 83 86 pairs.push((jwks::Check::KeysUseSigningUse.id(), "Keys use signing use")); 84 87 pairs.push(( 85 88 jwks::Check::AlgsAreModernEc.id(), ··· 132 135 "oauth_client::jws::jwks_uri_unreachable", 133 136 "oauth_client::jws::jwks_is_json", 134 137 "oauth_client::jws::keys_have_unique_kids", 135 - "oauth_client::jws::keys_have_alg", 138 + "oauth_client::jws::has_key_for_signing_alg", 136 139 "oauth_client::jws::keys_use_signing_use", 137 140 "oauth_client::jws::algs_are_modern_ec", 138 141 // Scope-variation sub-stage diagnostic codes.
+2 -2
tests/oauth_client_interactive.rs
··· 257 257 let static_gating = StaticGating { 258 258 scope_present: CheckStatus::Pass, 259 259 dpop_bound_required: CheckStatus::Pass, 260 - keys_have_alg: CheckStatus::Pass, 260 + has_key_for_signing_alg: CheckStatus::Pass, 261 261 grant_types_includes_authorization_code: CheckStatus::Pass, 262 262 grant_types_includes_refresh_token: CheckStatus::Pass, 263 263 response_types_is_code: CheckStatus::Pass, ··· 369 369 let static_gating = StaticGating { 370 370 scope_present: CheckStatus::Pass, 371 371 dpop_bound_required: CheckStatus::SpecViolation, 372 - keys_have_alg: CheckStatus::Pass, 372 + has_key_for_signing_alg: CheckStatus::Pass, 373 373 grant_types_includes_authorization_code: CheckStatus::Pass, 374 374 grant_types_includes_refresh_token: CheckStatus::Pass, 375 375 response_types_is_code: CheckStatus::Pass,
+11 -3
tests/oauth_client_jwks.rs
··· 153 153 } 154 154 155 155 // ============================================================================= 156 - // AC3.5: Missing alg field produces spec violation 156 + // AC3.5: JWKS has no key compatible with the client's 157 + // `token_endpoint_auth_signing_alg` → SpecViolation on 158 + // `oauth_client::jws::has_key_for_signing_alg`. 159 + // 160 + // Fixture declares `token_endpoint_auth_signing_alg: ES256K` but publishes 161 + // only a P-256 key that omits `alg`. RFC 7517 allows `alg` to be absent, 162 + // so the stage falls back to structural compatibility — and a P-256 curve 163 + // is not usable with ES256K, so the check must fail. 157 164 // ============================================================================= 158 165 159 166 #[tokio::test] 160 - async fn missing_alg_produces_spec_violation() { 167 + async fn signing_alg_has_no_compatible_key_produces_spec_violation() { 161 168 let http = common::FakeHttpClient::new(); 162 - let metadata = include_bytes!("fixtures/oauth_client/jwks/missing_alg/metadata.json"); 169 + let metadata = 170 + include_bytes!("fixtures/oauth_client/jwks/signing_alg_no_compatible_key/metadata.json"); 163 171 http.add_response( 164 172 &Url::parse("https://client.example.com/metadata.json").unwrap(), 165 173 200,
+1 -1
tests/oauth_client_substage_snapshots.rs
··· 215 215 let static_gating = StaticGating { 216 216 scope_present: CheckStatus::SpecViolation, 217 217 dpop_bound_required: CheckStatus::Pass, 218 - keys_have_alg: CheckStatus::Pass, 218 + has_key_for_signing_alg: CheckStatus::Pass, 219 219 grant_types_includes_authorization_code: CheckStatus::Pass, 220 220 grant_types_includes_refresh_token: CheckStatus::Pass, 221 221 response_types_is_code: CheckStatus::Pass,
+1 -1
tests/snapshots/oauth_client_discovery__https_404_produces_network_error.snap
··· 33 33 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 34 34 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 35 35 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 36 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 36 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 37 37 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 39 39
+1 -1
tests/snapshots/oauth_client_discovery__https_confidential_happy_discovery.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [OK] Keys use signing use 34 34 [OK] Algorithms are modern EC 35 35
+1 -1
tests/snapshots/oauth_client_discovery__https_not_json_produces_spec_violation.snap
··· 37 37 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 39 39 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 40 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 40 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 41 41 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 42 42 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 43 43
+1 -1
tests/snapshots/oauth_client_discovery__https_not_json_with_content_type_produces_spec_violation_with_ct.snap
··· 37 37 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 39 39 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 40 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 40 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 41 41 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 42 42 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 43 43
+1 -1
tests/snapshots/oauth_client_discovery__https_unreachable_produces_network_error.snap
··· 33 33 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 34 34 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 35 35 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 36 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 36 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 37 37 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 39 39
+1 -1
tests/snapshots/oauth_client_discovery__loopback_127_0_0_1.snap
··· 30 30 [SKIP] JWKS URI is fetchable — jwks not applicable to loopback clients 31 31 [SKIP] JWKS is valid JSON — jwks not applicable to loopback clients 32 32 [SKIP] Keys have unique kid values — jwks not applicable to loopback clients 33 - [SKIP] Keys declare alg field — jwks not applicable to loopback clients 33 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not applicable to loopback clients 34 34 [SKIP] Keys use signing use — jwks not applicable to loopback clients 35 35 [SKIP] Algorithms are modern EC — jwks not applicable to loopback clients 36 36
+1 -1
tests/snapshots/oauth_client_discovery__loopback_root_produces_skip_rows.snap
··· 30 30 [SKIP] JWKS URI is fetchable — jwks not applicable to loopback clients 31 31 [SKIP] JWKS is valid JSON — jwks not applicable to loopback clients 32 32 [SKIP] Keys have unique kid values — jwks not applicable to loopback clients 33 - [SKIP] Keys declare alg field — jwks not applicable to loopback clients 33 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not applicable to loopback clients 34 34 [SKIP] Keys use signing use — jwks not applicable to loopback clients 35 35 [SKIP] Algorithms are modern EC — jwks not applicable to loopback clients 36 36
+1 -1
tests/snapshots/oauth_client_discovery__loopback_with_port_produces_same_skip_rows.snap
··· 30 30 [SKIP] JWKS URI is fetchable — jwks not applicable to loopback clients 31 31 [SKIP] JWKS is valid JSON — jwks not applicable to loopback clients 32 32 [SKIP] Keys have unique kid values — jwks not applicable to loopback clients 33 - [SKIP] Keys declare alg field — jwks not applicable to loopback clients 33 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not applicable to loopback clients 34 34 [SKIP] Keys use signing use — jwks not applicable to loopback clients 35 35 [SKIP] Algorithms are modern EC — jwks not applicable to loopback clients 36 36
+1 -1
tests/snapshots/oauth_client_endtoend__full_pipeline_all_pass.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [OK] Keys use signing use 34 34 [OK] Algorithms are modern EC 35 35
+1 -1
tests/snapshots/oauth_client_jwks__discovery_failure_blocks_jwks.snap
··· 33 33 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 34 34 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 35 35 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 36 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 36 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 37 37 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 39 39
+1 -1
tests/snapshots/oauth_client_jwks__duplicate_kids_produces_spec_violation.snap
··· 38 38 · │ ╰── duplicate kid 39 39 · ╰── duplicate kid 40 40 ╰──── 41 - [OK] Keys declare alg field 41 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 42 42 [OK] Keys use signing use 43 43 [OK] Algorithms are modern EC 44 44
+1 -1
tests/snapshots/oauth_client_jwks__inline_es256_happy_jwks_passes.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [OK] Keys use signing use 34 34 [OK] Algorithms are modern EC 35 35
+1 -1
tests/snapshots/oauth_client_jwks__loopback_skips_all_jwks.snap
··· 30 30 [SKIP] JWKS URI is fetchable — jwks not applicable to loopback clients 31 31 [SKIP] JWKS is valid JSON — jwks not applicable to loopback clients 32 32 [SKIP] Keys have unique kid values — jwks not applicable to loopback clients 33 - [SKIP] Keys declare alg field — jwks not applicable to loopback clients 33 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not applicable to loopback clients 34 34 [SKIP] Keys use signing use — jwks not applicable to loopback clients 35 35 [SKIP] Algorithms are modern EC — jwks not applicable to loopback clients 36 36
+1 -1
tests/snapshots/oauth_client_jwks__public_client_skips_all_jwks.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks not required for public clients 30 30 [SKIP] JWKS is valid JSON — jwks not required for public clients 31 31 [SKIP] Keys have unique kid values — jwks not required for public clients 32 - [SKIP] Keys declare alg field — jwks not required for public clients 32 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not required for public clients 33 33 [SKIP] Keys use signing use — jwks not required for public clients 34 34 [SKIP] Algorithms are modern EC — jwks not required for public clients 35 35
+39
tests/snapshots/oauth_client_jwks__signing_alg_has_no_compatible_key_produces_spec_violation.snap
··· 1 + --- 2 + source: tests/oauth_client_jwks.rs 3 + expression: rendered 4 + --- 5 + Target: https://client.example.com/metadata.json 6 + elapsed: XXms 7 + 8 + == Discovery == 9 + [OK] Client ID well-formed 10 + [OK] Metadata document fetchable 11 + [OK] Metadata is valid JSON 12 + == Metadata == 13 + [OK] Metadata document deserializes 14 + [OK] Metadata `client_id` matches fetched URL 15 + [OK] `application_type` field is present 16 + [OK] `application_type` is `web` or `native` 17 + [OK] `response_types` is `["code"]` 18 + [OK] `grant_types` includes `authorization_code` 19 + [OK] `dpop_bound_access_tokens` is `true` 20 + [OK] `redirect_uris` is non-empty 21 + [OK] Every `redirect_uri` has the right shape for the client kind 22 + [OK] `token_endpoint_auth_method` matches client kind 23 + [OK] Confidential client provides exactly one of `jwks`/`jwks_uri` 24 + [OK] `scope` field is present 25 + [OK] `scope` includes the `atproto` token 26 + [OK] `scope` parses against the atproto permission grammar 27 + == JWKS == 28 + [OK] JWKS is present 29 + [SKIP] JWKS URI is fetchable — jwks is inline 30 + [OK] JWKS is valid JSON 31 + [OK] Keys have unique kid values 32 + [FAIL] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 + oauth_client::jws::has_key_for_signing_alg 34 + 35 + × no key in the JWKS is compatible with `token_endpoint_auth_signing_alg = ES256K` 36 + [OK] Keys use signing use 37 + [OK] Algorithms are modern EC 38 + 39 + Summary: 22 passed, 1 failed (spec), 0 network errors, 0 advisories, 1 skipped. Exit code: 1
+1 -1
tests/snapshots/oauth_client_jwks__uri_es256_happy_jwks_passes.snap
··· 29 29 [OK] JWKS URI is fetchable 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [OK] Keys use signing use 34 34 [OK] Algorithms are modern EC 35 35
+1 -1
tests/snapshots/oauth_client_jwks__uri_invalid_json_produces_spec_violation.snap
··· 32 32 33 33 × JWKS document is not valid JSON 34 34 [SKIP] Keys have unique kid values — blocked by oauth_client::jws::jwks_is_json 35 - [SKIP] Keys declare alg field — blocked by oauth_client::jws::jwks_is_json 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::jws::jwks_is_json 36 36 [SKIP] Keys use signing use — blocked by oauth_client::jws::jwks_is_json 37 37 [SKIP] Algorithms are modern EC — blocked by oauth_client::jws::jwks_is_json 38 38
+1 -1
tests/snapshots/oauth_client_jwks__uri_returns_404_produces_network_error.snap
··· 32 32 × JWKS URI returned 404: https://client.example.com/jwks.json 33 33 [SKIP] JWKS is valid JSON — blocked by oauth_client::jws::jwks_uri_fetchable 34 34 [SKIP] Keys have unique kid values — blocked by oauth_client::jws::jwks_uri_fetchable 35 - [SKIP] Keys declare alg field — blocked by oauth_client::jws::jwks_uri_fetchable 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::jws::jwks_uri_fetchable 36 36 [SKIP] Keys use signing use — blocked by oauth_client::jws::jwks_uri_fetchable 37 37 [SKIP] Algorithms are modern EC — blocked by oauth_client::jws::jwks_uri_fetchable 38 38
+1 -1
tests/snapshots/oauth_client_jwks__uri_unreachable_produces_network_error.snap
··· 32 32 × network error fetching JWKS at `https://client.example.com/jwks.json`: connection refused 33 33 [SKIP] JWKS is valid JSON — blocked by oauth_client::jws::jwks_uri_fetchable 34 34 [SKIP] Keys have unique kid values — blocked by oauth_client::jws::jwks_uri_fetchable 35 - [SKIP] Keys declare alg field — blocked by oauth_client::jws::jwks_uri_fetchable 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::jws::jwks_uri_fetchable 36 36 [SKIP] Keys use signing use — blocked by oauth_client::jws::jwks_uri_fetchable 37 37 [SKIP] Algorithms are modern EC — blocked by oauth_client::jws::jwks_uri_fetchable 38 38
+5 -2
tests/snapshots/oauth_client_jwks__weak_alg_rs1_produces_spec_violation.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [FAIL] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 + oauth_client::jws::has_key_for_signing_alg 34 + 35 + × no key in the JWKS is compatible with `token_endpoint_auth_signing_alg = ES256` 33 36 [OK] Keys use signing use 34 37 [FAIL] Algorithms are modern EC 35 38 oauth_client::jws::algs_are_modern_ec 36 39 37 40 × One or more keys declare non-modern algorithms 38 41 39 - Summary: 22 passed, 1 failed (spec), 0 network errors, 0 advisories, 1 skipped. Exit code: 1 42 + Summary: 21 passed, 2 failed (spec), 0 network errors, 0 advisories, 1 skipped. Exit code: 1
+1 -1
tests/snapshots/oauth_client_jwks__wrong_use_produces_spec_violation.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [FAIL] Keys use signing use 34 34 oauth_client::jws::keys_use_signing_use 35 35
+1 -1
tests/snapshots/oauth_client_metadata__confidential_happy.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks is inline 30 30 [OK] JWKS is valid JSON 31 31 [OK] Keys have unique kid values 32 - [OK] Keys declare alg field 32 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 33 33 [OK] Keys use signing use 34 34 [OK] Algorithms are modern EC 35 35
+1 -1
tests/snapshots/oauth_client_metadata__confidential_missing_jwks.snap
··· 32 32 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::confidential_requires_jwks 33 33 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::confidential_requires_jwks 34 34 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::confidential_requires_jwks 35 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::confidential_requires_jwks 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::confidential_requires_jwks 36 36 [SKIP] Keys use signing use — blocked by oauth_client::metadata::confidential_requires_jwks 37 37 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::confidential_requires_jwks 38 38
+1 -1
tests/snapshots/oauth_client_metadata__discovery_failure_blocks_metadata.snap
··· 33 33 [SKIP] JWKS URI is fetchable — blocked by oauth_client::metadata::raw_document_deserializes 34 34 [SKIP] JWKS is valid JSON — blocked by oauth_client::metadata::raw_document_deserializes 35 35 [SKIP] Keys have unique kid values — blocked by oauth_client::metadata::raw_document_deserializes 36 - [SKIP] Keys declare alg field — blocked by oauth_client::metadata::raw_document_deserializes 36 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — blocked by oauth_client::metadata::raw_document_deserializes 37 37 [SKIP] Keys use signing use — blocked by oauth_client::metadata::raw_document_deserializes 38 38 [SKIP] Algorithms are modern EC — blocked by oauth_client::metadata::raw_document_deserializes 39 39
+1 -1
tests/snapshots/oauth_client_metadata__dpop_bound_false.snap
··· 32 32 [SKIP] JWKS URI is fetchable — jwks is inline 33 33 [OK] JWKS is valid JSON 34 34 [OK] Keys have unique kid values 35 - [OK] Keys declare alg field 35 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 36 36 [OK] Keys use signing use 37 37 [OK] Algorithms are modern EC 38 38
+1 -1
tests/snapshots/oauth_client_metadata__loopback_skips_all_metadata_checks.snap
··· 30 30 [SKIP] JWKS URI is fetchable — jwks not applicable to loopback clients 31 31 [SKIP] JWKS is valid JSON — jwks not applicable to loopback clients 32 32 [SKIP] Keys have unique kid values — jwks not applicable to loopback clients 33 - [SKIP] Keys declare alg field — jwks not applicable to loopback clients 33 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not applicable to loopback clients 34 34 [SKIP] Keys use signing use — jwks not applicable to loopback clients 35 35 [SKIP] Algorithms are modern EC — jwks not applicable to loopback clients 36 36
+1 -1
tests/snapshots/oauth_client_metadata__native_happy.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks not required for native clients 30 30 [SKIP] JWKS is valid JSON — jwks not required for native clients 31 31 [SKIP] Keys have unique kid values — jwks not required for native clients 32 - [SKIP] Keys declare alg field — jwks not required for native clients 32 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not required for native clients 33 33 [SKIP] Keys use signing use — jwks not required for native clients 34 34 [SKIP] Algorithms are modern EC — jwks not required for native clients 35 35
+1 -1
tests/snapshots/oauth_client_metadata__native_redirect_scheme_mismatch.snap
··· 32 32 [SKIP] JWKS URI is fetchable — jwks not required for native clients 33 33 [SKIP] JWKS is valid JSON — jwks not required for native clients 34 34 [SKIP] Keys have unique kid values — jwks not required for native clients 35 - [SKIP] Keys declare alg field — jwks not required for native clients 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not required for native clients 36 36 [SKIP] Keys use signing use — jwks not required for native clients 37 37 [SKIP] Algorithms are modern EC — jwks not required for native clients 38 38
+1 -1
tests/snapshots/oauth_client_metadata__public_happy.snap
··· 29 29 [SKIP] JWKS URI is fetchable — jwks not required for public clients 30 30 [SKIP] JWKS is valid JSON — jwks not required for public clients 31 31 [SKIP] Keys have unique kid values — jwks not required for public clients 32 - [SKIP] Keys declare alg field — jwks not required for public clients 32 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not required for public clients 33 33 [SKIP] Keys use signing use — jwks not required for public clients 34 34 [SKIP] Algorithms are modern EC — jwks not required for public clients 35 35
+1 -1
tests/snapshots/oauth_client_metadata__public_with_token_endpoint_auth.snap
··· 32 32 [SKIP] JWKS URI is fetchable — jwks not required for public clients 33 33 [SKIP] JWKS is valid JSON — jwks not required for public clients 34 34 [SKIP] Keys have unique kid values — jwks not required for public clients 35 - [SKIP] Keys declare alg field — jwks not required for public clients 35 + [SKIP] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` — jwks not required for public clients 36 36 [SKIP] Keys use signing use — jwks not required for public clients 37 37 [SKIP] Algorithms are modern EC — jwks not required for public clients 38 38
+2 -2
tests/snapshots/oauth_client_metadata__scope_grammar_invalid.snap
··· 32 32 27 │ "scope": "atproto invalid-scope-token", 33 33 · ─────────┬───────── 34 34 · ╰── invalid token 35 - 28 │ "token_endpoint_auth_method": "private_key_jwt" 35 + 28 │ "token_endpoint_auth_method": "private_key_jwt", 36 36 ╰──── 37 37 == JWKS == 38 38 [OK] JWKS is present 39 39 [SKIP] JWKS URI is fetchable — jwks is inline 40 40 [OK] JWKS is valid JSON 41 41 [OK] Keys have unique kid values 42 - [OK] Keys declare alg field 42 + [OK] JWKS contains a key compatible with `token_endpoint_auth_signing_alg` 43 43 [OK] Keys use signing use 44 44 [OK] Algorithms are modern EC 45 45