this repo has no description
1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6
7use crate::OAuthError;
8use crate::types::ClientAuth;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ClientMetadata {
12 pub client_id: String,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub client_name: Option<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub client_uri: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub logo_uri: Option<String>,
19 pub redirect_uris: Vec<String>,
20 #[serde(default)]
21 pub grant_types: Vec<String>,
22 #[serde(default)]
23 pub response_types: Vec<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub scope: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub token_endpoint_auth_method: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub dpop_bound_access_tokens: Option<bool>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub jwks: Option<serde_json::Value>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub jwks_uri: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub application_type: Option<String>,
36}
37
38impl Default for ClientMetadata {
39 fn default() -> Self {
40 Self {
41 client_id: String::new(),
42 client_name: None,
43 client_uri: None,
44 logo_uri: None,
45 redirect_uris: Vec::new(),
46 grant_types: vec!["authorization_code".to_string()],
47 response_types: vec!["code".to_string()],
48 scope: None,
49 token_endpoint_auth_method: Some("none".to_string()),
50 dpop_bound_access_tokens: None,
51 jwks: None,
52 jwks_uri: None,
53 application_type: None,
54 }
55 }
56}
57
58#[derive(Clone)]
59pub struct ClientMetadataCache {
60 cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
61 jwks_cache: Arc<RwLock<HashMap<String, CachedJwks>>>,
62 http_client: Client,
63 cache_ttl_secs: u64,
64}
65
66struct CachedMetadata {
67 metadata: ClientMetadata,
68 cached_at: std::time::Instant,
69}
70
71struct CachedJwks {
72 jwks: serde_json::Value,
73 cached_at: std::time::Instant,
74}
75
76impl ClientMetadataCache {
77 pub fn new(cache_ttl_secs: u64) -> Self {
78 Self {
79 cache: Arc::new(RwLock::new(HashMap::new())),
80 jwks_cache: Arc::new(RwLock::new(HashMap::new())),
81 http_client: Client::builder()
82 .timeout(std::time::Duration::from_secs(30))
83 .connect_timeout(std::time::Duration::from_secs(10))
84 .pool_max_idle_per_host(10)
85 .pool_idle_timeout(std::time::Duration::from_secs(90))
86 .user_agent(
87 "Tranquil-PDS/1.0 (ATProto; +https://tangled.org/lewis.moe/bspds-sandbox)",
88 )
89 .build()
90 .unwrap_or_else(|_| Client::new()),
91 cache_ttl_secs,
92 }
93 }
94
95 fn is_loopback_client(client_id: &str) -> bool {
96 if let Ok(url) = reqwest::Url::parse(client_id) {
97 url.scheme() == "http"
98 && url.host_str() == Some("localhost")
99 && url.port().is_none()
100 && url.path() == "/"
101 } else {
102 false
103 }
104 }
105
106 fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> {
107 let url = reqwest::Url::parse(client_id)
108 .map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".into()))?;
109 let mut redirect_uris = Vec::<String>::new();
110 let mut scope: Option<String> = None;
111 url.query_pairs().for_each(|(key, value)| {
112 if key == "redirect_uri" && redirect_uris.is_empty() {
113 redirect_uris.push(value.to_string());
114 }
115 if key == "scope" && scope.is_none() {
116 scope = Some(value.into());
117 }
118 });
119 if redirect_uris.is_empty() {
120 redirect_uris.push("http://127.0.0.1/".into());
121 redirect_uris.push("http://[::1]/".into());
122 }
123 if scope.is_none() {
124 scope = Some("atproto".into());
125 }
126 Ok(ClientMetadata {
127 client_id: client_id.into(),
128 client_name: Some("Loopback Client".into()),
129 client_uri: None,
130 logo_uri: None,
131 redirect_uris,
132 grant_types: vec!["authorization_code".into(), "refresh_token".into()],
133 response_types: vec!["code".into()],
134 scope,
135 token_endpoint_auth_method: Some("none".into()),
136 dpop_bound_access_tokens: Some(false),
137 jwks: None,
138 jwks_uri: None,
139 application_type: Some("native".into()),
140 })
141 }
142
143 pub async fn get(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
144 if Self::is_loopback_client(client_id) {
145 return Self::build_loopback_metadata(client_id);
146 }
147 {
148 let cache = self.cache.read().await;
149 if let Some(cached) = cache.get(client_id)
150 && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
151 {
152 return Ok(cached.metadata.clone());
153 }
154 }
155 let metadata = self.fetch_metadata(client_id).await?;
156 {
157 let mut cache = self.cache.write().await;
158 cache.insert(
159 client_id.to_string(),
160 CachedMetadata {
161 metadata: metadata.clone(),
162 cached_at: std::time::Instant::now(),
163 },
164 );
165 }
166 Ok(metadata)
167 }
168
169 pub async fn get_jwks(
170 &self,
171 metadata: &ClientMetadata,
172 ) -> Result<serde_json::Value, OAuthError> {
173 if let Some(jwks) = &metadata.jwks {
174 return Ok(jwks.clone());
175 }
176 let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
177 OAuthError::InvalidClient(
178 "Client using private_key_jwt must have jwks or jwks_uri".to_string(),
179 )
180 })?;
181 {
182 let cache = self.jwks_cache.read().await;
183 if let Some(cached) = cache.get(jwks_uri)
184 && cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
185 {
186 return Ok(cached.jwks.clone());
187 }
188 }
189 let jwks = self.fetch_jwks(jwks_uri).await?;
190 {
191 let mut cache = self.jwks_cache.write().await;
192 cache.insert(
193 jwks_uri.clone(),
194 CachedJwks {
195 jwks: jwks.clone(),
196 cached_at: std::time::Instant::now(),
197 },
198 );
199 }
200 Ok(jwks)
201 }
202
203 async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
204 if !jwks_uri.starts_with("https://")
205 && (!jwks_uri.starts_with("http://")
206 || (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
207 {
208 return Err(OAuthError::InvalidClient(
209 "jwks_uri must use https (except for localhost)".to_string(),
210 ));
211 }
212 let response = self
213 .http_client
214 .get(jwks_uri)
215 .header("Accept", "application/json")
216 .send()
217 .await
218 .map_err(|e| {
219 OAuthError::InvalidClient(format!("Failed to fetch JWKS from {}: {}", jwks_uri, e))
220 })?;
221 if !response.status().is_success() {
222 return Err(OAuthError::InvalidClient(format!(
223 "Failed to fetch JWKS: HTTP {}",
224 response.status()
225 )));
226 }
227 let jwks: serde_json::Value = response
228 .json()
229 .await
230 .map_err(|e| OAuthError::InvalidClient(format!("Invalid JWKS JSON: {}", e)))?;
231 if jwks.get("keys").and_then(|k| k.as_array()).is_none() {
232 return Err(OAuthError::InvalidClient(
233 "JWKS must contain a 'keys' array".to_string(),
234 ));
235 }
236 Ok(jwks)
237 }
238
239 async fn fetch_metadata(&self, client_id: &str) -> Result<ClientMetadata, OAuthError> {
240 if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
241 return Err(OAuthError::InvalidClient(
242 "client_id must be a URL".to_string(),
243 ));
244 }
245 if client_id.starts_with("http://")
246 && !client_id.contains("localhost")
247 && !client_id.contains("127.0.0.1")
248 {
249 return Err(OAuthError::InvalidClient(
250 "Non-localhost client_id must use https".to_string(),
251 ));
252 }
253 let response = self
254 .http_client
255 .get(client_id)
256 .header("Accept", "application/json")
257 .send()
258 .await
259 .map_err(|e| {
260 tracing::warn!(client_id = %client_id, error = %e, "Failed to fetch client metadata");
261 OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e))
262 })?;
263 if !response.status().is_success() {
264 tracing::warn!(client_id = %client_id, status = %response.status(), "Failed to fetch client metadata");
265 return Err(OAuthError::InvalidClient(format!(
266 "Failed to fetch client metadata: HTTP {}",
267 response.status()
268 )));
269 }
270 let mut metadata: ClientMetadata = response.json().await.map_err(|e| {
271 OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e))
272 })?;
273 if metadata.client_id.is_empty() {
274 metadata.client_id = client_id.to_string();
275 } else if metadata.client_id != client_id {
276 return Err(OAuthError::InvalidClient(
277 "client_id in metadata does not match request".to_string(),
278 ));
279 }
280 self.validate_metadata(&metadata)?;
281 Ok(metadata)
282 }
283
284 fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> {
285 if metadata.redirect_uris.is_empty() {
286 return Err(OAuthError::InvalidClient(
287 "redirect_uris is required".to_string(),
288 ));
289 }
290 metadata
291 .redirect_uris
292 .iter()
293 .try_for_each(|uri| self.validate_redirect_uri_format(uri))?;
294 if !metadata.grant_types.is_empty()
295 && !metadata
296 .grant_types
297 .contains(&"authorization_code".to_string())
298 {
299 return Err(OAuthError::InvalidClient(
300 "authorization_code grant type is required".to_string(),
301 ));
302 }
303 if !metadata.response_types.is_empty()
304 && !metadata.response_types.contains(&"code".to_string())
305 {
306 return Err(OAuthError::InvalidClient(
307 "code response type is required".to_string(),
308 ));
309 }
310 Ok(())
311 }
312
313 pub fn validate_redirect_uri(
314 &self,
315 metadata: &ClientMetadata,
316 redirect_uri: &str,
317 ) -> Result<(), OAuthError> {
318 if metadata.redirect_uris.contains(&redirect_uri.to_string()) {
319 return Ok(());
320 }
321 if Self::is_loopback_client(&metadata.client_id)
322 && let Ok(req_url) = reqwest::Url::parse(redirect_uri)
323 {
324 let req_host = req_url.host_str().unwrap_or("");
325 let is_loopback_redirect = req_url.scheme() == "http"
326 && (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
327 if is_loopback_redirect {
328 return Ok(());
329 }
330 }
331 Err(OAuthError::InvalidRequest(
332 "redirect_uri not registered for client".to_string(),
333 ))
334 }
335
336 fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
337 if uri.contains('#') {
338 return Err(OAuthError::InvalidClient(
339 "redirect_uri must not contain a fragment".to_string(),
340 ));
341 }
342 let parsed = reqwest::Url::parse(uri)
343 .map_err(|_| OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)))?;
344 let scheme = parsed.scheme();
345 if scheme == "http" {
346 let host = parsed.host_str().unwrap_or("");
347 if host != "localhost" && host != "127.0.0.1" && host != "[::1]" {
348 return Err(OAuthError::InvalidClient(
349 "http redirect_uri only allowed for localhost".to_string(),
350 ));
351 }
352 } else if scheme == "https" {
353 } else if scheme.chars().all(|c| {
354 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-'
355 }) {
356 if !scheme
357 .chars()
358 .next()
359 .is_some_and(|c| c.is_ascii_lowercase())
360 {
361 return Err(OAuthError::InvalidClient(format!(
362 "Invalid redirect_uri scheme: {}",
363 scheme
364 )));
365 }
366 } else {
367 return Err(OAuthError::InvalidClient(format!(
368 "Invalid redirect_uri scheme: {}",
369 scheme
370 )));
371 }
372 Ok(())
373 }
374}
375
376impl ClientMetadata {
377 pub fn requires_dpop(&self) -> bool {
378 self.dpop_bound_access_tokens.unwrap_or(false)
379 }
380
381 pub fn auth_method(&self) -> &str {
382 self.token_endpoint_auth_method.as_deref().unwrap_or("none")
383 }
384}
385
386pub async fn verify_client_auth(
387 cache: &ClientMetadataCache,
388 metadata: &ClientMetadata,
389 client_auth: &ClientAuth,
390) -> Result<(), OAuthError> {
391 let expected_method = metadata.auth_method();
392 match (expected_method, client_auth) {
393 ("none", ClientAuth::None) => Ok(()),
394 ("none", _) => Err(OAuthError::InvalidClient(
395 "Client is configured for no authentication, but credentials were provided".to_string(),
396 )),
397 ("private_key_jwt", ClientAuth::PrivateKeyJwt { client_assertion }) => {
398 verify_private_key_jwt_async(cache, metadata, client_assertion).await
399 }
400 ("private_key_jwt", _) => Err(OAuthError::InvalidClient(
401 "Client requires private_key_jwt authentication".to_string(),
402 )),
403 ("client_secret_post", ClientAuth::SecretPost { .. }) => Err(OAuthError::InvalidClient(
404 "client_secret_post is not supported for ATProto OAuth".to_string(),
405 )),
406 ("client_secret_basic", ClientAuth::SecretBasic { .. }) => Err(OAuthError::InvalidClient(
407 "client_secret_basic is not supported for ATProto OAuth".to_string(),
408 )),
409 (method, _) => Err(OAuthError::InvalidClient(format!(
410 "Unsupported or mismatched authentication method: {}",
411 method
412 ))),
413 }
414}
415
416async fn verify_private_key_jwt_async(
417 cache: &ClientMetadataCache,
418 metadata: &ClientMetadata,
419 client_assertion: &str,
420) -> Result<(), OAuthError> {
421 use base64::{
422 Engine as _,
423 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
424 };
425 let parts: Vec<&str> = client_assertion.split('.').collect();
426 if parts.len() != 3 {
427 return Err(OAuthError::InvalidClient(
428 "Invalid client_assertion format".to_string(),
429 ));
430 }
431 let header_bytes = URL_SAFE_NO_PAD
432 .decode(parts[0])
433 .or_else(|_| STANDARD.decode(parts[0]))
434 .map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
435 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
436 .map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
437 let alg = header
438 .get("alg")
439 .and_then(|a| a.as_str())
440 .ok_or_else(|| OAuthError::InvalidClient("Missing alg in client_assertion".to_string()))?;
441 if !matches!(
442 alg,
443 "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA"
444 ) {
445 return Err(OAuthError::InvalidClient(format!(
446 "Unsupported client_assertion algorithm: {}",
447 alg
448 )));
449 }
450 let kid = header.get("kid").and_then(|k| k.as_str());
451 let payload_bytes = URL_SAFE_NO_PAD
452 .decode(parts[1])
453 .or_else(|_| STANDARD.decode(parts[1]))
454 .map_err(|e| {
455 tracing::warn!(error = %e, payload_part = parts[1], "Invalid assertion payload encoding");
456 OAuthError::InvalidClient("Invalid assertion payload encoding".to_string())
457 })?;
458 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
459 .map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
460 let iss = payload
461 .get("iss")
462 .and_then(|i| i.as_str())
463 .ok_or_else(|| OAuthError::InvalidClient("Missing iss in client_assertion".to_string()))?;
464 if iss != metadata.client_id {
465 return Err(OAuthError::InvalidClient(
466 "client_assertion iss does not match client_id".to_string(),
467 ));
468 }
469 let sub = payload
470 .get("sub")
471 .and_then(|s| s.as_str())
472 .ok_or_else(|| OAuthError::InvalidClient("Missing sub in client_assertion".to_string()))?;
473 if sub != metadata.client_id {
474 return Err(OAuthError::InvalidClient(
475 "client_assertion sub does not match client_id".to_string(),
476 ));
477 }
478 let now = chrono::Utc::now().timestamp();
479 let exp = payload.get("exp").and_then(|e| e.as_i64());
480 let iat = payload.get("iat").and_then(|i| i.as_i64());
481 if let Some(exp) = exp {
482 if exp < now {
483 return Err(OAuthError::InvalidClient(
484 "client_assertion has expired".to_string(),
485 ));
486 }
487 } else if let Some(iat) = iat {
488 let max_age_secs = 300;
489 if now - iat > max_age_secs {
490 tracing::warn!(
491 iat = iat,
492 now = now,
493 "client_assertion too old (no exp, using iat)"
494 );
495 return Err(OAuthError::InvalidClient(
496 "client_assertion is too old".to_string(),
497 ));
498 }
499 } else {
500 return Err(OAuthError::InvalidClient(
501 "client_assertion must have exp or iat claim".to_string(),
502 ));
503 }
504 if let Some(iat) = iat
505 && iat > now + 60
506 {
507 return Err(OAuthError::InvalidClient(
508 "client_assertion iat is in the future".to_string(),
509 ));
510 }
511 let jwks = cache.get_jwks(metadata).await?;
512 let keys = jwks
513 .get("keys")
514 .and_then(|k| k.as_array())
515 .ok_or_else(|| OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()))?;
516 let matching_keys: Vec<&serde_json::Value> = match kid {
517 Some(kid) => keys
518 .iter()
519 .filter(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid))
520 .collect(),
521 None => keys.iter().collect(),
522 };
523 if matching_keys.is_empty() {
524 return Err(OAuthError::InvalidClient(
525 "No matching key found in client JWKS".to_string(),
526 ));
527 }
528 let signing_input = format!("{}.{}", parts[0], parts[1]);
529 let signature_bytes = URL_SAFE_NO_PAD
530 .decode(parts[2])
531 .map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
532 for key in matching_keys {
533 let key_alg = key.get("alg").and_then(|a| a.as_str());
534 if key_alg.is_some() && key_alg != Some(alg) {
535 continue;
536 }
537 let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
538 let verified = match (alg, kty) {
539 ("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes),
540 ("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes),
541 ("RS256" | "RS384" | "RS512", "RSA") => {
542 verify_rsa(alg, key, &signing_input, &signature_bytes)
543 }
544 ("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes),
545 _ => continue,
546 };
547 if verified.is_ok() {
548 return Ok(());
549 }
550 }
551 Err(OAuthError::InvalidClient(
552 "client_assertion signature verification failed".to_string(),
553 ))
554}
555
556fn verify_es256(
557 key: &serde_json::Value,
558 signing_input: &str,
559 signature: &[u8],
560) -> Result<(), OAuthError> {
561 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
562 use p256::EncodedPoint;
563 use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
564 let x = key
565 .get("x")
566 .and_then(|v| v.as_str())
567 .ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
568 let y = key
569 .get("y")
570 .and_then(|v| v.as_str())
571 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
572 let x_bytes = URL_SAFE_NO_PAD
573 .decode(x)
574 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
575 let y_bytes = URL_SAFE_NO_PAD
576 .decode(y)
577 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
578 let mut point_bytes = vec![0x04];
579 point_bytes.extend_from_slice(&x_bytes);
580 point_bytes.extend_from_slice(&y_bytes);
581 let point = EncodedPoint::from_bytes(&point_bytes)
582 .map_err(|_| OAuthError::InvalidClient("Invalid EC point".to_string()))?;
583 let verifying_key = VerifyingKey::from_encoded_point(&point)
584 .map_err(|_| OAuthError::InvalidClient("Invalid EC key".to_string()))?;
585 let sig = Signature::from_slice(signature)
586 .map_err(|_| OAuthError::InvalidClient("Invalid ES256 signature format".to_string()))?;
587 verifying_key
588 .verify(signing_input.as_bytes(), &sig)
589 .map_err(|_| OAuthError::InvalidClient("ES256 signature verification failed".to_string()))
590}
591
592fn verify_es384(
593 key: &serde_json::Value,
594 signing_input: &str,
595 signature: &[u8],
596) -> Result<(), OAuthError> {
597 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
598 use p384::EncodedPoint;
599 use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
600 let x = key
601 .get("x")
602 .and_then(|v| v.as_str())
603 .ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
604 let y = key
605 .get("y")
606 .and_then(|v| v.as_str())
607 .ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
608 let x_bytes = URL_SAFE_NO_PAD
609 .decode(x)
610 .map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
611 let y_bytes = URL_SAFE_NO_PAD
612 .decode(y)
613 .map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
614 let mut point_bytes = vec![0x04];
615 point_bytes.extend_from_slice(&x_bytes);
616 point_bytes.extend_from_slice(&y_bytes);
617 let point = EncodedPoint::from_bytes(&point_bytes)
618 .map_err(|_| OAuthError::InvalidClient("Invalid EC point".to_string()))?;
619 let verifying_key = VerifyingKey::from_encoded_point(&point)
620 .map_err(|_| OAuthError::InvalidClient("Invalid EC key".to_string()))?;
621 let sig = Signature::from_slice(signature)
622 .map_err(|_| OAuthError::InvalidClient("Invalid ES384 signature format".to_string()))?;
623 verifying_key
624 .verify(signing_input.as_bytes(), &sig)
625 .map_err(|_| OAuthError::InvalidClient("ES384 signature verification failed".to_string()))
626}
627
628fn verify_rsa(
629 _alg: &str,
630 _key: &serde_json::Value,
631 _signing_input: &str,
632 _signature: &[u8],
633) -> Result<(), OAuthError> {
634 Err(OAuthError::InvalidClient(
635 "RSA signature verification not yet supported - use EC keys".to_string(),
636 ))
637}
638
639fn verify_eddsa(
640 key: &serde_json::Value,
641 signing_input: &str,
642 signature: &[u8],
643) -> Result<(), OAuthError> {
644 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
645 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
646 let crv = key.get("crv").and_then(|c| c.as_str()).unwrap_or("");
647 if crv != "Ed25519" {
648 return Err(OAuthError::InvalidClient(format!(
649 "Unsupported EdDSA curve: {}",
650 crv
651 )));
652 }
653 let x = key
654 .get("x")
655 .and_then(|v| v.as_str())
656 .ok_or_else(|| OAuthError::InvalidClient("Missing x in OKP key".to_string()))?;
657 let x_bytes = URL_SAFE_NO_PAD
658 .decode(x)
659 .map_err(|_| OAuthError::InvalidClient("Invalid x encoding".to_string()))?;
660 let key_bytes: [u8; 32] = x_bytes
661 .try_into()
662 .map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key length".to_string()))?;
663 let verifying_key = VerifyingKey::from_bytes(&key_bytes)
664 .map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key".to_string()))?;
665 let sig_bytes: [u8; 64] = signature
666 .try_into()
667 .map_err(|_| OAuthError::InvalidClient("Invalid EdDSA signature length".to_string()))?;
668 let sig = Signature::from_bytes(&sig_bytes);
669 verifying_key
670 .verify(signing_input.as_bytes(), &sig)
671 .map_err(|_| OAuthError::InvalidClient("EdDSA signature verification failed".to_string()))
672}