mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-27 20:54:19 +00:00
714 lines
26 KiB
Rust
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()))
|
|
}
|