use crate::auth::{AuthError, Session};
use crate::error::AppError;
use base64::Engine;
use p256::ecdsa::SigningKey;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub struct DPoPManager {
signing_key: SigningKey,
public_jwk: serde_json::Value,
nonce: Option<String>,
}
impl DPoPManager {
pub fn new() -> Result<Self, AppError> {
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let encoded_point = verifying_key.to_encoded_point(false);
let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
encoded_point
.x()
.ok_or_else(|| AppError::ParseError("Failed to get x coordinate".to_string()))?,
);
let y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
encoded_point
.y()
.ok_or_else(|| AppError::ParseError("Failed to get y coordinate".to_string()))?,
);
let public_jwk = serde_json::json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y,
});
Ok(Self {
signing_key,
public_jwk,
nonce: None,
})
}
pub fn set_nonce(&mut self, nonce: String) {
self.nonce = Some(nonce);
}
pub fn create_proof(&self, http_method: &str, http_uri: &str) -> Result<String, AppError> {
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
let jti = {
use rand::Rng;
let random_bytes: Vec<u8> = (0..16).map(|_| rand::thread_rng().gen()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&random_bytes)
};
#[derive(Serialize)]
struct DPoPClaims {
jti: String,
htm: String,
htu: String,
iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
nonce: Option<String>,
}
let claims = DPoPClaims {
jti,
htm: http_method.to_uppercase(),
htu: http_uri.to_string(),
iat: chrono::Utc::now().timestamp(),
nonce: self.nonce.clone(),
};
let mut header = Header::new(Algorithm::ES256);
header.typ = Some("dpop+jwt".to_string());
let jwk_str = serde_json::to_string(&self.public_jwk)
.map_err(|e| AppError::ParseError(format!("Failed to serialize JWK: {}", e)))?;
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_str(&jwk_str)
.map_err(|e| AppError::ParseError(format!("Failed to parse JWK: {}", e)))?;
header.jwk = Some(jwk);
use p256::pkcs8::EncodePrivateKey;
let key_der = self
.signing_key
.to_pkcs8_der()
.map_err(|e| AppError::ParseError(format!("Failed to encode key: {}", e)))?;
let encoding_key = EncodingKey::from_ec_der(key_der.as_bytes());
encode(&header, &claims, &encoding_key)
.map_err(|e| AppError::ParseError(format!("Failed to create DPoP proof: {}", e)))
}
}
#[allow(dead_code)] pub struct AtProtoOAuthConfig {
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
}
impl Default for AtProtoOAuthConfig {
fn default() -> Self {
Self {
client_id: "http://localhost".to_string(),
redirect_uri: "http://127.0.0.1/callback".to_string(),
scope: "atproto".to_string(),
}
}
}
#[allow(dead_code)] #[derive(Debug, Deserialize)]
pub struct AuthServerMetadata {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub pushed_authorization_request_endpoint: String,
#[serde(default)]
pub dpop_signing_alg_values_supported: Vec<String>,
#[serde(default)]
pub scopes_supported: Vec<String>,
}
#[allow(dead_code)] #[derive(Debug, Deserialize)]
pub struct ProtectedResourceMetadata {
pub resource: String,
pub authorization_servers: Vec<String>,
}
#[allow(dead_code)] #[derive(Debug, Deserialize)]
pub struct PARResponse {
pub request_uri: String,
pub expires_in: u64,
}
#[allow(dead_code)] #[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[allow(dead_code)]
pub sub: Option<String>,
}
pub struct AtProtoOAuthManager {
config: AtProtoOAuthConfig,
client: reqwest::Client,
dpop: DPoPManager,
}
impl AtProtoOAuthManager {
pub fn new() -> Result<Self, AppError> {
Self::with_config(AtProtoOAuthConfig::default())
}
pub fn with_config(config: AtProtoOAuthConfig) -> Result<Self, AppError> {
let client = crate::http::client_with_timeout(Duration::from_secs(120));
let dpop = DPoPManager::new()?;
Ok(Self {
config,
client,
dpop,
})
}
pub fn set_redirect_uri(&mut self, redirect_uri: String) {
self.config.redirect_uri = redirect_uri;
}
pub async fn resolve_handle_to_did(&self, handle: &str) -> Result<String, AppError> {
let dns_url = format!("https://{}/.well-known/atproto-did", handle);
match self
.client
.get(&dns_url)
.timeout(Duration::from_secs(120))
.send()
.await
{
Ok(response) if response.status().is_success() => {
if let Ok(did) = response.text().await {
let did = did.trim().to_string();
if did.starts_with("did:") {
return Ok(did);
}
}
}
_ => {}
}
let api_url = format!(
"https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}",
handle
);
let response = self
.client
.get(&api_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| {
AuthError::AuthenticationFailed(format!("Handle resolution failed: {}", e))
})?;
if !response.status().is_success() {
return Err(AuthError::AuthenticationFailed(format!(
"Handle resolution failed with status {} for handle '{}'",
response.status(),
handle
))
.into());
}
#[derive(Deserialize)]
struct ResolveResponse {
did: String,
}
let result: ResolveResponse = response.json().await.map_err(|e| {
AuthError::AuthenticationFailed(format!("Failed to parse resolution response: {}", e))
})?;
let did = result.did;
if !did.starts_with("did:") {
return Err(
AuthError::AuthenticationFailed(format!("Invalid DID format: {}", did)).into(),
);
}
Ok(did)
}
pub async fn resolve_did_to_pds(&self, did: &str) -> Result<String, AppError> {
let did_doc_url = if did.starts_with("did:plc:") {
format!("https://plc.directory/{}", did)
} else if did.starts_with("did:web:") {
let domain = did.strip_prefix("did:web:").unwrap();
format!("https://{}/.well-known/did.json", domain)
} else {
return Err(AuthError::AuthenticationFailed(format!(
"Unsupported DID method: {}",
did
))
.into());
};
let response = self
.client
.get(&did_doc_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| AppError::NetworkError(format!("DID resolution failed: {}", e)))?;
if !response.status().is_success() {
return Err(AuthError::AuthenticationFailed(format!(
"DID resolution failed with status {}",
response.status()
))
.into());
}
let did_doc: serde_json::Value = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse DID document: {}", e)))?;
let services = did_doc
.get("service")
.and_then(|s| s.as_array())
.ok_or_else(|| {
AuthError::AuthenticationFailed("No services in DID document".to_string())
})?;
for service in services {
if service.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") {
if let Some(endpoint) = service.get("serviceEndpoint").and_then(|e| e.as_str()) {
return Ok(endpoint.to_string());
}
}
}
Err(
AuthError::AuthenticationFailed("No PDS endpoint found in DID document".to_string())
.into(),
)
}
pub async fn discover_authorization_server(
&self,
pds_url: &str,
) -> Result<AuthServerMetadata, AppError> {
let protected_resource_url = format!("{}/.well-known/oauth-protected-resource", pds_url);
let pr_response = self
.client
.get(&protected_resource_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| {
AppError::NetworkError(format!(
"Failed to fetch protected resource metadata: {}",
e
))
})?;
if !pr_response.status().is_success() {
return Err(AuthError::AuthenticationFailed(format!(
"Protected resource metadata fetch failed with status {}",
pr_response.status()
))
.into());
}
let pr_metadata: ProtectedResourceMetadata = pr_response.json().await.map_err(|e| {
AppError::ParseError(format!(
"Failed to parse protected resource metadata: {}",
e
))
})?;
let auth_server_issuer = pr_metadata.authorization_servers.first().ok_or_else(|| {
AuthError::AuthenticationFailed("No authorization servers found".to_string())
})?;
let auth_metadata_url = format!(
"{}/.well-known/oauth-authorization-server",
auth_server_issuer
);
let as_response = self
.client
.get(&auth_metadata_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| {
AppError::NetworkError(format!("Failed to fetch auth server metadata: {}", e))
})?;
if !as_response.status().is_success() {
return Err(AuthError::AuthenticationFailed(format!(
"Auth server metadata fetch failed with status {}",
as_response.status()
))
.into());
}
let auth_metadata: AuthServerMetadata = as_response.json().await.map_err(|e| {
AppError::ParseError(format!("Failed to parse auth server metadata: {}", e))
})?;
Ok(auth_metadata)
}
pub(crate) fn generate_pkce() -> (String, String) {
use base64::Engine;
use rand::Rng;
use sha2::{Digest, Sha256};
let mut rng = rand::thread_rng();
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
let code_verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&random_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let challenge_bytes = hasher.finalize();
let code_challenge =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes);
(code_verifier, code_challenge)
}
pub(crate) fn generate_state() -> String {
use base64::Engine;
use rand::Rng;
let random_bytes: Vec<u8> = (0..16).map(|_| rand::thread_rng().gen()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&random_bytes)
}
pub async fn start_browser_flow(
&mut self,
handle: Option<&str>,
) -> Result<BrowserFlowState, AppError> {
let valid_handle = handle.filter(|h| h.contains('.'));
let (did, pds_url) = match valid_handle {
Some(h) => {
tracing::debug!("Starting atproto OAuth flow for handle: {}", h);
tracing::debug!("Resolving handle to DID...");
let did = self.resolve_handle_to_did(h).await?;
tracing::debug!("Resolved handle to DID: {}", did);
tracing::debug!("Resolving DID to PDS...");
let pds_url = self.resolve_did_to_pds(&did).await?;
tracing::debug!("Resolved PDS: {}", pds_url);
(did, pds_url)
}
None => {
if let Some(invalid) = handle {
tracing::debug!("Handle '{}' is invalid (no domain), using default OAuth flow with account selection", invalid);
} else {
tracing::debug!("Starting atproto OAuth flow with default service (account selection)");
}
tracing::debug!("Using default entryway for account selection");
let issuer = "https://bsky.social";
tracing::debug!("Discovering authorization server from entryway: {}", issuer);
let auth_metadata = self.discover_from_issuer(issuer).await?;
tracing::debug!("Authorization server: {}", auth_metadata.issuer);
return self.complete_browser_flow(auth_metadata, handle, String::new()).await;
}
};
tracing::debug!("Discovering authorization server...");
let auth_metadata = self.discover_authorization_server(&pds_url).await?;
tracing::debug!("Authorization server: {}", auth_metadata.issuer);
self.complete_browser_flow(auth_metadata, handle, did).await
}
async fn complete_browser_flow(
&mut self,
auth_metadata: AuthServerMetadata,
handle: Option<&str>,
did: String,
) -> Result<BrowserFlowState, AppError> {
let pds_url = String::new();
let (code_verifier, code_challenge) = Self::generate_pkce();
let state = Self::generate_state();
tracing::debug!("Submitting PAR...");
let par_response = self
.submit_par(
&auth_metadata.pushed_authorization_request_endpoint,
&code_challenge,
&state,
handle, )
.await?;
tracing::debug!(
"PAR submitted successfully, request_uri valid for {} seconds",
par_response.expires_in
);
tracing::debug!("Building auth URL with client_id: {}", self.config.client_id);
tracing::debug!("request_uri from PAR: {}", par_response.request_uri);
let auth_url = format!(
"{}?client_id={}&request_uri={}",
auth_metadata.authorization_endpoint,
urlencoding::encode(&self.config.client_id),
urlencoding::encode(&par_response.request_uri)
);
tracing::debug!("Final authorization URL: {}", auth_url);
Ok(BrowserFlowState {
auth_url,
code_verifier,
state,
token_endpoint: auth_metadata.token_endpoint,
did,
pds_url,
})
}
async fn discover_from_issuer(&self, issuer: &str) -> Result<AuthServerMetadata, AppError> {
let auth_metadata_url = format!(
"{}/.well-known/oauth-authorization-server",
issuer
);
let response = self
.client
.get(&auth_metadata_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| {
AppError::NetworkError(format!(
"Failed to fetch authorization server metadata: {}",
e
))
})?;
if !response.status().is_success() {
return Err(AuthError::AuthenticationFailed(format!(
"Authorization server metadata fetch failed with status {}",
response.status()
))
.into());
}
response.json().await.map_err(|e| {
AppError::ParseError(format!(
"Failed to parse authorization server metadata: {}",
e
))
})
}
async fn submit_par(
&mut self,
par_endpoint: &str,
code_challenge: &str,
state: &str,
login_hint: Option<&str>,
) -> Result<PARResponse, AppError> {
let mut params = vec![
("response_type", "code"),
("client_id", self.config.client_id.as_str()),
("redirect_uri", self.config.redirect_uri.as_str()),
("code_challenge", code_challenge),
("code_challenge_method", "S256"),
("state", state),
("scope", self.config.scope.as_str()),
];
if let Some(hint) = login_hint {
params.push(("login_hint", hint));
tracing::debug!("Including login_hint in PAR: {}", hint);
} else {
tracing::debug!("No login_hint - user will select account during OAuth");
}
let dpop_proof = self.dpop.create_proof("POST", par_endpoint)?;
let response = self
.client
.post(par_endpoint)
.header("DPoP", dpop_proof)
.form(¶ms)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("PAR request failed: {}", e)))?;
if let Some(nonce) = response.headers().get("dpop-nonce") {
if let Ok(nonce_str) = nonce.to_str() {
self.dpop.set_nonce(nonce_str.to_string());
}
}
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if error_text.contains("use_dpop_nonce") {
let dpop_proof = self.dpop.create_proof("POST", par_endpoint)?;
let retry_response = self
.client
.post(par_endpoint)
.header("DPoP", dpop_proof)
.form(¶ms)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("PAR retry failed: {}", e)))?;
if !retry_response.status().is_success() {
let retry_error = retry_response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AuthError::AuthenticationFailed(format!(
"PAR failed after retry: {}",
retry_error
))
.into());
}
let par_response: PARResponse = retry_response.json().await.map_err(|e| {
AppError::ParseError(format!("Failed to parse PAR response: {}", e))
})?;
return Ok(par_response);
}
return Err(
AuthError::AuthenticationFailed(format!("PAR failed: {}", error_text)).into(),
);
}
let par_response: PARResponse = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse PAR response: {}", e)))?;
Ok(par_response)
}
pub async fn exchange_code(
&mut self,
code: &str,
code_verifier: &str,
token_endpoint: &str,
) -> Result<TokenResponse, AppError> {
let params = [
("grant_type", "authorization_code"),
("code", code),
("client_id", &self.config.client_id),
("redirect_uri", &self.config.redirect_uri),
("code_verifier", code_verifier),
];
let dpop_proof = self.dpop.create_proof("POST", token_endpoint)?;
let response = self
.client
.post(token_endpoint)
.header("DPoP", dpop_proof)
.form(¶ms)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("Token exchange failed: {}", e)))?;
if let Some(nonce) = response.headers().get("dpop-nonce") {
if let Ok(nonce_str) = nonce.to_str() {
self.dpop.set_nonce(nonce_str.to_string());
}
}
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if error_text.contains("use_dpop_nonce") || error_text.contains("invalid_dpop_proof") {
tracing::debug!("Retrying token exchange with DPoP nonce");
let dpop_proof = self.dpop.create_proof("POST", token_endpoint)?;
let retry_response = self
.client
.post(token_endpoint)
.header("DPoP", dpop_proof)
.form(¶ms)
.send()
.await
.map_err(|e| {
AppError::NetworkError(format!("Token exchange retry failed: {}", e))
})?;
if !retry_response.status().is_success() {
let retry_error = retry_response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AuthError::AuthenticationFailed(format!(
"Token exchange failed after retry: {}",
retry_error
))
.into());
}
let token_response: TokenResponse = retry_response.json().await.map_err(|e| {
AppError::ParseError(format!("Failed to parse token response: {}", e))
})?;
return Ok(token_response);
}
return Err(AuthError::AuthenticationFailed(format!(
"Token exchange failed: {}",
error_text
))
.into());
}
let token_response: TokenResponse = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse token response: {}", e)))?;
Ok(token_response)
}
pub async fn complete_flow(
&mut self,
code: &str,
state: &BrowserFlowState,
) -> Result<Session, AppError> {
let token_response = self
.exchange_code(code, &state.code_verifier, &state.token_endpoint)
.await?;
let did = token_response
.sub
.as_ref()
.ok_or_else(|| AppError::Authentication("Token response missing 'sub' field (DID)".to_string()))?
.clone();
if !state.did.is_empty() && state.did != did {
return Err(AppError::Authentication(format!(
"DID mismatch: expected {}, got {}",
state.did, did
)));
}
let did_doc_url = if did.starts_with("did:plc:") {
format!("https://plc.directory/{}", did)
} else if did.starts_with("did:web:") {
let domain = did.strip_prefix("did:web:").unwrap();
format!("https://{}/.well-known/did.json", domain)
} else {
return Err(AppError::Authentication(format!("Unsupported DID method: {}", did)));
};
let did_doc: serde_json::Value = self
.client
.get(&did_doc_url)
.timeout(Duration::from_secs(120))
.send()
.await
.map_err(|e| AppError::NetworkError(format!("DID resolution failed: {}", e)))?
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse DID document: {}", e)))?;
let handle = did_doc
.get("alsoKnownAs")
.and_then(|aka| aka.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.and_then(|s| s.strip_prefix("at://"))
.ok_or_else(|| AppError::Authentication("DID document missing handle".to_string()))?
.to_string();
let services = did_doc
.get("service")
.and_then(|s| s.as_array())
.ok_or_else(|| AppError::Authentication("DID document missing services".to_string()))?;
let pds_url = services
.iter()
.find(|s| s.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds"))
.and_then(|s| s.get("serviceEndpoint"))
.and_then(|e| e.as_str())
.ok_or_else(|| AppError::Authentication("DID document missing PDS endpoint".to_string()))?
.to_string();
let session = Session {
handle,
did,
access_jwt: token_response.access_token,
refresh_jwt: token_response.refresh_token.unwrap_or_default(),
service: pds_url,
expires_at: Some(
chrono::Utc::now() + chrono::Duration::seconds(token_response.expires_in as i64),
),
};
Ok(session)
}
}
#[derive(Debug, Clone)]
pub struct BrowserFlowState {
pub auth_url: String,
pub code_verifier: String,
pub state: String,
pub token_endpoint: String,
pub did: String,
pub pds_url: String,
}