Files
tranquil-pds/crates/tranquil-oauth/src/client.rs
T

714 lines
26 KiB
Rust

use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use crate::OAuthError;
use crate::types::ClientAuth;
use tranquil_infra::cache_keys::{
oauth_client_jwks_cooldown_key, oauth_client_jwks_key, oauth_client_meta_key,
};
use tranquil_infra::{Cache, cached_json, write_json};
use tranquil_types::{
ClientId, JwksUri, ReachPolicy, dns_guard, redirect_policy, url_reach_permits,
};
const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
pub client_id: ClientId,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
pub redirect_uris: Vec<String>,
#[serde(default)]
pub grant_types: Vec<String>,
#[serde(default)]
pub response_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_endpoint_auth_method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dpop_bound_access_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "tranquil_types::http_url::deserialize_optional"
)]
pub jwks_uri: Option<JwksUri>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
}
impl Default for ClientMetadata {
fn default() -> Self {
Self {
client_id: ClientId::new(""),
client_name: None,
client_uri: None,
logo_uri: None,
redirect_uris: Vec::new(),
grant_types: vec!["authorization_code".to_string()],
response_types: vec!["code".to_string()],
scope: None,
token_endpoint_auth_method: Some("none".to_string()),
dpop_bound_access_tokens: None,
jwks: None,
jwks_uri: None,
application_type: None,
}
}
}
#[derive(Clone)]
pub struct ClientMetadataCache {
cache: Arc<dyn Cache>,
http_client: Client,
cache_ttl: Duration,
fetch_policy: ReachPolicy,
}
impl ClientMetadataCache {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration, fetch_policy: ReachPolicy) -> Self {
Self {
cache,
http_client: {
let builder = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.redirect(redirect_policy(fetch_policy))
.dns_resolver(dns_guard(fetch_policy))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
" (ATProto; +https://tangled.org/tranquil.farm/tranquil-pds)"
));
#[cfg(feature = "native-tls-roots")]
let builder = builder.danger_accept_invalid_certs(true);
builder
.build()
.expect("failed to build client metadata HTTP client")
},
cache_ttl,
fetch_policy,
}
}
fn is_loopback_client(client_id: &ClientId) -> bool {
if let Ok(url) = reqwest::Url::parse(client_id) {
url.scheme() == "http"
&& url.host_str() == Some("localhost")
&& url.port().is_none()
&& url.path() == "/"
} else {
false
}
}
fn build_loopback_metadata(client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".into()))?;
let mut redirect_uris = Vec::<String>::new();
let mut scope: Option<String> = None;
url.query_pairs().for_each(|(key, value)| {
if key == "redirect_uri" && redirect_uris.is_empty() {
redirect_uris.push(value.to_string());
}
if key == "scope" && scope.is_none() {
scope = Some(value.into());
}
});
if redirect_uris.is_empty() {
redirect_uris.push("http://127.0.0.1/".into());
redirect_uris.push("http://[::1]/".into());
}
if scope.is_none() {
scope = Some("atproto".into());
}
Ok(ClientMetadata {
client_id: client_id.clone(),
client_name: Some("Loopback Client".into()),
client_uri: None,
logo_uri: None,
redirect_uris,
grant_types: vec!["authorization_code".into(), "refresh_token".into()],
response_types: vec!["code".into()],
scope,
token_endpoint_auth_method: Some("none".into()),
dpop_bound_access_tokens: Some(true),
jwks: None,
jwks_uri: None,
application_type: Some("native".into()),
})
}
pub async fn get(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
if Self::is_loopback_client(client_id) {
return Self::build_loopback_metadata(client_id);
}
cached_json(
self.cache.as_ref(),
&oauth_client_meta_key(client_id),
self.cache_ttl,
|| self.fetch_metadata(client_id),
)
.await
}
pub async fn get_jwks(
&self,
metadata: &ClientMetadata,
) -> Result<serde_json::Value, OAuthError> {
if let Some(jwks) = &metadata.jwks {
return Ok(jwks.clone());
}
let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
OAuthError::InvalidClient(
"Client using private_key_jwt must have jwks or a usable jwks_uri".to_string(),
)
})?;
cached_json(
self.cache.as_ref(),
&oauth_client_jwks_key(jwks_uri),
self.cache_ttl,
|| self.fetch_jwks(jwks_uri),
)
.await
}
async fn refresh_jwks(
&self,
metadata: &ClientMetadata,
) -> Result<Option<serde_json::Value>, OAuthError> {
match (&metadata.jwks, &metadata.jwks_uri) {
(None, Some(jwks_uri)) => {
let cooldown_key = oauth_client_jwks_cooldown_key(jwks_uri);
if self.cache.get(&cooldown_key).await.is_some() {
return Ok(None);
}
let _ = self
.cache
.set(&cooldown_key, "1", JWKS_REFRESH_COOLDOWN)
.await;
self.fetch_and_store_jwks(jwks_uri).await.map(Some)
}
_ => Ok(None),
}
}
async fn fetch_and_store_jwks(
&self,
jwks_uri: &JwksUri,
) -> Result<serde_json::Value, OAuthError> {
let jwks = self.fetch_jwks(jwks_uri).await?;
write_json(
self.cache.as_ref(),
&oauth_client_jwks_key(jwks_uri),
&jwks,
self.cache_ttl,
)
.await;
Ok(jwks)
}
async fn fetch_jwks(&self, jwks_uri: &JwksUri) -> Result<serde_json::Value, OAuthError> {
let response = self
.http_client
.get(jwks_uri.as_str())
.header("Accept", "application/json")
.send()
.await
.map_err(|e| {
OAuthError::InvalidClient(format!("Failed to fetch JWKS from {}: {}", jwks_uri, e))
})?;
if !response.status().is_success() {
return Err(OAuthError::InvalidClient(format!(
"Failed to fetch JWKS: HTTP {}",
response.status()
)));
}
let jwks: serde_json::Value = response
.json()
.await
.map_err(|e| OAuthError::InvalidClient(format!("Invalid JWKS JSON: {}", e)))?;
if jwks.get("keys").and_then(|k| k.as_array()).is_none() {
return Err(OAuthError::InvalidClient(
"JWKS must contain a 'keys' array".to_string(),
));
}
Ok(jwks)
}
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
if !url_reach_permits(&url, self.fetch_policy) {
return Err(OAuthError::InvalidClient(
"client_id must be an https URL inside the allowed host reach".to_string(),
));
}
let response = self
.http_client
.get(url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| {
tracing::warn!(client_id = %client_id, error = %e, "Failed to fetch client metadata");
OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e))
})?;
if !response.status().is_success() {
tracing::warn!(client_id = %client_id, status = %response.status(), "Failed to fetch client metadata");
return Err(OAuthError::InvalidClient(format!(
"Failed to fetch client metadata: HTTP {}",
response.status()
)));
}
let mut metadata: ClientMetadata = response.json().await.map_err(|e| {
OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e))
})?;
if metadata.client_id.is_empty() {
metadata.client_id = client_id.clone();
} else if metadata.client_id != *client_id {
return Err(OAuthError::InvalidClient(
"client_id in metadata does not match request".to_string(),
));
}
self.validate_metadata(&metadata)?;
Ok(metadata)
}
fn validate_metadata(&self, metadata: &ClientMetadata) -> Result<(), OAuthError> {
if metadata.redirect_uris.is_empty() {
return Err(OAuthError::InvalidClient(
"redirect_uris is required".to_string(),
));
}
metadata
.redirect_uris
.iter()
.try_for_each(|uri| self.validate_redirect_uri_format(uri))?;
if !metadata.grant_types.is_empty()
&& !metadata
.grant_types
.contains(&"authorization_code".to_string())
{
return Err(OAuthError::InvalidClient(
"authorization_code grant type is required".to_string(),
));
}
if !metadata.response_types.is_empty()
&& !metadata.response_types.contains(&"code".to_string())
{
return Err(OAuthError::InvalidClient(
"code response type is required".to_string(),
));
}
Ok(())
}
pub fn validate_redirect_uri(
&self,
metadata: &ClientMetadata,
redirect_uri: &str,
) -> Result<(), OAuthError> {
if metadata.redirect_uris.contains(&redirect_uri.to_string()) {
return Ok(());
}
if Self::is_loopback_client(&metadata.client_id)
&& let Ok(req_url) = reqwest::Url::parse(redirect_uri)
{
let req_host = req_url.host_str().unwrap_or("");
let is_loopback_redirect = req_url.scheme() == "http"
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
if is_loopback_redirect {
return Ok(());
}
}
Err(OAuthError::InvalidRequest(
"redirect_uri not registered for client".to_string(),
))
}
fn validate_redirect_uri_format(&self, uri: &str) -> Result<(), OAuthError> {
if uri.contains('#') {
return Err(OAuthError::InvalidClient(
"redirect_uri must not contain a fragment".to_string(),
));
}
let parsed = reqwest::Url::parse(uri)
.map_err(|_| OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)))?;
let scheme = parsed.scheme();
if scheme == "http" {
let host = parsed.host_str().unwrap_or("");
if host != "localhost" && host != "127.0.0.1" && host != "[::1]" {
return Err(OAuthError::InvalidClient(
"http redirect_uri only allowed for localhost".to_string(),
));
}
} else if scheme == "https" {
} else if scheme.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-'
}) {
if !scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase())
{
return Err(OAuthError::InvalidClient(format!(
"Invalid redirect_uri scheme: {}",
scheme
)));
}
} else {
return Err(OAuthError::InvalidClient(format!(
"Invalid redirect_uri scheme: {}",
scheme
)));
}
Ok(())
}
}
impl ClientMetadata {
pub fn requires_dpop(&self) -> bool {
self.dpop_bound_access_tokens.unwrap_or(false)
}
pub fn auth_method(&self) -> &str {
self.token_endpoint_auth_method.as_deref().unwrap_or("none")
}
}
pub async fn verify_client_auth(
cache: &ClientMetadataCache,
metadata: &ClientMetadata,
client_auth: &ClientAuth,
) -> Result<(), OAuthError> {
let expected_method = metadata.auth_method();
match (expected_method, client_auth) {
("none", ClientAuth::None) => Ok(()),
("none", _) => Err(OAuthError::InvalidClient(
"Client is configured for no authentication, but credentials were provided".to_string(),
)),
("private_key_jwt", ClientAuth::PrivateKeyJwt { client_assertion }) => {
verify_private_key_jwt_async(cache, metadata, client_assertion).await
}
("private_key_jwt", _) => Err(OAuthError::InvalidClient(
"Client requires private_key_jwt authentication".to_string(),
)),
("client_secret_post", ClientAuth::SecretPost { .. }) => Err(OAuthError::InvalidClient(
"client_secret_post is not supported for ATProto OAuth".to_string(),
)),
("client_secret_basic", ClientAuth::SecretBasic { .. }) => Err(OAuthError::InvalidClient(
"client_secret_basic is not supported for ATProto OAuth".to_string(),
)),
(method, _) => Err(OAuthError::InvalidClient(format!(
"Unsupported or mismatched authentication method: {}",
method
))),
}
}
async fn verify_private_key_jwt_async(
cache: &ClientMetadataCache,
metadata: &ClientMetadata,
client_assertion: &str,
) -> Result<(), OAuthError> {
use base64::{
Engine as _,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
let parts: Vec<&str> = client_assertion.split('.').collect();
if parts.len() != 3 {
return Err(OAuthError::InvalidClient(
"Invalid client_assertion format".to_string(),
));
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.or_else(|_| STANDARD.decode(parts[0]))
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
let alg = header
.get("alg")
.and_then(|a| a.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing alg in client_assertion".to_string()))?;
if !matches!(
alg,
"ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA"
) {
return Err(OAuthError::InvalidClient(format!(
"Unsupported client_assertion algorithm: {}",
alg
)));
}
let kid = header.get("kid").and_then(|k| k.as_str());
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.or_else(|_| STANDARD.decode(parts[1]))
.map_err(|e| {
tracing::warn!(error = %e, payload_part = parts[1], "Invalid assertion payload encoding");
OAuthError::InvalidClient("Invalid assertion payload encoding".to_string())
})?;
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
let iss = payload
.get("iss")
.and_then(|i| i.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing iss in client_assertion".to_string()))?;
if iss != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion iss does not match client_id".to_string(),
));
}
let sub = payload
.get("sub")
.and_then(|s| s.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing sub in client_assertion".to_string()))?;
if sub != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion sub does not match client_id".to_string(),
));
}
let now = chrono::Utc::now().timestamp();
let exp = payload.get("exp").and_then(|e| e.as_i64());
let iat = payload.get("iat").and_then(|i| i.as_i64());
if let Some(exp) = exp {
if exp < now {
return Err(OAuthError::InvalidClient(
"client_assertion has expired".to_string(),
));
}
} else if let Some(iat) = iat {
let max_age_secs = 300;
if now - iat > max_age_secs {
tracing::warn!(
iat = iat,
now = now,
"client_assertion too old (no exp, using iat)"
);
return Err(OAuthError::InvalidClient(
"client_assertion is too old".to_string(),
));
}
} else {
return Err(OAuthError::InvalidClient(
"client_assertion must have exp or iat claim".to_string(),
));
}
if let Some(iat) = iat
&& iat > now + 60
{
return Err(OAuthError::InvalidClient(
"client_assertion iat is in the future".to_string(),
));
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let signature_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
let jwks = cache.get_jwks(metadata).await?;
match verify_assertion_signature(&jwks, kid, alg, &signing_input, &signature_bytes) {
Ok(()) => Ok(()),
Err(cached_failure) => match cache.refresh_jwks(metadata).await {
Ok(Some(fresh)) => {
verify_assertion_signature(&fresh, kid, alg, &signing_input, &signature_bytes)
}
Ok(None) | Err(_) => Err(cached_failure),
},
}
}
fn verify_assertion_signature(
jwks: &serde_json::Value,
kid: Option<&str>,
alg: &str,
signing_input: &str,
signature: &[u8],
) -> Result<(), OAuthError> {
let keys = jwks
.get("keys")
.and_then(|k| k.as_array())
.ok_or_else(|| OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()))?;
let matching_keys: Vec<&serde_json::Value> = match kid {
Some(kid) => keys
.iter()
.filter(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid))
.collect(),
None => keys.iter().collect(),
};
if matching_keys.is_empty() {
return Err(OAuthError::InvalidClient(
"No matching key found in client JWKS".to_string(),
));
}
matching_keys
.into_iter()
.filter(|key| {
let key_alg = key.get("alg").and_then(|a| a.as_str());
key_alg.is_none() || key_alg == Some(alg)
})
.find_map(|key| {
let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
match (alg, kty) {
("ES256", "EC") => verify_es256(key, signing_input, signature).ok(),
("ES384", "EC") => verify_es384(key, signing_input, signature).ok(),
("RS256" | "RS384" | "RS512", "RSA") => {
verify_rsa(alg, key, signing_input, signature).ok()
}
("EdDSA", "OKP") => verify_eddsa(key, signing_input, signature).ok(),
_ => None,
}
})
.ok_or_else(|| {
OAuthError::InvalidClient("client_assertion signature verification failed".to_string())
})
}
fn verify_es256(
key: &serde_json::Value,
signing_input: &str,
signature: &[u8],
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use p256::EncodedPoint;
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
let y = key
.get("y")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
let x_decoded = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
let y_decoded = URL_SAFE_NO_PAD
.decode(y)
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
if x_decoded.len() > 32 || y_decoded.len() > 32 {
return Err(OAuthError::InvalidClient(
"EC coordinate too long".to_string(),
));
}
let mut x_bytes = [0u8; 32];
let mut y_bytes = [0u8; 32];
x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded);
y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded);
let mut point_bytes = vec![0x04];
point_bytes.extend_from_slice(&x_bytes);
point_bytes.extend_from_slice(&y_bytes);
let point = EncodedPoint::from_bytes(&point_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid EC point".to_string()))?;
let verifying_key = VerifyingKey::from_encoded_point(&point)
.map_err(|_| OAuthError::InvalidClient("Invalid EC key".to_string()))?;
let sig = Signature::from_slice(signature)
.map_err(|_| OAuthError::InvalidClient("Invalid ES256 signature format".to_string()))?;
verifying_key
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| OAuthError::InvalidClient("ES256 signature verification failed".to_string()))
}
fn verify_es384(
key: &serde_json::Value,
signing_input: &str,
signature: &[u8],
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use p384::EncodedPoint;
use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
let y = key
.get("y")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
let x_decoded = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
let y_decoded = URL_SAFE_NO_PAD
.decode(y)
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
if x_decoded.len() > 48 || y_decoded.len() > 48 {
return Err(OAuthError::InvalidClient(
"EC coordinate too long".to_string(),
));
}
let mut x_bytes = [0u8; 48];
let mut y_bytes = [0u8; 48];
x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded);
y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded);
let mut point_bytes = vec![0x04];
point_bytes.extend_from_slice(&x_bytes);
point_bytes.extend_from_slice(&y_bytes);
let point = EncodedPoint::from_bytes(&point_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid EC point".to_string()))?;
let verifying_key = VerifyingKey::from_encoded_point(&point)
.map_err(|_| OAuthError::InvalidClient("Invalid EC key".to_string()))?;
let sig = Signature::from_slice(signature)
.map_err(|_| OAuthError::InvalidClient("Invalid ES384 signature format".to_string()))?;
verifying_key
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| OAuthError::InvalidClient("ES384 signature verification failed".to_string()))
}
fn verify_rsa(
_alg: &str,
_key: &serde_json::Value,
_signing_input: &str,
_signature: &[u8],
) -> Result<(), OAuthError> {
Err(OAuthError::InvalidClient(
"RSA signature verification not yet supported - use EC keys".to_string(),
))
}
fn verify_eddsa(
key: &serde_json::Value,
signing_input: &str,
signature: &[u8],
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let crv = key.get("crv").and_then(|c| c.as_str()).unwrap_or("");
if crv != "Ed25519" {
return Err(OAuthError::InvalidClient(format!(
"Unsupported EdDSA curve: {}",
crv
)));
}
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x in OKP key".to_string()))?;
let x_bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x encoding".to_string()))?;
let key_bytes: [u8; 32] = x_bytes
.try_into()
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key length".to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key".to_string()))?;
let sig_bytes: [u8; 64] = signature
.try_into()
.map_err(|_| OAuthError::InvalidClient("Invalid EdDSA signature length".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
verifying_key
.verify(signing_input.as_bytes(), &sig)
.map_err(|_| OAuthError::InvalidClient("EdDSA signature verification failed".to_string()))
}