diff --git a/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json b/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json deleted file mode 100644 index 6e51788..0000000 --- a/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "request_uri", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "provider: SsoProviderType", - "type_info": { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "provider_user_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "provider_username", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "provider_email", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false - ] - }, - "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" -} diff --git a/.sqlx/query-1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66.json b/.sqlx/query-1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66.json new file mode 100644 index 0000000..0f8954d --- /dev/null +++ b/.sqlx/query-1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1bed07000aff39b721c84bfa415f1891605f6561953374e6cae6af66dcecca66" +} diff --git a/.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json b/.sqlx/query-44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934.json similarity index 62% rename from .sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json rename to .sqlx/query-44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934.json index 9b813df..e244b24 100644 --- a/.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json +++ b/.sqlx/query-44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", + "query": "SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" + "hash": "44530ef353fd645b31da69f1ac9858755b4e7b870216ccca094a7ec407898934" } diff --git a/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json b/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json deleted file mode 100644 index fa4098d..0000000 --- a/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "request_uri", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "provider: SsoProviderType", - "type_info": { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "provider_user_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "provider_username", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "provider_email", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false - ] - }, - "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" -} diff --git a/.sqlx/query-8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3.json b/.sqlx/query-8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3.json new file mode 100644 index 0000000..9729af0 --- /dev/null +++ b/.sqlx/query-8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8005b417f4dc3cdd2a667be39250e4e7af7555f262d8db36ada0e99281f16ac3" +} diff --git a/.sqlx/query-821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67.json b/.sqlx/query-821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67.json new file mode 100644 index 0000000..2d681a5 --- /dev/null +++ b/.sqlx/query-821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3, expires_at = $4\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "821f8b1443648faa5e6302b1efb15719ed8dc6111ce0fcc1fa0504e67aacce67" +} diff --git a/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json b/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json deleted file mode 100644 index a7fb42d..0000000 --- a/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" -} diff --git a/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json b/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json deleted file mode 100644 index fa8b7de..0000000 --- a/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" -} diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 88376a6..5eeffcb 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -359,6 +359,16 @@ pub trait UserRepository: Send + Sync { handle: &str, ) -> Result, DbError>; + async fn count_accounts_by_email(&self, email: &str) -> Result; + + async fn count_accounts_by_comms_identifier( + &self, + channel: CommsChannel, + identifier: &str, + ) -> Result; + + async fn get_handles_by_email(&self, email: &str) -> Result, DbError>; + async fn set_password_reset_code( &self, user_id: Uuid, diff --git a/crates/tranquil-db/src/postgres/oauth.rs b/crates/tranquil-db/src/postgres/oauth.rs index 7e6d071..7a71ff4 100644 --- a/crates/tranquil-db/src/postgres/oauth.rs +++ b/crates/tranquil-db/src/postgres/oauth.rs @@ -18,6 +18,8 @@ use uuid::Uuid; use super::user::map_sqlx_error; +const REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS: i64 = 600; + fn to_json(value: &T) -> Result { serde_json::to_value(value).map_err(|e| { tracing::error!("JSON serialization error: {}", e); @@ -462,15 +464,18 @@ impl OAuthRepository for PostgresOAuthRepository { did: &Did, device_id: Option<&DeviceId>, ) -> Result<(), DbError> { + let extended_expiry = + chrono::Utc::now() + chrono::Duration::seconds(REGISTRATION_FLOW_EXTENDED_EXPIRY_SECS); sqlx::query!( r#" UPDATE oauth_authorization_request - SET did = $2, device_id = $3 + SET did = $2, device_id = $3, expires_at = $4 WHERE id = $1 "#, request_id.as_str(), did.as_str(), - device_id.map(|d| d.as_str()) + device_id.map(|d| d.as_str()), + extended_expiry ) .execute(&self.pool) .await diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index 99bc887..2f33251 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -549,7 +549,7 @@ impl UserRepository for PostgresUserRepository { identifier: &str, ) -> Result, DbError> { let row = sqlx::query_scalar!( - "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", + "SELECT email_verified FROM users WHERE did = $1 OR email = $1 OR handle = $1", identifier ) .fetch_optional(&self.pool) @@ -717,26 +717,15 @@ impl UserRepository for PostgresUserRepository { } async fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result { - let result = sqlx::query!( + sqlx::query!( "UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2", email, user_id ) .execute(&self.pool) - .await; - match result { - Ok(_) => Ok(true), - Err(e) => { - if e.as_database_error() - .map(|db| db.is_unique_violation()) - .unwrap_or(false) - { - Ok(false) - } else { - Err(map_sqlx_error(e)) - } - } - } + .await + .map_err(map_sqlx_error)?; + Ok(true) } async fn verify_discord_channel(&self, user_id: Uuid, discord_id: &str) -> Result<(), DbError> { @@ -1593,6 +1582,55 @@ impl UserRepository for PostgresUserRepository { .map_err(map_sqlx_error) } + async fn count_accounts_by_email(&self, email: &str) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL", + email + ) + .fetch_one(&self.pool) + .await + .map(|c| c.unwrap_or(0)) + .map_err(map_sqlx_error) + } + + async fn count_accounts_by_comms_identifier( + &self, + channel: CommsChannel, + identifier: &str, + ) -> Result { + let query = match channel { + CommsChannel::Email => { + "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL" + } + CommsChannel::Discord => { + "SELECT COUNT(*) FROM users WHERE discord_id = $1 AND deactivated_at IS NULL" + } + CommsChannel::Telegram => { + "SELECT COUNT(*) FROM users WHERE LOWER(telegram_username) = LOWER($1) AND deactivated_at IS NULL" + } + CommsChannel::Signal => { + "SELECT COUNT(*) FROM users WHERE signal_number = $1 AND deactivated_at IS NULL" + } + }; + sqlx::query_scalar(query) + .bind(identifier) + .fetch_one(&self.pool) + .await + .map(|c: Option| c.unwrap_or(0)) + .map_err(map_sqlx_error) + } + + async fn get_handles_by_email(&self, email: &str) -> Result, DbError> { + sqlx::query_scalar!( + "SELECT handle FROM users WHERE LOWER(email) = LOWER($1) AND deactivated_at IS NULL ORDER BY created_at DESC", + email + ) + .fetch_all(&self.pool) + .await + .map(|handles| handles.into_iter().map(Handle::from).collect()) + .map_err(map_sqlx_error) + } + async fn set_password_reset_code( &self, user_id: Uuid, diff --git a/crates/tranquil-oauth/src/types.rs b/crates/tranquil-oauth/src/types.rs index 42ce643..2d36ae5 100644 --- a/crates/tranquil-oauth/src/types.rs +++ b/crates/tranquil-oauth/src/types.rs @@ -94,6 +94,7 @@ pub struct AuthorizationRequestParameters { pub response_mode: Option, pub login_hint: Option, pub dpop_jkt: Option, + pub prompt: Option, #[serde(flatten)] pub extra: Option, } @@ -423,6 +424,7 @@ mod tests { response_mode: None, login_hint: None, dpop_jkt: None, + prompt: None, extra: None, }, expires_at: Utc::now() + expires_in, diff --git a/crates/tranquil-pds/src/api/notification_prefs.rs b/crates/tranquil-pds/src/api/notification_prefs.rs index d4902d8..3dd3e95 100644 --- a/crates/tranquil-pds/src/api/notification_prefs.rs +++ b/crates/tranquil-pds/src/api/notification_prefs.rs @@ -234,19 +234,6 @@ pub async fn update_notification_prefs( if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) { info!(did = %user.did, "Email unchanged, skipping"); } else { - match state - .user_repo - .check_email_exists(&email_clean, user_id) - .await - { - Ok(true) => return ApiError::EmailTaken.into_response(), - Err(e) => { - return ApiError::InternalError(Some(format!("Database error: {}", e))) - .into_response(); - } - Ok(false) => {} - } - if let Err(e) = request_channel_verification( &state, user_id, diff --git a/crates/tranquil-pds/src/api/server/app_password.rs b/crates/tranquil-pds/src/api/server/app_password.rs index 6a17a17..edb3f71 100644 --- a/crates/tranquil-pds/src/api/server/app_password.rs +++ b/crates/tranquil-pds/src/api/server/app_password.rs @@ -1,6 +1,6 @@ use crate::api::EmptyResponse; use crate::api::error::ApiError; -use crate::auth::BearerAuth; +use crate::auth::{BearerAuth, generate_app_password}; use crate::delegation::{DelegationActionType, intersect_scopes}; use crate::state::{AppState, RateLimitKind}; use axum::{ @@ -154,17 +154,7 @@ pub async fn create_app_password( (input.scopes.clone(), None) }; - let password: String = (0..4) - .map(|_| { - use rand::Rng; - let mut rng = rand::thread_rng(); - let chars: Vec = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); - (0..4) - .map(|_| chars[rng.gen_range(0..chars.len())]) - .collect::() - }) - .collect::>() - .join("-"); + let password = generate_app_password(); let password_clone = password.clone(); let password_hash = match tokio::task::spawn_blocking(move || { diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index 86064e9..5720bec 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha256}; use std::time::Duration; use subtle::ConstantTimeEq; use tracing::{error, info, warn}; +use tranquil_db_traits::CommsChannel; const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60); @@ -92,22 +93,23 @@ pub async fn request_email_update( let formatted_code = crate::auth::verification_token::format_token_for_display(&code); if let Some(Json(ref inp)) = input - && let Some(ref new_email) = inp.new_email { - let new_email = new_email.trim().to_lowercase(); - if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { - let pending = PendingEmailUpdate { - new_email, - token_hash: hash_token(&code), - authorized: false, - }; - if let Ok(json) = serde_json::to_string(&pending) { - let cache_key = email_update_cache_key(&auth.0.did); - if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { - warn!("Failed to cache pending email update: {:?}", e); - } + && let Some(ref new_email) = inp.new_email + { + let new_email = new_email.trim().to_lowercase(); + if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { + let pending = PendingEmailUpdate { + new_email, + token_hash: hash_token(&code), + authorized: false, + }; + if let Ok(json) = serde_json::to_string(&pending) { + let cache_key = email_update_cache_key(&auth.0.did); + if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { + warn!("Failed to cache pending email update: {:?}", e); } } } + } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( @@ -278,11 +280,13 @@ pub async fn update_email( let cache_key = email_update_cache_key(did); if let Some(pending_json) = state.cache.get(&cache_key).await && let Ok(pending) = serde_json::from_str::(&pending_json) - && pending.authorized && pending.new_email == new_email { - authorized_via_link = true; - let _ = state.cache.delete(&cache_key).await; - info!(did = %did, "Email update completed via link authorization"); - } + && pending.authorized + && pending.new_email == new_email + { + authorized_via_link = true; + let _ = state.cache.delete(&cache_key).await; + info!(did = %did, "Email update completed via link authorization"); + } if !authorized_via_link { let Some(ref t) = input.token else { @@ -318,14 +322,6 @@ pub async fn update_email( } } - if let Ok(true) = state - .user_repo - .check_email_exists(&new_email, user_id) - .await - { - return ApiError::InvalidRequest("Email is already in use".into()).into_response(); - } - if let Err(e) = state.user_repo.update_email(user_id, &new_email).await { error!("DB error updating email: {:?}", e); return ApiError::InternalError(None).into_response(); @@ -481,10 +477,11 @@ pub async fn authorize_email_update( pending.authorized = true; if let Ok(json) = serde_json::to_string(&pending) - && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { - warn!("Failed to update pending email authorization: {:?}", e); - return ApiError::InternalError(None).into_response(); - } + && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await + { + warn!("Failed to update pending email authorization: {:?}", e); + return ApiError::InternalError(None).into_response(); + } info!(did = %did, "Email update authorized via link click"); @@ -541,3 +538,92 @@ pub async fn check_email_update_status( })) .into_response() } + +#[derive(Deserialize)] +pub struct CheckEmailInUseInput { + pub email: String, +} + +pub async fn check_email_in_use( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) + .await + { + return ApiError::RateLimitExceeded(None).into_response(); + } + + let email = input.email.trim().to_lowercase(); + if email.is_empty() { + return ApiError::InvalidRequest("email is required".into()).into_response(); + } + + let count = match state.user_repo.count_accounts_by_email(&email).await { + Ok(c) => c, + Err(e) => { + error!("DB error checking email usage: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + }; + + Json(json!({ + "inUse": count > 0, + })) + .into_response() +} + +#[derive(Deserialize)] +pub struct CheckCommsChannelInUseInput { + pub channel: String, + pub identifier: String, +} + +pub async fn check_comms_channel_in_use( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) + .await + { + return ApiError::RateLimitExceeded(None).into_response(); + } + + let channel = match input.channel.to_lowercase().as_str() { + "email" => CommsChannel::Email, + "discord" => CommsChannel::Discord, + "telegram" => CommsChannel::Telegram, + "signal" => CommsChannel::Signal, + _ => { + return ApiError::InvalidRequest("invalid channel".into()).into_response(); + } + }; + + let identifier = input.identifier.trim(); + if identifier.is_empty() { + return ApiError::InvalidRequest("identifier is required".into()).into_response(); + } + + let count = match state + .user_repo + .count_accounts_by_comms_identifier(channel, identifier) + .await + { + Ok(c) => c, + Err(e) => { + error!("DB error checking comms channel usage: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + }; + + Json(json!({ + "inUse": count > 0, + })) + .into_response() +} diff --git a/crates/tranquil-pds/src/api/server/mod.rs b/crates/tranquil-pds/src/api/server/mod.rs index 2a6f074..3b4499a 100644 --- a/crates/tranquil-pds/src/api/server/mod.rs +++ b/crates/tranquil-pds/src/api/server/mod.rs @@ -23,8 +23,9 @@ pub use account_status::{ }; pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; pub use email::{ - authorize_email_update, check_email_update_status, check_email_verified, confirm_email, - request_email_update, update_email, + authorize_email_update, check_comms_channel_in_use, check_email_in_use, + check_email_update_status, check_email_verified, confirm_email, request_email_update, + update_email, }; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use logo::get_logo; diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index b84102e..40649e1 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -18,7 +18,7 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::api::repo::record::utils::create_signed_commit; -use crate::auth::{ServiceTokenVerifier, is_service_token}; +use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; use crate::state::{AppState, RateLimitKind}; use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; use crate::validation::validate_password; @@ -52,19 +52,6 @@ fn generate_setup_token() -> String { .collect() } -fn generate_app_password() -> String { - let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; - let mut rng = rand::thread_rng(); - let segments: Vec = (0..4) - .map(|_| { - (0..4) - .map(|_| chars[rng.gen_range(0..chars.len())] as char) - .collect() - }) - .collect(); - segments.join("-") -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreatePasskeyAccountInput { diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 6020df0..d27f9d7 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -60,11 +60,22 @@ pub async fn request_password_reset( let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname); let normalized = identifier.to_lowercase(); let normalized = normalized.strip_prefix('@').unwrap_or(&normalized); + let is_email_lookup = normalized.contains('@'); let normalized_handle = if normalized.contains('@') || normalized.contains('.') { normalized.to_string() } else { format!("{}.{}", normalized, hostname_for_handles) }; + + let multiple_accounts_warning = if is_email_lookup { + match state.user_repo.count_accounts_by_email(normalized).await { + Ok(count) if count > 1 => Some(count), + _ => None, + } + } else { + None + }; + let user_id = match state .user_repo .get_id_by_email_or_handle(normalized, &normalized_handle) @@ -73,7 +84,7 @@ pub async fn request_password_reset( Ok(Some(id)) => id, Ok(None) => { info!("Password reset requested for unknown identifier"); - return EmptyResponse::ok().into_response(); + return Json(serde_json::json!({ "success": true })).into_response(); } Err(e) => { error!("DB error in request_password_reset: {:?}", e); @@ -103,7 +114,17 @@ pub async fn request_password_reset( warn!("Failed to enqueue password reset notification: {:?}", e); } info!("Password reset requested for user {}", user_id); - EmptyResponse::ok().into_response() + + match multiple_accounts_warning { + Some(count) => Json(serde_json::json!({ + "success": true, + "multipleAccounts": true, + "accountCount": count, + "message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account." + })) + .into_response(), + None => Json(serde_json::json!({ "success": true })).into_response(), + } } #[derive(Deserialize)] diff --git a/crates/tranquil-pds/src/api/server/reauth.rs b/crates/tranquil-pds/src/api/server/reauth.rs index cfd3198..cd3570b 100644 --- a/crates/tranquil-pds/src/api/server/reauth.rs +++ b/crates/tranquil-pds/src/api/server/reauth.rs @@ -84,7 +84,9 @@ pub async fn reauth_password( let app_password_valid = app_password_hashes .iter() - .any(|h| bcrypt::verify(&input.password, h).unwrap_or(false)); + .fold(false, |acc, h| { + acc | bcrypt::verify(&input.password, h).unwrap_or(false) + }); if !app_password_valid { warn!(did = %&auth.0.did, "Re-auth failed: invalid password"); diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index c061105..5bc4670 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -44,6 +44,16 @@ pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result, St crate::config::decrypt_key(encrypted, Some(version)) } +pub fn generate_app_password() -> String { + use rand::Rng; + let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; + let mut rng = rand::thread_rng(); + let segments: Vec = (0..4) + .map(|_| (0..4).map(|_| chars[rng.gen_range(0..chars.len())] as char).collect()) + .collect(); + segments.join("-") +} + const KEY_CACHE_TTL_SECS: u64 = 300; const SESSION_CACHE_TTL_SECS: u64 = 60; const USER_STATUS_CACHE_TTL_SECS: u64 = 60; diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 21f5a54..1f023b5 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router { "/_account.checkEmailUpdateStatus", get(api::server::check_email_update_status), ) + .route( + "/_account.checkEmailInUse", + post(api::server::check_email_in_use), + ) + .route( + "/_account.checkCommsChannelInUse", + post(api::server::check_comms_channel_in_use), + ) .route( "/com.atproto.server.reserveSigningKey", post(api::server::reserve_signing_key), @@ -556,6 +564,10 @@ pub fn app(state: AppState) -> Router { .route("/passkey/start", post(oauth::endpoints::passkey_start)) .route("/passkey/finish", post(oauth::endpoints::passkey_finish)) .route("/authorize/deny", post(oauth::endpoints::authorize_deny)) + .route( + "/register/complete", + post(oauth::endpoints::register_complete), + ) .route("/authorize/consent", get(oauth::endpoints::consent_get)) .route("/authorize/consent", post(oauth::endpoints::consent_post)) .route( diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index c56af78..e4641a2 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -329,6 +329,13 @@ pub async fn authorize_get( tracing::info!("No login_hint in request"); } + if request_data.parameters.prompt.as_deref() == Some("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 @@ -3208,3 +3215,308 @@ pub async fn authorize_passkey_finish( ) .into_response() } + +#[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, + headers: HeaderMap, + Json(form): Json, +) -> Response { + let client_ip = extract_client_ip(&headers); + + if !state + .check_rate_limit(RateLimitKind::OAuthRegisterComplete, &client_ip) + .await + { + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ + "error": "RateLimitExceeded", + "error_description": "Too many attempts. Please try again later." + })), + ) + .into_response(); + } + + let did = Did::from(form.did.clone()); + + let request_id = RequestId::from(form.request_uri.clone()); + let request_data = match state + .oauth_repo + .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 + .oauth_repo + .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.as_deref() != Some("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 + .session_repo + .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 password_valid = password_hashes + .iter() + .fold(false, |acc, hash| { + acc | bcrypt::verify(&form.app_password, 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.user_repo.get_session_info_by_did(&did).await { + Ok(Some(info)) => { + info.email_verified + || info.discord_verified + || info.telegram_verified + || info.signal_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 { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Please verify your account before continuing." + })), + ) + .into_response(); + } + + if let Err(e) = state + .oauth_repo + .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 = 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.oauth_repo.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 + .oauth_repo + .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.as_deref(), + ); + Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() +} diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index 52fad83..9a61db1 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -50,6 +50,8 @@ pub struct AuthorizationServerMetadata { pub introspection_endpoint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub client_id_metadata_document_supported: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_values_supported: Option>, } pub async fn oauth_protected_resource( @@ -113,6 +115,13 @@ pub async fn oauth_authorization_server( revocation_endpoint: Some(format!("{}/oauth/revoke", issuer)), introspection_endpoint: Some(format!("{}/oauth/introspect", issuer)), client_id_metadata_document_supported: Some(true), + prompt_values_supported: Some(vec![ + "none".to_string(), + "login".to_string(), + "consent".to_string(), + "select_account".to_string(), + "create".to_string(), + ]), }) } diff --git a/crates/tranquil-pds/src/oauth/endpoints/par.rs b/crates/tranquil-pds/src/oauth/endpoints/par.rs index d03eb89..c6f06fa 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/par.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/par.rs @@ -37,6 +37,8 @@ pub struct ParRequest { pub client_assertion: Option, #[serde(default)] pub client_assertion_type: Option, + #[serde(default)] + pub prompt: Option, } #[derive(Debug, Serialize)] @@ -109,6 +111,7 @@ pub async fn pushed_authorization_request( ))); } }; + let prompt = validate_prompt(&request.prompt)?; let parameters = AuthorizationRequestParameters { response_type: request.response_type, client_id: request.client_id.clone(), @@ -120,6 +123,7 @@ pub async fn pushed_authorization_request( response_mode, login_hint: request.login_hint, dpop_jkt: request.dpop_jkt, + prompt, extra: None, }; let request_data = RequestData { @@ -261,3 +265,22 @@ fn scope_matches(client_scope: &str, requested_scope: &str) -> bool { false } + +fn validate_prompt(prompt: &Option) -> Result, OAuthError> { + const VALID_PROMPTS: &[&str] = &["none", "login", "consent", "select_account", "create"]; + + match prompt { + None => Ok(None), + Some(p) if p.is_empty() => Ok(None), + Some(p) => { + if VALID_PROMPTS.contains(&p.as_str()) { + Ok(Some(p.clone())) + } else { + Err(OAuthError::InvalidRequest(format!( + "Unsupported prompt value: {}", + p + ))) + } + } + } +} diff --git a/crates/tranquil-pds/src/rate_limit.rs b/crates/tranquil-pds/src/rate_limit.rs index 69b4e98..fc533fd 100644 --- a/crates/tranquil-pds/src/rate_limit.rs +++ b/crates/tranquil-pds/src/rate_limit.rs @@ -36,6 +36,7 @@ pub struct RateLimiters { pub sso_initiate: Arc, pub sso_callback: Arc, pub sso_unlink: Arc, + pub oauth_register_complete: Arc, } impl Default for RateLimiters { @@ -107,6 +108,11 @@ impl RateLimiters { sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute( NonZeroU32::new(10).unwrap(), ))), + oauth_register_complete: Arc::new(RateLimiter::keyed( + Quota::with_period(std::time::Duration::from_secs(60)) + .unwrap() + .allow_burst(NonZeroU32::new(5).unwrap()), + )), } } diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 6458f53..8ca8ef5 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -12,7 +12,7 @@ use tranquil_types::RequestId; use super::config::SsoConfig; use crate::api::error::ApiError; use crate::auth::extractor::extract_bearer_token_from_header; -use crate::auth::validate_bearer_token_cached; +use crate::auth::{generate_app_password, validate_bearer_token_cached}; use crate::rate_limit::extract_client_ip; use crate::state::{AppState, RateLimitKind}; @@ -87,13 +87,15 @@ pub async fn sso_initiate( return Err(ApiError::SsoProviderNotFound); } if let Some(ref uri) = input.request_uri - && uri.len() > 500 { - return Err(ApiError::InvalidRequest("Request URI too long".into())); - } + && uri.len() > 500 + { + return Err(ApiError::InvalidRequest("Request URI too long".into())); + } if let Some(ref action) = input.action - && action.len() > 20 { - return Err(ApiError::SsoInvalidAction); - } + && action.len() > 20 + { + return Err(ApiError::SsoInvalidAction); + } let provider_type = SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?; @@ -426,6 +428,35 @@ async fn handle_sso_login( } }; + let is_verified = match state.user_repo.get_session_info_by_did(&identity.did).await { + Ok(Some(info)) => { + info.email_verified + || info.discord_verified + || info.telegram_verified + || info.signal_verified + } + Ok(None) => { + tracing::error!("User not found for SSO login: {}", identity.did); + return redirect_to_error("Account not found"); + } + Err(e) => { + tracing::error!("Database error checking verification status: {:?}", e); + return redirect_to_error("Database error"); + } + }; + + if !is_verified { + tracing::warn!( + did = %identity.did, + provider = %provider.as_str(), + "SSO login attempt for unverified account" + ); + return redirect_to_login_with_error( + request_uri, + "Please verify your account before logging in", + ); + } + if let Err(e) = state .sso_repo .update_external_identity_login( @@ -813,6 +844,8 @@ pub struct CompleteRegistrationInput { pub discord_id: Option, pub telegram_username: Option, pub signal_number: Option, + pub did_type: Option, + pub did: Option, } #[derive(Debug, Serialize)] @@ -825,6 +858,10 @@ pub struct CompleteRegistrationResponse { pub access_jwt: Option, #[serde(skip_serializing_if = "Option::is_none")] pub refresh_jwt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_password_name: Option, } pub async fn complete_registration( @@ -914,14 +951,6 @@ pub async fn complete_registration( if !crate::api::validation::is_valid_email(e) { return Err(ApiError::InvalidEmail); } - let email_exists = state - .user_repo - .check_email_exists(e, uuid::Uuid::nil()) - .await - .unwrap_or(true); - if email_exists { - return Err(ApiError::EmailTaken); - } Some(e.clone()) } None => None, @@ -967,37 +996,66 @@ pub async fn complete_registration( }; let pds_endpoint = format!("https://{}", hostname); - let rotation_key = std::env::var("PLC_ROTATION_KEY") - .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); + let did_type = input.did_type.as_deref().unwrap_or("plc"); - let genesis_result = match crate::plc::create_genesis_operation( - &signing_key, - &rotation_key, - &handle, - &pds_endpoint, - ) { - Ok(r) => r, - Err(e) => { - tracing::error!("Error creating PLC genesis operation: {:?}", e); - return Err(ApiError::InternalError(Some( - "Failed to create PLC operation".into(), - ))); + let did = match did_type { + "web" => { + let subdomain_host = format!("{}.{}", input.handle, hostname_for_handles); + let encoded_subdomain = subdomain_host.replace(':', "%3A"); + let self_hosted_did = format!("did:web:{}", encoded_subdomain); + tracing::info!(did = %self_hosted_did, "Creating self-hosted did:web SSO account"); + self_hosted_did + } + "web-external" => { + let d = match &input.did { + Some(d) if !d.trim().is_empty() => d.trim(), + _ => { + return Err(ApiError::InvalidRequest( + "External did:web requires the 'did' field to be provided".into(), + )); + } + }; + if !d.starts_with("did:web:") { + return Err(ApiError::InvalidDid( + "External DID must be a did:web".into(), + )); + } + tracing::info!(did = %d, "Creating external did:web SSO account"); + d.to_string() + } + _ => { + let rotation_key = std::env::var("PLC_ROTATION_KEY") + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); + + let genesis_result = match crate::plc::create_genesis_operation( + &signing_key, + &rotation_key, + &handle, + &pds_endpoint, + ) { + Ok(r) => r, + Err(e) => { + tracing::error!("Error creating PLC genesis operation: {:?}", e); + return Err(ApiError::InternalError(Some( + "Failed to create PLC operation".into(), + ))); + } + }; + + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); + if let Err(e) = plc_client + .send_operation(&genesis_result.did, &genesis_result.signed_operation) + .await + { + tracing::error!("Failed to submit PLC genesis operation: {:?}", e); + return Err(ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + ))); + } + genesis_result.did } }; - - let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); - if let Err(e) = plc_client - .send_operation(&genesis_result.did, &genesis_result.signed_operation) - .await - { - tracing::error!("Failed to submit PLC genesis operation: {:?}", e); - return Err(ApiError::UpstreamErrorMsg(format!( - "Failed to register DID with PLC directory: {}", - e - ))); - } - - let did = genesis_result.did; tracing::info!(did = %did, handle = %handle, provider = %pending_preview.provider.as_str(), "Created DID for SSO account"); let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { @@ -1093,7 +1151,7 @@ pub async fn complete_registration( pending_registration_token: input.token.clone(), }; - let _create_result = match state.user_repo.create_sso_account(&create_input).await { + let create_result = match state.user_repo.create_sso_account(&create_input).await { Ok(r) => r, Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { return Err(ApiError::HandleNotAvailable(None)); @@ -1145,6 +1203,28 @@ pub async fn complete_registration( tracing::warn!("Failed to create default profile for {}: {}", did, e); } + let app_password = generate_app_password(); + let app_password_name = "bsky.app".to_string(); + let app_password_hash = match bcrypt::hash(&app_password, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + tracing::error!("Failed to hash app password: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let app_password_data = tranquil_db_traits::AppPasswordCreate { + user_id: create_result.user_id, + name: app_password_name.clone(), + password_hash: app_password_hash, + privileged: false, + scopes: None, + created_by_controller_did: None, + }; + if let Err(e) = state.session_repo.create_app_password(&app_password_data).await { + tracing::warn!("Failed to create initial app password: {:?}", e); + } + let is_standalone = pending_preview.request_uri == "standalone"; if !is_standalone { @@ -1250,6 +1330,8 @@ pub async fn complete_registration( redirect_url: "/app/dashboard".to_string(), access_jwt: Some(access_meta.token), refresh_jwt: Some(refresh_meta.token), + app_password: Some(app_password), + app_password_name: Some(app_password_name), })); } @@ -1262,6 +1344,8 @@ pub async fn complete_registration( ), access_jwt: None, refresh_jwt: None, + app_password: Some(app_password), + app_password_name: Some(app_password_name), })); } @@ -1291,7 +1375,8 @@ pub async fn complete_registration( format!("/app/verify?did={}", urlencoding::encode(&did)) } else { format!( - "/app/oauth/verify?request_uri={}", + "/app/verify?did={}&request_uri={}", + urlencoding::encode(&did), urlencoding::encode(&pending_preview.request_uri) ) }; @@ -1302,5 +1387,7 @@ pub async fn complete_registration( redirect_url, access_jwt: None, refresh_jwt: None, + app_password: Some(app_password), + app_password_name: Some(app_password_name), })) } diff --git a/crates/tranquil-pds/src/sso/providers.rs b/crates/tranquil-pds/src/sso/providers.rs index 74daf76..896ebed 100644 --- a/crates/tranquil-pds/src/sso/providers.rs +++ b/crates/tranquil-pds/src/sso/providers.rs @@ -833,9 +833,10 @@ impl AppleProvider { { let cache = self.client_secret_cache.read().await; if let Some(ref cached) = *cache - && cached.expires_at > now + 3600 { - return Ok(cached.secret.clone()); - } + && cached.expires_at > now + 3600 + { + return Ok(cached.secret.clone()); + } } let (secret, expires_at) = self.generate_client_secret()?; @@ -1069,15 +1070,16 @@ impl SsoManager { cfg, Some("https://accounts.google.com"), "Google", - ) { - providers.insert(SsoProviderType::Google, Arc::new(provider)); - } + ) + { + providers.insert(SsoProviderType::Google, Arc::new(provider)); + } if let Some(ref cfg) = config.gitlab && let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab") - { - providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); - } + { + providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); + } if let Some(ref cfg) = config.oidc && let Some(provider) = OidcProvider::new( @@ -1085,9 +1087,10 @@ impl SsoManager { cfg, None, cfg.display_name.as_deref().unwrap_or("SSO"), - ) { - providers.insert(SsoProviderType::Oidc, Arc::new(provider)); - } + ) + { + providers.insert(SsoProviderType::Oidc, Arc::new(provider)); + } if let Some(ref cfg) = config.apple { match AppleProvider::new(cfg) { diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 720c8df..68d6778 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -62,6 +62,7 @@ pub enum RateLimitKind { SsoInitiate, SsoCallback, SsoUnlink, + OAuthRegisterComplete, } impl RateLimitKind { @@ -85,6 +86,7 @@ impl RateLimitKind { Self::SsoInitiate => "sso_initiate", Self::SsoCallback => "sso_callback", Self::SsoUnlink => "sso_unlink", + Self::OAuthRegisterComplete => "oauth_register_complete", } } @@ -108,6 +110,7 @@ impl RateLimitKind { Self::SsoInitiate => (10, 60_000), Self::SsoCallback => (30, 60_000), Self::SsoUnlink => (10, 60_000), + Self::OAuthRegisterComplete => (5, 300_000), } } } @@ -251,6 +254,7 @@ impl AppState { RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate, RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback, RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink, + RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete, }; let ok = limiter.check_key(&client_ip.to_string()).is_ok(); diff --git a/crates/tranquil-pds/tests/email_update.rs b/crates/tranquil-pds/tests/email_update.rs index 8808d0a..51fb8a1 100644 --- a/crates/tranquil-pds/tests/email_update.rs +++ b/crates/tranquil-pds/tests/email_update.rs @@ -463,7 +463,7 @@ async fn test_unverified_account_can_update_email_without_token() { } #[tokio::test] -async fn test_update_email_taken_by_another_user() { +async fn test_update_email_to_same_as_another_user_allowed() { let client = common::client(); let base_url = common::base_url().await; let pool = common::get_test_db_pool().await; @@ -499,13 +499,16 @@ async fn test_update_email_taken_by_another_user() { .send() .await .expect("Failed to update email"); - assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "InvalidRequest"); - assert!( - body["message"] - .as_str() - .unwrap_or("") - .contains("already in use") + assert_eq!( + res.status(), + StatusCode::OK, + "Multiple accounts can share the same email address" ); + + let user_email: Option = + sqlx::query_scalar!("SELECT email FROM users WHERE did = $1", did2) + .fetch_one(pool) + .await + .expect("User not found"); + assert_eq!(user_email, Some(email1.clone())); } diff --git a/crates/tranquil-pds/tests/oauth.rs b/crates/tranquil-pds/tests/oauth.rs index 0d341bf..b617fc8 100644 --- a/crates/tranquil-pds/tests/oauth.rs +++ b/crates/tranquil-pds/tests/oauth.rs @@ -1237,3 +1237,412 @@ async fn test_granular_scope_rpc_specific_method() { "Should require lxm parameter for granular scopes" ); } + +#[tokio::test] +async fn test_oauth_metadata_includes_prompt_values_supported() { + let url = base_url().await; + let client = client(); + let as_res = client + .get(format!("{}/.well-known/oauth-authorization-server", url)) + .send() + .await + .unwrap(); + assert_eq!(as_res.status(), StatusCode::OK); + let as_body: Value = as_res.json().await.unwrap(); + let prompt_values = as_body["prompt_values_supported"] + .as_array() + .expect("prompt_values_supported should be an array"); + assert!( + prompt_values.contains(&json!("none")), + "Should support prompt=none" + ); + assert!( + prompt_values.contains(&json!("login")), + "Should support prompt=login" + ); + assert!( + prompt_values.contains(&json!("consent")), + "Should support prompt=consent" + ); + assert!( + prompt_values.contains(&json!("select_account")), + "Should support prompt=select_account" + ); + assert!( + prompt_values.contains(&json!("create")), + "Should support prompt=create" + ); +} + +#[tokio::test] +async fn test_par_accepts_valid_prompt_values() { + let url = base_url().await; + let client = client(); + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + let valid_prompts = ["none", "login", "consent", "select_account", "create"]; + for prompt in valid_prompts { + let par_res = client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", "test-state"), + ("prompt", prompt), + ]) + .send() + .await + .unwrap(); + assert_eq!( + par_res.status(), + StatusCode::CREATED, + "PAR should accept prompt={}", + prompt + ); + } +} + +#[tokio::test] +async fn test_par_rejects_invalid_prompt_value() { + let url = base_url().await; + let client = client(); + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + let par_res = client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", "test-state"), + ("prompt", "invalid_prompt"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + par_res.status(), + StatusCode::BAD_REQUEST, + "PAR should reject invalid prompt value" + ); + let body: Value = par_res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_request"); + assert!( + body["error_description"] + .as_str() + .unwrap_or("") + .contains("prompt"), + "Error should mention prompt" + ); +} + +#[tokio::test] +async fn test_prompt_create_redirects_to_register() { + let url = base_url().await; + let client = no_redirect_client(); + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + let par_res = reqwest::Client::new() + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", "test-state"), + ("prompt", "create"), + ]) + .send() + .await + .unwrap(); + assert_eq!(par_res.status(), StatusCode::CREATED); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + let auth_res = client + .get(format!("{}/oauth/authorize", url)) + .query(&[("request_uri", request_uri)]) + .send() + .await + .unwrap(); + assert!( + auth_res.status().is_redirection(), + "Should redirect when prompt=create" + ); + let location = auth_res + .headers() + .get("location") + .expect("Should have Location header") + .to_str() + .unwrap(); + assert!( + location.contains("/app/oauth/register"), + "Should redirect to /app/oauth/register, got: {}", + location + ); + assert!( + location.contains("request_uri="), + "Should include request_uri in redirect" + ); +} + +#[tokio::test] +async fn test_register_complete_rejects_invalid_request_uri() { + let url = base_url().await; + let client = client(); + let res = client + .post(format!("{}/oauth/register/complete", url)) + .json(&json!({ + "request_uri": "urn:ietf:params:oauth:request_uri:nonexistent", + "did": "did:plc:test123", + "app_password": "test-password" + })) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "Should reject invalid request_uri" + ); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "invalid_request"); +} + +#[tokio::test] +async fn test_register_complete_rejects_wrong_credentials() { + let url = base_url().await; + let http_client = client(); + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("rc{}", suffix); + let email = format!("rc{}@example.com", suffix); + let password = "Regcomplete123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ "handle": handle, "email": email, "password": password })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, user_did).await; + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (_, code_challenge) = generate_pkce(); + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", "test-state"), + ("prompt", "create"), + ]) + .send() + .await + .unwrap(); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + let res = http_client + .post(format!("{}/oauth/register/complete", url)) + .json(&json!({ + "request_uri": request_uri, + "did": user_did, + "app_password": "wrong-password" + })) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::FORBIDDEN, + "Should reject wrong credentials" + ); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "access_denied"); +} + +#[tokio::test] +async fn test_full_oauth_registration_flow() { + let url = base_url().await; + let http_client = client(); + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("oauthreg{}", suffix); + let email = format!("oauthreg{}@example.com", suffix); + let password = "OauthRegTest123!"; + + let redirect_uri = "https://example.com/callback"; + let mock_client = setup_mock_client_metadata(redirect_uri).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + let state = format!("state-{}", suffix); + + let par_res = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ("scope", "atproto"), + ("state", &state), + ("prompt", "create"), + ]) + .send() + .await + .unwrap(); + assert_eq!( + par_res.status(), + StatusCode::CREATED, + "PAR with prompt=create should succeed" + ); + let par_body: Value = par_res.json().await.unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ "handle": handle, "email": email, "password": password })) + .send() + .await + .unwrap(); + assert_eq!( + create_res.status(), + StatusCode::OK, + "Account creation should succeed" + ); + let account: Value = create_res.json().await.unwrap(); + let user_did = account["did"].as_str().unwrap(); + let access_jwt = account["accessJwt"].as_str().unwrap(); + + let app_password_res = http_client + .post(format!( + "{}/xrpc/com.atproto.server.createAppPassword", + url + )) + .header("Authorization", format!("Bearer {}", access_jwt)) + .json(&json!({ "name": "oauth-test-app" })) + .send() + .await + .unwrap(); + assert_eq!( + app_password_res.status(), + StatusCode::OK, + "App password creation should succeed" + ); + let app_password_body: Value = app_password_res.json().await.unwrap(); + let app_password = app_password_body["password"].as_str().unwrap(); + + verify_new_account(&http_client, user_did).await; + + let complete_res = http_client + .post(format!("{}/oauth/register/complete", url)) + .json(&json!({ + "request_uri": request_uri, + "did": user_did, + "app_password": app_password + })) + .send() + .await + .unwrap(); + assert_eq!( + complete_res.status(), + StatusCode::OK, + "register_complete should succeed" + ); + let complete_body: Value = complete_res.json().await.unwrap(); + let mut redirect_location = complete_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from register_complete") + .to_string(); + + if redirect_location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + assert_eq!( + consent_res.status(), + StatusCode::OK, + "Consent should succeed" + ); + let consent_body: Value = consent_res.json().await.unwrap(); + redirect_location = consent_body["redirect_uri"] + .as_str() + .expect("Expected redirect_uri from consent") + .to_string(); + } + + assert!( + redirect_location.contains("code="), + "Should have authorization code in redirect: {}", + redirect_location + ); + + let code = redirect_location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); + let token_body: Value = token_res.json().await.unwrap(); + assert!( + token_body["access_token"].is_string(), + "Should have access_token" + ); + assert!( + token_body["refresh_token"].is_string(), + "Should have refresh_token" + ); + assert_eq!(token_body["token_type"], "Bearer"); + assert_eq!( + token_body["sub"], user_did, + "Token sub should match user DID" + ); +} diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs index 0e69a0e..1adeec8 100644 --- a/crates/tranquil-pds/tests/sso.rs +++ b/crates/tranquil-pds/tests/sso.rs @@ -1039,7 +1039,7 @@ async fn test_sso_complete_registration_multichannel_discord() { let redirect_url = body["redirectUrl"].as_str().unwrap(); assert!( - redirect_url.contains("/app/oauth/verify"), + redirect_url.contains("/app/verify"), "Non-auto-verified channel should redirect to verify, got: {}", redirect_url ); diff --git a/frontend/public/homepage.html b/frontend/public/homepage.html index 5b3edc2..8c15f5e 100644 --- a/frontend/public/homepage.html +++ b/frontend/public/homepage.html @@ -439,6 +439,7 @@ @@ -545,6 +546,7 @@
Join This Server + Login
diff --git a/frontend/src/components/AccountTypeSwitcher.svelte b/frontend/src/components/AccountTypeSwitcher.svelte index fe80493..bd5ef64 100644 --- a/frontend/src/components/AccountTypeSwitcher.svelte +++ b/frontend/src/components/AccountTypeSwitcher.svelte @@ -6,20 +6,30 @@ interface Props { active: 'passkey' | 'password' | 'sso' ssoAvailable?: boolean + oauthRequestUri?: string | null } - let { active, ssoAvailable = true }: Props = $props() + let { active, ssoAvailable = true, oauthRequestUri = null }: Props = $props() + + function buildOauthUrl(route: string): string { + const url = getFullUrl(route) + return oauthRequestUri ? `${url}?request_uri=${encodeURIComponent(oauthRequestUri)}` : url + } + + const passkeyUrl = $derived(buildOauthUrl(routes.oauthRegister)) + const passwordUrl = $derived(buildOauthUrl(routes.oauthRegisterPassword)) + const ssoUrl = $derived(buildOauthUrl(routes.oauthRegisterSso))