mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-26 04:04:14 +00:00
sso signup & login
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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",
|
||||
¤t_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",
|
||||
¤t_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()
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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]
|
||||
|
||||
Reference in New Issue
Block a user