sso signup & login

This commit is contained in:
lewis
2026-01-18 01:15:13 +02:00
parent 4e29861990
commit b3ec7feb96
141 changed files with 9982 additions and 314 deletions
+10 -6
View File
@@ -7,6 +7,7 @@ mod infra;
mod oauth;
mod repo;
mod session;
mod sso;
mod user;
pub use backlink::{Backlink, BacklinkRepository};
@@ -40,15 +41,18 @@ pub use session::{
AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem,
SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
};
pub use sso::{
ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
};
pub use user::{
AccountSearchResult, CompletePasskeySetupInput, CreateAccountError,
CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput,
CreatePasswordAccountResult, DidWebOverrides, MigrationReactivationError,
MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult,
ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult,
ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, TotpRecord, User2faStatus,
UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo,
UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides,
MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser,
PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput,
RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey,
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
+176
View File
@@ -0,0 +1,176 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::Did;
use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")]
pub enum SsoProviderType {
Github,
Discord,
Google,
Gitlab,
Oidc,
Apple,
}
impl SsoProviderType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Github => "github",
Self::Discord => "discord",
Self::Google => "google",
Self::Gitlab => "gitlab",
Self::Oidc => "oidc",
Self::Apple => "apple",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"github" => Some(Self::Github),
"discord" => Some(Self::Discord),
"google" => Some(Self::Google),
"gitlab" => Some(Self::Gitlab),
"oidc" => Some(Self::Oidc),
"apple" => Some(Self::Apple),
_ => None,
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::Github => "GitHub",
Self::Discord => "Discord",
Self::Google => "Google",
Self::Gitlab => "GitLab",
Self::Oidc => "SSO",
Self::Apple => "Apple",
}
}
pub fn icon_name(&self) -> &'static str {
match self {
Self::Github => "github",
Self::Discord => "discord",
Self::Google => "google",
Self::Gitlab => "gitlab",
Self::Oidc => "oidc",
Self::Apple => "apple",
}
}
}
#[derive(Debug, Clone)]
pub struct ExternalIdentity {
pub id: Uuid,
pub did: Did,
pub provider: SsoProviderType,
pub provider_user_id: String,
pub provider_username: Option<String>,
pub provider_email: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct SsoAuthState {
pub state: String,
pub request_uri: String,
pub provider: SsoProviderType,
pub action: String,
pub nonce: Option<String>,
pub code_verifier: Option<String>,
pub did: Option<Did>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct SsoPendingRegistration {
pub token: String,
pub request_uri: String,
pub provider: SsoProviderType,
pub provider_user_id: String,
pub provider_username: Option<String>,
pub provider_email: Option<String>,
pub provider_email_verified: bool,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[async_trait]
pub trait SsoRepository: Send + Sync {
async fn create_external_identity(
&self,
did: &Did,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<Uuid, DbError>;
async fn get_external_identity_by_provider(
&self,
provider: SsoProviderType,
provider_user_id: &str,
) -> Result<Option<ExternalIdentity>, DbError>;
async fn get_external_identities_by_did(
&self,
did: &Did,
) -> Result<Vec<ExternalIdentity>, DbError>;
async fn update_external_identity_login(
&self,
id: Uuid,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<(), DbError>;
async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result<bool, DbError>;
#[allow(clippy::too_many_arguments)]
async fn create_sso_auth_state(
&self,
state: &str,
request_uri: &str,
provider: SsoProviderType,
action: &str,
nonce: Option<&str>,
code_verifier: Option<&str>,
did: Option<&Did>,
) -> Result<(), DbError>;
async fn consume_sso_auth_state(&self, state: &str) -> Result<Option<SsoAuthState>, DbError>;
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError>;
#[allow(clippy::too_many_arguments)]
async fn create_pending_registration(
&self,
token: &str,
request_uri: &str,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
provider_email_verified: bool,
) -> Result<(), DbError>;
async fn get_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError>;
async fn consume_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError>;
async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError>;
}
+37 -1
View File
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
use tranquil_types::{Did, Handle};
use uuid::Uuid;
use crate::{CommsChannel, DbError};
use crate::{CommsChannel, DbError, SsoProviderType};
#[derive(Debug, Clone)]
pub struct UserRow {
@@ -480,6 +480,11 @@ pub trait UserRepository: Send + Sync {
input: &CreatePasskeyAccountInput,
) -> Result<CreatePasswordAccountResult, CreateAccountError>;
async fn create_sso_account(
&self,
input: &CreateSsoAccountInput,
) -> Result<CreatePasswordAccountResult, CreateAccountError>;
async fn reactivate_migration_account(
&self,
input: &MigrationReactivationInput,
@@ -490,6 +495,12 @@ pub trait UserRepository: Send + Sync {
handle: &Handle,
) -> Result<bool, DbError>;
async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError>;
async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError>;
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError>;
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError>;
async fn complete_passkey_setup(
@@ -842,6 +853,7 @@ pub enum CreateAccountError {
HandleTaken,
EmailTaken,
DidExists,
InvalidToken,
Database(String),
}
@@ -882,6 +894,30 @@ pub struct CreatePasskeyAccountInput {
pub birthdate_pref: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct CreateSsoAccountInput {
pub handle: Handle,
pub email: Option<String>,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub discord_id: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<String>,
pub birthdate_pref: Option<serde_json::Value>,
pub sso_provider: SsoProviderType,
pub sso_provider_user_id: String,
pub sso_provider_username: Option<String>,
pub sso_provider_email: Option<String>,
pub sso_provider_email_verified: bool,
pub pending_registration_token: String,
}
#[derive(Debug, Clone)]
pub struct CompletePasskeySetupInput {
pub user_id: Uuid,
+6 -1
View File
@@ -7,6 +7,7 @@ mod infra;
mod oauth;
mod repo;
mod session;
mod sso;
mod user;
use sqlx::PgPool;
@@ -21,9 +22,11 @@ pub use infra::PostgresInfraRepository;
pub use oauth::PostgresOAuthRepository;
pub use repo::PostgresRepoRepository;
pub use session::PostgresSessionRepository;
pub use sso::PostgresSsoRepository;
use tranquil_db_traits::{
BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository,
OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, UserRepository,
OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, SsoRepository,
UserRepository,
};
pub use user::PostgresUserRepository;
@@ -38,6 +41,7 @@ pub struct PostgresRepositories {
pub infra: Arc<dyn InfraRepository>,
pub backup: Arc<dyn BackupRepository>,
pub backlink: Arc<dyn BacklinkRepository>,
pub sso: Arc<dyn SsoRepository>,
pub event_notifier: Arc<dyn RepoEventNotifier>,
}
@@ -54,6 +58,7 @@ impl PostgresRepositories {
infra: Arc::new(PostgresInfraRepository::new(pool.clone())),
backup: Arc::new(PostgresBackupRepository::new(pool.clone())),
backlink: Arc::new(PostgresBacklinkRepository::new(pool.clone())),
sso: Arc::new(PostgresSsoRepository::new(pool.clone())),
event_notifier: Arc::new(PostgresRepoEventNotifier::new(pool)),
}
}
+337
View File
@@ -0,0 +1,337 @@
use async_trait::async_trait;
use chrono::Utc;
use sqlx::PgPool;
use tranquil_db_traits::{
DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
};
use tranquil_types::Did;
use uuid::Uuid;
use super::user::map_sqlx_error;
pub struct PostgresSsoRepository {
pool: PgPool,
}
impl PostgresSsoRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl SsoRepository for PostgresSsoRepository {
async fn create_external_identity(
&self,
did: &Did,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<Uuid, DbError> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
did.as_str(),
provider as SsoProviderType,
provider_user_id,
provider_username,
provider_email,
)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(id)
}
async fn get_external_identity_by_provider(
&self,
provider: SsoProviderType,
provider_user_id: &str,
) -> Result<Option<ExternalIdentity>, DbError> {
let row = sqlx::query!(
r#"
SELECT id, did, provider as "provider: SsoProviderType", provider_user_id,
provider_username, provider_email, created_at, updated_at, last_login_at
FROM external_identities
WHERE provider = $1 AND provider_user_id = $2
"#,
provider as SsoProviderType,
provider_user_id,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
}))
}
async fn get_external_identities_by_did(
&self,
did: &Did,
) -> Result<Vec<ExternalIdentity>, DbError> {
let rows = sqlx::query!(
r#"
SELECT id, did, provider as "provider: SsoProviderType", provider_user_id,
provider_username, provider_email, created_at, updated_at, last_login_at
FROM external_identities
WHERE did = $1
ORDER BY created_at ASC
"#,
did.as_str(),
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
.collect())
}
async fn update_external_identity_login(
&self,
id: Uuid,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
UPDATE external_identities
SET provider_username = COALESCE($2, provider_username),
provider_email = COALESCE($3, provider_email),
last_login_at = NOW(),
updated_at = NOW()
WHERE id = $1
"#,
id,
provider_username,
provider_email,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM external_identities
WHERE id = $1 AND did = $2
"#,
id,
did.as_str(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn create_sso_auth_state(
&self,
state: &str,
request_uri: &str,
provider: SsoProviderType,
action: &str,
nonce: Option<&str>,
code_verifier: Option<&str>,
did: Option<&Did>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
state,
request_uri,
provider as SsoProviderType,
action,
nonce,
code_verifier,
did.map(|d| d.as_str()),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn consume_sso_auth_state(&self, state: &str) -> Result<Option<SsoAuthState>, DbError> {
let row = sqlx::query!(
r#"
DELETE FROM sso_auth_state
WHERE state = $1 AND expires_at > NOW()
RETURNING state, request_uri, provider as "provider: SsoProviderType", action,
nonce, code_verifier, did, created_at, expires_at
"#,
state,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoAuthState {
state: r.state,
request_uri: r.request_uri,
provider: r.provider,
action: r.action,
nonce: r.nonce,
code_verifier: r.code_verifier,
did: r.did.map(|d| Did::new_unchecked(&d)),
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM sso_auth_state
WHERE expires_at < $1
"#,
Utc::now(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn create_pending_registration(
&self,
token: &str,
request_uri: &str,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
provider_email_verified: bool,
) -> Result<(), DbError> {
sqlx::query!(
r#"
INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
token,
request_uri,
provider as SsoProviderType,
provider_user_id,
provider_username,
provider_email,
provider_email_verified,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError> {
let row = sqlx::query!(
r#"
SELECT token, request_uri, provider as "provider: SsoProviderType",
provider_user_id, provider_username, provider_email, provider_email_verified,
created_at, expires_at
FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
"#,
token,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoPendingRegistration {
token: r.token,
request_uri: r.request_uri,
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn consume_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError> {
let row = sqlx::query!(
r#"
DELETE FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
RETURNING token, request_uri, provider as "provider: SsoProviderType",
provider_user_id, provider_username, provider_email, provider_email_verified,
created_at, expires_at
"#,
token,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoPendingRegistration {
token: r.token,
request_uri: r.request_uri,
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM sso_pending_registration
WHERE expires_at < $1
"#,
Utc::now(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
}
+230 -9
View File
@@ -6,9 +6,9 @@ use uuid::Uuid;
use tranquil_db_traits::{
AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs,
OAuthTokenWithUser, PasswordResetResult, StoredBackupCode, StoredPasskey, TotpRecord,
User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo,
UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey,
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
@@ -2671,6 +2671,173 @@ impl UserRepository for PostgresUserRepository {
})
}
async fn create_sso_account(
&self,
input: &tranquil_db_traits::CreateSsoAccountInput,
) -> Result<
tranquil_db_traits::CreatePasswordAccountResult,
tranquil_db_traits::CreateAccountError,
> {
let mut tx = self.pool.begin().await.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
let token_consumed: Option<(String,)> = sqlx::query_as(
r#"
DELETE FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
RETURNING token
"#,
)
.bind(&input.pending_registration_token)
.fetch_optional(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
if token_consumed.is_none() {
return Err(tranquil_db_traits::CreateAccountError::InvalidToken);
}
let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut *tx)
.await
.map(|c| c.unwrap_or(0) == 0)
.unwrap_or(false);
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
preferred_comms_channel, discord_id, telegram_username, signal_number,
is_admin
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#,
)
.bind(input.handle.as_str())
.bind(&input.email)
.bind(input.did.as_str())
.bind(input.preferred_comms_channel)
.bind(&input.discord_id)
.bind(&input.telegram_username)
.bind(&input.signal_number)
.bind(is_first_user)
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("23505")
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") {
return Err(tranquil_db_traits::CreateAccountError::HandleTaken);
} else if constraint.contains("email") {
return Err(tranquil_db_traits::CreateAccountError::EmailTaken);
}
}
return Err(tranquil_db_traits::CreateAccountError::Database(
e.to_string(),
));
}
};
sqlx::query!(
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
user_id,
&input.encrypted_key_bytes[..],
input.encryption_version
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?;
sqlx::query!(
"INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
user_id,
input.commit_cid,
input.repo_rev
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
sqlx::query(
r#"
INSERT INTO user_blocks (user_id, block_cid, repo_rev)
SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid)
ON CONFLICT (user_id, block_cid) DO NOTHING
"#,
)
.bind(user_id)
.bind(&input.genesis_block_cids)
.bind(&input.repo_rev)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
}
if let Some(birthdate_pref) = &input.birthdate_pref {
let _ = sqlx::query!(
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)
ON CONFLICT (user_id, name) DO NOTHING",
user_id,
"app.bsky.actor.defs#personalDetailsPref",
birthdate_pref
)
.execute(&mut *tx)
.await;
}
sqlx::query!(
r#"
INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
input.did.as_str(),
input.sso_provider as SsoProviderType,
&input.sso_provider_user_id,
input.sso_provider_username.as_deref(),
input.sso_provider_email.as_deref(),
input.sso_provider_email_verified,
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
tx.commit().await.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
Ok(tranquil_db_traits::CreatePasswordAccountResult {
user_id,
is_admin: is_first_user,
})
}
async fn reactivate_migration_account(
&self,
input: &tranquil_db_traits::MigrationReactivationInput,
@@ -2744,16 +2911,70 @@ impl UserRepository for PostgresUserRepository {
&self,
handle: &Handle,
) -> Result<bool, DbError> {
let exists: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
.bind(handle.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
let exists: Option<(i32,)> = sqlx::query_as(
r#"
SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL
UNION ALL
SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()
LIMIT 1
"#,
)
.bind(handle.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(exists.is_none())
}
async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError> {
sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()")
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
let result = sqlx::query!(
r#"
INSERT INTO handle_reservations (handle, reserved_by)
SELECT $1, $2
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()
)
"#,
handle.as_str(),
reserved_by,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM handle_reservations WHERE handle = $1",
handle.as_str()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError> {
let result = sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()")
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
+2
View File
@@ -18,6 +18,7 @@ tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
aes-gcm = { workspace = true }
async-trait = { workspace = true }
backon = { workspace = true }
anyhow = { workspace = true }
aws-config = { workspace = true }
@@ -44,6 +45,7 @@ ipld-core = { workspace = true }
iroh-car = { workspace = true }
jacquard-common = { workspace = true }
jacquard-repo = { workspace = true }
jsonwebtoken = { workspace = true }
k256 = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }
+12
View File
@@ -0,0 +1,12 @@
use std::process::Command;
fn main() {
let timestamp = Command::new("date")
.arg("+%Y-%m-%d %H:%M:%S UTC")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=BUILD_TIMESTAMP={}", timestamp);
println!("cargo:rerun-if-changed=build.rs");
}
+42 -2
View File
@@ -107,6 +107,13 @@ pub enum ApiError {
error: Option<String>,
message: Option<String>,
},
SsoProviderNotFound,
SsoProviderNotEnabled,
SsoInvalidAction,
SsoNotAuthenticated,
SsoSessionExpired,
SsoAlreadyLinked,
SsoLinkNotFound,
}
impl ApiError {
@@ -197,8 +204,14 @@ impl ApiError {
| Self::InvalidVerificationChannel
| Self::SelfHostedDidWebDisabled
| Self::AccountAlreadyExists
| Self::TokenRequired => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
| Self::TokenRequired
| Self::SsoProviderNotFound
| Self::SsoProviderNotEnabled
| Self::SsoInvalidAction
| Self::SsoNotAuthenticated
| Self::SsoSessionExpired
| Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND,
}
}
fn error_name(&self) -> Cow<'static, str> {
@@ -293,6 +306,13 @@ impl ApiError {
Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"),
Self::HandleNotFound => Cow::Borrowed("HandleNotFound"),
Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"),
Self::SsoProviderNotFound => Cow::Borrowed("SsoProviderNotFound"),
Self::SsoProviderNotEnabled => Cow::Borrowed("SsoProviderNotEnabled"),
Self::SsoInvalidAction => Cow::Borrowed("SsoInvalidAction"),
Self::SsoNotAuthenticated => Cow::Borrowed("SsoNotAuthenticated"),
Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"),
Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"),
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
}
}
fn message(&self) -> Option<String> {
@@ -392,6 +412,19 @@ impl ApiError {
Self::AccountAlreadyExists => Some("Account already exists".to_string()),
Self::HandleNotFound => Some("Unable to resolve handle".to_string()),
Self::SubjectNotFound => Some("Subject not found".to_string()),
Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()),
Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()),
Self::SsoInvalidAction => {
Some("Action must be login, link, or register".to_string())
}
Self::SsoNotAuthenticated => {
Some("Must be authenticated to link SSO account".to_string())
}
Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()),
Self::SsoAlreadyLinked => {
Some("This SSO account is already linked to a different user".to_string())
}
Self::SsoLinkNotFound => Some("Linked account not found".to_string()),
Self::IdentifierMismatch => {
Some("The identifier does not match the verification token".to_string())
}
@@ -467,6 +500,13 @@ impl From<sqlx::Error> for ApiError {
}
}
impl From<tranquil_db_traits::DbError> for ApiError {
fn from(e: tranquil_db_traits::DbError) -> Self {
tracing::error!("Database error: {:?}", e);
Self::DatabaseError
}
}
impl From<crate::auth::TokenValidationError> for ApiError {
fn from(e: crate::auth::TokenValidationError) -> Self {
match e {
@@ -428,7 +428,10 @@ pub async fn activate_account(
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
warn!("[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did);
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
did
);
}
info!(
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
+232 -23
View File
@@ -7,14 +7,45 @@ use axum::{
extract::State,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use std::time::Duration;
use subtle::ConstantTimeEq;
use tracing::{error, info, warn};
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
fn email_update_cache_key(did: &str) -> String {
format!("email_update:{}", did)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
#[derive(Serialize, Deserialize)]
struct PendingEmailUpdate {
new_email: String,
token_hash: String,
authorized: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
#[serde(default)]
pub new_email: Option<String>,
}
pub async fn request_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
input: Option<Json<RequestEmailUpdateInput>>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
@@ -60,11 +91,30 @@ 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 hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
&code,
&formatted_code,
&hostname,
)
@@ -223,34 +273,48 @@ pub async fn update_email(
}
if email_verified {
let Some(ref t) = input.token else {
return ApiError::TokenRequired.into_response();
};
let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim());
let mut authorized_via_link = false;
let current_email_lower = current_email
.as_ref()
.map(|e| e.to_lowercase())
.unwrap_or_default();
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::<PendingEmailUpdate>(&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");
}
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
&current_email_lower,
);
if !authorized_via_link {
let Some(ref t) = input.token else {
return ApiError::TokenRequired.into_response();
};
let confirmation_token =
crate::auth::verification_token::normalize_token_input(t.trim());
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
let current_email_lower = current_email
.as_ref()
.map(|e| e.to_lowercase())
.unwrap_or_default();
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
&current_email_lower,
);
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return ApiError::InvalidToken(None).into_response();
}
}
}
@@ -332,3 +396,148 @@ pub async fn check_email_verified(
}
}
}
#[derive(Deserialize)]
pub struct AuthorizeEmailUpdateQuery {
pub token: String,
}
pub async fn authorize_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
axum::extract::Query(query): axum::extract::Query<AuthorizeEmailUpdateQuery>,
) -> 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 verified = crate::auth::verification_token::verify_token_signature(&query.token);
let token_data = match verified {
Ok(data) => data,
Err(crate::auth::verification_token::VerifyError::Expired) => {
warn!("authorize_email_update: token expired");
return ApiError::ExpiredToken(None).into_response();
}
Err(e) => {
warn!("authorize_email_update: token verification failed: {:?}", e);
return ApiError::InvalidToken(None).into_response();
}
};
if token_data.purpose != crate::auth::verification_token::VerificationPurpose::ChannelUpdate {
warn!(
"authorize_email_update: wrong purpose: {:?}",
token_data.purpose
);
return ApiError::InvalidToken(None).into_response();
}
if token_data.channel != "email_update" {
warn!(
"authorize_email_update: wrong channel: {}",
token_data.channel
);
return ApiError::InvalidToken(None).into_response();
}
let did = token_data.did;
info!("authorize_email_update: token valid for did={}", did);
let cache_key = email_update_cache_key(&did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
None => {
warn!(
"authorize_email_update: no pending email update in cache for did={}",
did
);
return ApiError::InvalidRequest("No pending email update found".into())
.into_response();
}
};
let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let token_hash = hash_token(&query.token);
if pending
.token_hash
.as_bytes()
.ct_eq(token_hash.as_bytes())
.unwrap_u8()
!= 1
{
warn!("authorize_email_update: token hash mismatch");
return ApiError::InvalidToken(None).into_response();
}
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();
}
info!(did = %did, "Email update authorized via link click");
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let redirect_url = format!(
"https://{}/app/verify?type=email-authorize-success",
hostname
);
axum::response::Redirect::to(&redirect_url).into_response()
}
pub async fn check_email_update_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
) -> 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();
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.0.is_oauth,
auth.0.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Read,
) {
return e;
}
let cache_key = email_update_cache_key(&auth.0.did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
None => {
return Json(json!({ "pending": false, "authorized": false })).into_response();
}
};
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return Json(json!({ "pending": false, "authorized": false })).into_response();
}
};
Json(json!({
"pending": true,
"authorized": pending.authorized,
"newEmail": pending.new_email,
}))
.into_response()
}
+4 -1
View File
@@ -22,7 +22,10 @@ pub use account_status::{
request_account_delete,
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{check_email_verified, confirm_email, request_email_update, update_email};
pub use email::{
authorize_email_update, 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;
pub use meta::{describe_server, health, robots_txt};
+27 -6
View File
@@ -366,12 +366,33 @@ pub async fn set_password(
auth: BearerAuth,
Json(input): Json<SetPasswordInput>,
) -> Response {
if crate::api::server::reauth::check_reauth_required_cached(
&*state.session_repo,
&state.cache,
&auth.0.did,
)
.await
let has_password = state
.user_repo
.has_password_by_did(&auth.0.did)
.await
.ok()
.flatten()
.unwrap_or(false);
let has_passkeys = state
.user_repo
.has_passkeys(&auth.0.did)
.await
.unwrap_or(false);
let has_totp = state
.user_repo
.has_totp_enabled(&auth.0.did)
.await
.unwrap_or(false);
let has_any_reauth_method = has_password || has_passkeys || has_totp;
if has_any_reauth_method
&& crate::api::server::reauth::check_reauth_required_cached(
&*state.session_repo,
&state.cache,
&auth.0.did,
)
.await
{
return crate::api::server::reauth::reauth_required_response(
&*state.user_repo,
+6 -5
View File
@@ -366,7 +366,8 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
code: &str,
raw_token: &str,
display_code: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
let prefs = user_repo
@@ -375,17 +376,17 @@ pub mod repo {
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let current_email = prefs.email.unwrap_or_default();
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
let verify_page = format!("https://{}/app/settings", hostname);
let verify_link = format!(
"https://{}/app/verify?type=email-update&token={}",
"https://{}/xrpc/_account.authorizeEmailUpdate?token={}",
hostname,
urlencoding::encode(code)
urlencoding::encode(raw_token)
);
let body = format_message(
strings.email_update_body,
&[
("handle", &prefs.handle),
("code", code),
("code", display_code),
("verify_page", &verify_page),
("verify_link", &verify_link),
],
+30 -1
View File
@@ -16,6 +16,7 @@ pub mod plc;
pub mod rate_limit;
pub mod repo;
pub mod scheduled;
pub mod sso;
pub mod state;
pub mod storage;
pub mod sync;
@@ -287,6 +288,14 @@ pub fn app(state: AppState) -> Router {
"/com.atproto.server.updateEmail",
post(api::server::update_email),
)
.route(
"/_account.authorizeEmailUpdate",
get(api::server::authorize_email_update),
)
.route(
"/_account.checkEmailUpdateStatus",
get(api::server::check_email_update_status),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(api::server::reserve_signing_key),
@@ -569,7 +578,27 @@ pub fn app(state: AppState) -> Router {
)
.route("/token", post(oauth::endpoints::token_endpoint))
.route("/revoke", post(oauth::endpoints::revoke_token))
.route("/introspect", post(oauth::endpoints::introspect_token));
.route("/introspect", post(oauth::endpoints::introspect_token))
.route("/sso/providers", get(sso::endpoints::get_sso_providers))
.route("/sso/initiate", post(sso::endpoints::sso_initiate))
.route(
"/sso/callback",
get(sso::endpoints::sso_callback).post(sso::endpoints::sso_callback_post),
)
.route("/sso/linked", get(sso::endpoints::get_linked_accounts))
.route("/sso/unlink", post(sso::endpoints::unlink_account))
.route(
"/sso/pending-registration",
get(sso::endpoints::get_pending_registration),
)
.route(
"/sso/complete-registration",
post(sso::endpoints::complete_registration),
)
.route(
"/sso/check-handle-available",
get(sso::endpoints::check_handle_available),
);
let well_known_router = Router::new()
.route("/did.json", get(api::identity::well_known_did))
+9 -1
View File
@@ -4,6 +4,13 @@ use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info, warn};
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
const BUILD_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (built ",
env!("BUILD_TIMESTAMP"),
")"
);
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
use tranquil_pds::scheduled::{
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
@@ -106,6 +113,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
state.user_repo.clone(),
state.blob_repo.clone(),
state.blob_store.clone(),
state.sso_repo.clone(),
shutdown_rx,
));
@@ -121,7 +129,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.parse()
.map_err(|e| format!("Invalid SERVER_HOST or SERVER_PORT: {}", e))?;
info!("listening on {}", addr);
info!("tranquil-pds {} listening on {}", BUILD_VERSION, addr);
let listener = tokio::net::TcpListener::bind(addr)
.await
@@ -459,9 +459,7 @@ pub async fn delegation_auth_token(
headers: HeaderMap,
Json(form): Json<DelegationTokenAuthSubmit>,
) -> Response {
let auth_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok());
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
let extracted = match extract_auth_token_from_header(auth_header) {
Some(e) => e,
@@ -176,7 +176,7 @@ pub async fn frontend_client_metadata(
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*"
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*"
.to_string(),
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
+19
View File
@@ -33,6 +33,9 @@ pub struct RateLimiters {
pub handle_update: Arc<KeyedRateLimiter>,
pub handle_update_daily: Arc<KeyedRateLimiter>,
pub verification_check: Arc<KeyedRateLimiter>,
pub sso_initiate: Arc<KeyedRateLimiter>,
pub sso_callback: Arc<KeyedRateLimiter>,
pub sso_unlink: Arc<KeyedRateLimiter>,
}
impl Default for RateLimiters {
@@ -95,6 +98,15 @@ impl RateLimiters {
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(60).unwrap(),
))),
sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
))),
sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
}
}
@@ -139,6 +151,13 @@ impl RateLimiters {
)));
self
}
pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self {
self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
)));
self
}
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
+33 -1
View File
@@ -9,7 +9,8 @@ use tokio::sync::watch;
use tokio::time::interval;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::{
BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, UserRepository,
BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository,
UserRepository,
};
use tranquil_types::{AtUri, CidLink, Did};
@@ -390,6 +391,7 @@ pub async fn start_scheduled_tasks(
user_repo: Arc<dyn UserRepository>,
blob_repo: Arc<dyn BlobRepository>,
blob_store: Arc<dyn BlobStorage>,
sso_repo: Arc<dyn SsoRepository>,
mut shutdown_rx: watch::Receiver<bool>,
) {
let check_interval = Duration::from_secs(
@@ -423,6 +425,36 @@ pub async fn start_scheduled_tasks(
).await {
error!("Error processing scheduled deletions: {}", e);
}
match sso_repo.cleanup_expired_sso_auth_states().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired SSO auth states");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up SSO auth states: {:?}", e);
}
}
match sso_repo.cleanup_expired_pending_registrations().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired SSO pending registrations");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up SSO pending registrations: {:?}", e);
}
}
match user_repo.cleanup_expired_handle_reservations().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired handle reservations");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up handle reservations: {:?}", e);
}
}
}
}
}
+211
View File
@@ -0,0 +1,211 @@
use std::sync::OnceLock;
use tranquil_db_traits::SsoProviderType;
static SSO_CONFIG: OnceLock<SsoConfig> = OnceLock::new();
static SSO_REDIRECT_URI: OnceLock<String> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct ProviderConfig {
pub client_id: String,
pub client_secret: String,
pub issuer: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AppleProviderConfig {
pub client_id: String,
pub team_id: String,
pub key_id: String,
pub private_key_pem: String,
}
#[derive(Debug, Clone, Default)]
pub struct SsoConfig {
pub github: Option<ProviderConfig>,
pub discord: Option<ProviderConfig>,
pub google: Option<ProviderConfig>,
pub gitlab: Option<ProviderConfig>,
pub oidc: Option<ProviderConfig>,
pub apple: Option<AppleProviderConfig>,
}
impl SsoConfig {
pub fn init() -> &'static Self {
SSO_CONFIG.get_or_init(|| {
let github = Self::load_provider("GITHUB", false);
let discord = Self::load_provider("DISCORD", false);
let google = Self::load_provider("GOOGLE", false);
let gitlab = Self::load_provider("GITLAB", true);
let oidc = Self::load_provider("OIDC", true);
let apple = Self::load_apple_provider();
let config = SsoConfig {
github,
discord,
google,
gitlab,
oidc,
apple,
};
if config.is_any_enabled() {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_default();
if hostname.is_empty() || hostname == "localhost" {
panic!(
"PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \
SSO redirect URIs require a proper hostname for security."
);
}
SSO_REDIRECT_URI
.set(format!("https://{}/oauth/sso/callback", hostname))
.expect("SSO_REDIRECT_URI already set");
tracing::info!(
hostname = %hostname,
providers = ?config.enabled_providers().iter().map(|p| p.as_str()).collect::<Vec<_>>(),
"SSO initialized"
);
}
config
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> {
let enabled = std::env::var(format!("SSO_{}_ENABLED", name))
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if !enabled {
return None;
}
let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?;
let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?;
if client_id.is_empty() || client_secret.is_empty() {
tracing::warn!(
"SSO_{} enabled but missing client_id or client_secret",
name
);
return None;
}
let issuer = if needs_issuer {
let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok();
if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
tracing::warn!("SSO_{} requires ISSUER but none provided", name);
return None;
}
issuer_val
} else {
None
};
let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok();
Some(ProviderConfig {
client_id,
client_secret,
issuer,
display_name,
})
}
fn load_apple_provider() -> Option<AppleProviderConfig> {
let enabled = std::env::var("SSO_APPLE_ENABLED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if !enabled {
return None;
}
let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?;
let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?;
let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?;
let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?;
if client_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID");
return None;
}
if team_id.is_empty() || team_id.len() != 10 {
tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)");
return None;
}
if key_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing KEY_ID");
return None;
}
if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") {
tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid");
return None;
}
Some(AppleProviderConfig {
client_id,
team_id,
key_id,
private_key_pem,
})
}
pub fn get() -> &'static Self {
SSO_CONFIG.get_or_init(SsoConfig::default)
}
pub fn get_provider_config(&self, provider: SsoProviderType) -> Option<&ProviderConfig> {
match provider {
SsoProviderType::Github => self.github.as_ref(),
SsoProviderType::Discord => self.discord.as_ref(),
SsoProviderType::Google => self.google.as_ref(),
SsoProviderType::Gitlab => self.gitlab.as_ref(),
SsoProviderType::Oidc => self.oidc.as_ref(),
SsoProviderType::Apple => None,
}
}
pub fn get_apple_config(&self) -> Option<&AppleProviderConfig> {
self.apple.as_ref()
}
pub fn enabled_providers(&self) -> Vec<SsoProviderType> {
let mut providers = Vec::new();
if self.github.is_some() {
providers.push(SsoProviderType::Github);
}
if self.discord.is_some() {
providers.push(SsoProviderType::Discord);
}
if self.google.is_some() {
providers.push(SsoProviderType::Google);
}
if self.gitlab.is_some() {
providers.push(SsoProviderType::Gitlab);
}
if self.oidc.is_some() {
providers.push(SsoProviderType::Oidc);
}
if self.apple.is_some() {
providers.push(SsoProviderType::Apple);
}
providers
}
pub fn is_any_enabled(&self) -> bool {
self.github.is_some()
|| self.discord.is_some()
|| self.google.is_some()
|| self.gitlab.is_some()
|| self.oidc.is_some()
|| self.apple.is_some()
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
pub mod config;
pub mod endpoints;
pub mod providers;
pub use config::SsoConfig;
pub use providers::{AuthUrlResult, SsoError, SsoManager, SsoProvider, SsoUserInfo};
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -4,6 +4,7 @@ use crate::circuit_breaker::CircuitBreakers;
use crate::config::AuthConfig;
use crate::rate_limit::RateLimiters;
use crate::repo::PostgresBlockStore;
use crate::sso::{SsoConfig, SsoManager};
use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage};
use crate::sync::firehose::SequencedEvent;
use sqlx::PgPool;
@@ -13,7 +14,7 @@ use tokio::sync::broadcast;
use tranquil_db::{
BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository,
OAuthRepository, PostgresRepositories, RepoEventNotifier, RepoRepository, SessionRepository,
UserRepository,
SsoRepository, UserRepository,
};
#[derive(Clone)]
@@ -38,6 +39,8 @@ pub struct AppState {
pub cache: Arc<dyn Cache>,
pub distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
pub did_resolver: Arc<DidResolver>,
pub sso_repo: Arc<dyn SsoRepository>,
pub sso_manager: SsoManager,
}
pub enum RateLimitKind {
@@ -56,6 +59,9 @@ pub enum RateLimitKind {
HandleUpdate,
HandleUpdateDaily,
VerificationCheck,
SsoInitiate,
SsoCallback,
SsoUnlink,
}
impl RateLimitKind {
@@ -76,6 +82,9 @@ impl RateLimitKind {
Self::HandleUpdate => "handle_update",
Self::HandleUpdateDaily => "handle_update_daily",
Self::VerificationCheck => "verification_check",
Self::SsoInitiate => "sso_initiate",
Self::SsoCallback => "sso_callback",
Self::SsoUnlink => "sso_unlink",
}
}
@@ -96,6 +105,9 @@ impl RateLimitKind {
Self::HandleUpdate => (10, 300_000),
Self::HandleUpdateDaily => (50, 86_400_000),
Self::VerificationCheck => (60, 60_000),
Self::SsoInitiate => (10, 60_000),
Self::SsoCallback => (30, 60_000),
Self::SsoUnlink => (10, 60_000),
}
}
}
@@ -163,6 +175,8 @@ impl AppState {
let circuit_breakers = Arc::new(CircuitBreakers::new());
let (cache, distributed_rate_limiter) = create_cache().await;
let did_resolver = Arc::new(DidResolver::new());
let sso_config = SsoConfig::init();
let sso_manager = SsoManager::from_config(sso_config);
Self {
user_repo: repos.user.clone(),
@@ -175,6 +189,7 @@ impl AppState {
backup_repo: repos.backup.clone(),
backlink_repo: repos.backlink.clone(),
event_notifier: repos.event_notifier.clone(),
sso_repo: repos.sso.clone(),
repos,
block_store,
blob_store: Arc::new(blob_store),
@@ -185,6 +200,7 @@ impl AppState {
cache,
distributed_rate_limiter,
did_resolver,
sso_manager,
}
}
@@ -232,6 +248,9 @@ impl AppState {
RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update,
RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily,
RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check,
RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate,
RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback,
RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink,
};
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+4 -15
View File
@@ -114,21 +114,10 @@ pub async fn list_repos(
let mut repos: Vec<RepoInfo> = Vec::new();
for row in rows.iter().take(limit as usize) {
let cid_str = row.repo_root_cid.to_string();
let rev = match get_rev_from_commit(&state, &cid_str).await {
Some(r) => r,
None => {
if let Some(ref stored_rev) = row.repo_rev {
stored_rev.clone()
} else {
tracing::warn!(
"Failed to parse commit for DID {} in list_repos: CID {}",
row.did,
row.repo_root_cid
);
continue;
}
}
};
let rev = get_rev_from_commit(&state, &cid_str)
.await
.or_else(|| row.repo_rev.clone())
.unwrap_or_default();
let status = if row.takedown_ref.is_some() {
AccountStatus::Takendown
} else if row.deactivated_at.is_some() {
+131
View File
@@ -0,0 +1,131 @@
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header};
use serde::{Deserialize, Serialize};
const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1G9/WIOAqDBWQd/v
fu+G8OdNg3cVx9sdnp90JRpm8j6hRANCAAR9NOwKON6tu9NG1jtyqqsAuDDq18lc
z+h/EEbR9hbfBEuCzxKhLrlYFLDLNrE/N3KkIPlQm38hnjUO3QXW0ZhY
-----END PRIVATE KEY-----";
const TEST_PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfTTsCjjerbvTRtY7cqqrALgw6tfJ
XM/ofxBG0fYW3wRLgs8SoS65WBSwyzaxPzdypCD5UJt/IZ41Dt0F1tGYWA==
-----END PUBLIC KEY-----";
const TEST_CLIENT_ID: &str = "com.example.test";
const TEST_TEAM_ID: &str = "ABCDE12345";
const TEST_KEY_ID: &str = "KEY123ABCD";
#[derive(Debug, Serialize, Deserialize)]
struct AppleClientSecretClaims {
iss: String,
iat: u64,
exp: u64,
aud: String,
sub: String,
}
fn generate_test_client_secret() -> Result<String, String> {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let exp = now + (150 * 24 * 60 * 60);
let claims = AppleClientSecretClaims {
iss: TEST_TEAM_ID.to_string(),
iat: now,
exp,
aud: "https://appleid.apple.com".to_string(),
sub: TEST_CLIENT_ID.to_string(),
};
let mut header = jsonwebtoken::Header::new(Algorithm::ES256);
header.kid = Some(TEST_KEY_ID.to_string());
let encoding_key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes())
.map_err(|e| format!("Failed to create encoding key: {}", e))?;
jsonwebtoken::encode(&header, &claims, &encoding_key)
.map_err(|e| format!("Failed to encode JWT: {}", e))
}
#[test]
fn test_apple_client_secret_generation() {
let token = generate_test_client_secret().expect("Failed to generate client secret");
assert!(!token.is_empty());
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3, "JWT should have 3 parts");
let header = decode_header(&token).expect("Failed to decode header");
assert_eq!(header.alg, Algorithm::ES256);
assert_eq!(header.kid, Some(TEST_KEY_ID.to_string()));
}
#[test]
fn test_apple_client_secret_claims() {
let token = generate_test_client_secret().expect("Failed to generate client secret");
let parts: Vec<&str> = token.split('.').collect();
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.expect("Failed to decode payload");
let claims: AppleClientSecretClaims =
serde_json::from_slice(&payload_bytes).expect("Failed to parse claims");
assert_eq!(claims.iss, TEST_TEAM_ID);
assert_eq!(claims.sub, TEST_CLIENT_ID);
assert_eq!(claims.aud, "https://appleid.apple.com");
assert!(claims.exp > claims.iat);
let expected_exp_days = (claims.exp - claims.iat) / (24 * 60 * 60);
assert_eq!(expected_exp_days, 150, "Token should expire in 150 days");
}
#[test]
fn test_apple_client_secret_signature_valid() {
let token = generate_test_client_secret().expect("Failed to generate client secret");
let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes())
.expect("Failed to create decoding key");
let mut validation = Validation::new(Algorithm::ES256);
validation.set_audience(&["https://appleid.apple.com"]);
validation.set_issuer(&[TEST_TEAM_ID]);
let token_data =
jsonwebtoken::decode::<AppleClientSecretClaims>(&token, &decoding_key, &validation)
.expect("Failed to decode and verify token");
assert_eq!(token_data.claims.iss, TEST_TEAM_ID);
assert_eq!(token_data.claims.sub, TEST_CLIENT_ID);
assert_eq!(token_data.claims.aud, "https://appleid.apple.com");
}
#[test]
fn test_apple_private_key_validation() {
let result = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes());
assert!(
result.is_ok(),
"Should parse valid PKCS#8 P-256 private key"
);
let invalid_pem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----";
let result = jsonwebtoken::EncodingKey::from_ec_pem(invalid_pem.as_bytes());
assert!(result.is_err(), "Should reject invalid private key");
}
#[test]
fn test_apple_private_key_escaped_newlines() {
let escaped_pem = TEST_PRIVATE_KEY_PEM.replace('\n', "\\n");
let unescaped = escaped_pem.replace("\\n", "\n");
let result = jsonwebtoken::EncodingKey::from_ec_pem(unescaped.as_bytes());
assert!(result.is_ok(), "Should handle escaped newlines in PEM");
}
File diff suppressed because it is too large Load Diff
+66 -38
View File
@@ -110,49 +110,77 @@ async fn test_list_repos_pagination() {
let (_, did2) = create_account_and_login(&client).await;
let (_, did3) = create_account_and_login(&client).await;
let our_dids: std::collections::HashSet<String> = [did1, did2, did3].into_iter().collect();
let mut all_dids_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut cursor: Option<String> = None;
let mut page_count = 0;
let max_pages = 100;
loop {
let mut params: Vec<(&str, String)> = vec![("limit", "10".into())];
if let Some(ref c) = cursor {
params.push(("cursor", c.clone()));
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.listRepos",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
let repos = body["repos"].as_array().unwrap();
for repo in repos {
let did = repo["did"].as_str().unwrap().to_string();
assert!(
!all_dids_seen.contains(&did),
"Pagination returned duplicate DID: {}",
let base = base_url().await;
let verify_futures = our_dids.iter().map(|did| {
let client = &client;
let base = &base;
async move {
let res = client
.get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", base))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::OK,
"Account {} should exist and be queryable via getRepoStatus",
did
);
all_dids_seen.insert(did);
}
cursor = body["cursor"].as_str().map(String::from);
page_count += 1;
if cursor.is_none() || page_count >= max_pages {
break;
});
futures::future::join_all(verify_futures).await;
async fn paginate_repos(
client: &reqwest::Client,
base: &str,
) -> std::collections::HashSet<String> {
let mut all_dids = std::collections::HashSet::new();
let mut cursor: Option<String> = None;
let mut pages = 0;
while pages < 1000 {
let params: Vec<(&str, String)> = cursor
.as_ref()
.map(|c| vec![("limit", "100".into()), ("cursor", c.clone())])
.unwrap_or_else(|| vec![("limit", "100".into())]);
let res = client
.get(format!("{}/xrpc/com.atproto.sync.listRepos", base))
.query(&params)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
body["repos"]
.as_array()
.unwrap()
.iter()
.map(|r| r["did"].as_str().unwrap().to_string())
.for_each(|did| {
assert!(
!all_dids.contains(&did),
"Pagination returned duplicate DID: {}",
did
);
all_dids.insert(did);
});
cursor = body["cursor"].as_str().map(String::from);
pages += 1;
if cursor.is_none() {
break;
}
}
all_dids
}
for did in &our_dids {
assert!(
all_dids_seen.contains(did),
"Our created DID {} was not found in paginated results",
did
);
}
let all_dids_seen = paginate_repos(&client, base).await;
let missing: Vec<_> = our_dids
.iter()
.filter(|did| !all_dids_seen.contains(*did))
.collect();
assert!(
missing.is_empty(),
"DIDs not found in paginated results: {:?}",
missing
);
}
#[tokio::test]