mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-06-09 16:42:40 +00:00
refactor(oauth): split authorize.rs into domain modules
This commit is contained in:
File diff suppressed because it is too large
Load Diff
534
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
Normal file
534
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
Normal file
@@ -0,0 +1,534 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScopeInfo {
|
||||
pub scope: String,
|
||||
pub category: String,
|
||||
pub required: bool,
|
||||
pub description: String,
|
||||
pub display_name: String,
|
||||
pub granted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConsentResponse {
|
||||
pub request_uri: String,
|
||||
pub client_id: String,
|
||||
pub client_name: Option<String>,
|
||||
pub client_uri: Option<String>,
|
||||
pub logo_uri: Option<String>,
|
||||
pub scopes: Vec<ScopeInfo>,
|
||||
pub show_consent: bool,
|
||||
pub did: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_delegation: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_did: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub controller_handle: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delegation_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConsentQuery {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConsentSubmit {
|
||||
pub request_uri: String,
|
||||
pub approved_scopes: Vec<String>,
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
pub async fn consent_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ConsentQuery>,
|
||||
) -> Response {
|
||||
let consent_request_id = RequestId::from(query.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&consent_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
|
||||
Ok(flow) => match flow.require_user() {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"expired_request",
|
||||
"Authorization request has expired",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_with_user.did().clone();
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok();
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("atproto");
|
||||
|
||||
let controller_did_parsed: Option<Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let delegation_grant = if let Some(ref ctrl_did) = controller_did_parsed {
|
||||
state
|
||||
.repos.delegation
|
||||
.get_delegation(&did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
requested_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
requested_scope_str.to_string()
|
||||
};
|
||||
|
||||
let expanded_scope_str = match expand_include_scopes(&effective_scope_str).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_scope",
|
||||
&format!("Failed to expand permission set: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
|
||||
let consent_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let preferences = state
|
||||
.repos.oauth
|
||||
.get_scope_preferences(&did, &consent_client_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let pref_map: std::collections::HashMap<_, _> = preferences
|
||||
.iter()
|
||||
.map(|p| (p.scope.as_str(), p.granted))
|
||||
.collect();
|
||||
let requested_scope_strings: Vec<String> =
|
||||
requested_scopes.iter().map(|s| s.to_string()).collect();
|
||||
let show_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&consent_client_id,
|
||||
&requested_scope_strings,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let scopes: Vec<ScopeInfo> = requested_scopes
|
||||
.iter()
|
||||
.map(|scope| {
|
||||
let (category, required, description, display_name) = if let Some(def) =
|
||||
tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(*scope)
|
||||
{
|
||||
let desc = if *scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol baseline scope (permissions determined by selected options below)"
|
||||
.to_string()
|
||||
} else {
|
||||
def.description.to_string()
|
||||
};
|
||||
let name = if *scope == "atproto" && has_granular_scopes {
|
||||
"AT Protocol Access".to_string()
|
||||
} else {
|
||||
def.display_name.to_string()
|
||||
};
|
||||
(
|
||||
def.category.display_name().to_string(),
|
||||
def.required,
|
||||
desc,
|
||||
name,
|
||||
)
|
||||
} else if scope.starts_with("ref:") {
|
||||
(
|
||||
"Reference".to_string(),
|
||||
false,
|
||||
"Referenced scope".to_string(),
|
||||
scope.to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Other".to_string(),
|
||||
false,
|
||||
format!("Access to {}", scope),
|
||||
scope.to_string(),
|
||||
)
|
||||
};
|
||||
let granted = pref_map.get(*scope).copied();
|
||||
ScopeInfo {
|
||||
scope: scope.to_string(),
|
||||
category,
|
||||
required,
|
||||
description,
|
||||
display_name,
|
||||
granted,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let account_handle = state
|
||||
.repos.user
|
||||
.get_handle_by_did(&did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| h.to_string());
|
||||
|
||||
let (is_delegation, controller_did_resp, controller_handle, delegation_level) =
|
||||
if let Some(ref ctrl_did) = controller_did_parsed {
|
||||
let ctrl_handle = state
|
||||
.repos.user
|
||||
.get_handle_by_did(ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|h| h.to_string());
|
||||
|
||||
let level = if let Some(ref grant) = delegation_grant {
|
||||
let preset = tranquil_pds::delegation::SCOPE_PRESETS
|
||||
.iter()
|
||||
.find(|p| p.scopes == grant.granted_scopes.as_str());
|
||||
preset
|
||||
.map(|p| p.label.to_string())
|
||||
.unwrap_or_else(|| "Custom".to_string())
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
|
||||
(
|
||||
Some(true),
|
||||
Some(ctrl_did.to_string()),
|
||||
ctrl_handle,
|
||||
Some(level),
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
Json(ConsentResponse {
|
||||
request_uri: query.request_uri.clone(),
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: client_metadata.as_ref().and_then(|m| m.client_name.clone()),
|
||||
client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
|
||||
logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
|
||||
scopes,
|
||||
show_consent,
|
||||
did: did.to_string(),
|
||||
handle: account_handle,
|
||||
is_delegation,
|
||||
controller_did: controller_did_resp,
|
||||
controller_handle,
|
||||
delegation_level,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn consent_post(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<ConsentSubmit>,
|
||||
) -> Response {
|
||||
tracing::info!(
|
||||
"consent_post: approved_scopes={:?}, remember={}",
|
||||
form.approved_scopes,
|
||||
form.remember
|
||||
);
|
||||
let consent_post_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&consent_post_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
&format!("Database error: {:?}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
let flow_with_user = match AuthFlow::from_request_data(request_data.clone()) {
|
||||
Ok(flow) => match flow.require_user() {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&consent_post_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let did = flow_with_user.did().clone();
|
||||
let original_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
|
||||
let controller_did_parsed: Option<Did> = request_data
|
||||
.controller_did
|
||||
.as_ref()
|
||||
.and_then(|s| s.parse().ok());
|
||||
|
||||
let delegation_grant = match controller_did_parsed.as_ref() {
|
||||
Some(ctrl_did) => state
|
||||
.repos.delegation
|
||||
.get_delegation(&did, ctrl_did)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let effective_scope_str = if let Some(ref grant) = delegation_grant {
|
||||
tranquil_pds::delegation::intersect_scopes(
|
||||
original_scope_str,
|
||||
grant.granted_scopes.as_str(),
|
||||
)
|
||||
} else {
|
||||
original_scope_str.to_string()
|
||||
};
|
||||
|
||||
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
|
||||
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
|
||||
let user_denied_some_granular = has_granular_scopes
|
||||
&& requested_scopes
|
||||
.iter()
|
||||
.filter(|s| is_granular_scope(s))
|
||||
.any(|s| !form.approved_scopes.contains(&s.to_string()));
|
||||
let atproto_was_requested = requested_scopes.contains(&"atproto");
|
||||
if atproto_was_requested
|
||||
&& !has_granular_scopes
|
||||
&& !form.approved_scopes.contains(&"atproto".to_string())
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"The atproto scope was requested and must be approved",
|
||||
);
|
||||
}
|
||||
let final_approved: Vec<String> = if user_denied_some_granular {
|
||||
form.approved_scopes
|
||||
.iter()
|
||||
.filter(|s| *s != "atproto")
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
form.approved_scopes.clone()
|
||||
};
|
||||
if final_approved.is_empty() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"At least one scope must be approved",
|
||||
);
|
||||
}
|
||||
let approved_scope_str = final_approved.join(" ");
|
||||
let has_valid_scope = final_approved.iter().all(|s| is_valid_scope(s));
|
||||
if !has_valid_scope {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid scope format",
|
||||
);
|
||||
}
|
||||
if form.remember {
|
||||
let preferences: Vec<ScopePreference> = requested_scopes
|
||||
.iter()
|
||||
.map(|s| ScopePreference {
|
||||
scope: s.to_string(),
|
||||
granted: form.approved_scopes.contains(&s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
let consent_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.upsert_scope_preferences(&did, &consent_post_client_id, &preferences)
|
||||
.await;
|
||||
}
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.update_request_scope(&consent_post_request_id, &approved_scope_str)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update request scope: {:?}", e);
|
||||
}
|
||||
let code = Code::generate();
|
||||
let consent_post_device_id = request_data
|
||||
.device_id
|
||||
.as_ref()
|
||||
.map(|d| DeviceIdType::new(d.0.clone()));
|
||||
let consent_post_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos.oauth
|
||||
.update_authorization_request(
|
||||
&consent_post_request_id,
|
||||
&did,
|
||||
consent_post_device_id.as_ref(),
|
||||
&consent_post_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Failed to complete authorization",
|
||||
);
|
||||
}
|
||||
let redirect_uri = &request_data.parameters.redirect_uri;
|
||||
let intermediate_url = build_intermediate_redirect_url(
|
||||
redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
tracing::info!(
|
||||
intermediate_url = %intermediate_url,
|
||||
client_redirect = %redirect_uri,
|
||||
"consent_post returning JSON with intermediate URL (for 303 redirect)"
|
||||
);
|
||||
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenewRequest {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
pub async fn authorize_renew(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
Json(form): Json<RenewRequest>,
|
||||
) -> Response {
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Unknown authorization request",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if request_data.did.is_none() {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request not yet authenticated",
|
||||
);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
if request_data.expires_at >= now {
|
||||
return Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": false
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let staleness = now - request_data.expires_at;
|
||||
if staleness.num_seconds() > MAX_RENEWAL_STALENESS_SECONDS {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request expired too long ago to renew",
|
||||
);
|
||||
}
|
||||
|
||||
let new_expires_at = now + chrono::Duration::seconds(RENEW_EXPIRY_SECONDS);
|
||||
match state
|
||||
.repos.oauth
|
||||
.extend_authorization_request_expiry(&request_id, new_expires_at)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Json(serde_json::json!({
|
||||
"request_uri": form.request_uri,
|
||||
"renewed": true
|
||||
}))
|
||||
.into_response(),
|
||||
Ok(false) => json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request could not be renewed",
|
||||
),
|
||||
Err(_) => json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"Database error",
|
||||
),
|
||||
}
|
||||
}
|
||||
939
crates/tranquil-oauth-server/src/endpoints/authorize/login.rs
Normal file
939
crates/tranquil-oauth-server/src/endpoints/authorize/login.rs
Normal file
@@ -0,0 +1,939 @@
|
||||
use super::*;
|
||||
|
||||
pub async fn authorize_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Response {
|
||||
let request_uri = match query.request_uri {
|
||||
Some(uri) => uri,
|
||||
None => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing request_uri parameter. Use PAR to initiate authorization."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Missing request_uri parameter. Use PAR to initiate authorization.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let request_id = RequestId::from(request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri. Please start a new authorization request."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error("server_error", "A database error occurred.");
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
if wants_json(&headers) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired. Please start a new request."
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let client_cache = ClientMetadataCache::new(3600);
|
||||
let client_name = client_cache
|
||||
.get(&request_data.parameters.client_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.client_name);
|
||||
if wants_json(&headers) {
|
||||
return Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: client_name.clone(),
|
||||
scope: request_data.parameters.scope.clone(),
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
let force_new_account = query.new_account.unwrap_or(false);
|
||||
|
||||
if let Some(ref login_hint) = request_data.parameters.login_hint {
|
||||
tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation");
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized = NormalizedLoginIdentifier::normalize(login_hint, hostname_for_handles);
|
||||
tracing::info!(normalized = %normalized, "Normalized login_hint");
|
||||
|
||||
match state
|
||||
.repos.user
|
||||
.get_login_check_by_handle_or_email(normalized.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(user)) => {
|
||||
tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint");
|
||||
let is_delegated = state
|
||||
.repos.delegation
|
||||
.is_delegated_account(&user.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let has_password = user.password_hash.is_some();
|
||||
tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check");
|
||||
|
||||
if is_delegated {
|
||||
tracing::info!("Redirecting to delegation auth");
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.set_request_did(&request_id, &user.did)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "Failed to set delegated DID on authorization request");
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"Failed to initialize delegation flow",
|
||||
);
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&request_uri),
|
||||
url_encode(&user.did)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!(normalized = %normalized, "No user found for login_hint");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Error looking up user for login_hint");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!("No login_hint in request");
|
||||
}
|
||||
|
||||
if request_data.parameters.prompt == Some(Prompt::Create) {
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/register?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
));
|
||||
}
|
||||
|
||||
if !force_new_account
|
||||
&& let Some(device_id) = extract_device_cookie(&headers)
|
||||
&& let Ok(accounts) = state
|
||||
.repos.oauth
|
||||
.get_device_accounts(&device_id.clone())
|
||||
.await
|
||||
&& !accounts.is_empty()
|
||||
{
|
||||
let login_hint_param = request_data
|
||||
.parameters
|
||||
.login_hint
|
||||
.as_ref()
|
||||
.map(|h| format!("&login_hint={}", url_encode(h)))
|
||||
.unwrap_or_default();
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/accounts?request_uri={}{}",
|
||||
url_encode(&request_uri),
|
||||
login_hint_param
|
||||
));
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn authorize_get_json(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Result<Json<AuthorizeResponse>, OAuthError> {
|
||||
let request_uri = query
|
||||
.request_uri
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?;
|
||||
let request_id_json = RequestId::from(request_uri.clone());
|
||||
let request_data = state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&request_id_json)
|
||||
.await
|
||||
.map_err(tranquil_pds::oauth::db_err_to_oauth)?
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&request_id_json)
|
||||
.await;
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"request_uri has expired".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
client_name: None,
|
||||
scope: request_data.parameters.scope.clone(),
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountInfo {
|
||||
pub did: String,
|
||||
pub handle: Handle,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountsResponse {
|
||||
pub accounts: Vec<AccountInfo>,
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
fn mask_email(email: &str) -> String {
|
||||
if let Some(at_pos) = email.find('@') {
|
||||
let local = &email[..at_pos];
|
||||
let domain = &email[at_pos..];
|
||||
if local.len() <= 2 {
|
||||
format!("{}***{}", local.chars().next().unwrap_or('*'), domain)
|
||||
} else {
|
||||
let first = local.chars().next().unwrap_or('*');
|
||||
let last = local.chars().last().unwrap_or('*');
|
||||
format!("{}***{}{}", first, last, domain)
|
||||
}
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_accounts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Response {
|
||||
let request_uri = match query.request_uri {
|
||||
Some(uri) => uri,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing request_uri parameter"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let device_id = match extract_device_cookie(&headers) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Json(AccountsResponse {
|
||||
accounts: vec![],
|
||||
request_uri,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let accounts = match state.repos.oauth.get_device_accounts(&device_id).await {
|
||||
Ok(accts) => accts,
|
||||
Err(_) => {
|
||||
return Json(AccountsResponse {
|
||||
accounts: vec![],
|
||||
request_uri,
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let account_infos: Vec<AccountInfo> = accounts
|
||||
.into_iter()
|
||||
.map(|row| AccountInfo {
|
||||
did: row.did.to_string(),
|
||||
handle: row.handle,
|
||||
email: row.email.map(|e| mask_email(&e)),
|
||||
})
|
||||
.collect();
|
||||
Json(AccountsResponse {
|
||||
accounts: account_infos,
|
||||
request_uri,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_post(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<AuthorizeSubmit>,
|
||||
) -> Response {
|
||||
let json_response = wants_json(&headers);
|
||||
let form_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&form_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error("server_error", &format!("Database error: {:?}", e));
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&form_request_id)
|
||||
.await;
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let show_login_error = |error_msg: &str, json: bool| -> Response {
|
||||
if json {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": error_msg
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(error_msg)
|
||||
))
|
||||
};
|
||||
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
|
||||
let normalized_username =
|
||||
NormalizedLoginIdentifier::normalize(&form.username, hostname_for_handles);
|
||||
tracing::debug!(
|
||||
original_username = %form.username,
|
||||
normalized_username = %normalized_username,
|
||||
pds_hostname = %tranquil_config::get().server.hostname,
|
||||
"Normalized username for lookup"
|
||||
);
|
||||
let user = match state
|
||||
.repos.user
|
||||
.get_login_info_by_handle_or_email(normalized_username.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
let _ = bcrypt::verify(
|
||||
&form.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
}
|
||||
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
|
||||
};
|
||||
if user.deactivated_at.is_some() {
|
||||
return show_login_error("This account has been deactivated.", json_response);
|
||||
}
|
||||
if user.takedown_ref.is_some() {
|
||||
return show_login_error("This account has been taken down.", json_response);
|
||||
}
|
||||
if user.account_type.is_delegated() {
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(&user.did)
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "delegation",
|
||||
"delegated_did": user.did,
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
if !user.password_required {
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/app/oauth/passkey?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"next": "passkey",
|
||||
"redirect": redirect_url
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&redirect_url);
|
||||
}
|
||||
|
||||
let password_valid = match &user.password_hash {
|
||||
Some(hash) => match bcrypt::verify(&form.password, hash) {
|
||||
Ok(valid) => valid,
|
||||
Err(_) => {
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
if !password_valid {
|
||||
return show_login_error("Invalid handle/email or password.", json_response);
|
||||
}
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await;
|
||||
let handle = resend_info
|
||||
.as_ref()
|
||||
.map(|r| r.handle.to_string())
|
||||
.unwrap_or_else(|| form.username.clone());
|
||||
let channel = resend_info
|
||||
.map(|r| r.channel.as_str().to_owned())
|
||||
.unwrap_or_else(|| user.preferred_comms_channel.as_str().to_owned());
|
||||
if json_response {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": user.did,
|
||||
"handle": handle,
|
||||
"channel": channel
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode("account_not_verified")
|
||||
));
|
||||
}
|
||||
let has_totp = tranquil_api::server::has_totp_enabled(&state, &user.did).await;
|
||||
if has_totp {
|
||||
let device_cookie = extract_device_cookie(&headers);
|
||||
let device_is_trusted = if let Some(ref dev_id) = device_cookie {
|
||||
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), dev_id, &user.did)
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if device_is_trusted {
|
||||
if let Some(ref dev_id) = device_cookie {
|
||||
let _ =
|
||||
tranquil_api::server::extend_device_trust(state.repos.oauth.as_ref(), dev_id)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, None)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
if json_response {
|
||||
return Json(serde_json::json!({
|
||||
"needs_totp": true
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/totp?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
));
|
||||
}
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_2fa_challenge_by_request_uri(&form_request_id)
|
||||
.await;
|
||||
match state
|
||||
.repos.oauth
|
||||
.create_2fa_challenge(&user.did, &form_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(challenge) => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user.id,
|
||||
&challenge.code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %user.did,
|
||||
error = %e,
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = user.preferred_comms_channel.display_name();
|
||||
if json_response {
|
||||
return Json(serde_json::json!({
|
||||
"needs_2fa": true,
|
||||
"channel": channel_name
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(channel_name)
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut device_id: Option<DeviceIdType> = extract_device_cookie(&headers);
|
||||
let mut new_cookie: Option<String> = None;
|
||||
if form.remember_device {
|
||||
let final_device_id = if let Some(existing_id) = &device_id {
|
||||
existing_id.clone()
|
||||
} else {
|
||||
let new_id = DeviceId::generate();
|
||||
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
if state
|
||||
.repos.oauth
|
||||
.create_device(&new_device_id_typed, &device_data)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
new_cookie = Some(make_device_cookie(&new_device_id_typed));
|
||||
device_id = Some(new_device_id_typed.clone());
|
||||
}
|
||||
new_device_id_typed
|
||||
};
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.upsert_account_device(&user.did, &final_device_id)
|
||||
.await;
|
||||
}
|
||||
let set_auth_device_id = device_id.clone();
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&user.did,
|
||||
&client_id_typed,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::SEE_OTHER,
|
||||
[(SET_COOKIE, cookie), (LOCATION, consent_url)],
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&consent_url);
|
||||
}
|
||||
let code = Code::generate();
|
||||
let auth_post_device_id = device_id.clone();
|
||||
let auth_post_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos.oauth
|
||||
.update_authorization_request(
|
||||
&form_request_id,
|
||||
&user.did,
|
||||
auth_post_device_id.as_ref(),
|
||||
&auth_post_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
if json_response {
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
} else {
|
||||
let redirect_url = build_success_redirect(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
redirect_see_other(&redirect_url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorize_select(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<AuthorizeSelectSubmit>,
|
||||
) -> Response {
|
||||
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
let select_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&select_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&select_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired. Please start a new request.",
|
||||
);
|
||||
}
|
||||
let device_id = match extract_device_cookie(&headers) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No device session found. Please sign in.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let did: Did = match form.did.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid DID format.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let verify_device_id = device_id.clone();
|
||||
let account_valid = match state
|
||||
.repos.oauth
|
||||
.verify_account_on_device(&verify_device_id, &did)
|
||||
.await
|
||||
{
|
||||
Ok(valid) => valid,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if !account_valid {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"This account is not available on this device. Please sign in.",
|
||||
);
|
||||
}
|
||||
let user = match state.repos.user.get_2fa_status_by_did(&did).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Account not found. Please sign in.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let is_verified = user.channel_verification.has_any_verified();
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before logging in.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let has_totp = tranquil_api::server::has_totp_enabled(&state, &did).await;
|
||||
let select_early_device_typed = device_id.clone();
|
||||
if has_totp {
|
||||
let device_is_trusted =
|
||||
tranquil_api::server::is_device_trusted(state.repos.oauth.as_ref(), &device_id, &did)
|
||||
.await;
|
||||
if !device_is_trusted {
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_early_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
return Json(serde_json::json!({
|
||||
"needs_totp": true
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let _ =
|
||||
tranquil_api::server::extend_device_trust(state.repos.oauth.as_ref(), &device_id).await;
|
||||
}
|
||||
if user.two_factor_enabled {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_2fa_challenge_by_request_uri(&select_request_id)
|
||||
.await;
|
||||
match state
|
||||
.repos.oauth
|
||||
.create_2fa_challenge(&did, &select_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(challenge) => {
|
||||
let hostname = &tranquil_config::get().server.hostname;
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
state.repos.user.as_ref(),
|
||||
state.repos.infra.as_ref(),
|
||||
user.id,
|
||||
&challenge.code,
|
||||
hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %form.did,
|
||||
error = %e,
|
||||
"Failed to enqueue 2FA notification"
|
||||
);
|
||||
}
|
||||
let channel_name = user.preferred_comms_channel.display_name();
|
||||
return Json(serde_json::json!({
|
||||
"needs_2fa": true,
|
||||
"channel": channel_name
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let select_device_typed = device_id.clone();
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.upsert_account_device(&did, &select_device_typed)
|
||||
.await;
|
||||
|
||||
if state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})).into_response()
|
||||
}
|
||||
309
crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs
Normal file
309
crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{LOCATION, SET_COOKIE},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
|
||||
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
|
||||
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
|
||||
use tranquil_pds::oauth::{
|
||||
AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
|
||||
db::should_show_consent, scopes::expand_include_scopes,
|
||||
};
|
||||
use tranquil_pds::rate_limit::{
|
||||
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
|
||||
check_user_rate_limit,
|
||||
};
|
||||
use tranquil_pds::state::AppState;
|
||||
use tranquil_pds::types::{Did, Handle, PlainPassword};
|
||||
use tranquil_pds::util::extract_client_ip;
|
||||
use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId};
|
||||
use urlencoding::encode as url_encode;
|
||||
|
||||
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
|
||||
const RENEW_EXPIRY_SECONDS: i64 = 600;
|
||||
const MAX_RENEWAL_STALENESS_SECONDS: i64 = 3600;
|
||||
|
||||
fn redirect_see_other(uri: &str) -> Response {
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[
|
||||
(LOCATION, uri.to_string()),
|
||||
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
|
||||
(
|
||||
SET_COOKIE,
|
||||
"bfCacheBypass=foo; max-age=1; SameSite=Lax".to_string(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/error?error={}&error_description={}",
|
||||
url_encode(error),
|
||||
url_encode(description)
|
||||
))
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, error: &str, description: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn is_granular_scope(s: &str) -> bool {
|
||||
s.starts_with("repo:")
|
||||
|| s.starts_with("repo?")
|
||||
|| s == "repo"
|
||||
|| s.starts_with("blob:")
|
||||
|| s.starts_with("blob?")
|
||||
|| s == "blob"
|
||||
|| s.starts_with("rpc:")
|
||||
|| s.starts_with("rpc?")
|
||||
|| s.starts_with("account:")
|
||||
|| s.starts_with("identity:")
|
||||
}
|
||||
|
||||
fn is_valid_scope(s: &str) -> bool {
|
||||
s == "atproto"
|
||||
|| s == "transition:generic"
|
||||
|| s == "transition:chat.bsky"
|
||||
|| s == "transition:email"
|
||||
|| is_granular_scope(s)
|
||||
|| s.starts_with("include:")
|
||||
}
|
||||
|
||||
fn extract_device_cookie(headers: &HeaderMap) -> Option<tranquil_types::DeviceId> {
|
||||
headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookie_str| {
|
||||
cookie_str.split(';').map(|c| c.trim()).find_map(|cookie| {
|
||||
cookie
|
||||
.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME))
|
||||
.and_then(|value| {
|
||||
tranquil_pds::config::AuthConfig::get().verify_device_cookie(value)
|
||||
})
|
||||
.map(tranquil_types::DeviceId::new)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String {
|
||||
let signed_value =
|
||||
tranquil_pds::config::AuthConfig::get().sign_device_cookie(device_id.as_str());
|
||||
format!(
|
||||
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
|
||||
DEVICE_COOKIE_NAME, signed_value
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeQuery {
|
||||
pub request_uri: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub new_account: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthorizeResponse {
|
||||
pub client_id: String,
|
||||
pub client_name: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub redirect_uri: String,
|
||||
pub state: Option<String>,
|
||||
pub login_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSubmit {
|
||||
pub request_uri: String,
|
||||
pub username: String,
|
||||
pub password: PlainPassword,
|
||||
#[serde(default)]
|
||||
pub remember_device: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeSelectSubmit {
|
||||
pub request_uri: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
fn wants_json(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("accept")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|accept| accept.contains("application/json"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn build_success_redirect(
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: Option<&str>,
|
||||
response_mode: Option<&str>,
|
||||
) -> String {
|
||||
let mut redirect_url = redirect_uri.to_string();
|
||||
let use_fragment = response_mode == Some("fragment");
|
||||
let separator = if use_fragment {
|
||||
'#'
|
||||
} else if redirect_url.contains('?') {
|
||||
'&'
|
||||
} else {
|
||||
'?'
|
||||
};
|
||||
redirect_url.push(separator);
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
redirect_url.push_str(&format!(
|
||||
"iss={}",
|
||||
url_encode(&format!("https://{}", pds_host))
|
||||
));
|
||||
if let Some(req_state) = state {
|
||||
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
|
||||
}
|
||||
redirect_url.push_str(&format!("&code={}", url_encode(code)));
|
||||
redirect_url
|
||||
}
|
||||
|
||||
fn build_intermediate_redirect_url(
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: Option<&str>,
|
||||
response_mode: Option<&str>,
|
||||
) -> String {
|
||||
let pds_host = &tranquil_config::get().server.hostname;
|
||||
let mut url = format!(
|
||||
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
|
||||
pds_host,
|
||||
url_encode(redirect_uri),
|
||||
url_encode(code)
|
||||
);
|
||||
if let Some(s) = state {
|
||||
url.push_str(&format!("&state={}", url_encode(s)));
|
||||
}
|
||||
if let Some(rm) = response_mode {
|
||||
url.push_str(&format!("&response_mode={}", url_encode(rm)));
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeRedirectParams {
|
||||
redirect_uri: String,
|
||||
code: String,
|
||||
state: Option<String>,
|
||||
response_mode: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn authorize_redirect(Query(params): Query<AuthorizeRedirectParams>) -> Response {
|
||||
let final_url = build_success_redirect(
|
||||
¶ms.redirect_uri,
|
||||
¶ms.code,
|
||||
params.state.as_deref(),
|
||||
params.response_mode.as_deref(),
|
||||
);
|
||||
tracing::info!(
|
||||
final_url = %final_url,
|
||||
client_redirect = %params.redirect_uri,
|
||||
"authorize_redirect performing 303 redirect"
|
||||
);
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[
|
||||
(axum::http::header::LOCATION, final_url),
|
||||
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_deny(
|
||||
State(state): State<AppState>,
|
||||
Json(form): Json<AuthorizeDenyForm>,
|
||||
) -> Response {
|
||||
let deny_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&deny_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid request_uri"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&deny_request_id)
|
||||
.await;
|
||||
let redirect_uri = &request_data.parameters.redirect_uri;
|
||||
let mut redirect_url = redirect_uri.to_string();
|
||||
let separator = if redirect_url.contains('?') { '&' } else { '?' };
|
||||
redirect_url.push(separator);
|
||||
redirect_url.push_str("error=access_denied");
|
||||
redirect_url.push_str("&error_description=User%20denied%20the%20request");
|
||||
if let Some(state) = &request_data.parameters.state {
|
||||
redirect_url.push_str(&format!("&state={}", url_encode(state)));
|
||||
}
|
||||
Json(serde_json::json!({
|
||||
"redirect_uri": redirect_url
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthorizeDenyForm {
|
||||
pub request_uri: String,
|
||||
}
|
||||
|
||||
|
||||
mod consent;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod registration;
|
||||
mod two_factor;
|
||||
|
||||
pub use consent::*;
|
||||
pub use login::*;
|
||||
pub use passkey::*;
|
||||
pub use registration::*;
|
||||
pub use two_factor::*;
|
||||
1125
crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs
Normal file
1125
crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterCompleteInput {
|
||||
pub request_uri: String,
|
||||
pub did: String,
|
||||
pub app_password: String,
|
||||
}
|
||||
|
||||
pub async fn register_complete(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthRegisterCompleteLimit>,
|
||||
Json(form): Json<RegisterCompleteInput>,
|
||||
) -> Response {
|
||||
let did = Did::from(form.did.clone());
|
||||
|
||||
let request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(data)) => data,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch authorization request"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&request_id)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if request_data.parameters.prompt != Some(Prompt::Create) {
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
prompt = ?request_data.parameters.prompt,
|
||||
"register_complete called on non-registration OAuth flow"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "This endpoint is only for registration flows."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if request_data.code.is_some() {
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
"register_complete called on already-completed OAuth flow"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization has already been completed."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Some(existing_did) = &request_data.did
|
||||
&& existing_did != &form.did
|
||||
{
|
||||
tracing::warn!(
|
||||
request_uri = %form.request_uri,
|
||||
existing_did = %existing_did,
|
||||
attempted_did = %form.did,
|
||||
"register_complete attempted with different DID than already bound"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request is already bound to a different account."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let password_hashes = match state
|
||||
.repos.session
|
||||
.get_app_password_hashes_by_did(&did)
|
||||
.await
|
||||
{
|
||||
Ok(hashes) => hashes,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch app password hashes"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut password_valid = password_hashes.iter().fold(false, |acc, hash| {
|
||||
acc | bcrypt::verify(&form.app_password, hash).unwrap_or(false)
|
||||
});
|
||||
|
||||
if !password_valid
|
||||
&& let Ok(Some(account_hash)) = state.repos.user.get_password_hash_by_did(&did).await
|
||||
{
|
||||
password_valid = bcrypt::verify(&form.app_password, &account_hash).unwrap_or(false);
|
||||
}
|
||||
|
||||
if !password_valid {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Invalid credentials."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let is_verified = match state.repos.user.get_session_info_by_did(&did).await {
|
||||
Ok(Some(info)) => info.channel_verification.has_any_verified(),
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Account not found."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to fetch session info"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !is_verified {
|
||||
let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await;
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "account_not_verified",
|
||||
"error_description": "Please verify your account before continuing.",
|
||||
"did": did,
|
||||
"handle": resend_info.as_ref().map(|r| r.handle.to_string()),
|
||||
"channel": resend_info.as_ref().map(|r| r.channel.as_str())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.set_authorization_did(&request_id, &did, None)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to set authorization DID"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&client_id_typed,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_consent {
|
||||
tracing::info!(
|
||||
did = %did,
|
||||
client_id = %request_data.parameters.client_id,
|
||||
"OAuth registration complete, redirecting to consent"
|
||||
);
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
|
||||
let code = Code::generate();
|
||||
let auth_code = AuthorizationCode::from(code.0.clone());
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.update_authorization_request(&request_id, &did, None, &auth_code)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
request_uri = %form.request_uri,
|
||||
did = %did,
|
||||
error = ?e,
|
||||
"register_complete: failed to update authorization request with code"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "An error occurred."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
did = %did,
|
||||
client_id = %request_data.parameters.client_id,
|
||||
"OAuth registration flow completed successfully"
|
||||
);
|
||||
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
|
||||
pub async fn establish_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: tranquil_pds::auth::Auth<tranquil_pds::auth::Active>,
|
||||
) -> Response {
|
||||
let did = &auth.did;
|
||||
|
||||
let existing_device = extract_device_cookie(&headers);
|
||||
|
||||
let (device_id, new_cookie) = match existing_device {
|
||||
Some(id) => {
|
||||
let _ = state.repos.oauth.upsert_account_device(did, &id).await;
|
||||
(id, None)
|
||||
}
|
||||
None => {
|
||||
let new_id = DeviceId::generate();
|
||||
let device_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.create_device(&device_typed, &device_data)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = ?e, "Failed to create device");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to establish session"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = state
|
||||
.repos.oauth
|
||||
.upsert_account_device(did, &device_typed)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = ?e, "Failed to link device to account");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "server_error",
|
||||
"error_description": "Failed to establish session"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let cookie = make_device_cookie(&device_typed);
|
||||
(device_typed, Some(cookie))
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(did = %did, device_id = %device_id, "Device session established");
|
||||
|
||||
match new_cookie {
|
||||
Some(cookie) => (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({
|
||||
"success": true,
|
||||
"device_id": device_id
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
None => Json(serde_json::json!({
|
||||
"success": true,
|
||||
"device_id": device_id
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faQuery {
|
||||
pub request_uri: String,
|
||||
pub channel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Authorize2faSubmit {
|
||||
pub request_uri: String,
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub trust_device: bool,
|
||||
}
|
||||
|
||||
const MAX_2FA_ATTEMPTS: i32 = 5;
|
||||
|
||||
pub async fn authorize_2fa_get(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<Authorize2faQuery>,
|
||||
) -> Response {
|
||||
let twofa_request_id = RequestId::from(query.request_uri.clone());
|
||||
let challenge = match state.repos.oauth.get_2fa_challenge(&twofa_request_id).await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => {
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if challenge.expires_at < Utc::now() {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"2FA code has expired. Please start over.",
|
||||
);
|
||||
}
|
||||
let _request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&twofa_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return redirect_to_frontend_error(
|
||||
"invalid_request",
|
||||
"Authorization request not found. Please start over.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return redirect_to_frontend_error(
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let channel = query.channel.as_deref().unwrap_or("email");
|
||||
redirect_see_other(&format!(
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&query.request_uri),
|
||||
url_encode(channel)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn authorize_2fa_post(
|
||||
State(state): State<AppState>,
|
||||
_rate_limit: OAuthRateLimited<OAuthAuthorizeLimit>,
|
||||
headers: HeaderMap,
|
||||
Json(form): Json<Authorize2faSubmit>,
|
||||
) -> Response {
|
||||
let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": error,
|
||||
"error_description": description
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
let twofa_post_request_id = RequestId::from(form.request_uri.clone());
|
||||
let request_data = match state
|
||||
.repos.oauth
|
||||
.get_authorization_request(&twofa_post_request_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request not found.",
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.delete_authorization_request(&twofa_post_request_id)
|
||||
.await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Authorization request has expired.",
|
||||
);
|
||||
}
|
||||
let challenge = state
|
||||
.repos.oauth
|
||||
.get_2fa_challenge(&twofa_post_request_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(challenge) = challenge {
|
||||
if challenge.expires_at < Utc::now() {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"2FA code has expired. Please start over.",
|
||||
);
|
||||
}
|
||||
if challenge.attempts >= MAX_2FA_ATTEMPTS {
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"access_denied",
|
||||
"Too many failed attempts. Please start over.",
|
||||
);
|
||||
}
|
||||
let code_valid: bool = form
|
||||
.code
|
||||
.trim()
|
||||
.as_bytes()
|
||||
.ct_eq(challenge.code.as_bytes())
|
||||
.into();
|
||||
if !code_valid {
|
||||
let _ = state.repos.oauth.increment_2fa_attempts(challenge.id).await;
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"invalid_code",
|
||||
"Invalid verification code. Please try again.",
|
||||
);
|
||||
}
|
||||
let _ = state.repos.oauth.delete_2fa_challenge(challenge.id).await;
|
||||
let code = Code::generate();
|
||||
let device_id = extract_device_cookie(&headers);
|
||||
let twofa_totp_device_id = device_id.clone();
|
||||
let twofa_totp_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos.oauth
|
||||
.update_authorization_request(
|
||||
&twofa_post_request_id,
|
||||
&challenge.did,
|
||||
twofa_totp_device_id.as_ref(),
|
||||
&twofa_totp_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
return Json(serde_json::json!({
|
||||
"redirect_uri": redirect_url
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let did_str = match &request_data.did {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let did: tranquil_types::Did = match did_str.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"Invalid DID format.",
|
||||
);
|
||||
}
|
||||
};
|
||||
if !tranquil_api::server::has_totp_enabled(&state, &did).await {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request",
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
let _rate_proof = match check_user_rate_limit::<TotpVerifyLimit>(&state, &did).await {
|
||||
Ok(proof) => proof,
|
||||
Err(_) => {
|
||||
return json_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RateLimitExceeded",
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let totp_valid =
|
||||
tranquil_api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
|
||||
if !totp_valid {
|
||||
return json_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"invalid_code",
|
||||
"Invalid verification code. Please try again.",
|
||||
);
|
||||
}
|
||||
let mut device_id = extract_device_cookie(&headers);
|
||||
let mut new_cookie: Option<String> = None;
|
||||
if form.trust_device {
|
||||
let trust_device_id = match &device_id {
|
||||
Some(existing_id) => existing_id.clone(),
|
||||
None => {
|
||||
let new_id = DeviceId::generate();
|
||||
let new_device_id_typed = DeviceIdType::new(new_id.0.clone());
|
||||
let device_data = DeviceData {
|
||||
session_id: SessionId::generate(),
|
||||
user_agent: extract_user_agent(&headers),
|
||||
ip_address: extract_client_ip(&headers, None),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
if state
|
||||
.repos.oauth
|
||||
.create_device(&new_device_id_typed, &device_data)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
new_cookie = Some(make_device_cookie(&new_device_id_typed));
|
||||
device_id = Some(new_device_id_typed.clone());
|
||||
}
|
||||
new_device_id_typed
|
||||
}
|
||||
};
|
||||
let _ = state
|
||||
.repos.oauth
|
||||
.upsert_account_device(&did, &trust_device_id)
|
||||
.await;
|
||||
let _ =
|
||||
tranquil_api::server::trust_device(state.repos.oauth.as_ref(), &trust_device_id).await;
|
||||
}
|
||||
let requested_scope_str = request_data
|
||||
.parameters
|
||||
.scope
|
||||
.as_deref()
|
||||
.unwrap_or("atproto");
|
||||
let requested_scopes: Vec<String> = requested_scope_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let twofa_post_client_id = ClientId::from(request_data.parameters.client_id.clone());
|
||||
let needs_consent = should_show_consent(
|
||||
state.repos.oauth.as_ref(),
|
||||
&did,
|
||||
&twofa_post_client_id,
|
||||
&requested_scopes,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": consent_url})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
}
|
||||
let code = Code::generate();
|
||||
let twofa_final_device_id = device_id.clone();
|
||||
let twofa_final_code = AuthorizationCode::from(code.0.clone());
|
||||
if state
|
||||
.repos.oauth
|
||||
.update_authorization_request(
|
||||
&twofa_post_request_id,
|
||||
&did,
|
||||
twofa_final_device_id.as_ref(),
|
||||
&twofa_final_code,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"An error occurred. Please try again.",
|
||||
);
|
||||
}
|
||||
let redirect_url = build_intermediate_redirect_url(
|
||||
&request_data.parameters.redirect_uri,
|
||||
&code.0,
|
||||
request_data.parameters.state.as_deref(),
|
||||
request_data.parameters.response_mode.map(|m| m.as_str()),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(SET_COOKIE, cookie)],
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user