use std::time::{Duration, SystemTime};
use url::Url;
use crate::crypto::base64url_encode;
use crate::dpop::{compute_access_token_hash, extract_dpop_nonce, DPoPKey, DPoPNonceCache};
use crate::error::{AtprotoOAuthError, DPoPError, ParError, TokenError};
use crate::identity::{IdentityResolver, IdentityResolverBuilder};
use crate::par::{build_authorization_url, execute_par_request, ParParameters};
use crate::pkce::PkcePair;
use crate::session::OAuthSession;
use crate::ssrf::SsrfFilter;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthClientMetadata {
pub client_id: String,
pub redirect_uri: String,
pub scope: String,
pub client_name: Option<String>,
pub client_secret: Option<String>,
}
impl OAuthClientMetadata {
#[must_use]
pub fn new(client_id: impl Into<String>, redirect_uri: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
redirect_uri: redirect_uri.into(),
scope: "atproto".to_string(),
client_name: None,
client_secret: None,
}
}
#[must_use]
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scope = scope.into();
self
}
#[must_use]
pub fn with_client_name(mut self, name: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self
}
#[must_use]
pub fn with_client_secret(mut self, secret: impl Into<String>) -> Self {
self.client_secret = Some(secret.into());
self
}
}
#[derive(Debug, Clone)]
pub struct StoredStateEntry {
pub state: String,
pub client_id: String,
pub code_verifier: String,
pub dpop_key: DPoPKey,
pub issuer: String,
pub did: Option<String>,
pub handle: Option<String>,
pub redirect_uri: String,
pub pds_endpoint: String,
pub token_endpoint: String,
pub scopes: String,
pub created_at: SystemTime,
pub expires_in_secs: u64,
}
impl StoredStateEntry {
#[must_use]
pub fn is_expired(&self) -> bool {
let now = SystemTime::now();
let max_age = Duration::from_secs(self.expires_in_secs);
now.duration_since(self.created_at)
.map(|elapsed| elapsed > max_age)
.unwrap_or(false)
}
}
#[derive(Debug, Clone)]
pub struct AuthorizationRequest {
pub authorization_url: Url,
pub state: String,
pub request_uri: String,
pub expires_in: u64,
pub stored_state: StoredStateEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallbackParams {
pub code: String,
pub state: String,
pub iss: Option<String>,
}
impl CallbackParams {
#[must_use]
pub fn new(code: impl Into<String>, state: impl Into<String>) -> Self {
Self {
code: code.into(),
state: state.into(),
iss: None,
}
}
#[must_use]
pub fn with_iss(mut self, iss: impl Into<String>) -> Self {
self.iss = Some(iss.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub sub: String,
}
#[derive(Debug, Clone)]
pub struct AtprotoOAuthClientBuilder {
metadata: Option<OAuthClientMetadata>,
resolver: Option<IdentityResolver>,
nonce_cache: Option<DPoPNonceCache>,
ssrf_filter: SsrfFilter,
}
impl Default for AtprotoOAuthClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl AtprotoOAuthClientBuilder {
#[must_use]
pub fn new() -> Self {
Self {
metadata: None,
resolver: None,
nonce_cache: None,
ssrf_filter: SsrfFilter::default(),
}
}
#[must_use]
pub fn client_metadata(mut self, metadata: OAuthClientMetadata) -> Self {
self.metadata = Some(metadata);
self
}
#[must_use]
pub fn identity_resolver(mut self, resolver: IdentityResolver) -> Self {
self.resolver = Some(resolver);
self
}
#[must_use]
pub fn nonce_cache(mut self, cache: DPoPNonceCache) -> Self {
self.nonce_cache = Some(cache);
self
}
#[must_use]
pub fn ssrf_filter(mut self, filter: SsrfFilter) -> Self {
self.ssrf_filter = filter;
self
}
#[must_use]
pub fn allow_insecure_localhost(mut self, allow: bool) -> Self {
self.ssrf_filter.allow_insecure_localhost = allow;
self
}
pub fn build(self) -> Result<AtprotoOAuthClient, AtprotoOAuthError> {
let metadata = self
.metadata
.ok_or(ParError::MissingField("client_metadata"))?;
let resolver = self.resolver.unwrap_or_else(|| {
IdentityResolverBuilder::new()
.ssrf_filter(self.ssrf_filter)
.build()
});
let nonce_cache = self.nonce_cache.unwrap_or_default();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| TokenError::Http(e.to_string()))?;
Ok(AtprotoOAuthClient {
metadata,
resolver,
nonce_cache,
ssrf_filter: self.ssrf_filter,
http_client,
})
}
}
#[derive(Debug, Clone)]
pub struct AtprotoOAuthClient {
metadata: OAuthClientMetadata,
resolver: IdentityResolver,
nonce_cache: DPoPNonceCache,
ssrf_filter: SsrfFilter,
http_client: reqwest::Client,
}
impl AtprotoOAuthClient {
#[must_use]
pub fn new(client_id: impl Into<String>, redirect_uri: impl Into<String>) -> Self {
let metadata = OAuthClientMetadata::new(client_id, redirect_uri);
let ssrf_filter = SsrfFilter::default();
let resolver = IdentityResolverBuilder::new()
.ssrf_filter(ssrf_filter)
.build();
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.unwrap_or_default();
Self {
metadata,
resolver,
nonce_cache: DPoPNonceCache::new(),
ssrf_filter,
http_client,
}
}
#[must_use]
pub fn builder() -> AtprotoOAuthClientBuilder {
AtprotoOAuthClientBuilder::new()
}
#[must_use]
pub const fn metadata(&self) -> &OAuthClientMetadata {
&self.metadata
}
#[must_use]
pub const fn resolver(&self) -> &IdentityResolver {
&self.resolver
}
#[must_use]
pub const fn nonce_cache(&self) -> &DPoPNonceCache {
&self.nonce_cache
}
#[must_use]
pub const fn ssrf_filter(&self) -> &SsrfFilter {
&self.ssrf_filter
}
pub async fn initiate_login(
&self,
handle_or_did: &str,
) -> Result<(AuthorizationRequest, StoredStateEntry), AtprotoOAuthError> {
self.initiate_login_with_scope(handle_or_did, &self.metadata.scope)
.await
}
pub async fn initiate_login_with_scope(
&self,
handle_or_did: &str,
scope: &str,
) -> Result<(AuthorizationRequest, StoredStateEntry), AtprotoOAuthError> {
let endpoints = self
.resolver
.discover_oauth_endpoints(handle_or_did)
.await?;
let pkce = PkcePair::generate();
let mut state_bytes = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut state_bytes);
let state = base64url_encode(&state_bytes);
let dpop_key = DPoPKey::generate();
let mut params = ParParameters::new(
&self.metadata.client_id,
&self.metadata.redirect_uri,
scope,
&state,
&pkce.challenge,
);
if let Some(ref handle) = endpoints.handle {
params = params.with_login_hint(handle);
} else {
params = params.with_login_hint(&endpoints.did);
}
let par_res = execute_par_request(
&self.ssrf_filter,
&endpoints.par_endpoint,
¶ms,
&dpop_key,
&self.nonce_cache,
)
.await?;
let auth_url = build_authorization_url(
&endpoints.authorization_endpoint,
&self.metadata.client_id,
&par_res.request_uri,
)?;
let stored_state = StoredStateEntry {
state: state.clone(),
client_id: self.metadata.client_id.clone(),
code_verifier: pkce.verifier,
dpop_key,
issuer: endpoints.auth_server_issuer.clone(),
did: Some(endpoints.did),
handle: endpoints.handle,
redirect_uri: self.metadata.redirect_uri.clone(),
pds_endpoint: endpoints.pds_endpoint,
token_endpoint: endpoints.token_endpoint,
scopes: scope.to_string(),
created_at: SystemTime::now(),
expires_in_secs: 300,
};
let auth_req = AuthorizationRequest {
authorization_url: auth_url,
state,
request_uri: par_res.request_uri,
expires_in: par_res.expires_in,
stored_state: stored_state.clone(),
};
Ok((auth_req, stored_state))
}
pub async fn exchange_code(
&self,
code: &str,
state_entry: &StoredStateEntry,
) -> Result<OAuthSession, AtprotoOAuthError> {
let form_body = serde_urlencoded::to_string([
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", &state_entry.redirect_uri),
("client_id", &state_entry.client_id),
("code_verifier", &state_entry.code_verifier),
])
.map_err(|e| TokenError::Http(e.to_string()))?;
let resp_json: TokenResponse = self
.send_dpop_token_request(
&state_entry.token_endpoint,
&state_entry.dpop_key,
form_body.into_bytes(),
)
.await?;
if !resp_json.token_type.eq_ignore_ascii_case("DPoP") {
return Err(TokenError::InvalidTokenType(resp_json.token_type).into());
}
if resp_json.sub.trim().is_empty() {
return Err(TokenError::MissingDid.into());
}
if let Some(ref expected_did) = state_entry.did {
if &resp_json.sub != expected_did {
return Err(TokenError::SubMismatch {
expected: expected_did.clone(),
actual: resp_json.sub.clone(),
}
.into());
}
}
if let Some(ref scope_str) = resp_json.scope {
let has_atproto = scope_str.split_whitespace().any(|s| s == "atproto");
if !has_atproto {
return Err(TokenError::MissingAtprotoScope(scope_str.clone()).into());
}
}
OAuthSession::new(
resp_json.sub,
resp_json.access_token,
resp_json.refresh_token,
resp_json.token_type,
resp_json.scope,
resp_json.expires_in,
state_entry.dpop_key.clone(),
Some(state_entry.pds_endpoint.clone()),
Some(state_entry.issuer.clone()),
Some(state_entry.token_endpoint.clone()),
)
}
pub async fn handle_callback(
&self,
callback_params: &CallbackParams,
state_entry: &StoredStateEntry,
) -> Result<OAuthSession, AtprotoOAuthError> {
if callback_params.state != state_entry.state {
return Err(TokenError::InvalidState(format!(
"Callback state '{}' does not match expected state '{}'",
callback_params.state, state_entry.state
))
.into());
}
if let Some(ref callback_iss) = callback_params.iss {
let norm_callback = callback_iss.trim().trim_end_matches('/');
let norm_expected = state_entry.issuer.trim().trim_end_matches('/');
if norm_callback != norm_expected {
return Err(TokenError::IssuerMismatch {
expected: state_entry.issuer.clone(),
actual: callback_iss.clone(),
}
.into());
}
}
self.exchange_code(&callback_params.code, state_entry).await
}
pub async fn refresh_session(
&self,
session: &mut OAuthSession,
) -> Result<(), AtprotoOAuthError> {
let refresh_token = session
.refresh_token()
.ok_or(TokenError::MissingRefreshToken)?;
let token_endpoint = session
.token_endpoint()
.ok_or(TokenError::MissingField("token_endpoint"))?
.to_string();
let form_body = serde_urlencoded::to_string([
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &self.metadata.client_id),
])
.map_err(|e| TokenError::Http(e.to_string()))?;
let resp_json: TokenResponse = self
.send_dpop_token_request(&token_endpoint, session.dpop_key(), form_body.into_bytes())
.await?;
if !resp_json.token_type.eq_ignore_ascii_case("DPoP") {
return Err(TokenError::InvalidTokenType(resp_json.token_type).into());
}
if !resp_json.sub.is_empty() && resp_json.sub != session.sub() {
return Err(TokenError::SubMismatch {
expected: session.sub().to_string(),
actual: resp_json.sub,
}
.into());
}
session.rotate_tokens(
resp_json.access_token,
resp_json.refresh_token,
resp_json.expires_in,
);
Ok(())
}
pub async fn refresh_token(
&self,
session: &OAuthSession,
) -> Result<OAuthSession, AtprotoOAuthError> {
let mut cloned = session.clone();
self.refresh_session(&mut cloned).await?;
Ok(cloned)
}
async fn send_dpop_token_request(
&self,
token_endpoint: &str,
dpop_key: &DPoPKey,
body_bytes: Vec<u8>,
) -> Result<TokenResponse, AtprotoOAuthError> {
let parsed_url = Url::parse(token_endpoint).map_err(|e| {
TokenError::Http(format!(
"Invalid token endpoint URL '{token_endpoint}': {e}"
))
})?;
self.ssrf_filter
.validate_url(&parsed_url)
.map_err(TokenError::from)?;
let server_origin = parsed_url.origin().ascii_serialization();
let initial_nonce = self.nonce_cache.get_nonce(&server_origin);
let proof =
dpop_key.create_proof("POST", token_endpoint, initial_nonce.as_deref(), None)?;
let resp = self
.http_client
.post(token_endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.header("accept", "application/json")
.header("dpop", proof)
.body(body_bytes.clone())
.send()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
if let Some(new_nonce) = extract_dpop_nonce(
resp.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok()),
) {
self.nonce_cache.set_nonce(&server_origin, new_nonce);
}
let status = resp.status();
if status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::UNAUTHORIZED
{
let resp_bytes = resp
.bytes()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
let json_err: Option<serde_json::Value> = serde_json::from_slice(&resp_bytes).ok();
let is_nonce_error = json_err
.as_ref()
.and_then(|j| j.get("error"))
.and_then(|e| e.as_str())
== Some("use_dpop_nonce");
if is_nonce_error {
let fresh_nonce = self.nonce_cache.get_nonce(&server_origin).ok_or_else(|| {
TokenError::RequestFailed {
status: status.as_u16(),
error: "use_dpop_nonce".to_string(),
description: Some(
"Missing DPoP-Nonce header in challenge response".to_string(),
),
}
})?;
let retry_proof =
dpop_key.create_proof("POST", token_endpoint, Some(&fresh_nonce), None)?;
let retry_resp = self
.http_client
.post(token_endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.header("accept", "application/json")
.header("dpop", retry_proof)
.body(body_bytes)
.send()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
if let Some(new_nonce) = extract_dpop_nonce(
retry_resp
.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok()),
) {
self.nonce_cache.set_nonce(&server_origin, new_nonce);
}
let retry_status = retry_resp.status();
if retry_status.is_success() {
let bytes = retry_resp
.bytes()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
let res: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|e| TokenError::Json(e.to_string()))?;
return Ok(res);
}
let err_bytes = retry_resp
.bytes()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
let err_json: Option<serde_json::Value> = serde_json::from_slice(&err_bytes).ok();
if err_json
.as_ref()
.and_then(|j| j.get("error"))
.and_then(|e| e.as_str())
== Some("use_dpop_nonce")
{
return Err(DPoPError::NonceRetryLimitExceeded.into());
}
let error_code = err_json
.as_ref()
.and_then(|j| j.get("error"))
.and_then(|e| e.as_str())
.unwrap_or("token_request_failed")
.to_string();
let error_desc = err_json
.as_ref()
.and_then(|j| j.get("error_description"))
.and_then(|d| d.as_str())
.map(ToString::to_string);
return Err(TokenError::RequestFailed {
status: retry_status.as_u16(),
error: error_code,
description: error_desc,
}
.into());
}
let error_code = json_err
.as_ref()
.and_then(|j| j.get("error"))
.and_then(|e| e.as_str())
.unwrap_or("token_request_failed")
.to_string();
let error_desc = json_err
.as_ref()
.and_then(|j| j.get("error_description"))
.and_then(|d| d.as_str())
.map(ToString::to_string);
return Err(TokenError::RequestFailed {
status: status.as_u16(),
error: error_code,
description: error_desc,
}
.into());
}
if !status.is_success() {
let err_bytes = resp
.bytes()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
let err_json: Option<serde_json::Value> = serde_json::from_slice(&err_bytes).ok();
let error_code = err_json
.as_ref()
.and_then(|j| j.get("error"))
.and_then(|e| e.as_str())
.unwrap_or("token_request_failed")
.to_string();
let error_desc = err_json
.as_ref()
.and_then(|j| j.get("error_description"))
.and_then(|d| d.as_str())
.map(ToString::to_string);
return Err(TokenError::RequestFailed {
status: status.as_u16(),
error: error_code,
description: error_desc,
}
.into());
}
let bytes = resp
.bytes()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
let res: TokenResponse =
serde_json::from_slice(&bytes).map_err(|e| TokenError::Json(e.to_string()))?;
Ok(res)
}
pub async fn send_dpop_request(
&self,
dpop_key: &DPoPKey,
method: reqwest::Method,
url_str: &str,
access_token: Option<&str>,
body_bytes: Option<Vec<u8>>,
content_type: Option<&str>,
) -> Result<reqwest::Response, AtprotoOAuthError> {
let parsed_url = Url::parse(url_str)
.map_err(|e| TokenError::Http(format!("Invalid URL '{url_str}': {e}")))?;
self.ssrf_filter
.validate_url(&parsed_url)
.map_err(TokenError::from)?;
let server_origin = parsed_url.origin().ascii_serialization();
let ath = access_token.map(compute_access_token_hash);
let initial_nonce = self.nonce_cache.get_nonce(&server_origin);
let proof = dpop_key.create_proof(
method.as_str(),
url_str,
initial_nonce.as_deref(),
ath.as_deref(),
)?;
let mut req = self
.http_client
.request(method.clone(), url_str)
.header("dpop", proof);
if let Some(token) = access_token {
req = req.header("authorization", format!("DPoP {token}"));
}
if let Some(ct) = content_type {
req = req.header("content-type", ct);
}
if let Some(ref bytes) = body_bytes {
req = req.body(bytes.clone());
}
let resp = req
.send()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
if let Some(new_nonce) = extract_dpop_nonce(
resp.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok()),
) {
self.nonce_cache.set_nonce(&server_origin, new_nonce);
}
let status = resp.status();
if status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::UNAUTHORIZED
{
let is_nonce_challenge = resp
.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok())
.is_some();
if is_nonce_challenge {
let fresh_nonce = self.nonce_cache.get_nonce(&server_origin).ok_or_else(|| {
TokenError::RequestFailed {
status: status.as_u16(),
error: "use_dpop_nonce".to_string(),
description: Some("Missing DPoP-Nonce header".to_string()),
}
})?;
let retry_proof = dpop_key.create_proof(
method.as_str(),
url_str,
Some(&fresh_nonce),
ath.as_deref(),
)?;
let mut retry_req = self
.http_client
.request(method, url_str)
.header("dpop", retry_proof);
if let Some(token) = access_token {
retry_req = retry_req.header("authorization", format!("DPoP {token}"));
}
if let Some(ct) = content_type {
retry_req = retry_req.header("content-type", ct);
}
if let Some(bytes) = body_bytes {
retry_req = retry_req.body(bytes);
}
let retry_resp = retry_req
.send()
.await
.map_err(|e| TokenError::Http(e.to_string()))?;
if let Some(new_nonce) = extract_dpop_nonce(
retry_resp
.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok()),
) {
self.nonce_cache.set_nonce(&server_origin, new_nonce);
}
let retry_status = retry_resp.status();
if retry_status == reqwest::StatusCode::BAD_REQUEST
|| retry_status == reqwest::StatusCode::UNAUTHORIZED
{
let retry_is_nonce = retry_resp
.headers()
.get("dpop-nonce")
.and_then(|h| h.to_str().ok())
.is_some();
if retry_is_nonce {
return Err(DPoPError::NonceRetryLimitExceeded.into());
}
}
return Ok(retry_resp);
}
}
Ok(resp)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, missing_docs)]
mod tests {
use super::*;
#[test]
fn test_client_builder_and_metadata() {
let client = AtprotoOAuthClient::builder()
.client_metadata(
OAuthClientMetadata::new(
"https://app.example.com/client.json",
"https://app.example.com/callback",
)
.with_scope("atproto transition:generic")
.with_client_name("Example App"),
)
.allow_insecure_localhost(true)
.build()
.unwrap();
assert_eq!(
client.metadata().client_id,
"https://app.example.com/client.json"
);
assert_eq!(
client.metadata().redirect_uri,
"https://app.example.com/callback"
);
assert_eq!(client.metadata().scope, "atproto transition:generic");
assert_eq!(
client.metadata().client_name.as_deref(),
Some("Example App")
);
}
#[test]
fn test_stored_state_expiration() {
let entry = StoredStateEntry {
state: "state123".to_string(),
client_id: "client123".to_string(),
code_verifier: "verifier123".to_string(),
dpop_key: DPoPKey::generate(),
issuer: "https://auth.example.com".to_string(),
did: Some("did:plc:alice".to_string()),
handle: Some("alice.bsky.social".to_string()),
redirect_uri: "https://app.example.com/callback".to_string(),
pds_endpoint: "https://pds.example.com".to_string(),
token_endpoint: "https://auth.example.com/oauth/token".to_string(),
scopes: "atproto".to_string(),
created_at: SystemTime::now() - Duration::from_secs(400),
expires_in_secs: 300,
};
assert!(entry.is_expired());
}
#[test]
fn test_callback_params() {
let cb = CallbackParams::new("code123", "state123").with_iss("https://auth.example.com");
assert_eq!(cb.code, "code123");
assert_eq!(cb.state, "state123");
assert_eq!(cb.iss.as_deref(), Some("https://auth.example.com"));
}
}