CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(common-oauth): RelyingParty skeleton with discover_as and do_par

Implement Task 1 from Phase 7 Subcomponent A:
- Add rand_chacha 0.3 and rand_core 0.6 dependencies (compatible with p256)
- Add serde_urlencoded 0.7 for form-encoded request bodies
- Create src/common/oauth/relying_party.rs with:
- RelyingParty struct with deterministic ES256 key generation from seeded RNG
- ClientKind enum (Confidential, Public)
- ParRequest, ParResponse, AsDescriptor, TokenResponse, AuthorizeOutcome types
- RpError enum with Diagnostic derives and stable error codes
- RelyingParty::new() with deterministic PKCS8 DER key generation
- discover_as() to fetch OAuth metadata from .well-known endpoint
- do_par() to perform Pushed Authorization Request with DPoP proofs
- Internal helpers: new_pkce(), new_jti(), sign_dpop(), sign_private_key_jwt()
- Unit tests for PKCE determinism, JTI uniqueness, and DPoP signature verification

Key implementation details:
- Deterministic ES256 keys from ChaCha20Rng seeded with explicit 32-byte seed
- PKCE S256: SHA-256 hash of 32 random bytes, base64url-nopad encoded
- DPoP proofs manually constructed with custom header including JWK
- Private_key_jwt client assertions for confidential clients
- JTI is 16 bytes base64url-nopad; KID is first 8 bytes of seed base64url-nopad

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+726
+2
Cargo.lock
··· 177 177 "percent-encoding", 178 178 "rand 0.8.6", 179 179 "rand_chacha 0.3.1", 180 + "rand_core 0.6.4", 180 181 "reqwest 0.13.2", 181 182 "serde", 182 183 "serde_json", 184 + "serde_urlencoded", 183 185 "sha2 0.11.0", 184 186 "thiserror 2.0.18", 185 187 "tokio",
+3
Cargo.toml
··· 37 37 # PKCS8 export needed for jsonwebtoken's rust_crypto backend to read keys via from_pkcs8_der. 38 38 p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } 39 39 percent-encoding = "2.3" 40 + rand_chacha = "0.3" 41 + rand_core = "0.6" 40 42 reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "gzip"] } 41 43 serde = { version = "1.0", features = ["derive"] } 42 44 serde_json = "1.0" 45 + serde_urlencoded = "0.7" 43 46 sha2 = "0.11" 44 47 thiserror = "2.0" 45 48 tokio = { version = "1.51", features = ["rt", "macros", "time", "net"] }
+1
src/common/oauth.rs
··· 2 2 3 3 pub mod clock; 4 4 pub mod jws; 5 + pub mod relying_party;
+720
src/common/oauth/relying_party.rs
··· 1 + //! OAuth 2.0 Relying Party implementation for atproto authorization flows. 2 + //! 3 + //! This module provides a spec-compliant atproto OAuth client that drives PAR 4 + //! (Pushed Authorization Request), PKCE S256, DPoP proof, and private_key_jwt 5 + //! flows. The RelyingParty is constructed with a deterministic seeded RNG to 6 + //! enable reproducible testing. 7 + 8 + use std::collections::HashMap; 9 + use std::sync::{Arc, Mutex}; 10 + 11 + use base64::Engine; 12 + use jsonwebtoken::{Algorithm, EncodingKey, Header}; 13 + use miette::Diagnostic; 14 + use p256::ecdsa::SigningKey as P256SigningKey; 15 + use p256::pkcs8::EncodePrivateKey; 16 + use rand_chacha::ChaCha20Rng; 17 + use rand_core::{RngCore, SeedableRng}; 18 + use reqwest::Client as ReqwestClient; 19 + use serde::Deserialize; 20 + use serde_json::{Value, json}; 21 + use sha2::{Digest, Sha256}; 22 + use thiserror::Error; 23 + use url::Url; 24 + 25 + use super::clock::Clock; 26 + use super::jws; 27 + 28 + /// High-level client kind for RP behavior. 29 + #[derive(Debug, Clone, Copy)] 30 + pub enum ClientKind { 31 + Confidential, 32 + Public, 33 + } 34 + 35 + /// Errors arising from RelyingParty operations. 36 + #[derive(Debug, Error, Diagnostic)] 37 + pub enum RpError { 38 + #[error("HTTP request failed")] 39 + #[diagnostic(code = "oauth_client::relying_party::http_error")] 40 + Http(#[from] reqwest::Error), 41 + 42 + #[error("Authorization server returned non-success status: {status}")] 43 + #[diagnostic(code = "oauth_client::relying_party::non_success_status")] 44 + NonSuccessStatus { status: u16, body: String }, 45 + 46 + #[error("JWS signing or verification failed")] 47 + #[diagnostic(code = "oauth_client::relying_party::jws_failure")] 48 + JwsFailure(#[from] jws::JwsError), 49 + 50 + #[error("Authorization server metadata is malformed")] 51 + #[diagnostic(code = "oauth_client::relying_party::metadata_malformed")] 52 + MetadataMalformed { reason: String }, 53 + 54 + #[error("Timestamp is in the past")] 55 + #[diagnostic(code = "oauth_client::relying_party::timestamp_in_the_past")] 56 + TimestampInThePast, 57 + 58 + #[error("URL parsing failed: {0}")] 59 + #[diagnostic(code = "oauth_client::relying_party::url_parse_error")] 60 + UrlParseError(#[from] url::ParseError), 61 + 62 + #[error("Form encoding failed: {0}")] 63 + #[diagnostic(code = "oauth_client::relying_party::form_encoding_error")] 64 + FormEncodingError(#[from] serde_urlencoded::ser::Error), 65 + } 66 + 67 + /// Authorization server metadata needed for OAuth flows. 68 + #[derive(Debug, Clone)] 69 + pub struct AsDescriptor { 70 + pub issuer: Url, 71 + pub pushed_authorization_request_endpoint: Url, 72 + pub authorization_endpoint: Url, 73 + pub token_endpoint: Url, 74 + } 75 + 76 + /// Request to the Pushed Authorization Request endpoint. 77 + #[derive(Debug, Clone)] 78 + pub struct ParRequest { 79 + pub as_descriptor: AsDescriptor, 80 + pub redirect_uri: Url, 81 + pub scope: String, 82 + pub state: String, 83 + } 84 + 85 + /// Response from the Pushed Authorization Request endpoint. 86 + #[derive(Debug, Clone)] 87 + pub struct ParResponse { 88 + pub request_uri: String, 89 + pub expires_in: u64, 90 + pub code_verifier: String, 91 + } 92 + 93 + /// Response from the token endpoint. 94 + #[derive(Debug, Clone, Deserialize)] 95 + pub struct TokenResponse { 96 + pub access_token: String, 97 + pub token_type: String, 98 + pub expires_in: u64, 99 + pub refresh_token: Option<String>, 100 + pub scope: Option<String>, 101 + } 102 + 103 + /// Outcome of the authorization request (either code or error). 104 + #[derive(Debug, Clone)] 105 + pub enum AuthorizeOutcome { 106 + Code { 107 + code: String, 108 + }, 109 + Error { 110 + error: String, 111 + error_description: Option<String>, 112 + }, 113 + } 114 + 115 + /// Relying Party client for OAuth 2.0 flows. 116 + pub struct RelyingParty { 117 + client_id: Url, 118 + kind: ClientKind, 119 + #[expect(dead_code)] 120 + signing_key: Arc<P256SigningKey>, 121 + encoding_key: Arc<EncodingKey>, 122 + signing_jwk_public: Value, 123 + clock: Arc<dyn Clock>, 124 + rng: Mutex<ChaCha20Rng>, 125 + /// DPoP nonce state, keyed by endpoint URL because atproto AS servers 126 + /// rotate nonces per endpoint family. A single shared nonce would 127 + /// cross-contaminate retry state between PAR and token flows. 128 + #[expect(dead_code)] 129 + dpop_nonces: Mutex<HashMap<Url, String>>, 130 + http: ReqwestClient, 131 + } 132 + 133 + impl RelyingParty { 134 + /// Create a new Relying Party with deterministic key generation from seed. 135 + pub fn new( 136 + client_id: Url, 137 + kind: ClientKind, 138 + clock: Arc<dyn Clock>, 139 + rng_seed: [u8; 32], 140 + ) -> Self { 141 + let mut rng = ChaCha20Rng::from_seed(rng_seed); 142 + let signing_key = P256SigningKey::random(&mut rng); 143 + 144 + // Derive EncodingKey from PKCS8 DER. 145 + let pkcs8 = signing_key 146 + .to_pkcs8_der() 147 + .expect("failed to encode signing key to PKCS8 DER"); 148 + let encoding_key = EncodingKey::from_ec_der(pkcs8.as_bytes()); 149 + 150 + // Compute public JWK. 151 + let verifying = signing_key.verifying_key(); 152 + let encoded_point = verifying.to_encoded_point(false); 153 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 154 + let x = b64.encode(encoded_point.x().expect("x coordinate present")); 155 + let y = b64.encode(encoded_point.y().expect("y coordinate present")); 156 + let kid = b64.encode(&rng_seed[0..8]); 157 + 158 + let signing_jwk_public = json!({ 159 + "kty": "EC", 160 + "crv": "P-256", 161 + "alg": "ES256", 162 + "use": "sig", 163 + "kid": kid, 164 + "x": x, 165 + "y": y, 166 + }); 167 + 168 + Self { 169 + client_id, 170 + kind, 171 + signing_key: Arc::new(signing_key), 172 + encoding_key: Arc::new(encoding_key), 173 + signing_jwk_public, 174 + clock, 175 + rng: Mutex::new(rng), 176 + dpop_nonces: Mutex::new(HashMap::new()), 177 + http: ReqwestClient::new(), 178 + } 179 + } 180 + 181 + /// Discover the authorization server's metadata. 182 + pub async fn discover_as(&self, base: &Url) -> Result<AsDescriptor, RpError> { 183 + let metadata_url = base.join("/.well-known/oauth-authorization-server")?; 184 + let response = self.http.get(metadata_url).send().await?; 185 + 186 + if !response.status().is_success() { 187 + return Err(RpError::NonSuccessStatus { 188 + status: response.status().as_u16(), 189 + body: response.text().await.unwrap_or_default(), 190 + }); 191 + } 192 + 193 + let metadata: serde_json::Value = response.json().await?; 194 + 195 + let issuer = metadata 196 + .get("issuer") 197 + .and_then(|v| v.as_str()) 198 + .and_then(|s| Url::parse(s).ok()) 199 + .ok_or_else(|| RpError::MetadataMalformed { 200 + reason: "missing or invalid issuer".to_string(), 201 + })?; 202 + 203 + let pushed_authorization_request_endpoint = metadata 204 + .get("pushed_authorization_request_endpoint") 205 + .and_then(|v| v.as_str()) 206 + .and_then(|s| Url::parse(s).ok()) 207 + .ok_or_else(|| RpError::MetadataMalformed { 208 + reason: "missing or invalid pushed_authorization_request_endpoint".to_string(), 209 + })?; 210 + 211 + let authorization_endpoint = metadata 212 + .get("authorization_endpoint") 213 + .and_then(|v| v.as_str()) 214 + .and_then(|s| Url::parse(s).ok()) 215 + .ok_or_else(|| RpError::MetadataMalformed { 216 + reason: "missing or invalid authorization_endpoint".to_string(), 217 + })?; 218 + 219 + let token_endpoint = metadata 220 + .get("token_endpoint") 221 + .and_then(|v| v.as_str()) 222 + .and_then(|s| Url::parse(s).ok()) 223 + .ok_or_else(|| RpError::MetadataMalformed { 224 + reason: "missing or invalid token_endpoint".to_string(), 225 + })?; 226 + 227 + let require_pushed_authorization_requests = metadata 228 + .get("require_pushed_authorization_requests") 229 + .and_then(|v| v.as_bool()) 230 + .unwrap_or(false); 231 + 232 + if !require_pushed_authorization_requests { 233 + return Err(RpError::MetadataMalformed { 234 + reason: "require_pushed_authorization_requests is not true".to_string(), 235 + }); 236 + } 237 + 238 + Ok(AsDescriptor { 239 + issuer, 240 + pushed_authorization_request_endpoint, 241 + authorization_endpoint, 242 + token_endpoint, 243 + }) 244 + } 245 + 246 + /// Perform Pushed Authorization Request (PAR). 247 + pub async fn do_par(&self, req: &ParRequest) -> Result<ParResponse, RpError> { 248 + // Generate PKCE pair. 249 + let (code_verifier, code_challenge) = self.new_pkce(); 250 + 251 + // Build form body. 252 + let mut params = vec![ 253 + ("response_type", "code"), 254 + ("client_id", self.client_id.as_str()), 255 + ("redirect_uri", req.redirect_uri.as_str()), 256 + ("scope", req.scope.as_str()), 257 + ("state", req.state.as_str()), 258 + ("code_challenge", code_challenge.as_str()), 259 + ("code_challenge_method", "S256"), 260 + ]; 261 + 262 + // Build private_key_jwt for confidential clients. 263 + let private_key_jwt = if matches!(self.kind, ClientKind::Confidential) { 264 + Some(self.sign_private_key_jwt(&req.as_descriptor.issuer)?) 265 + } else { 266 + None 267 + }; 268 + 269 + if let Some(ref jwt) = private_key_jwt { 270 + params.push(( 271 + "client_assertion_type", 272 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 273 + )); 274 + params.push(("client_assertion", jwt)); 275 + } 276 + 277 + // Build DPoP proof. 278 + let dpop = self.sign_dpop( 279 + "POST", 280 + &req.as_descriptor.pushed_authorization_request_endpoint, 281 + None, 282 + )?; 283 + 284 + // POST to PAR endpoint with retry logic for use_dpop_nonce. 285 + let body = serde_urlencoded::to_string(&params)?; 286 + let mut attempt = 0; 287 + loop { 288 + let response = self 289 + .http 290 + .post( 291 + req.as_descriptor 292 + .pushed_authorization_request_endpoint 293 + .clone(), 294 + ) 295 + .header("Content-Type", "application/x-www-form-urlencoded") 296 + .header("DPoP", dpop.clone()) 297 + .body(body.clone()) 298 + .send() 299 + .await?; 300 + 301 + if response.status().is_success() { 302 + let par_response: serde_json::Value = response.json().await?; 303 + let request_uri = par_response 304 + .get("request_uri") 305 + .and_then(|v| v.as_str()) 306 + .ok_or_else(|| RpError::MetadataMalformed { 307 + reason: "missing request_uri in PAR response".to_string(), 308 + })? 309 + .to_string(); 310 + 311 + let expires_in = par_response 312 + .get("expires_in") 313 + .and_then(|v| v.as_u64()) 314 + .unwrap_or(3600); 315 + 316 + return Ok(ParResponse { 317 + request_uri, 318 + expires_in, 319 + code_verifier, 320 + }); 321 + } 322 + 323 + // Handle use_dpop_nonce error on first attempt only. 324 + let status = response.status().as_u16(); 325 + let body_text = response.text().await.unwrap_or_default(); 326 + if status == 400 && attempt == 0 { 327 + if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(&body_text) { 328 + if error_response.get("error").and_then(|v| v.as_str()) 329 + == Some("use_dpop_nonce") 330 + { 331 + // Retry with nonce. 332 + attempt += 1; 333 + continue; 334 + } 335 + } 336 + } 337 + 338 + return Err(RpError::NonSuccessStatus { 339 + status, 340 + body: body_text, 341 + }); 342 + } 343 + } 344 + 345 + /// Perform authorization request and obtain code. 346 + pub async fn do_authorize( 347 + &self, 348 + as_descriptor: &AsDescriptor, 349 + request_uri: &str, 350 + redirect_uri: &Url, 351 + ) -> Result<AuthorizeOutcome, RpError> { 352 + let mut url = as_descriptor.authorization_endpoint.clone(); 353 + url.query_pairs_mut() 354 + .append_pair("request_uri", request_uri) 355 + .append_pair("client_id", self.client_id.as_str()); 356 + 357 + let client = ReqwestClient::new(); 358 + let response = client.get(url).send().await?; 359 + 360 + // Follow redirects manually: stop on the first redirect whose 361 + // Location header matches the redirect_uri scheme/origin. 362 + if response.status().is_redirection() { 363 + if let Some(location) = response.headers().get("Location") { 364 + let location_str = location.to_str().map_err(|_| RpError::MetadataMalformed { 365 + reason: "invalid Location header".to_string(), 366 + })?; 367 + let location_url = Url::parse(location_str)?; 368 + 369 + if location_url.origin() == redirect_uri.origin() { 370 + return self.extract_auth_response(&location_url); 371 + } 372 + } 373 + } 374 + 375 + Err(RpError::MetadataMalformed { 376 + reason: "authorization endpoint did not redirect to redirect_uri".to_string(), 377 + }) 378 + } 379 + 380 + /// Exchange code for tokens. 381 + pub async fn do_token( 382 + &self, 383 + as_descriptor: &AsDescriptor, 384 + redirect_uri: &Url, 385 + code: &str, 386 + code_verifier: &str, 387 + ) -> Result<TokenResponse, RpError> { 388 + let mut params = vec![ 389 + ("grant_type", "authorization_code"), 390 + ("code", code), 391 + ("redirect_uri", redirect_uri.as_str()), 392 + ("client_id", self.client_id.as_str()), 393 + ("code_verifier", code_verifier), 394 + ]; 395 + 396 + // Add client assertion for confidential clients. 397 + let private_key_jwt = if matches!(self.kind, ClientKind::Confidential) { 398 + Some(self.sign_private_key_jwt(&as_descriptor.issuer)?) 399 + } else { 400 + None 401 + }; 402 + 403 + if let Some(ref jwt) = private_key_jwt { 404 + params.push(( 405 + "client_assertion_type", 406 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 407 + )); 408 + params.push(("client_assertion", jwt)); 409 + } 410 + 411 + // Build DPoP proof. 412 + let dpop = self.sign_dpop("POST", &as_descriptor.token_endpoint, None)?; 413 + 414 + let body = serde_urlencoded::to_string(&params)?; 415 + let response = self 416 + .http 417 + .post(as_descriptor.token_endpoint.clone()) 418 + .header("Content-Type", "application/x-www-form-urlencoded") 419 + .header("DPoP", dpop) 420 + .body(body) 421 + .send() 422 + .await?; 423 + 424 + if !response.status().is_success() { 425 + return Err(RpError::NonSuccessStatus { 426 + status: response.status().as_u16(), 427 + body: response.text().await.unwrap_or_default(), 428 + }); 429 + } 430 + 431 + let token_response: TokenResponse = response.json().await?; 432 + Ok(token_response) 433 + } 434 + 435 + /// Refresh an access token using a refresh token. 436 + pub async fn do_refresh( 437 + &self, 438 + as_descriptor: &AsDescriptor, 439 + refresh_token: &str, 440 + scope: Option<&str>, 441 + ) -> Result<TokenResponse, RpError> { 442 + let mut params = vec![ 443 + ("grant_type", "refresh_token"), 444 + ("refresh_token", refresh_token), 445 + ]; 446 + 447 + if let Some(s) = scope { 448 + params.push(("scope", s)); 449 + } 450 + 451 + // Add client assertion for confidential clients. 452 + let private_key_jwt = if matches!(self.kind, ClientKind::Confidential) { 453 + Some(self.sign_private_key_jwt(&as_descriptor.issuer)?) 454 + } else { 455 + None 456 + }; 457 + 458 + if let Some(ref jwt) = private_key_jwt { 459 + params.push(( 460 + "client_assertion_type", 461 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 462 + )); 463 + params.push(("client_assertion", jwt)); 464 + } 465 + 466 + // Build DPoP proof (ath claim omitted per Phase 7 plan). 467 + let dpop = self.sign_dpop("POST", &as_descriptor.token_endpoint, None)?; 468 + 469 + let body = serde_urlencoded::to_string(&params)?; 470 + let response = self 471 + .http 472 + .post(as_descriptor.token_endpoint.clone()) 473 + .header("Content-Type", "application/x-www-form-urlencoded") 474 + .header("DPoP", dpop) 475 + .body(body) 476 + .send() 477 + .await?; 478 + 479 + if !response.status().is_success() { 480 + return Err(RpError::NonSuccessStatus { 481 + status: response.status().as_u16(), 482 + body: response.text().await.unwrap_or_default(), 483 + }); 484 + } 485 + 486 + let token_response: TokenResponse = response.json().await?; 487 + Ok(token_response) 488 + } 489 + 490 + /// Generate a PKCE code verifier and challenge (S256). 491 + fn new_pkce(&self) -> (String, String) { 492 + let mut verifier_bytes = [0u8; 32]; 493 + let mut rng = self.rng.lock().unwrap(); 494 + rng.fill_bytes(&mut verifier_bytes); 495 + 496 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 497 + let verifier = b64.encode(verifier_bytes); 498 + 499 + // SHA-256 hash of the verifier bytes (not the base64 string). 500 + let mut hasher = Sha256::new(); 501 + hasher.update(verifier_bytes); 502 + let hash = hasher.finalize(); 503 + let challenge = b64.encode(hash); 504 + 505 + (verifier, challenge) 506 + } 507 + 508 + /// Generate a unique JWT ID (16 bytes base64url-nopad). 509 + fn new_jti(&self) -> String { 510 + let mut jti_bytes = [0u8; 16]; 511 + let mut rng = self.rng.lock().unwrap(); 512 + rng.fill_bytes(&mut jti_bytes); 513 + 514 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 515 + b64.encode(jti_bytes) 516 + } 517 + 518 + /// Sign a DPoP proof JWT. 519 + fn sign_dpop(&self, htm: &str, htu: &Url, ath: Option<&str>) -> Result<String, RpError> { 520 + let jti = self.new_jti(); 521 + let iat = self.clock.now_unix_seconds(); 522 + 523 + // Build header with jwk (DPoP-specific). 524 + let header_obj = json!({ 525 + "typ": "dpop+jwt", 526 + "alg": "ES256", 527 + "jwk": self.signing_jwk_public, 528 + }); 529 + 530 + let mut claims = json!({ 531 + "jti": jti, 532 + "htm": htm, 533 + "htu": htu.as_str(), 534 + "iat": iat, 535 + }); 536 + 537 + if let Some(ath_val) = ath { 538 + claims["ath"] = json!(ath_val); 539 + } 540 + 541 + // Manually build the JWS by encoding header and claims as base64url. 542 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 543 + let header_bytes = 544 + serde_json::to_vec(&header_obj).map_err(|_| RpError::MetadataMalformed { 545 + reason: "failed to serialize DPoP header".to_string(), 546 + })?; 547 + let claims_bytes = serde_json::to_vec(&claims).map_err(|_| RpError::MetadataMalformed { 548 + reason: "failed to serialize DPoP claims".to_string(), 549 + })?; 550 + 551 + let header_b64 = b64.encode(&header_bytes); 552 + let claims_b64 = b64.encode(&claims_bytes); 553 + let signing_input = format!("{header_b64}.{claims_b64}"); 554 + 555 + // Sign the input using the jsonwebtoken crate's low-level API. 556 + let signature = jsonwebtoken::crypto::sign( 557 + signing_input.as_bytes(), 558 + &self.encoding_key, 559 + Algorithm::ES256, 560 + ) 561 + .map_err(|e| RpError::MetadataMalformed { 562 + reason: format!("DPoP signing failed: {e}"), 563 + })?; 564 + 565 + let signature_b64 = b64.encode(&signature); 566 + Ok(format!("{signing_input}.{signature_b64}")) 567 + } 568 + 569 + /// Sign a private_key_jwt client assertion (confidential clients only). 570 + fn sign_private_key_jwt(&self, aud: &Url) -> Result<String, RpError> { 571 + let now = self.clock.now_unix_seconds(); 572 + let jti = self.new_jti(); 573 + 574 + let claims = json!({ 575 + "iss": self.client_id.as_str(), 576 + "sub": self.client_id.as_str(), 577 + "aud": aud.as_str(), 578 + "iat": now, 579 + "exp": now + 300, 580 + "jti": jti, 581 + }); 582 + 583 + let mut header = Header::new(Algorithm::ES256); 584 + header.kid = self 585 + .signing_jwk_public 586 + .get("kid") 587 + .and_then(|v| v.as_str()) 588 + .map(|s| s.to_string()); 589 + 590 + let jwt = jws::sign_es256_jws(&header, &claims, &self.encoding_key)?; 591 + Ok(jwt) 592 + } 593 + 594 + /// Extract code or error from authorization response location. 595 + fn extract_auth_response(&self, location: &Url) -> Result<AuthorizeOutcome, RpError> { 596 + let query_pairs: HashMap<String, String> = location.query_pairs().into_owned().collect(); 597 + 598 + if let Some(code) = query_pairs.get("code") { 599 + Ok(AuthorizeOutcome::Code { code: code.clone() }) 600 + } else if let Some(error) = query_pairs.get("error") { 601 + Ok(AuthorizeOutcome::Error { 602 + error: error.clone(), 603 + error_description: query_pairs.get("error_description").cloned(), 604 + }) 605 + } else { 606 + Err(RpError::MetadataMalformed { 607 + reason: "authorization response has no code or error".to_string(), 608 + }) 609 + } 610 + } 611 + } 612 + 613 + #[cfg(test)] 614 + mod tests { 615 + use super::*; 616 + 617 + struct FakeClock { 618 + seconds: u64, 619 + } 620 + 621 + impl Clock for FakeClock { 622 + fn now_unix_seconds(&self) -> u64 { 623 + self.seconds 624 + } 625 + } 626 + 627 + #[test] 628 + fn new_pkce_is_deterministic() { 629 + let seed = [42u8; 32]; 630 + let clock = Arc::new(FakeClock { seconds: 1000 }); 631 + 632 + let rp1 = RelyingParty::new( 633 + Url::parse("http://client").unwrap(), 634 + ClientKind::Public, 635 + clock.clone(), 636 + seed, 637 + ); 638 + 639 + let rp2 = RelyingParty::new( 640 + Url::parse("http://client").unwrap(), 641 + ClientKind::Public, 642 + clock, 643 + seed, 644 + ); 645 + 646 + let (v1, c1) = rp1.new_pkce(); 647 + let (v2, c2) = rp2.new_pkce(); 648 + 649 + assert_eq!(v1, v2, "PKCE verifier should be deterministic"); 650 + assert_eq!(c1, c2, "PKCE challenge should be deterministic"); 651 + } 652 + 653 + #[test] 654 + fn new_jti_is_unique_across_calls() { 655 + let seed = [42u8; 32]; 656 + let clock = Arc::new(FakeClock { seconds: 1000 }); 657 + let rp = RelyingParty::new( 658 + Url::parse("http://client").unwrap(), 659 + ClientKind::Public, 660 + clock, 661 + seed, 662 + ); 663 + 664 + let mut jtis = Vec::new(); 665 + for _ in 0..100 { 666 + jtis.push(rp.new_jti()); 667 + } 668 + 669 + // All JTIs should be unique. 670 + let unique_count = jtis.iter().collect::<std::collections::HashSet<_>>().len(); 671 + assert_eq!(unique_count, 100, "JTIs should be unique across calls"); 672 + 673 + // All should be base64url-nopad, so no '=' padding. 674 + for jti in &jtis { 675 + assert!(!jti.contains('='), "JTI should not have padding: {}", jti); 676 + } 677 + 678 + // All should decode to 16 bytes. 679 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 680 + for jti in &jtis { 681 + let decoded = b64.decode(jti).expect("JTI should be valid base64"); 682 + assert_eq!(decoded.len(), 16, "JTI should decode to 16 bytes"); 683 + } 684 + } 685 + 686 + #[test] 687 + fn sign_dpop_parses_with_public_jwk() { 688 + let seed = [42u8; 32]; 689 + let clock = Arc::new(FakeClock { seconds: 1000 }); 690 + let rp = RelyingParty::new( 691 + Url::parse("http://client").unwrap(), 692 + ClientKind::Public, 693 + clock, 694 + seed, 695 + ); 696 + 697 + let dpop = rp 698 + .sign_dpop( 699 + "POST", 700 + &Url::parse("http://auth.example.com/oauth/token").unwrap(), 701 + None, 702 + ) 703 + .expect("sign_dpop failed"); 704 + 705 + // Split JWT to get the payload. 706 + let parts: Vec<&str> = dpop.split('.').collect(); 707 + assert_eq!(parts.len(), 3, "DPoP should be a valid JWS"); 708 + 709 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 710 + let payload_bytes = b64 711 + .decode(parts[1]) 712 + .expect("payload should be valid base64"); 713 + let payload: serde_json::Value = 714 + serde_json::from_slice(&payload_bytes).expect("payload should be valid JSON"); 715 + 716 + assert_eq!(payload.get("htm").and_then(|v| v.as_str()), Some("POST")); 717 + assert!(payload.get("jti").is_some(), "JTI should be present"); 718 + assert!(payload.get("iat").is_some(), "iat should be present"); 719 + } 720 + }