fix: better type-safety

This commit is contained in:
lewis
2026-01-26 17:15:51 +00:00
committed by Tangled
parent 8eb4b5516f
commit d7b96773fd
131 changed files with 4999 additions and 3982 deletions
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
{
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
},
"Text"
]
},
"nullable": []
},
"hash": "03fc2ba947ee547e000b044fafb486e71b9b65a7dd923b5354c5a4dde98332eb"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
"name": "event_type",
"name": "event_type: RepoEventType",
"type_info": "Text"
},
{
@@ -96,5 +96,5 @@
true
]
},
"hash": "805a344e73f2c19caaffe71de227ddd505599839033e83ae4be5b243d343d651"
"hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT available_uses, COALESCE(disabled, false) as \"disabled!\" FROM invite_codes WHERE code = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "available_uses",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "disabled!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null
]
},
"hash": "200ecf153f1433ae8f6fbe81ab888a04ddd035ec9e88ef5f207e2487a02a1224"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n email,\n preferred_comms_channel::text as \"preferred_channel!\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
"query": "SELECT\n email,\n preferred_comms_channel as \"preferred_channel!: CommsChannel\",\n discord_id,\n discord_verified,\n telegram_username,\n telegram_verified,\n signal_number,\n signal_verified\n FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -10,8 +10,20 @@
},
{
"ordinal": 1,
"name": "preferred_channel!",
"type_info": "Text"
"name": "preferred_channel!: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 2,
@@ -51,7 +63,7 @@
},
"nullable": [
true,
null,
false,
true,
false,
true,
@@ -60,5 +72,5 @@
false
]
},
"hash": "426fedba6791c420fe7af6decc296c681d05a5c24a38b8cd7083c8dfa9178ded"
"hash": "247470d26a90617e7dc9b5b3a2146ee3f54448e3c24943f7005e3a8e28820d43"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n created_at,\n channel as \"channel: String\",\n comms_type as \"comms_type: String\",\n status as \"status: String\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ",
"query": "\n SELECT\n created_at,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n subject,\n body\n FROM comms_queue\n WHERE user_id = $1\n ORDER BY created_at DESC\n LIMIT $2\n ",
"describe": {
"columns": [
{
@@ -10,7 +10,7 @@
},
{
"ordinal": 1,
"name": "channel: String",
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
@@ -27,7 +27,7 @@
},
{
"ordinal": 2,
"name": "comms_type: String",
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
@@ -52,7 +52,7 @@
},
{
"ordinal": 3,
"name": "status: String",
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
@@ -93,5 +93,5 @@
false
]
},
"hash": "9fea6394495b70ef5af2c2f5298e651d1ae78aa9ac6b03f952b6b0416023f671"
"hash": "25309f4a08845a49557d694ad9b5b9a137be4dcce28e9293551c8c3fd40fdd86"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as \"valid!\" FROM invite_codes WHERE code = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "valid!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "36441073d3fb87230f88ddce4e597c248fbf7360e510d703b9eec42efe9e049e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel!: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type as \"account_type!: AccountType\"\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"describe": {
"columns": [
{
@@ -82,8 +82,18 @@
},
{
"ordinal": 13,
"name": "account_type!",
"type_info": "Text"
"name": "account_type!: AccountType",
"type_info": {
"Custom": {
"name": "account_type",
"kind": {
"Enum": [
"personal",
"delegated"
]
}
}
}
}
],
"parameters": {
@@ -105,8 +115,8 @@
false,
false,
false,
null
false
]
},
"hash": "445c2ebb72f3833119f32284b9e721cf34c8ae581e6ae58a392fc93e77a7a015"
"hash": "7061e8763ef7d91ff152ed0124f99e1820172fd06916d225ca6c5137a507b8fa"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
"name": "event_type",
"name": "event_type: RepoEventType",
"type_info": "Text"
},
{
@@ -97,5 +97,5 @@
true
]
},
"hash": "605dc962cf86004de763aee65757a5a77da150b36aa8470c52fd5835e9b895fc"
"hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
"name": "event_type",
"name": "event_type: RepoEventType",
"type_info": "Text"
},
{
@@ -96,5 +96,5 @@
true
]
},
"hash": "e2befe7fa07a1072a8b3f0ed6c1a54a39ffc8769aa65391ea282c78d2cd29f23"
"hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
"name": "event_type",
"name": "event_type: RepoEventType",
"type_info": "Text"
},
{
@@ -97,5 +97,5 @@
true
]
},
"hash": "8f6a1e09351dc716eaadc9e30c5cfea45212901a139e98f0fccfacfbb3371dec"
"hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, handle, preferred_comms_channel as \"preferred_channel!: CommsChannel\", preferred_locale\n FROM users WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "preferred_channel!: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 3,
"name": "preferred_locale",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
false,
false,
true
]
},
"hash": "d8fd97c8be3211b2509669dd859245b14e15f81a42d7e0c4c428b65f466af5ee"
}
@@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, handle, preferred_comms_channel::text as \"preferred_channel!\", preferred_locale\n FROM users WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "preferred_channel!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "preferred_locale",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
false,
null,
true
]
},
"hash": "e3aeec9a759b2b68cb11fa48b5d34ffc19430a6b16adb0c49307da0cacdf1ca3"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2",
"describe": {
"columns": [
{
@@ -20,7 +20,7 @@
},
{
"ordinal": 3,
"name": "event_type",
"name": "event_type: RepoEventType",
"type_info": "Text"
},
{
@@ -97,5 +97,5 @@
true
]
},
"hash": "caffa68d10445a42878b66e6b0224dafb8527c8a4cc9806d6f733edff72bc9db"
"hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a"
}
Generated
+3 -2
View File
@@ -5733,9 +5733,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.17"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
@@ -6115,6 +6115,7 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tower",
"tower-http",
"tower-layer",
+1
View File
@@ -87,6 +87,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"
subtle = "2.5"
thiserror = "2.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "time", "signal", "process"] }
tokio-util = "0.7.18"
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
totp-rs = { version = "5", features = ["qr"] }
tower = "0.5"
@@ -0,0 +1,51 @@
use crate::CommsChannel;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelVerificationStatus {
pub email: bool,
pub discord: bool,
pub telegram: bool,
pub signal: bool,
}
impl ChannelVerificationStatus {
pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self {
Self {
email,
discord,
telegram,
signal,
}
}
pub fn has_any_verified(&self) -> bool {
self.email || self.discord || self.telegram || self.signal
}
pub fn verified_channels(&self) -> Vec<CommsChannel> {
let mut channels = Vec::with_capacity(4);
if self.email {
channels.push(CommsChannel::Email);
}
if self.discord {
channels.push(CommsChannel::Discord);
}
if self.telegram {
channels.push(CommsChannel::Telegram);
}
if self.signal {
channels.push(CommsChannel::Signal);
}
channels
}
pub fn is_verified(&self, channel: CommsChannel) -> bool {
match channel {
CommsChannel::Email => self.email,
CommsChannel::Discord => self.discord,
CommsChannel::Telegram => self.telegram,
CommsChannel::Signal => self.signal,
}
}
}
+6 -5
View File
@@ -5,13 +5,14 @@ use tranquil_types::{Did, Handle};
use uuid::Uuid;
use crate::DbError;
use crate::scope::DbScope;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationGrant {
pub id: Uuid,
pub delegated_did: Did,
pub controller_did: Did,
pub granted_scopes: String,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
pub granted_by: Did,
pub revoked_at: Option<DateTime<Utc>>,
@@ -22,7 +23,7 @@ pub struct DelegationGrant {
pub struct DelegatedAccountInfo {
pub did: Did,
pub handle: Handle,
pub granted_scopes: String,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
}
@@ -30,7 +31,7 @@ pub struct DelegatedAccountInfo {
pub struct ControllerInfo {
pub did: Did,
pub handle: Handle,
pub granted_scopes: String,
pub granted_scopes: DbScope,
pub granted_at: DateTime<Utc>,
pub is_active: bool,
}
@@ -67,7 +68,7 @@ pub trait DelegationRepository: Send + Sync {
&self,
delegated_did: &Did,
controller_did: &Did,
granted_scopes: &str,
granted_scopes: &DbScope,
granted_by: &Did,
) -> Result<Uuid, DbError>;
@@ -82,7 +83,7 @@ pub trait DelegationRepository: Send + Sync {
&self,
delegated_did: &Did,
controller_did: &Did,
new_scopes: &str,
new_scopes: &DbScope,
) -> Result<bool, DbError>;
async fn get_delegation(
+63 -7
View File
@@ -5,6 +5,7 @@ use tranquil_types::{CidLink, Did, Handle};
use uuid::Uuid;
use crate::DbError;
use crate::invite_code::{InviteCodeError, ValidatedInviteCode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InviteCodeSortOrder {
@@ -13,6 +14,45 @@ pub enum InviteCodeSortOrder {
Usage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum InviteCodeState {
#[default]
Active,
Disabled,
}
impl InviteCodeState {
pub fn is_active(self) -> bool {
matches!(self, Self::Active)
}
pub fn is_disabled(self) -> bool {
matches!(self, Self::Disabled)
}
}
impl From<bool> for InviteCodeState {
fn from(disabled: bool) -> Self {
if disabled {
Self::Disabled
} else {
Self::Active
}
}
}
impl From<Option<bool>> for InviteCodeState {
fn from(disabled: Option<bool>) -> Self {
Self::from(disabled.unwrap_or(false))
}
}
impl From<InviteCodeState> for bool {
fn from(state: InviteCodeState) -> Self {
matches!(state, InviteCodeState::Disabled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "comms_channel", rename_all = "snake_case")]
pub enum CommsChannel {
@@ -72,7 +112,7 @@ pub struct QueuedComms {
pub struct InviteCodeInfo {
pub code: String,
pub available_uses: i32,
pub disabled: bool,
pub state: InviteCodeState,
pub for_account: Option<Did>,
pub created_at: DateTime<Utc>,
pub created_by: Option<Did>,
@@ -95,6 +135,12 @@ pub struct InviteCodeRow {
pub created_at: DateTime<Utc>,
}
impl InviteCodeRow {
pub fn state(&self) -> InviteCodeState {
InviteCodeState::from(self.disabled)
}
}
#[derive(Debug, Clone)]
pub struct ReservedSigningKey {
pub id: Uuid,
@@ -148,11 +194,21 @@ pub trait InfraRepository: Send + Sync {
async fn get_invite_code_available_uses(&self, code: &str) -> Result<Option<i32>, DbError>;
async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError>;
async fn validate_invite_code<'a>(
&self,
code: &'a str,
) -> Result<ValidatedInviteCode<'a>, InviteCodeError>;
async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError>;
async fn decrement_invite_code_uses(
&self,
code: &ValidatedInviteCode<'_>,
) -> Result<(), DbError>;
async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError>;
async fn record_invite_code_use(
&self,
code: &ValidatedInviteCode<'_>,
used_by_user: Uuid,
) -> Result<(), DbError>;
async fn get_invite_codes_for_account(
&self,
@@ -317,9 +373,9 @@ pub trait InfraRepository: Send + Sync {
#[derive(Debug, Clone)]
pub struct NotificationHistoryRow {
pub created_at: DateTime<Utc>,
pub channel: String,
pub comms_type: String,
pub status: String,
pub channel: CommsChannel,
pub comms_type: CommsType,
pub status: CommsStatus,
pub subject: Option<String>,
pub body: String,
}
@@ -0,0 +1,56 @@
use std::marker::PhantomData;
use crate::DbError;
#[derive(Debug)]
pub struct ValidatedInviteCode<'a> {
code: &'a str,
_marker: PhantomData<&'a ()>,
}
impl<'a> ValidatedInviteCode<'a> {
pub fn new_validated(code: &'a str) -> Self {
Self {
code,
_marker: PhantomData,
}
}
pub fn code(&self) -> &str {
self.code
}
}
#[derive(Debug)]
pub enum InviteCodeError {
NotFound,
ExhaustedUses,
Disabled,
DatabaseError(DbError),
}
impl std::fmt::Display for InviteCodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "Invite code not found"),
Self::ExhaustedUses => write!(f, "Invite code has no remaining uses"),
Self::Disabled => write!(f, "Invite code is disabled"),
Self::DatabaseError(e) => write!(f, "Database error: {}", e),
}
}
}
impl std::error::Error for InviteCodeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DatabaseError(e) => Some(e),
_ => None,
}
}
}
impl From<DbError> for InviteCodeError {
fn from(e: DbError) -> Self {
Self::DatabaseError(e)
}
}
+30 -19
View File
@@ -1,11 +1,15 @@
mod backlink;
mod backup;
mod blob;
mod channel_verification;
mod delegation;
mod error;
mod infra;
mod invite_code;
mod oauth;
mod repo;
mod scope;
mod sequence;
mod session;
mod sso;
mod user;
@@ -16,6 +20,7 @@ pub use backup::{
OldBackupInfo, UserBackupInfo,
};
pub use blob::{BlobForExport, BlobMetadata, BlobRepository, BlobWithTakedown, MissingBlobInfo};
pub use channel_verification::ChannelVerificationStatus;
pub use delegation::{
AuditLogEntry, ControllerInfo, DelegatedAccountInfo, DelegationActionType, DelegationGrant,
DelegationRepository,
@@ -23,39 +28,45 @@ pub use delegation::{
pub use error::DbError;
pub use infra::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DeletionRequest, InfraRepository,
InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, NotificationHistoryRow,
QueuedComms, ReservedSigningKey,
InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeState, InviteCodeUse,
NotificationHistoryRow, QueuedComms, ReservedSigningKey,
};
pub use invite_code::{InviteCodeError, ValidatedInviteCode};
pub use oauth::{
DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem, RefreshTokenLookup,
ScopePreference, TrustedDeviceRow, TwoFactorChallenge,
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
};
pub use repo::{
ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, CommitEventData,
EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, RecordDelete,
RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier,
RepoEventReceiver, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, RepoWithoutRev,
SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit,
CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError,
RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier,
RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent,
RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
};
pub use scope::{DbScope, InvalidScopeError};
pub use sequence::{SequenceNumber, deserialize_optional_sequence};
pub use session::{
AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem,
SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult,
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
SessionRepository, SessionToken, SessionTokenCreate,
};
pub use sso::{
ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction, SsoAuthState,
SsoPendingRegistration, SsoProviderType, SsoRepository,
};
pub use user::{
AccountSearchResult, CompletePasskeySetupInput, CreateAccountError,
AccountSearchResult, AccountType, CompletePasskeySetupInput, CreateAccountError,
CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput,
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,
UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo,
UserStatus, UserVerificationInfo, UserWithKey,
TotpRecord, TotpRecordState, UnverifiedTotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs,
UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc,
UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery,
UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail,
UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck,
UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification,
UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey,
VerifiedTotpRecord,
};
+47 -12
View File
@@ -10,6 +10,37 @@ use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TokenFamilyId(i32);
impl TokenFamilyId {
pub fn new(id: i32) -> Self {
Self(id)
}
pub fn as_i32(self) -> i32 {
self.0
}
}
impl From<i32> for TokenFamilyId {
fn from(id: i32) -> Self {
Self(id)
}
}
impl From<TokenFamilyId> for i32 {
fn from(id: TokenFamilyId) -> Self {
id.0
}
}
impl std::fmt::Display for TokenFamilyId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopePreference {
pub scope: String,
@@ -53,7 +84,7 @@ pub struct DeviceTrustInfo {
#[derive(Debug, Clone)]
pub struct OAuthSessionListItem {
pub id: i32,
pub id: TokenFamilyId,
pub token_id: TokenId,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
@@ -62,19 +93,19 @@ pub struct OAuthSessionListItem {
pub enum RefreshTokenLookup {
Valid {
db_id: i32,
db_id: TokenFamilyId,
token_data: TokenData,
},
InGracePeriod {
db_id: i32,
db_id: TokenFamilyId,
token_data: TokenData,
rotated_at: DateTime<Utc>,
},
Used {
original_token_id: i32,
original_token_id: TokenFamilyId,
},
Expired {
db_id: i32,
db_id: TokenFamilyId,
},
NotFound,
}
@@ -93,28 +124,28 @@ impl RefreshTokenLookup {
#[async_trait]
pub trait OAuthRepository: Send + Sync {
async fn create_token(&self, data: &TokenData) -> Result<i32, DbError>;
async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError>;
async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError>;
async fn get_token_by_refresh_token(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<(i32, TokenData)>, DbError>;
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>;
async fn get_token_by_previous_refresh_token(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<(i32, TokenData)>, DbError>;
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError>;
async fn rotate_token(
&self,
old_db_id: i32,
old_db_id: TokenFamilyId,
new_refresh_token: &RefreshToken,
new_expires_at: DateTime<Utc>,
) -> Result<(), DbError>;
async fn check_refresh_token_used(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<i32>, DbError>;
) -> Result<Option<TokenFamilyId>, DbError>;
async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError>;
async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError>;
async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError>;
async fn list_tokens_for_user(&self, did: &Did) -> Result<Vec<TokenData>, DbError>;
async fn count_tokens_for_user(&self, did: &Did) -> Result<i64, DbError>;
async fn delete_oldest_tokens_for_user(
@@ -274,7 +305,11 @@ pub trait OAuthRepository: Send + Sync {
) -> Result<(), DbError>;
async fn list_sessions_by_did(&self, did: &Did) -> Result<Vec<OAuthSessionListItem>, DbError>;
async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError>;
async fn delete_session_by_id(
&self,
session_id: TokenFamilyId,
did: &Did,
) -> Result<u64, DbError>;
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
async fn delete_sessions_by_did_except(
&self,
+146 -24
View File
@@ -5,6 +5,116 @@ use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
use uuid::Uuid;
use crate::DbError;
use crate::sequence::SequenceNumber;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RepoEventType {
Commit,
Identity,
Account,
Sync,
}
impl RepoEventType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Commit => "commit",
Self::Identity => "identity",
Self::Account => "account",
Self::Sync => "sync",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
Active,
Takendown,
Suspended,
Deactivated,
Deleted,
}
impl AccountStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Takendown => "takendown",
Self::Suspended => "suspended",
Self::Deactivated => "deactivated",
Self::Deleted => "deleted",
}
}
pub fn for_firehose(&self) -> Option<&'static str> {
match self {
Self::Active => None,
other => Some(other.as_str()),
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"active" => Some(Self::Active),
"takendown" => Some(Self::Takendown),
"suspended" => Some(Self::Suspended),
"deactivated" => Some(Self::Deactivated),
"deleted" => Some(Self::Deleted),
_ => None,
}
}
pub fn is_active(&self) -> bool {
matches!(self, Self::Active)
}
pub fn is_takendown(&self) -> bool {
matches!(self, Self::Takendown)
}
pub fn is_deactivated(&self) -> bool {
matches!(self, Self::Deactivated)
}
pub fn is_suspended(&self) -> bool {
matches!(self, Self::Suspended)
}
pub fn is_deleted(&self) -> bool {
matches!(self, Self::Deleted)
}
pub fn allows_read(&self) -> bool {
matches!(self, Self::Active | Self::Deactivated)
}
pub fn allows_write(&self) -> bool {
matches!(self, Self::Active)
}
pub fn from_db_fields(
takedown_ref: Option<&str>,
deactivated_at: Option<DateTime<Utc>>,
) -> Self {
if takedown_ref.is_some() {
Self::Takendown
} else if deactivated_at.is_some() {
Self::Deactivated
} else {
Self::Active
}
}
}
impl std::fmt::Display for AccountStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoAccountInfo {
@@ -49,7 +159,7 @@ pub struct RepoWithoutRev {
#[derive(Debug, Clone)]
pub struct BrokenGenesisCommit {
pub seq: i64,
pub seq: SequenceNumber,
pub did: Did,
pub commit_cid: Option<CidLink>,
}
@@ -69,15 +179,15 @@ pub struct UserNeedingRecordBlobsBackfill {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoSeqEvent {
pub seq: i64,
pub seq: SequenceNumber,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequencedEvent {
pub seq: i64,
pub seq: SequenceNumber,
pub did: Did,
pub created_at: DateTime<Utc>,
pub event_type: String,
pub event_type: RepoEventType,
pub commit_cid: Option<CidLink>,
pub prev_cid: Option<CidLink>,
pub prev_data_cid: Option<CidLink>,
@@ -86,14 +196,14 @@ pub struct SequencedEvent {
pub blocks_cids: Option<Vec<String>>,
pub handle: Option<Handle>,
pub active: Option<bool>,
pub status: Option<String>,
pub status: Option<AccountStatus>,
pub rev: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CommitEventData {
pub did: Did,
pub event_type: String,
pub event_type: RepoEventType,
pub commit_cid: Option<CidLink>,
pub prev_cid: Option<CidLink>,
pub ops: Option<serde_json::Value>,
@@ -283,27 +393,26 @@ pub trait RepoRepository: Send + Sync {
async fn count_user_blocks(&self, user_id: Uuid) -> Result<i64, DbError>;
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError>;
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
async fn insert_identity_event(
&self,
did: &Did,
handle: Option<&Handle>,
) -> Result<i64, DbError>;
) -> Result<SequenceNumber, DbError>;
async fn insert_account_event(
&self,
did: &Did,
active: bool,
status: Option<&str>,
) -> Result<i64, DbError>;
status: AccountStatus,
) -> Result<SequenceNumber, DbError>;
async fn insert_sync_event(
&self,
did: &Did,
commit_cid: &CidLink,
rev: Option<&str>,
) -> Result<i64, DbError>;
) -> Result<SequenceNumber, DbError>;
async fn insert_genesis_commit_event(
&self,
@@ -311,36 +420,49 @@ pub trait RepoRepository: Send + Sync {
commit_cid: &CidLink,
mst_root_cid: &CidLink,
rev: &str,
) -> Result<i64, DbError>;
) -> Result<SequenceNumber, DbError>;
async fn update_seq_blocks_cids(&self, seq: i64, blocks_cids: &[String])
-> Result<(), DbError>;
async fn update_seq_blocks_cids(
&self,
seq: SequenceNumber,
blocks_cids: &[String],
) -> Result<(), DbError>;
async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError>;
async fn delete_sequences_except(
&self,
did: &Did,
keep_seq: SequenceNumber,
) -> Result<(), DbError>;
async fn get_max_seq(&self) -> Result<i64, DbError>;
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError>;
async fn get_min_seq_since(
&self,
since: DateTime<Utc>,
) -> Result<Option<SequenceNumber>, DbError>;
async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError>;
async fn get_events_since_seq(
&self,
since_seq: i64,
since_seq: SequenceNumber,
limit: Option<i64>,
) -> Result<Vec<SequencedEvent>, DbError>;
async fn get_events_in_seq_range(
&self,
start_seq: i64,
end_seq: i64,
start_seq: SequenceNumber,
end_seq: SequenceNumber,
) -> Result<Vec<SequencedEvent>, DbError>;
async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError>;
async fn get_event_by_seq(
&self,
seq: SequenceNumber,
) -> Result<Option<SequencedEvent>, DbError>;
async fn get_events_since_cursor(
&self,
cursor: i64,
cursor: SequenceNumber,
limit: i64,
) -> Result<Vec<SequencedEvent>, DbError>;
@@ -359,7 +481,7 @@ pub trait RepoRepository: Send + Sync {
async fn get_repo_root_cid_by_user_id(&self, user_id: Uuid)
-> Result<Option<CidLink>, DbError>;
async fn notify_update(&self, seq: i64) -> Result<(), DbError>;
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError>;
async fn import_repo_data(
&self,
+180
View File
@@ -0,0 +1,180 @@
use serde::{Deserialize, Deserializer, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DbScope(String);
impl DbScope {
pub fn new(scope: impl Into<String>) -> Result<Self, InvalidScopeError> {
let scope = scope.into();
validate_scope_string(&scope)?;
Ok(Self(scope))
}
pub fn empty() -> Self {
Self(String::new())
}
pub fn from_db(scope: String) -> Self {
match validate_scope_string(&scope) {
Ok(()) => Self(scope),
Err(e) => panic!("corrupted scope data from database: {}", e),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl Default for DbScope {
fn default() -> Self {
Self::empty()
}
}
impl fmt::Display for DbScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for DbScope {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for DbScope {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for DbScope {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone)]
pub struct InvalidScopeError {
message: String,
}
impl InvalidScopeError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for InvalidScopeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for InvalidScopeError {}
fn validate_scope_string(scopes: &str) -> Result<(), InvalidScopeError> {
if scopes.is_empty() {
return Ok(());
}
scopes.split_whitespace().try_for_each(|scope| {
let base = scope.split_once('?').map_or(scope, |(b, _)| b);
if is_valid_scope_prefix(base) {
Ok(())
} else {
Err(InvalidScopeError::new(format!("Invalid scope: {}", scope)))
}
})
}
fn is_valid_scope_prefix(base: &str) -> bool {
const VALID_PREFIXES: [&str; 8] = [
"atproto",
"repo:",
"blob:",
"rpc:",
"account:",
"identity:",
"transition:",
"include:",
];
VALID_PREFIXES
.iter()
.any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_scopes() {
assert!(DbScope::new("atproto").is_ok());
assert!(DbScope::new("repo:*").is_ok());
assert!(DbScope::new("blob:*/*").is_ok());
assert!(DbScope::new("repo:* blob:*/*").is_ok());
assert!(DbScope::new("").is_ok());
assert!(DbScope::new("account:email?action=read").is_ok());
assert!(DbScope::new("identity:handle").is_ok());
assert!(DbScope::new("transition:generic").is_ok());
assert!(DbScope::new("include:app.bsky.authFullApp").is_ok());
}
#[test]
fn test_invalid_scopes() {
assert!(DbScope::new("invalid:scope").is_err());
assert!(DbScope::new("garbage").is_err());
assert!(DbScope::new("repo:* invalid:scope").is_err());
}
#[test]
fn test_empty_scope() {
let scope = DbScope::empty();
assert!(scope.is_empty());
assert_eq!(scope.as_str(), "");
}
#[test]
fn test_display() {
let scope = DbScope::new("repo:*").unwrap();
assert_eq!(format!("{}", scope), "repo:*");
}
#[test]
#[should_panic(expected = "corrupted scope data from database")]
fn test_from_db_panics_on_corrupted_data() {
DbScope::from_db("totally_invalid_garbage_scope".to_string());
}
#[test]
fn test_from_db_accepts_valid_data() {
let scope = DbScope::from_db("repo:* blob:*/*".to_string());
assert_eq!(scope.as_str(), "repo:* blob:*/*");
}
}
+73
View File
@@ -0,0 +1,73 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SequenceNumber(i64);
impl SequenceNumber {
pub const ZERO: Self = Self(0);
pub const UNSET: Self = Self(-1);
pub fn new(n: i64) -> Option<Self> {
if n >= 0 { Some(Self(n)) } else { None }
}
pub fn from_raw(n: i64) -> Self {
Self(n)
}
pub fn as_i64(&self) -> i64 {
self.0
}
pub fn is_valid(&self) -> bool {
self.0 >= 0
}
}
impl fmt::Display for SequenceNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<i64> for SequenceNumber {
fn from(n: i64) -> Self {
Self(n)
}
}
impl From<SequenceNumber> for i64 {
fn from(seq: SequenceNumber) -> Self {
seq.0
}
}
impl Serialize for SequenceNumber {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SequenceNumber {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let n = i64::deserialize(deserializer)?;
Ok(Self(n))
}
}
pub fn deserialize_optional_sequence<'de, D>(
deserializer: D,
) -> Result<Option<SequenceNumber>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<i64> = Option::deserialize(deserializer)?;
Ok(opt.map(SequenceNumber::from_raw))
}
+107 -15
View File
@@ -5,15 +5,104 @@ use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum LoginType {
#[default]
Modern,
Legacy,
}
impl LoginType {
pub fn is_legacy(self) -> bool {
matches!(self, Self::Legacy)
}
pub fn is_modern(self) -> bool {
matches!(self, Self::Modern)
}
}
impl From<bool> for LoginType {
fn from(legacy: bool) -> Self {
if legacy { Self::Legacy } else { Self::Modern }
}
}
impl From<LoginType> for bool {
fn from(lt: LoginType) -> Self {
matches!(lt, LoginType::Legacy)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum AppPasswordPrivilege {
#[default]
Standard,
Privileged,
}
impl AppPasswordPrivilege {
pub fn is_privileged(self) -> bool {
matches!(self, Self::Privileged)
}
}
impl From<bool> for AppPasswordPrivilege {
fn from(privileged: bool) -> Self {
if privileged {
Self::Privileged
} else {
Self::Standard
}
}
}
impl From<AppPasswordPrivilege> for bool {
fn from(p: AppPasswordPrivilege) -> Self {
matches!(p, AppPasswordPrivilege::Privileged)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionId(i32);
impl SessionId {
pub fn new(id: i32) -> Self {
Self(id)
}
pub fn as_i32(self) -> i32 {
self.0
}
}
impl From<i32> for SessionId {
fn from(id: i32) -> Self {
Self(id)
}
}
impl From<SessionId> for i32 {
fn from(id: SessionId) -> Self {
id.0
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone)]
pub struct SessionToken {
pub id: i32,
pub id: SessionId,
pub did: Did,
pub access_jti: String,
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub legacy_login: bool,
pub login_type: LoginType,
pub mfa_verified: bool,
pub scope: Option<String>,
pub controller_did: Option<Did>,
@@ -29,7 +118,7 @@ pub struct SessionTokenCreate {
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub legacy_login: bool,
pub login_type: LoginType,
pub mfa_verified: bool,
pub scope: Option<String>,
pub controller_did: Option<Did>,
@@ -38,7 +127,7 @@ pub struct SessionTokenCreate {
#[derive(Debug, Clone)]
pub struct SessionForRefresh {
pub id: i32,
pub id: SessionId,
pub did: Did,
pub scope: Option<String>,
pub controller_did: Option<Did>,
@@ -48,7 +137,7 @@ pub struct SessionForRefresh {
#[derive(Debug, Clone)]
pub struct SessionListItem {
pub id: i32,
pub id: SessionId,
pub access_jti: String,
pub created_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
@@ -61,7 +150,7 @@ pub struct AppPasswordRecord {
pub name: String,
pub password_hash: String,
pub created_at: DateTime<Utc>,
pub privileged: bool,
pub privilege: AppPasswordPrivilege,
pub scopes: Option<String>,
pub created_by_controller_did: Option<Did>,
}
@@ -71,14 +160,14 @@ pub struct AppPasswordCreate {
pub user_id: Uuid,
pub name: String,
pub password_hash: String,
pub privileged: bool,
pub privilege: AppPasswordPrivilege,
pub scopes: Option<String>,
pub created_by_controller_did: Option<Did>,
}
#[derive(Debug, Clone)]
pub struct SessionMfaStatus {
pub legacy_login: bool,
pub login_type: LoginType,
pub mfa_verified: bool,
pub last_reauth_at: Option<DateTime<Utc>>,
}
@@ -93,7 +182,7 @@ pub enum RefreshSessionResult {
#[derive(Debug, Clone)]
pub struct SessionRefreshData {
pub old_refresh_jti: String,
pub session_id: i32,
pub session_id: SessionId,
pub new_access_jti: String,
pub new_refresh_jti: String,
pub new_access_expires_at: DateTime<Utc>,
@@ -102,7 +191,7 @@ pub struct SessionRefreshData {
#[async_trait]
pub trait SessionRepository: Send + Sync {
async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError>;
async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError>;
async fn get_session_by_access_jti(
&self,
@@ -116,7 +205,7 @@ pub trait SessionRepository: Send + Sync {
async fn update_session_tokens(
&self,
session_id: i32,
session_id: SessionId,
new_access_jti: &str,
new_refresh_jti: &str,
new_access_expires_at: DateTime<Utc>,
@@ -125,7 +214,7 @@ pub trait SessionRepository: Send + Sync {
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>;
async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError>;
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>;
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
@@ -139,7 +228,7 @@ pub trait SessionRepository: Send + Sync {
async fn get_session_access_jti_by_id(
&self,
session_id: i32,
session_id: SessionId,
did: &Did,
) -> Result<Option<String>, DbError>;
@@ -155,12 +244,15 @@ pub trait SessionRepository: Send + Sync {
app_password_name: &str,
) -> Result<Vec<String>, DbError>;
async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError>;
async fn check_refresh_token_used(
&self,
refresh_jti: &str,
) -> Result<Option<SessionId>, DbError>;
async fn mark_refresh_token_used(
&self,
refresh_jti: &str,
session_id: i32,
session_id: SessionId,
) -> Result<bool, DbError>;
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
+147 -8
View File
@@ -6,6 +6,111 @@ use uuid::Uuid;
use crate::DbError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExternalUserId(String);
impl ExternalUserId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for ExternalUserId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ExternalUserId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<ExternalUserId> for String {
fn from(id: ExternalUserId) -> Self {
id.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExternalUsername(String);
impl ExternalUsername {
pub fn new(username: impl Into<String>) -> Self {
Self(username.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for ExternalUsername {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ExternalUsername {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<ExternalUsername> for String {
fn from(username: ExternalUsername) -> Self {
username.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExternalEmail(String);
impl ExternalEmail {
pub fn new(email: impl Into<String>) -> Self {
Self(email.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl std::fmt::Display for ExternalEmail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ExternalEmail {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<ExternalEmail> for String {
fn from(email: ExternalEmail) -> Self {
email.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")]
pub enum SsoProviderType {
@@ -17,6 +122,40 @@ pub enum SsoProviderType {
Apple,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "text", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum SsoAction {
Login,
Link,
Register,
}
impl SsoAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Login => "login",
Self::Link => "link",
Self::Register => "register",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"login" => Some(Self::Login),
"link" => Some(Self::Link),
"register" => Some(Self::Register),
_ => None,
}
}
}
impl std::fmt::Display for SsoAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl SsoProviderType {
pub fn as_str(&self) -> &'static str {
match self {
@@ -69,9 +208,9 @@ 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 provider_user_id: ExternalUserId,
pub provider_username: Option<ExternalUsername>,
pub provider_email: Option<ExternalEmail>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
@@ -82,7 +221,7 @@ pub struct SsoAuthState {
pub state: String,
pub request_uri: String,
pub provider: SsoProviderType,
pub action: String,
pub action: SsoAction,
pub nonce: Option<String>,
pub code_verifier: Option<String>,
pub did: Option<Did>,
@@ -95,9 +234,9 @@ 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_user_id: ExternalUserId,
pub provider_username: Option<ExternalUsername>,
pub provider_email: Option<ExternalEmail>,
pub provider_email_verified: bool,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
@@ -140,7 +279,7 @@ pub trait SsoRepository: Send + Sync {
state: &str,
request_uri: &str,
provider: SsoProviderType,
action: &str,
action: SsoAction,
nonce: Option<&str>,
code_verifier: Option<&str>,
did: Option<&Did>,
+100 -34
View File
@@ -1,9 +1,23 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tranquil_types::{Did, Handle};
use uuid::Uuid;
use crate::{CommsChannel, DbError, SsoProviderType};
use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "account_type", rename_all = "snake_case")]
pub enum AccountType {
Personal,
Delegated,
}
impl AccountType {
pub fn is_delegated(&self) -> bool {
matches!(self, Self::Delegated)
}
}
#[derive(Debug, Clone)]
pub struct UserRow {
@@ -62,11 +76,8 @@ pub struct UserLoginInfo {
pub preferred_comms_channel: CommsChannel,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub account_type: String,
pub channel_verification: ChannelVerificationStatus,
pub account_type: AccountType,
}
#[derive(Debug, Clone)]
@@ -74,10 +85,7 @@ pub struct User2faStatus {
pub id: Uuid,
pub two_factor_enabled: bool,
pub preferred_comms_channel: CommsChannel,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
}
#[async_trait]
@@ -202,8 +210,11 @@ pub trait UserRepository: Send + Sync {
did: &Did,
) -> Result<Option<UserIdHandleEmail>, DbError>;
async fn update_preferred_comms_channel(&self, did: &Did, channel: &str)
-> Result<(), DbError>;
async fn update_preferred_comms_channel(
&self,
did: &Did,
channel: CommsChannel,
) -> Result<(), DbError>;
async fn clear_discord(&self, user_id: Uuid) -> Result<(), DbError>;
@@ -292,6 +303,8 @@ pub trait UserRepository: Send + Sync {
async fn get_totp_record(&self, did: &Did) -> Result<Option<TotpRecord>, DbError>;
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError>;
async fn upsert_totp_secret(
&self,
did: &Did,
@@ -560,7 +573,7 @@ pub struct DidWebOverrides {
pub struct UserCommsPrefs {
pub email: Option<String>,
pub handle: Handle,
pub preferred_channel: String,
pub preferred_channel: CommsChannel,
pub preferred_locale: Option<String>,
}
@@ -611,16 +624,13 @@ pub struct UserAuthInfo {
pub password_hash: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
}
#[derive(Debug, Clone)]
pub struct NotificationPrefs {
pub email: String,
pub preferred_channel: String,
pub preferred_channel: CommsChannel,
pub discord_id: Option<String>,
pub discord_verified: bool,
pub telegram_username: Option<String>,
@@ -641,10 +651,7 @@ pub struct UserVerificationInfo {
pub id: Uuid,
pub handle: Handle,
pub email: Option<String>,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
}
#[derive(Debug, Clone)]
@@ -675,6 +682,74 @@ pub struct TotpRecord {
pub verified: bool,
}
#[derive(Debug, Clone)]
pub struct VerifiedTotpRecord {
pub secret_encrypted: Vec<u8>,
pub encryption_version: i32,
}
#[derive(Debug, Clone)]
pub struct UnverifiedTotpRecord {
pub secret_encrypted: Vec<u8>,
pub encryption_version: i32,
}
#[derive(Debug, Clone)]
pub enum TotpRecordState {
Verified(VerifiedTotpRecord),
Unverified(UnverifiedTotpRecord),
}
impl TotpRecordState {
pub fn is_verified(&self) -> bool {
matches!(self, Self::Verified(_))
}
pub fn as_verified(&self) -> Option<&VerifiedTotpRecord> {
match self {
Self::Verified(r) => Some(r),
Self::Unverified(_) => None,
}
}
pub fn as_unverified(&self) -> Option<&UnverifiedTotpRecord> {
match self {
Self::Unverified(r) => Some(r),
Self::Verified(_) => None,
}
}
pub fn into_verified(self) -> Option<VerifiedTotpRecord> {
match self {
Self::Verified(r) => Some(r),
Self::Unverified(_) => None,
}
}
pub fn into_unverified(self) -> Option<UnverifiedTotpRecord> {
match self {
Self::Unverified(r) => Some(r),
Self::Verified(_) => None,
}
}
}
impl From<TotpRecord> for TotpRecordState {
fn from(record: TotpRecord) -> Self {
if record.verified {
Self::Verified(VerifiedTotpRecord {
secret_encrypted: record.secret_encrypted,
encryption_version: record.encryption_version,
})
} else {
Self::Unverified(UnverifiedTotpRecord {
secret_encrypted: record.secret_encrypted,
encryption_version: record.encryption_version,
})
}
}
}
#[derive(Debug, Clone)]
pub struct StoredBackupCode {
pub id: Uuid,
@@ -685,15 +760,12 @@ pub struct StoredBackupCode {
pub struct UserSessionInfo {
pub handle: Handle,
pub email: Option<String>,
pub email_verified: bool,
pub is_admin: bool,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub preferred_locale: Option<String>,
pub preferred_comms_channel: CommsChannel,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
pub migrated_to_pds: Option<String>,
pub migrated_at: Option<DateTime<Utc>>,
}
@@ -713,10 +785,7 @@ pub struct UserLoginFull {
pub email: Option<String>,
pub deactivated_at: Option<DateTime<Utc>>,
pub takedown_ref: Option<String>,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
pub allow_legacy_login: bool,
pub migrated_to_pds: Option<String>,
pub preferred_comms_channel: CommsChannel,
@@ -748,10 +817,7 @@ pub struct UserResendVerification {
pub discord_id: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub email_verified: bool,
pub discord_verified: bool,
pub telegram_verified: bool,
pub signal_verified: bool,
pub channel_verification: ChannelVerificationStatus,
}
#[derive(Debug, Clone)]
@@ -1,7 +1,7 @@
use async_trait::async_trait;
use sqlx::PgPool;
use tranquil_db_traits::{
AuditLogEntry, ControllerInfo, DbError, DelegatedAccountInfo, DelegationActionType,
AuditLogEntry, ControllerInfo, DbError, DbScope, DelegatedAccountInfo, DelegationActionType,
DelegationGrant, DelegationRepository,
};
use tranquil_types::Did;
@@ -80,7 +80,7 @@ impl DelegationRepository for PostgresDelegationRepository {
&self,
delegated_did: &Did,
controller_did: &Did,
granted_scopes: &str,
granted_scopes: &DbScope,
granted_by: &Did,
) -> Result<Uuid, DbError> {
let id = sqlx::query_scalar!(
@@ -91,7 +91,7 @@ impl DelegationRepository for PostgresDelegationRepository {
"#,
delegated_did.as_str(),
controller_did.as_str(),
granted_scopes,
granted_scopes.as_str(),
granted_by.as_str()
)
.fetch_one(&self.pool)
@@ -128,7 +128,7 @@ impl DelegationRepository for PostgresDelegationRepository {
&self,
delegated_did: &Did,
controller_did: &Did,
new_scopes: &str,
new_scopes: &DbScope,
) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
@@ -136,7 +136,7 @@ impl DelegationRepository for PostgresDelegationRepository {
SET granted_scopes = $1
WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL
"#,
new_scopes,
new_scopes.as_str(),
delegated_did.as_str(),
controller_did.as_str()
)
@@ -170,7 +170,7 @@ impl DelegationRepository for PostgresDelegationRepository {
id: r.id,
delegated_did: r.delegated_did.into(),
controller_did: r.controller_did.into(),
granted_scopes: r.granted_scopes,
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
granted_by: r.granted_by.into(),
revoked_at: r.revoked_at,
@@ -206,7 +206,7 @@ impl DelegationRepository for PostgresDelegationRepository {
.map(|r| ControllerInfo {
did: r.did.into(),
handle: r.handle.into(),
granted_scopes: r.granted_scopes,
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
is_active: r.is_active,
})
@@ -243,7 +243,7 @@ impl DelegationRepository for PostgresDelegationRepository {
.map(|r| DelegatedAccountInfo {
did: r.did.into(),
handle: r.handle.into(),
granted_scopes: r.granted_scopes,
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
})
.collect())
@@ -280,7 +280,7 @@ impl DelegationRepository for PostgresDelegationRepository {
.map(|r| ControllerInfo {
did: r.did.into(),
handle: r.handle.into(),
granted_scopes: r.granted_scopes,
granted_scopes: DbScope::from_db(r.granted_scopes),
granted_at: r.granted_at,
is_active: r.is_active,
})
+34 -18
View File
@@ -3,8 +3,9 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool;
use tranquil_db_traits::{
AdminAccountInfo, CommsChannel, CommsStatus, CommsType, DbError, DeletionRequest,
InfraRepository, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse,
NotificationHistoryRow, QueuedComms, ReservedSigningKey,
InfraRepository, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder,
InviteCodeState, InviteCodeUse, NotificationHistoryRow, QueuedComms, ReservedSigningKey,
ValidatedInviteCode,
};
use tranquil_types::{CidLink, Did, Handle};
use uuid::Uuid;
@@ -182,22 +183,33 @@ impl InfraRepository for PostgresInfraRepository {
Ok(result)
}
async fn is_invite_code_valid(&self, code: &str) -> Result<bool, DbError> {
let result = sqlx::query_scalar!(
r#"SELECT (available_uses > 0 AND NOT COALESCE(disabled, false)) as "valid!" FROM invite_codes WHERE code = $1"#,
async fn validate_invite_code<'a>(
&self,
code: &'a str,
) -> Result<ValidatedInviteCode<'a>, InviteCodeError> {
let result = sqlx::query!(
r#"SELECT available_uses, COALESCE(disabled, false) as "disabled!" FROM invite_codes WHERE code = $1"#,
code
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
.map_err(|e| InviteCodeError::DatabaseError(map_sqlx_error(e)))?;
Ok(result.unwrap_or(false))
match result {
None => Err(InviteCodeError::NotFound),
Some(row) if row.disabled => Err(InviteCodeError::Disabled),
Some(row) if row.available_uses <= 0 => Err(InviteCodeError::ExhaustedUses),
Some(_) => Ok(ValidatedInviteCode::new_validated(code)),
}
}
async fn decrement_invite_code_uses(&self, code: &str) -> Result<(), DbError> {
async fn decrement_invite_code_uses(
&self,
code: &ValidatedInviteCode<'_>,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
code.code()
)
.execute(&self.pool)
.await
@@ -206,10 +218,14 @@ impl InfraRepository for PostgresInfraRepository {
Ok(())
}
async fn record_invite_code_use(&self, code: &str, used_by_user: Uuid) -> Result<(), DbError> {
async fn record_invite_code_use(
&self,
code: &ValidatedInviteCode<'_>,
used_by_user: Uuid,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
code.code(),
used_by_user
)
.execute(&self.pool)
@@ -245,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository {
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
disabled: r.disabled.unwrap_or(false),
state: InviteCodeState::from(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: None,
@@ -422,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository {
.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
disabled: r.disabled.unwrap_or(false),
state: InviteCodeState::from(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
@@ -445,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository {
Ok(result.map(|r| InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
disabled: r.disabled.unwrap_or(false),
state: InviteCodeState::from(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
@@ -476,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository {
InviteCodeInfo {
code: r.code,
available_uses: r.available_uses,
disabled: r.disabled.unwrap_or(false),
state: InviteCodeState::from(r.disabled),
for_account: Some(Did::from(r.for_account)),
created_at: r.created_at,
created_by: Some(Did::from(r.created_by)),
@@ -841,9 +857,9 @@ impl InfraRepository for PostgresInfraRepository {
r#"
SELECT
created_at,
channel as "channel: String",
comms_type as "comms_type: String",
status as "status: String",
channel as "channel: CommsChannel",
comms_type as "comms_type: CommsType",
status as "status: CommsStatus",
subject,
body
FROM comms_queue
+112 -63
View File
@@ -4,11 +4,12 @@ use rand::Rng;
use sqlx::PgPool;
use tranquil_db_traits::{
DbError, DeviceAccountRow, DeviceTrustInfo, OAuthRepository, OAuthSessionListItem,
ScopePreference, TrustedDeviceRow, TwoFactorChallenge,
ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge,
};
use tranquil_oauth::{
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, DeviceData, RequestData,
TokenData,
AuthorizationRequestParameters, AuthorizedClientData, ClientAuth, Code as OAuthCode,
DeviceData, DeviceId as OAuthDeviceId, RefreshToken as OAuthRefreshToken, RequestData,
SessionId as OAuthSessionId, TokenData, TokenId as OAuthTokenId,
};
use tranquil_types::{
AuthorizationCode, ClientId, DPoPProofId, DeviceId, Did, Handle, RefreshToken, RequestId,
@@ -48,7 +49,7 @@ const REFRESH_GRACE_PERIOD_SECS: i64 = 60;
#[async_trait]
impl OAuthRepository for PostgresOAuthRepository {
async fn create_token(&self, data: &TokenData) -> Result<i32, DbError> {
async fn create_token(&self, data: &TokenData) -> Result<TokenFamilyId, DbError> {
let client_auth_json = to_json(&data.client_auth)?;
let parameters_json = to_json(&data.parameters)?;
let row = sqlx::query!(
@@ -59,25 +60,25 @@ impl OAuthRepository for PostgresOAuthRepository {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
"#,
data.did,
data.token_id,
data.did.as_str(),
&data.token_id.0,
data.created_at,
data.updated_at,
data.expires_at,
data.client_id,
client_auth_json,
data.device_id,
data.device_id.as_ref().map(|d| d.0.as_str()),
parameters_json,
data.details,
data.code,
data.current_refresh_token,
data.code.as_ref().map(|c| c.0.as_str()),
data.current_refresh_token.as_ref().map(|r| r.0.as_str()),
data.scope,
data.controller_did,
data.controller_did.as_ref().map(|d| d.as_str()),
)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.id)
Ok(TokenFamilyId::new(row.id))
}
async fn get_token_by_id(&self, token_id: &TokenId) -> Result<Option<TokenData>, DbError> {
@@ -95,20 +96,27 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
match row {
Some(r) => Ok(Some(TokenData {
did: r.did,
token_id: r.token_id,
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r.controller_did,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
})),
None => Ok(None),
}
@@ -117,7 +125,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn get_token_by_refresh_token(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<(i32, TokenData)>, DbError> {
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> {
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
@@ -132,22 +140,29 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
match row {
Some(r) => Ok(Some((
r.id,
TokenFamilyId::new(r.id),
TokenData {
did: r.did,
token_id: r.token_id,
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r.controller_did,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
},
))),
None => Ok(None),
@@ -157,7 +172,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn get_token_by_previous_refresh_token(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<(i32, TokenData)>, DbError> {
) -> Result<Option<(TokenFamilyId, TokenData)>, DbError> {
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
let row = sqlx::query!(
r#"
@@ -174,22 +189,29 @@ impl OAuthRepository for PostgresOAuthRepository {
.map_err(map_sqlx_error)?;
match row {
Some(r) => Ok(Some((
r.id,
TokenFamilyId::new(r.id),
TokenData {
did: r.did,
token_id: r.token_id,
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r.controller_did,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
},
))),
None => Ok(None),
@@ -198,7 +220,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn rotate_token(
&self,
old_db_id: i32,
old_db_id: TokenFamilyId,
new_refresh_token: &RefreshToken,
new_expires_at: DateTime<Utc>,
) -> Result<(), DbError> {
@@ -207,7 +229,7 @@ impl OAuthRepository for PostgresOAuthRepository {
r#"
SELECT current_refresh_token FROM oauth_token WHERE id = $1
"#,
old_db_id
old_db_id.as_i32()
)
.fetch_one(&mut *tx)
.await
@@ -219,7 +241,7 @@ impl OAuthRepository for PostgresOAuthRepository {
VALUES ($1, $2)
"#,
old_rt,
old_db_id
old_db_id.as_i32()
)
.execute(&mut *tx)
.await
@@ -232,7 +254,7 @@ impl OAuthRepository for PostgresOAuthRepository {
previous_refresh_token = $4, rotated_at = NOW()
WHERE id = $1
"#,
old_db_id,
old_db_id.as_i32(),
new_refresh_token.as_str(),
new_expires_at,
old_refresh
@@ -247,7 +269,7 @@ impl OAuthRepository for PostgresOAuthRepository {
async fn check_refresh_token_used(
&self,
refresh_token: &RefreshToken,
) -> Result<Option<i32>, DbError> {
) -> Result<Option<TokenFamilyId>, DbError> {
let row = sqlx::query_scalar!(
r#"
SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1
@@ -257,7 +279,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row)
Ok(row.map(TokenFamilyId::new))
}
async fn delete_token(&self, token_id: &TokenId) -> Result<(), DbError> {
@@ -273,12 +295,12 @@ impl OAuthRepository for PostgresOAuthRepository {
Ok(())
}
async fn delete_token_family(&self, db_id: i32) -> Result<(), DbError> {
async fn delete_token_family(&self, db_id: TokenFamilyId) -> Result<(), DbError> {
sqlx::query!(
r#"
DELETE FROM oauth_token WHERE id = $1
"#,
db_id
db_id.as_i32()
)
.execute(&self.pool)
.await
@@ -302,20 +324,27 @@ impl OAuthRepository for PostgresOAuthRepository {
rows.into_iter()
.map(|r| {
Ok(TokenData {
did: r.did,
token_id: r.token_id,
did: r
.did
.parse()
.map_err(|_| DbError::Other("Invalid DID in token".into()))?,
token_id: OAuthTokenId(r.token_id),
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
device_id: r.device_id.map(OAuthDeviceId),
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
code: r.code.map(OAuthCode),
current_refresh_token: r.current_refresh_token.map(OAuthRefreshToken),
scope: r.scope,
controller_did: r.controller_did,
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID".into()))?,
})
})
.collect()
@@ -407,13 +436,13 @@ impl OAuthRepository for PostgresOAuthRepository {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
request_id.as_str(),
data.did,
data.device_id,
data.did.as_ref().map(|d| d.as_str()),
data.device_id.as_ref().map(|d| d.0.as_str()),
data.client_id,
client_auth_json,
parameters_json,
data.expires_at,
data.code,
data.code.as_ref().map(|c| c.0.as_str()),
)
.execute(&self.pool)
.await
@@ -448,10 +477,18 @@ impl OAuthRepository for PostgresOAuthRepository {
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did,
device_id: r.device_id,
code: r.code,
controller_did: r.controller_did,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
device_id: r.device_id.map(OAuthDeviceId),
code: r.code.map(OAuthCode),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
}))
}
None => Ok(None),
@@ -534,10 +571,18 @@ impl OAuthRepository for PostgresOAuthRepository {
client_auth,
parameters,
expires_at: r.expires_at,
did: r.did,
device_id: r.device_id,
code: r.code,
controller_did: r.controller_did,
did: r
.did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid DID in DB".into()))?,
device_id: r.device_id.map(OAuthDeviceId),
code: r.code.map(OAuthCode),
controller_did: r
.controller_did
.map(|s| s.parse())
.transpose()
.map_err(|_| DbError::Other("Invalid controller DID in DB".into()))?,
}))
}
None => Ok(None),
@@ -655,7 +700,7 @@ impl OAuthRepository for PostgresOAuthRepository {
VALUES ($1, $2, $3, $4, $5)
"#,
device_id.as_str(),
data.session_id,
&data.session_id.0,
data.user_agent,
data.ip_address,
data.last_seen_at,
@@ -679,7 +724,7 @@ impl OAuthRepository for PostgresOAuthRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| DeviceData {
session_id: r.session_id,
session_id: OAuthSessionId(r.session_id),
user_agent: r.user_agent,
ip_address: r.ip_address,
last_seen_at: r.last_seen_at,
@@ -1207,7 +1252,7 @@ impl OAuthRepository for PostgresOAuthRepository {
Ok(rows
.into_iter()
.map(|r| OAuthSessionListItem {
id: r.id,
id: TokenFamilyId::new(r.id),
token_id: TokenId::from(r.token_id),
created_at: r.created_at,
expires_at: r.expires_at,
@@ -1216,10 +1261,14 @@ impl OAuthRepository for PostgresOAuthRepository {
.collect())
}
async fn delete_session_by_id(&self, session_id: i32, did: &Did) -> Result<u64, DbError> {
async fn delete_session_by_id(
&self,
session_id: TokenFamilyId,
did: &Did,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM oauth_token WHERE id = $1 AND did = $2",
session_id,
session_id.as_i32(),
did.as_str()
)
.execute(&self.pool)
+146 -107
View File
@@ -2,10 +2,10 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use tranquil_db_traits::{
BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, ImportBlock,
ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, RepoInfo,
RepoListItem, RepoRepository, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill,
UserWithoutBlocks,
AccountStatus, BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo,
ImportBlock, ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo,
RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, SequenceNumber,
SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks,
};
use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey};
use uuid::Uuid;
@@ -21,7 +21,7 @@ struct SequencedEventRow {
seq: i64,
did: String,
created_at: DateTime<Utc>,
event_type: String,
event_type: RepoEventType,
commit_cid: Option<String>,
prev_cid: Option<String>,
prev_data_cid: Option<String>,
@@ -627,7 +627,7 @@ impl RepoRepository for PostgresRepoRepository {
Ok(rows.into_iter().map(|(cid,)| cid).collect())
}
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<i64, DbError> {
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError> {
let seq = sqlx::query_scalar!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev)
@@ -635,7 +635,7 @@ impl RepoRepository for PostgresRepoRepository {
RETURNING seq
"#,
data.did.as_str(),
data.event_type,
data.event_type.as_str(),
data.commit_cid.as_ref().map(|c| c.as_str()),
data.prev_cid.as_ref().map(|c| c.as_str()),
data.ops,
@@ -648,14 +648,14 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn insert_identity_event(
&self,
did: &Did,
handle: Option<&Handle>,
) -> Result<i64, DbError> {
) -> Result<SequenceNumber, DbError> {
let handle_str = handle.map(|h| h.as_str());
let seq = sqlx::query_scalar!(
r#"
@@ -675,15 +675,16 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn insert_account_event(
&self,
did: &Did,
active: bool,
status: Option<&str>,
) -> Result<i64, DbError> {
status: AccountStatus,
) -> Result<SequenceNumber, DbError> {
let active = status.is_active();
let status_str = status.for_firehose();
let seq = sqlx::query_scalar!(
r#"
INSERT INTO repo_seq (did, event_type, active, status)
@@ -692,7 +693,7 @@ impl RepoRepository for PostgresRepoRepository {
"#,
did.as_str(),
active,
status
status_str
)
.fetch_one(&self.pool)
.await
@@ -703,7 +704,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn insert_sync_event(
@@ -711,7 +712,7 @@ impl RepoRepository for PostgresRepoRepository {
did: &Did,
commit_cid: &CidLink,
rev: Option<&str>,
) -> Result<i64, DbError> {
) -> Result<SequenceNumber, DbError> {
let seq = sqlx::query_scalar!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, rev)
@@ -731,7 +732,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn insert_genesis_commit_event(
@@ -740,7 +741,7 @@ impl RepoRepository for PostgresRepoRepository {
commit_cid: &CidLink,
mst_root_cid: &CidLink,
rev: &str,
) -> Result<i64, DbError> {
) -> Result<SequenceNumber, DbError> {
let ops = serde_json::json!([]);
let blobs: Vec<String> = vec![];
let blocks_cids: Vec<String> = vec![mst_root_cid.to_string(), commit_cid.to_string()];
@@ -769,18 +770,18 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn update_seq_blocks_cids(
&self,
seq: i64,
seq: SequenceNumber,
blocks_cids: &[String],
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2",
blocks_cids,
seq
seq.as_i64()
)
.execute(&self.pool)
.await
@@ -789,11 +790,15 @@ impl RepoRepository for PostgresRepoRepository {
Ok(())
}
async fn delete_sequences_except(&self, did: &Did, keep_seq: i64) -> Result<(), DbError> {
async fn delete_sequences_except(
&self,
did: &Did,
keep_seq: SequenceNumber,
) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM repo_seq WHERE did = $1 AND seq != $2",
did.as_str(),
keep_seq
keep_seq.as_i64()
)
.execute(&self.pool)
.await
@@ -802,16 +807,19 @@ impl RepoRepository for PostgresRepoRepository {
Ok(())
}
async fn get_max_seq(&self) -> Result<i64, DbError> {
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError> {
let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.into())
}
async fn get_min_seq_since(&self, since: DateTime<Utc>) -> Result<Option<i64>, DbError> {
async fn get_min_seq_since(
&self,
since: DateTime<Utc>,
) -> Result<Option<SequenceNumber>, DbError> {
let seq = sqlx::query_scalar!(
"SELECT MIN(seq) FROM repo_seq WHERE created_at >= $1",
since
@@ -820,7 +828,7 @@ impl RepoRepository for PostgresRepoRepository {
.await
.map_err(map_sqlx_error)?;
Ok(seq)
Ok(seq.map(SequenceNumber::from))
}
async fn get_account_with_repo(&self, did: &Did) -> Result<Option<RepoAccountInfo>, DbError> {
@@ -846,36 +854,43 @@ impl RepoRepository for PostgresRepoRepository {
async fn get_events_since_seq(
&self,
since_seq: i64,
since_seq: SequenceNumber,
limit: Option<i64>,
) -> Result<Vec<SequencedEvent>, DbError> {
let map_row = |r: SequencedEventRow| SequencedEvent {
seq: r.seq,
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status: r.status,
rev: r.rev,
let map_row = |r: SequencedEventRow| {
let status = r
.status
.as_deref()
.and_then(AccountStatus::parse)
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
SequencedEvent {
seq: r.seq.into(),
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status,
rev: r.rev,
}
};
match limit {
Some(lim) => {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
ORDER BY seq ASC
LIMIT $2"#,
since_seq,
since_seq.as_i64(),
lim
)
.fetch_all(&self.pool)
@@ -886,12 +901,12 @@ impl RepoRepository for PostgresRepoRepository {
None => {
let rows = sqlx::query_as!(
SequencedEventRow,
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
ORDER BY seq ASC"#,
since_seq
since_seq.as_i64()
)
.fetch_all(&self.pool)
.await
@@ -903,25 +918,71 @@ impl RepoRepository for PostgresRepoRepository {
async fn get_events_in_seq_range(
&self,
start_seq: i64,
end_seq: i64,
start_seq: SequenceNumber,
end_seq: SequenceNumber,
) -> Result<Vec<SequencedEvent>, DbError> {
let rows = sqlx::query!(
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1 AND seq < $2
ORDER BY seq ASC"#,
start_seq,
end_seq
start_seq.as_i64(),
end_seq.as_i64()
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| SequencedEvent {
seq: r.seq,
.map(|r| {
let status = r
.status
.as_deref()
.and_then(AccountStatus::parse)
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
SequencedEvent {
seq: r.seq.into(),
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status,
rev: r.rev,
}
})
.collect())
}
async fn get_event_by_seq(
&self,
seq: SequenceNumber,
) -> Result<Option<SequencedEvent>, DbError> {
let row = sqlx::query!(
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq = $1"#,
seq.as_i64()
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| {
let status = r
.status
.as_deref()
.and_then(AccountStatus::parse)
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
SequencedEvent {
seq: r.seq.into(),
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
@@ -933,54 +994,25 @@ impl RepoRepository for PostgresRepoRepository {
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status: r.status,
status,
rev: r.rev,
})
.collect())
}
async fn get_event_by_seq(&self, seq: i64) -> Result<Option<SequencedEvent>, DbError> {
let row = sqlx::query!(
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq = $1"#,
seq
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SequencedEvent {
seq: r.seq,
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status: r.status,
rev: r.rev,
}
}))
}
async fn get_events_since_cursor(
&self,
cursor: i64,
cursor: SequenceNumber,
limit: i64,
) -> Result<Vec<SequencedEvent>, DbError> {
let rows = sqlx::query!(
r#"SELECT seq, did, created_at, event_type, commit_cid, prev_cid, prev_data_cid,
r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid,
ops, blobs, blocks_cids, handle, active, status, rev
FROM repo_seq
WHERE seq > $1
ORDER BY seq ASC
LIMIT $2"#,
cursor,
cursor.as_i64(),
limit
)
.fetch_all(&self.pool)
@@ -988,21 +1020,28 @@ impl RepoRepository for PostgresRepoRepository {
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| SequencedEvent {
seq: r.seq,
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status: r.status,
rev: r.rev,
.map(|r| {
let status = r
.status
.as_deref()
.and_then(AccountStatus::parse)
.or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active));
SequencedEvent {
seq: r.seq.into(),
did: Did::from(r.did),
created_at: r.created_at,
event_type: r.event_type,
commit_cid: r.commit_cid.map(CidLink::from),
prev_cid: r.prev_cid.map(CidLink::from),
prev_data_cid: r.prev_data_cid.map(CidLink::from),
ops: r.ops,
blobs: r.blobs,
blocks_cids: r.blocks_cids,
handle: r.handle.map(Handle::from),
active: r.active,
status,
rev: r.rev,
}
})
.collect())
}
@@ -1079,8 +1118,8 @@ impl RepoRepository for PostgresRepoRepository {
Ok(cid.map(CidLink::from))
}
async fn notify_update(&self, seq: i64) -> Result<(), DbError> {
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq))
async fn notify_update(&self, seq: SequenceNumber) -> Result<(), DbError> {
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq.as_i64()))
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
@@ -1329,7 +1368,7 @@ impl RepoRepository for PostgresRepoRepository {
"#,
)
.bind(&event.did)
.bind(&event.event_type)
.bind(event.event_type.as_str())
.bind(&event.commit_cid)
.bind(&event.prev_cid)
.bind(&event.ops)
@@ -1375,7 +1414,7 @@ impl RepoRepository for PostgresRepoRepository {
Ok(rows
.into_iter()
.map(|r| BrokenGenesisCommit {
seq: r.seq,
seq: r.seq.into(),
did: Did::from(r.did),
commit_cid: r.commit_cid.map(CidLink::from),
})
+42 -33
View File
@@ -2,9 +2,9 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use tranquil_db_traits::{
AppPasswordCreate, AppPasswordRecord, DbError, RefreshSessionResult, SessionForRefresh,
SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken,
SessionTokenCreate,
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType,
RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus,
SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
};
use tranquil_types::Did;
use uuid::Uuid;
@@ -23,7 +23,7 @@ impl PostgresSessionRepository {
#[async_trait]
impl SessionRepository for PostgresSessionRepository {
async fn create_session(&self, data: &SessionTokenCreate) -> Result<i32, DbError> {
async fn create_session(&self, data: &SessionTokenCreate) -> Result<SessionId, DbError> {
let row = sqlx::query!(
r#"
INSERT INTO session_tokens
@@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository {
data.refresh_jti,
data.access_expires_at,
data.refresh_expires_at,
data.legacy_login,
bool::from(data.login_type),
data.mfa_verified,
data.scope,
data.controller_did.as_ref().map(|d| d.as_str()),
@@ -47,7 +47,7 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row.id)
Ok(SessionId::new(row.id))
}
async fn get_session_by_access_jti(
@@ -69,13 +69,13 @@ impl SessionRepository for PostgresSessionRepository {
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionToken {
id: r.id,
id: SessionId::new(r.id),
did: Did::from(r.did),
access_jti: r.access_jti,
refresh_jti: r.refresh_jti,
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
legacy_login: r.legacy_login,
login_type: LoginType::from(r.legacy_login),
mfa_verified: r.mfa_verified,
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
@@ -104,7 +104,7 @@ impl SessionRepository for PostgresSessionRepository {
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionForRefresh {
id: r.id,
id: SessionId::new(r.id),
did: Did::from(r.did),
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
@@ -115,7 +115,7 @@ impl SessionRepository for PostgresSessionRepository {
async fn update_session_tokens(
&self,
session_id: i32,
session_id: SessionId,
new_access_jti: &str,
new_refresh_jti: &str,
new_access_expires_at: DateTime<Utc>,
@@ -132,7 +132,7 @@ impl SessionRepository for PostgresSessionRepository {
new_refresh_jti,
new_access_expires_at,
new_refresh_expires_at,
session_id
session_id.as_i32()
)
.execute(&self.pool)
.await
@@ -153,11 +153,14 @@ impl SessionRepository for PostgresSessionRepository {
Ok(result.rows_affected())
}
async fn delete_session_by_id(&self, session_id: i32) -> Result<u64, DbError> {
let result = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1",
session_id.as_i32()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
@@ -205,7 +208,7 @@ impl SessionRepository for PostgresSessionRepository {
Ok(rows
.into_iter()
.map(|r| SessionListItem {
id: r.id,
id: SessionId::new(r.id),
access_jti: r.access_jti,
created_at: r.created_at,
refresh_expires_at: r.refresh_expires_at,
@@ -215,12 +218,12 @@ impl SessionRepository for PostgresSessionRepository {
async fn get_session_access_jti_by_id(
&self,
session_id: i32,
session_id: SessionId,
did: &Did,
) -> Result<Option<String>, DbError> {
let row = sqlx::query_scalar!(
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
session_id,
session_id.as_i32(),
did.as_str()
)
.fetch_optional(&self.pool)
@@ -264,7 +267,10 @@ impl SessionRepository for PostgresSessionRepository {
Ok(rows)
}
async fn check_refresh_token_used(&self, refresh_jti: &str) -> Result<Option<i32>, DbError> {
async fn check_refresh_token_used(
&self,
refresh_jti: &str,
) -> Result<Option<SessionId>, DbError> {
let row = sqlx::query_scalar!(
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
refresh_jti
@@ -273,13 +279,13 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
Ok(row)
Ok(row.map(SessionId::new))
}
async fn mark_refresh_token_used(
&self,
refresh_jti: &str,
session_id: i32,
session_id: SessionId,
) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
@@ -288,7 +294,7 @@ impl SessionRepository for PostgresSessionRepository {
ON CONFLICT (refresh_jti) DO NOTHING
"#,
refresh_jti,
session_id
session_id.as_i32()
)
.execute(&self.pool)
.await
@@ -319,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privileged: r.privileged,
privilege: AppPasswordPrivilege::from(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
@@ -352,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privileged: r.privileged,
privilege: AppPasswordPrivilege::from(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
})
@@ -383,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository {
name: r.name,
password_hash: r.password_hash,
created_at: r.created_at,
privileged: r.privileged,
privilege: AppPasswordPrivilege::from(r.privileged),
scopes: r.scopes,
created_by_controller_did: r.created_by_controller_did.map(Did::from),
}))
@@ -399,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository {
data.user_id,
data.name,
data.password_hash,
data.privileged,
bool::from(data.privilege),
data.scopes,
data.created_by_controller_did.as_ref().map(|d| d.as_str())
)
@@ -480,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository {
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SessionMfaStatus {
legacy_login: r.legacy_login,
login_type: LoginType::from(r.legacy_login),
mfa_verified: r.mfa_verified,
last_reauth_at: r.last_reauth_at,
}))
@@ -535,16 +541,19 @@ impl SessionRepository for PostgresSessionRepository {
let result = sqlx::query!(
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
data.old_refresh_jti,
data.session_id
data.session_id.as_i32()
)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
if result.rows_affected() == 0 {
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", data.session_id)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1",
data.session_id.as_i32()
)
.execute(&mut *tx)
.await;
tx.commit().await.map_err(map_sqlx_error)?;
return Ok(RefreshSessionResult::ConcurrentRefresh);
}
@@ -555,7 +564,7 @@ impl SessionRepository for PostgresSessionRepository {
data.new_refresh_jti,
data.new_access_expires_at,
data.new_refresh_expires_at,
data.session_id
data.session_id.as_i32()
)
.execute(&mut *tx)
.await
+33 -28
View File
@@ -2,7 +2,8 @@ use async_trait::async_trait;
use chrono::Utc;
use sqlx::PgPool;
use tranquil_db_traits::{
DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
DbError, ExternalEmail, ExternalIdentity, ExternalUserId, ExternalUsername, SsoAction,
SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
};
use tranquil_types::Did;
use uuid::Uuid;
@@ -69,11 +70,11 @@ impl SsoRepository for PostgresSsoRepository {
Ok(row.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
did: unsafe { 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,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
@@ -102,11 +103,11 @@ impl SsoRepository for PostgresSsoRepository {
.into_iter()
.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
did: unsafe { 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,
provider_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
@@ -161,7 +162,7 @@ impl SsoRepository for PostgresSsoRepository {
state: &str,
request_uri: &str,
provider: SsoProviderType,
action: &str,
action: SsoAction,
nonce: Option<&str>,
code_verifier: Option<&str>,
did: Option<&Did>,
@@ -174,7 +175,7 @@ impl SsoRepository for PostgresSsoRepository {
state,
request_uri,
provider as SsoProviderType,
action,
action.as_str(),
nonce,
code_verifier,
did.map(|d| d.as_str()),
@@ -200,17 +201,21 @@ impl SsoRepository for PostgresSsoRepository {
.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,
}))
row.map(|r| {
let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?;
Ok(SsoAuthState {
state: r.state,
request_uri: r.request_uri,
provider: r.provider,
action,
nonce: r.nonce,
code_verifier: r.code_verifier,
did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }),
created_at: r.created_at,
expires_at: r.expires_at,
})
})
.transpose()
}
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> {
@@ -280,9 +285,9 @@ impl SsoRepository for PostgresSsoRepository {
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_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
@@ -311,9 +316,9 @@ impl SsoRepository for PostgresSsoRepository {
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_user_id: ExternalUserId::from(r.provider_user_id),
provider_username: r.provider_username.map(ExternalUsername::from),
provider_email: r.provider_email.map(ExternalEmail::from),
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
+66 -45
View File
@@ -5,15 +5,16 @@ use tranquil_types::{Did, Handle};
use uuid::Uuid;
use tranquil_db_traits::{
AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs,
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,
UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo,
UserStatus, UserVerificationInfo, UserWithKey,
AccountSearchResult, AccountType, ChannelVerificationStatus, CommsChannel, DbError,
DidWebOverrides, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, SsoProviderType,
StoredBackupCode, StoredPasskey, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo,
UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion,
UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup,
UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash,
UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref,
UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository,
UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus,
UserVerificationInfo, UserWithKey,
};
pub struct PostgresUserRepository {
@@ -280,10 +281,12 @@ impl UserRepository for PostgresUserRepository {
password_hash: r.password_hash,
deactivated_at: r.deactivated_at,
takedown_ref: r.takedown_ref,
email_verified: r.email_verified,
discord_verified: r.discord_verified,
telegram_verified: r.telegram_verified,
signal_verified: r.signal_verified,
channel_verification: ChannelVerificationStatus::new(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
}))
}
@@ -308,7 +311,7 @@ impl UserRepository for PostgresUserRepository {
async fn get_comms_prefs(&self, user_id: Uuid) -> Result<Option<UserCommsPrefs>, DbError> {
let row = sqlx::query!(
r#"SELECT email, handle, preferred_comms_channel::text as "preferred_channel!", preferred_locale
r#"SELECT email, handle, preferred_comms_channel as "preferred_channel!: CommsChannel", preferred_locale
FROM users WHERE id = $1"#,
user_id
)
@@ -601,7 +604,7 @@ impl UserRepository for PostgresUserRepository {
let row = sqlx::query!(
r#"SELECT
email,
preferred_comms_channel::text as "preferred_channel!",
preferred_comms_channel as "preferred_channel!: CommsChannel",
discord_id,
discord_verified,
telegram_username,
@@ -647,13 +650,13 @@ impl UserRepository for PostgresUserRepository {
async fn update_preferred_comms_channel(
&self,
did: &Did,
channel: &str,
channel: CommsChannel,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2",
sqlx::query!(
"UPDATE users SET preferred_comms_channel = $1, updated_at = NOW() WHERE did = $2",
channel as CommsChannel,
did.as_str()
)
.bind(channel)
.bind(did.as_str())
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
@@ -709,10 +712,12 @@ impl UserRepository for PostgresUserRepository {
id: r.id,
handle: Handle::from(r.handle),
email: r.email,
email_verified: r.email_verified,
discord_verified: r.discord_verified,
telegram_verified: r.telegram_verified,
signal_verified: r.signal_verified,
channel_verification: ChannelVerificationStatus::new(
r.email_verified,
r.discord_verified,
r.telegram_verified,
r.signal_verified,
),
}))
}
@@ -1065,6 +1070,12 @@ impl UserRepository for PostgresUserRepository {
}))
}
async fn get_totp_record_state(&self, did: &Did) -> Result<Option<TotpRecordState>, DbError> {
self.get_totp_record(did)
.await
.map(|opt| opt.map(TotpRecordState::from))
}
async fn upsert_totp_secret(
&self,
did: &Did,
@@ -1300,7 +1311,7 @@ impl UserRepository for PostgresUserRepository {
preferred_comms_channel as "preferred_comms_channel!: CommsChannel",
deactivated_at, takedown_ref,
email_verified, discord_verified, telegram_verified, signal_verified,
account_type::text as "account_type!"
account_type as "account_type!: AccountType"
FROM users
WHERE handle = $1 OR email = $1
"#,
@@ -1320,10 +1331,12 @@ impl UserRepository for PostgresUserRepository {
preferred_comms_channel: row.preferred_comms_channel,
deactivated_at: row.deactivated_at,
takedown_ref: row.takedown_ref,
email_verified: row.email_verified,
discord_verified: row.discord_verified,
telegram_verified: row.telegram_verified,
signal_verified: row.signal_verified,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
row.telegram_verified,
row.signal_verified,
),
account_type: row.account_type,
})
})
@@ -1348,10 +1361,12 @@ impl UserRepository for PostgresUserRepository {
id: row.id,
two_factor_enabled: row.two_factor_enabled,
preferred_comms_channel: row.preferred_comms_channel,
email_verified: row.email_verified,
discord_verified: row.discord_verified,
telegram_verified: row.telegram_verified,
signal_verified: row.signal_verified,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
row.telegram_verified,
row.signal_verified,
),
})
})
}
@@ -1376,15 +1391,17 @@ impl UserRepository for PostgresUserRepository {
opt.map(|row| UserSessionInfo {
handle: Handle::from(row.handle),
email: row.email,
email_verified: row.email_verified,
is_admin: row.is_admin,
deactivated_at: row.deactivated_at,
takedown_ref: row.takedown_ref,
preferred_locale: row.preferred_locale,
preferred_comms_channel: row.preferred_comms_channel,
discord_verified: row.discord_verified,
telegram_verified: row.telegram_verified,
signal_verified: row.signal_verified,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
row.telegram_verified,
row.signal_verified,
),
migrated_to_pds: row.migrated_to_pds,
migrated_at: row.migrated_at,
})
@@ -1469,10 +1486,12 @@ impl UserRepository for PostgresUserRepository {
email: row.email,
deactivated_at: row.deactivated_at,
takedown_ref: row.takedown_ref,
email_verified: row.email_verified,
discord_verified: row.discord_verified,
telegram_verified: row.telegram_verified,
signal_verified: row.signal_verified,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
row.telegram_verified,
row.signal_verified,
),
allow_legacy_login: row.allow_legacy_login,
migrated_to_pds: row.migrated_to_pds,
preferred_comms_channel: row.preferred_comms_channel,
@@ -1543,10 +1562,12 @@ impl UserRepository for PostgresUserRepository {
discord_id: row.discord_id,
telegram_username: row.telegram_username,
signal_number: row.signal_number,
email_verified: row.email_verified,
discord_verified: row.discord_verified,
telegram_verified: row.telegram_verified,
signal_verified: row.signal_verified,
channel_verification: ChannelVerificationStatus::new(
row.email_verified,
row.discord_verified,
row.telegram_verified,
row.signal_verified,
),
})
})
}
+6 -4
View File
@@ -10,8 +10,10 @@ pub use dpop::{
};
pub use error::OAuthError;
pub use types::{
AuthFlowState, AuthorizationRequestParameters, AuthorizationServerMetadata,
AuthorizedClientData, ClientAuth, Code, DPoPClaims, DeviceData, DeviceId, JwkPublicKey, Jwks,
OAuthClientMetadata, ParResponse, ProtectedResourceMetadata, RefreshToken, RefreshTokenState,
RequestData, RequestId, SessionId, TokenData, TokenId, TokenRequest, TokenResponse,
AuthFlow, AuthFlowWithUser, AuthorizationRequestParameters, AuthorizationServerMetadata,
AuthorizedClientData, ClientAuth, Code, CodeChallengeMethod, DPoPClaims, DeviceData, DeviceId,
FlowAuthenticated, FlowAuthorized, FlowExpired, FlowNotAuthenticated, FlowNotAuthorized,
FlowPending, JwkPublicKey, Jwks, OAuthClientMetadata, ParResponse, Prompt,
ProtectedResourceMetadata, RefreshToken, RefreshTokenState, RequestData, RequestId,
ResponseMode, ResponseType, SessionId, TokenData, TokenId, TokenRequest, TokenResponse,
};
+254 -151
View File
@@ -1,23 +1,36 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tranquil_types::Did;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct RequestId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct TokenId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct DeviceId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct SessionId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct Code(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct RefreshToken(pub String);
impl RequestId {
@@ -82,19 +95,76 @@ pub enum ClientAuth {
PrivateKeyJwt { client_assertion: String },
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseType {
#[default]
Code,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodeChallengeMethod {
#[default]
#[serde(rename = "S256")]
S256,
#[serde(rename = "plain")]
Plain,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResponseMode {
#[default]
Query,
Fragment,
FormPost,
}
impl ResponseMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Query => "query",
Self::Fragment => "fragment",
Self::FormPost => "form_post",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Prompt {
None,
Login,
Consent,
SelectAccount,
Create,
}
impl Prompt {
pub fn as_str(&self) -> &'static str {
match self {
Self::None => "none",
Self::Login => "login",
Self::Consent => "consent",
Self::SelectAccount => "select_account",
Self::Create => "create",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizationRequestParameters {
pub response_type: String,
pub response_type: ResponseType,
pub client_id: String,
pub redirect_uri: String,
pub scope: Option<String>,
pub state: Option<String>,
pub code_challenge: String,
pub code_challenge_method: String,
pub response_mode: Option<String>,
pub code_challenge_method: CodeChallengeMethod,
pub response_mode: Option<ResponseMode>,
pub login_hint: Option<String>,
pub dpop_jkt: Option<String>,
pub prompt: Option<String>,
pub prompt: Option<Prompt>,
#[serde(flatten)]
pub extra: Option<JsonValue>,
}
@@ -105,15 +175,15 @@ pub struct RequestData {
pub client_auth: Option<ClientAuth>,
pub parameters: AuthorizationRequestParameters,
pub expires_at: DateTime<Utc>,
pub did: Option<String>,
pub device_id: Option<String>,
pub code: Option<String>,
pub controller_did: Option<String>,
pub did: Option<Did>,
pub device_id: Option<DeviceId>,
pub code: Option<Code>,
pub controller_did: Option<Did>,
}
#[derive(Debug, Clone)]
pub struct DeviceData {
pub session_id: String,
pub session_id: SessionId,
pub user_agent: Option<String>,
pub ip_address: String,
pub last_seen_at: DateTime<Utc>,
@@ -121,20 +191,20 @@ pub struct DeviceData {
#[derive(Debug, Clone)]
pub struct TokenData {
pub did: String,
pub token_id: String,
pub did: Did,
pub token_id: TokenId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub client_id: String,
pub client_auth: ClientAuth,
pub device_id: Option<String>,
pub device_id: Option<DeviceId>,
pub parameters: AuthorizationRequestParameters,
pub details: Option<JsonValue>,
pub code: Option<String>,
pub current_refresh_token: Option<String>,
pub code: Option<Code>,
pub current_refresh_token: Option<RefreshToken>,
pub scope: Option<String>,
pub controller_did: Option<String>,
pub controller_did: Option<Did>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -247,99 +317,144 @@ pub struct Jwks {
pub keys: Vec<JwkPublicKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFlowState {
Pending,
Authenticated {
did: String,
device_id: Option<String>,
},
Authorized {
did: String,
device_id: Option<String>,
code: String,
},
Expired,
#[derive(Debug, Clone)]
pub struct FlowPending {
pub parameters: AuthorizationRequestParameters,
pub client_id: String,
pub client_auth: Option<ClientAuth>,
pub expires_at: DateTime<Utc>,
pub controller_did: Option<Did>,
}
impl AuthFlowState {
pub fn from_request_data(data: &RequestData) -> Self {
#[derive(Debug, Clone)]
pub struct FlowAuthenticated {
pub parameters: AuthorizationRequestParameters,
pub client_id: String,
pub client_auth: Option<ClientAuth>,
pub expires_at: DateTime<Utc>,
pub did: Did,
pub device_id: Option<DeviceId>,
pub controller_did: Option<Did>,
}
#[derive(Debug, Clone)]
pub struct FlowAuthorized {
pub parameters: AuthorizationRequestParameters,
pub client_id: String,
pub client_auth: Option<ClientAuth>,
pub expires_at: DateTime<Utc>,
pub did: Did,
pub device_id: Option<DeviceId>,
pub code: Code,
pub controller_did: Option<Did>,
}
#[derive(Debug)]
pub struct FlowExpired;
#[derive(Debug)]
pub struct FlowNotAuthenticated;
#[derive(Debug)]
pub struct FlowNotAuthorized;
#[derive(Debug, Clone)]
pub enum AuthFlow {
Pending(FlowPending),
Authenticated(FlowAuthenticated),
Authorized(FlowAuthorized),
}
#[derive(Debug, Clone)]
pub enum AuthFlowWithUser {
Authenticated(FlowAuthenticated),
Authorized(FlowAuthorized),
}
impl AuthFlow {
pub fn from_request_data(data: RequestData) -> Result<Self, FlowExpired> {
if data.expires_at < chrono::Utc::now() {
return AuthFlowState::Expired;
return Err(FlowExpired);
}
match (&data.did, &data.code) {
(Some(did), Some(code)) => AuthFlowState::Authorized {
did: did.clone(),
device_id: data.device_id.clone(),
code: code.clone(),
},
(Some(did), None) => AuthFlowState::Authenticated {
did: did.clone(),
device_id: data.device_id.clone(),
},
(None, _) => AuthFlowState::Pending,
match (data.did, data.code) {
(None, _) => Ok(AuthFlow::Pending(FlowPending {
parameters: data.parameters,
client_id: data.client_id,
client_auth: data.client_auth,
expires_at: data.expires_at,
controller_did: data.controller_did,
})),
(Some(did), None) => Ok(AuthFlow::Authenticated(FlowAuthenticated {
parameters: data.parameters,
client_id: data.client_id,
client_auth: data.client_auth,
expires_at: data.expires_at,
did,
device_id: data.device_id,
controller_did: data.controller_did,
})),
(Some(did), Some(code)) => Ok(AuthFlow::Authorized(FlowAuthorized {
parameters: data.parameters,
client_id: data.client_id,
client_auth: data.client_auth,
expires_at: data.expires_at,
did,
device_id: data.device_id,
code,
controller_did: data.controller_did,
})),
}
}
pub fn is_pending(&self) -> bool {
matches!(self, AuthFlowState::Pending)
}
pub fn is_authenticated(&self) -> bool {
matches!(self, AuthFlowState::Authenticated { .. })
}
pub fn is_authorized(&self) -> bool {
matches!(self, AuthFlowState::Authorized { .. })
}
pub fn is_expired(&self) -> bool {
matches!(self, AuthFlowState::Expired)
}
pub fn can_authenticate(&self) -> bool {
matches!(self, AuthFlowState::Pending)
}
pub fn can_authorize(&self) -> bool {
matches!(self, AuthFlowState::Authenticated { .. })
}
pub fn can_exchange(&self) -> bool {
matches!(self, AuthFlowState::Authorized { .. })
}
pub fn did(&self) -> Option<&str> {
pub fn require_user(self) -> Result<AuthFlowWithUser, FlowNotAuthenticated> {
match self {
AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => {
Some(did)
}
_ => None,
AuthFlow::Pending(_) => Err(FlowNotAuthenticated),
AuthFlow::Authenticated(a) => Ok(AuthFlowWithUser::Authenticated(a)),
AuthFlow::Authorized(a) => Ok(AuthFlowWithUser::Authorized(a)),
}
}
pub fn code(&self) -> Option<&str> {
pub fn require_authorized(self) -> Result<FlowAuthorized, FlowNotAuthorized> {
match self {
AuthFlowState::Authorized { code, .. } => Some(code),
_ => None,
AuthFlow::Authorized(a) => Ok(a),
_ => Err(FlowNotAuthorized),
}
}
}
impl std::fmt::Display for AuthFlowState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl AuthFlowWithUser {
pub fn did(&self) -> &Did {
match self {
AuthFlowState::Pending => write!(f, "pending"),
AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did),
AuthFlowState::Authorized { did, code, .. } => {
write!(
f,
"authorized ({}, code={}...)",
did,
&code[..8.min(code.len())]
)
}
AuthFlowState::Expired => write!(f, "expired"),
AuthFlowWithUser::Authenticated(a) => &a.did,
AuthFlowWithUser::Authorized(a) => &a.did,
}
}
pub fn device_id(&self) -> Option<&DeviceId> {
match self {
AuthFlowWithUser::Authenticated(a) => a.device_id.as_ref(),
AuthFlowWithUser::Authorized(a) => a.device_id.as_ref(),
}
}
pub fn parameters(&self) -> &AuthorizationRequestParameters {
match self {
AuthFlowWithUser::Authenticated(a) => &a.parameters,
AuthFlowWithUser::Authorized(a) => &a.parameters,
}
}
pub fn client_id(&self) -> &str {
match self {
AuthFlowWithUser::Authenticated(a) => &a.client_id,
AuthFlowWithUser::Authorized(a) => &a.client_id,
}
}
pub fn controller_did(&self) -> Option<&Did> {
match self {
AuthFlowWithUser::Authenticated(a) => a.controller_did.as_ref(),
AuthFlowWithUser::Authorized(a) => a.controller_did.as_ref(),
}
}
}
@@ -406,21 +521,21 @@ mod tests {
use chrono::{Duration, Utc};
fn make_request_data(
did: Option<String>,
code: Option<String>,
did: Option<Did>,
code: Option<Code>,
expires_in: Duration,
) -> RequestData {
RequestData {
client_id: "test-client".into(),
client_auth: None,
parameters: AuthorizationRequestParameters {
response_type: "code".into(),
response_type: ResponseType::Code,
client_id: "test-client".into(),
redirect_uri: "https://example.com/callback".into(),
scope: Some("atproto".into()),
state: None,
code_challenge: "test".into(),
code_challenge_method: "S256".into(),
code_challenge_method: CodeChallengeMethod::S256,
response_mode: None,
login_hint: None,
dpop_jkt: None,
@@ -435,67 +550,55 @@ mod tests {
}
}
fn test_did(s: &str) -> Did {
s.parse().expect("valid test DID")
}
fn test_code(s: &str) -> Code {
Code(s.to_string())
}
#[test]
fn test_auth_flow_state_pending() {
fn test_auth_flow_pending() {
let data = make_request_data(None, None, Duration::minutes(5));
let state = AuthFlowState::from_request_data(&data);
assert!(state.is_pending());
assert!(!state.is_authenticated());
assert!(!state.is_authorized());
assert!(!state.is_expired());
assert!(state.can_authenticate());
assert!(!state.can_authorize());
assert!(!state.can_exchange());
assert!(state.did().is_none());
assert!(state.code().is_none());
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
assert!(matches!(flow, AuthFlow::Pending(_)));
assert!(flow.clone().require_user().is_err());
assert!(flow.require_authorized().is_err());
}
#[test]
fn test_auth_flow_state_authenticated() {
let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5));
let state = AuthFlowState::from_request_data(&data);
assert!(!state.is_pending());
assert!(state.is_authenticated());
assert!(!state.is_authorized());
assert!(!state.is_expired());
assert!(!state.can_authenticate());
assert!(state.can_authorize());
assert!(!state.can_exchange());
assert_eq!(state.did(), Some("did:plc:test"));
assert!(state.code().is_none());
fn test_auth_flow_authenticated() {
let did = test_did("did:plc:test");
let data = make_request_data(Some(did.clone()), None, Duration::minutes(5));
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
assert!(matches!(flow, AuthFlow::Authenticated(_)));
let with_user = flow.clone().require_user().expect("should have user");
assert_eq!(with_user.did(), &did);
assert!(flow.require_authorized().is_err());
}
#[test]
fn test_auth_flow_state_authorized() {
let data = make_request_data(
Some("did:plc:test".into()),
Some("auth-code-123".into()),
Duration::minutes(5),
);
let state = AuthFlowState::from_request_data(&data);
assert!(!state.is_pending());
assert!(!state.is_authenticated());
assert!(state.is_authorized());
assert!(!state.is_expired());
assert!(!state.can_authenticate());
assert!(!state.can_authorize());
assert!(state.can_exchange());
assert_eq!(state.did(), Some("did:plc:test"));
assert_eq!(state.code(), Some("auth-code-123"));
fn test_auth_flow_authorized() {
let did = test_did("did:plc:test");
let code = test_code("auth-code-123");
let data = make_request_data(Some(did.clone()), Some(code.clone()), Duration::minutes(5));
let flow = AuthFlow::from_request_data(data).expect("should not be expired");
assert!(matches!(flow, AuthFlow::Authorized(_)));
let with_user = flow.clone().require_user().expect("should have user");
assert_eq!(with_user.did(), &did);
let authorized = flow.require_authorized().expect("should be authorized");
assert_eq!(authorized.did, did);
assert_eq!(authorized.code, code);
}
#[test]
fn test_auth_flow_state_expired() {
let data = make_request_data(
Some("did:plc:test".into()),
Some("code".into()),
Duration::minutes(-1),
);
let state = AuthFlowState::from_request_data(&data);
assert!(state.is_expired());
assert!(!state.can_authenticate());
assert!(!state.can_authorize());
assert!(!state.can_exchange());
fn test_auth_flow_expired() {
let did = test_did("did:plc:test");
let code = test_code("code");
let data = make_request_data(Some(did), Some(code), Duration::minutes(-1));
let result = AuthFlow::from_request_data(data);
assert!(result.is_err());
}
#[test]
+1
View File
@@ -67,6 +67,7 @@ subtle = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tower-layer = { workspace = true }
@@ -1,5 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::Did;
@@ -9,7 +9,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use tracing::{error, warn};
use tracing::warn;
#[derive(Deserialize)]
pub struct DeleteAccountInput {
@@ -26,10 +26,7 @@ pub async fn delete_account(
.user_repo
.get_id_and_handle_by_did(did)
.await
.map_err(|e| {
error!("DB error in delete_account: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("in delete_account")?
.ok_or(ApiError::AccountNotFound)
.map(|row| (row.id, row.handle))?;
@@ -37,13 +34,14 @@ pub async fn delete_account(
.user_repo
.admin_delete_account_complete(user_id, did)
.await
.map_err(|e| {
error!("Failed to delete account {}: {:?}", did, e);
ApiError::InternalError(Some("Failed to delete account".into()))
})?;
.log_db_err("deleting account")?;
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
did,
tranquil_db_traits::AccountStatus::Deleted,
)
.await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
@@ -1,7 +1,8 @@
use crate::api::error::{ApiError, AtpJson};
use crate::api::error::{ApiError, AtpJson, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -9,7 +10,7 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tracing::{error, warn};
use tracing::warn;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -39,15 +40,12 @@ pub async fn send_email(
.user_repo
.get_by_did(&input.recipient_did)
.await
.map_err(|e| {
error!("DB error in send_email: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("in send_email")?
.ok_or(ApiError::AccountNotFound)?;
let email = user.email.ok_or(ApiError::NoEmail)?;
let (user_id, handle) = (user.id, user.handle);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let subject = input
.subject
.clone()
@@ -1,4 +1,4 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::{Did, Handle};
@@ -10,7 +10,6 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::error;
#[derive(Deserialize)]
pub struct GetAccountInfoParams {
@@ -74,10 +73,7 @@ pub async fn get_account_info(
.infra_repo
.get_admin_account_info_by_did(&params.did)
.await
.map_err(|e| {
error!("DB error in get_account_info: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("in get_account_info")?
.ok_or(ApiError::AccountNotFound)?;
let invited_by = get_invited_by(&state, account.id).await;
@@ -153,7 +149,7 @@ async fn get_invites_for_user(
.map(|ic| InviteCodeInfo {
code: ic.code.clone(),
available: ic.available_uses,
disabled: ic.disabled,
disabled: ic.state.is_disabled(),
for_account: ic.for_account,
created_by: ic.created_by,
created_at: ic.created_at.to_rfc3339(),
@@ -181,7 +177,7 @@ async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCode
Some(InviteCodeInfo {
code: info.code,
available: info.available_uses,
disabled: info.disabled,
disabled: info.state.is_disabled(),
for_account: info.for_account,
created_by: info.created_by,
created_at: info.created_at.to_rfc3339(),
@@ -214,10 +210,7 @@ pub async fn get_account_infos(
.infra_repo
.get_admin_account_infos_by_dids(&dids_typed)
.await
.map_err(|e| {
error!("Failed to fetch account infos: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching account infos")?;
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
@@ -272,7 +265,7 @@ pub async fn get_account_infos(
let info = InviteCodeInfo {
code: ic.code.clone(),
available: ic.available_uses,
disabled: ic.disabled,
disabled: ic.state.is_disabled(),
for_account: ic.for_account,
created_by: ic.created_by,
created_at: ic.created_at.to_rfc3339(),
@@ -1,4 +1,4 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::{Did, Handle};
@@ -9,7 +9,6 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tracing::error;
#[derive(Deserialize)]
pub struct SearchAccountsParams {
@@ -66,10 +65,7 @@ pub async fn search_accounts(
limit + 1,
)
.await
.map_err(|e| {
error!("DB error in search_accounts: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("in search_accounts")?;
let has_more = rows.len() > limit as usize;
let accounts: Vec<AccountView> = rows
@@ -3,6 +3,7 @@ use crate::api::error::ApiError;
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use crate::types::{Did, Handle, PlainPassword};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::State,
@@ -69,8 +70,7 @@ pub async fn update_account_handle(
{
return Err(ApiError::InvalidHandle(None));
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname_for_handles = pds_hostname_without_port();
let handle = if !input_handle.contains('.') {
format!("{}.{}", input_handle, hostname_for_handles)
} else {
@@ -84,7 +84,7 @@ pub async fn update_account_handle(
.ok()
.flatten()
.ok_or(ApiError::AccountNotFound)?;
let handle_for_check = Handle::new_unchecked(&handle);
let handle_for_check = unsafe { Handle::new_unchecked(&handle) };
if let Ok(true) = state
.user_repo
.check_handle_exists(&handle_for_check, user_id)
+14 -50
View File
@@ -1,4 +1,4 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use axum::{Json, extract::State};
@@ -56,10 +56,7 @@ pub async fn get_server_config(
.infra_repo
.get_server_configs(keys)
.await
.map_err(|e| {
error!("DB error fetching server config: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching server config")?;
let config_map: std::collections::HashMap<String, String> = rows.into_iter().collect();
@@ -92,10 +89,7 @@ pub async fn update_server_config(
.infra_repo
.upsert_server_config("server_name", trimmed)
.await
.map_err(|e| {
error!("DB error upserting server_name: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting server_name")?;
}
if let Some(ref color) = req.primary_color {
@@ -104,19 +98,13 @@ pub async fn update_server_config(
.infra_repo
.delete_server_config("primary_color")
.await
.map_err(|e| {
error!("DB error deleting primary_color: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting primary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.upsert_server_config("primary_color", color)
.await
.map_err(|e| {
error!("DB error upserting primary_color: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting primary_color")?;
} else {
return Err(ApiError::InvalidRequest(
"Invalid primary color format (expected #RRGGBB)".into(),
@@ -130,19 +118,13 @@ pub async fn update_server_config(
.infra_repo
.delete_server_config("primary_color_dark")
.await
.map_err(|e| {
error!("DB error deleting primary_color_dark: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting primary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.upsert_server_config("primary_color_dark", color)
.await
.map_err(|e| {
error!("DB error upserting primary_color_dark: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting primary_color_dark")?;
} else {
return Err(ApiError::InvalidRequest(
"Invalid primary dark color format (expected #RRGGBB)".into(),
@@ -156,19 +138,13 @@ pub async fn update_server_config(
.infra_repo
.delete_server_config("secondary_color")
.await
.map_err(|e| {
error!("DB error deleting secondary_color: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting secondary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.upsert_server_config("secondary_color", color)
.await
.map_err(|e| {
error!("DB error upserting secondary_color: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting secondary_color")?;
} else {
return Err(ApiError::InvalidRequest(
"Invalid secondary color format (expected #RRGGBB)".into(),
@@ -182,19 +158,13 @@ pub async fn update_server_config(
.infra_repo
.delete_server_config("secondary_color_dark")
.await
.map_err(|e| {
error!("DB error deleting secondary_color_dark: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting secondary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.upsert_server_config("secondary_color_dark", color)
.await
.map_err(|e| {
error!("DB error upserting secondary_color_dark: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting secondary_color_dark")?;
} else {
return Err(ApiError::InvalidRequest(
"Invalid secondary dark color format (expected #RRGGBB)".into(),
@@ -217,7 +187,7 @@ pub async fn update_server_config(
};
if let Some(old_cid_str) = should_delete_old {
let old_cid = CidLink::new_unchecked(old_cid_str);
let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) };
if let Ok(Some(storage_key)) =
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
{
@@ -235,19 +205,13 @@ pub async fn update_server_config(
.infra_repo
.delete_server_config("logo_cid")
.await
.map_err(|e| {
error!("DB error deleting logo_cid: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting logo_cid")?;
} else {
state
.infra_repo
.upsert_server_config("logo_cid", logo_cid)
.await
.map_err(|e| {
error!("DB error upserting logo_cid: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting logo_cid")?;
}
}
+3 -6
View File
@@ -1,5 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Admin, Auth};
use crate::state::AppState;
use axum::{
@@ -91,10 +91,7 @@ pub async fn get_invite_codes(
.infra_repo
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
.await
.map_err(|e| {
error!("DB error fetching invite codes: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching invite codes")?;
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|r| r.created_by_user).collect();
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
@@ -138,7 +135,7 @@ pub async fn get_invite_codes(
InviteCodeInfo {
code: r.code.clone(),
available: r.available_uses,
disabled: r.disabled.unwrap_or(false),
disabled: r.state().is_disabled(),
for_account: creator_did.clone(),
created_by: creator_did,
created_at: r.created_at.to_rfc3339(),
+9 -19
View File
@@ -175,7 +175,7 @@ pub async fn update_subject_status(
Some("com.atproto.admin.defs#repoRef") => {
let did_str = input.subject.get("did").and_then(|d| d.as_str());
if let Some(did_str) = did_str {
let did = Did::new_unchecked(did_str);
let did = unsafe { Did::new_unchecked(did_str) };
if let Some(takedown) = &input.takedown {
let takedown_ref = if takedown.applied {
takedown.r#ref.as_deref()
@@ -207,34 +207,24 @@ pub async fn update_subject_status(
}
if let Some(takedown) = &input.takedown {
let status = if takedown.applied {
Some("takendown")
tranquil_db_traits::AccountStatus::Takendown
} else {
None
tranquil_db_traits::AccountStatus::Active
};
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
!takedown.applied,
status,
)
.await
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, status).await
{
warn!("Failed to sequence account event for takedown: {}", e);
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.applied {
Some("deactivated")
tranquil_db_traits::AccountStatus::Deactivated
} else {
None
tranquil_db_traits::AccountStatus::Active
};
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
!deactivated.applied,
status,
)
.await
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, status).await
{
warn!("Failed to sequence account event for deactivation: {}", e);
}
+2 -2
View File
@@ -33,13 +33,13 @@ pub async fn get_age_assurance_state() -> Response {
}
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_header = crate::util::get_header_str(headers, "Authorization");
tracing::debug!(?auth_header, "age assurance: extracting token");
let extracted = extract_auth_token_from_header(auth_header)?;
tracing::debug!("age assurance: got token, validating");
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let dpop_proof = crate::util::get_header_str(headers, "DPoP");
let http_uri = "/";
let auth_user = match validate_token_with_dpop(
+66 -116
View File
@@ -1,14 +1,18 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{Active, Auth};
use crate::delegation::{DelegationActionType, SCOPE_PRESETS, scopes};
use crate::state::{AppState, RateLimitKind};
use crate::delegation::{
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
verify_can_be_controller, verify_can_control_accounts,
};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, Rkey};
use crate::util::extract_client_ip;
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::{Query, State},
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{IntoResponse, Response},
};
use jacquard_common::types::{integer::LimitedU32, string::Tid};
@@ -57,7 +61,7 @@ pub async fn list_controllers(
.map(|c| ControllerInfo {
did: c.did,
handle: c.handle,
granted_scopes: c.granted_scopes,
granted_scopes: c.granted_scopes.into_string(),
granted_at: c.granted_at,
is_active: c.is_active,
})
@@ -69,7 +73,7 @@ pub async fn list_controllers(
#[derive(Debug, Deserialize)]
pub struct AddControllerInput {
pub controller_did: Did,
pub granted_scopes: String,
pub granted_scopes: ValidatedDelegationScope,
}
pub async fn add_controller(
@@ -77,10 +81,6 @@ pub async fn add_controller(
auth: Auth<Active>,
Json(input): Json<AddControllerInput>,
) -> Result<Response, ApiError> {
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
return Ok(ApiError::InvalidScopes(e).into_response());
}
let controller_exists = state
.user_repo
.get_by_did(&input.controller_did)
@@ -93,51 +93,23 @@ pub async fn add_controller(
return Ok(ApiError::ControllerNotFound.into_response());
}
match state.delegation_repo.controls_any_accounts(&auth.did).await {
Ok(true) => {
return Ok(ApiError::InvalidDelegation(
"Cannot add controllers to an account that controls other accounts".into(),
)
.into_response());
}
Err(e) => {
tracing::error!("Failed to check delegation status: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to verify delegation status".into()))
.into_response(),
);
}
Ok(false) => {}
}
let can_add = match verify_can_add_controllers(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
match state
.delegation_repo
.has_any_controllers(&input.controller_did)
.await
{
Ok(true) => {
return Ok(ApiError::InvalidDelegation(
"Cannot add a controlled account as a controller".into(),
)
.into_response());
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response(),
);
}
Ok(false) => {}
}
let can_be_controller = match verify_can_be_controller(&state, &input.controller_did).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
match state
.delegation_repo
.create_delegation(
&auth.did,
&input.controller_did,
can_add.did(),
can_be_controller.did(),
&input.granted_scopes,
&auth.did,
can_add.did(),
)
.await
{
@@ -145,12 +117,12 @@ pub async fn add_controller(
let _ = state
.delegation_repo
.log_delegation_action(
&auth.did,
&auth.did,
Some(&input.controller_did),
can_add.did(),
can_add.did(),
Some(can_be_controller.did()),
DelegationActionType::GrantCreated,
Some(serde_json::json!({
"granted_scopes": input.granted_scopes
"granted_scopes": input.granted_scopes.as_str()
})),
None,
None,
@@ -235,7 +207,7 @@ pub async fn remove_controller(
#[derive(Debug, Deserialize)]
pub struct UpdateControllerScopesInput {
pub controller_did: Did,
pub granted_scopes: String,
pub granted_scopes: ValidatedDelegationScope,
}
pub async fn update_controller_scopes(
@@ -243,10 +215,6 @@ pub async fn update_controller_scopes(
auth: Auth<Active>,
Json(input): Json<UpdateControllerScopesInput>,
) -> Result<Response, ApiError> {
if let Err(e) = scopes::validate_delegation_scopes(&input.granted_scopes) {
return Ok(ApiError::InvalidScopes(e).into_response());
}
match state
.delegation_repo
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
@@ -261,7 +229,7 @@ pub async fn update_controller_scopes(
Some(&input.controller_did),
DelegationActionType::ScopesModified,
Some(serde_json::json!({
"new_scopes": input.granted_scopes
"new_scopes": input.granted_scopes.as_str()
})),
None,
None,
@@ -326,7 +294,7 @@ pub async fn list_controlled_accounts(
.map(|a| DelegatedAccountInfo {
did: a.did,
handle: a.handle,
granted_scopes: a.granted_scopes,
granted_scopes: a.granted_scopes.into_string(),
granted_at: a.granted_at,
})
.collect(),
@@ -443,7 +411,7 @@ pub async fn get_scope_presets() -> Response {
pub struct CreateDelegatedAccountInput {
pub handle: String,
pub email: Option<String>,
pub controller_scopes: String,
pub controller_scopes: ValidatedDelegationScope,
pub invite_code: Option<String>,
}
@@ -456,45 +424,17 @@ pub struct CreateDelegatedAccountResponse {
pub async fn create_delegated_account(
State(state): State<AppState>,
headers: HeaderMap,
_rate_limit: RateLimited<AccountCreationLimit>,
auth: Auth<Active>,
Json(input): Json<CreateDelegatedAccountInput>,
) -> Result<Response, ApiError> {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
.await
{
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
return Ok(ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response());
}
let can_control = match verify_can_control_accounts(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
if let Err(e) = scopes::validate_delegation_scopes(&input.controller_scopes) {
return Ok(ApiError::InvalidScopes(e).into_response());
}
match state.delegation_repo.has_any_controllers(&auth.did).await {
Ok(true) => {
return Ok(ApiError::InvalidDelegation(
"Cannot create delegated accounts from a controlled account".into(),
)
.into_response());
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response(),
);
}
Ok(false) => {}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
@@ -527,15 +467,10 @@ pub async fn create_delegated_account(
return Ok(ApiError::InvalidEmail.into_response());
}
if let Some(ref code) = input.invite_code {
let valid = state
.infra_repo
.is_invite_code_valid(code)
.await
.unwrap_or(false);
if !valid {
return Ok(ApiError::InvalidInviteCode.into_response());
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
@@ -544,7 +479,8 @@ pub async fn create_delegated_account(
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
}
}
None
};
use k256::ecdsa::SigningKey;
use rand::rngs::OsRng;
@@ -593,9 +529,9 @@ pub async fn create_delegated_account(
.into_response());
}
let did = Did::new_unchecked(&genesis_result.did);
let handle = Handle::new_unchecked(&handle);
info!(did = %did, handle = %handle, controller = %&auth.did, "Created DID for delegated account");
let did = unsafe { Did::new_unchecked(&genesis_result.did) };
let handle = unsafe { Handle::new_unchecked(&handle) };
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(bytes) => bytes,
@@ -635,8 +571,8 @@ pub async fn create_delegated_account(
handle: handle.clone(),
email: email.clone(),
did: did.clone(),
controller_did: auth.did.clone(),
controller_scopes: input.controller_scopes.clone(),
controller_did: can_control.did().clone(),
controller_scopes: input.controller_scopes.as_str().to_string(),
encrypted_key_bytes,
encryption_version: crate::config::ENCRYPTION_VERSION,
commit_cid: commit_cid.to_string(),
@@ -645,7 +581,7 @@ pub async fn create_delegated_account(
invite_code: input.invite_code.clone(),
};
let _user_id = match state
let user_id = match state
.user_repo
.create_delegated_account(&create_input)
.await
@@ -663,12 +599,26 @@ pub async fn create_delegated_account(
}
};
if let Some(validated) = validated_invite_code
&& let Err(e) = state
.infra_repo
.record_invite_code_use(&validated, user_id)
.await
{
warn!("Failed to record invite code use for {}: {:?}", did, e);
}
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
@@ -677,8 +627,8 @@ pub async fn create_delegated_account(
"$type": "app.bsky.actor.profile",
"displayName": handle
});
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
let profile_rkey = Rkey::new_unchecked("self");
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did,
@@ -700,7 +650,7 @@ pub async fn create_delegated_account(
DelegationActionType::GrantCreated,
Some(json!({
"account_created": true,
"granted_scopes": input.controller_scopes
"granted_scopes": input.controller_scopes.as_str()
})),
None,
None,
+19
View File
@@ -694,6 +694,12 @@ impl From<crate::storage::StorageError> for ApiError {
}
}
impl From<crate::rate_limit::UserRateLimitError> for ApiError {
fn from(e: crate::rate_limit::UserRateLimitError) -> Self {
Self::RateLimitExceeded(e.message)
}
}
#[allow(clippy::result_large_err)]
pub fn parse_did(s: &str) -> Result<tranquil_types::Did, Response> {
s.parse()
@@ -756,3 +762,16 @@ fn extract_json_error_message(rejection: &JsonRejection) -> String {
_ => "Invalid request body".to_string(),
}
}
pub trait DbResultExt<T> {
fn log_db_err(self, ctx: &str) -> Result<T, ApiError>;
}
impl<T, E: std::fmt::Debug> DbResultExt<T> for Result<T, E> {
fn log_db_err(self, ctx: &str) -> Result<T, ApiError> {
self.map_err(|e| {
tracing::error!("DB error {}: {:?}", ctx, e);
ApiError::DatabaseError
})
}
}
+40 -65
View File
@@ -3,8 +3,10 @@ use crate::api::error::ApiError;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{AccountCreationLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
@@ -22,21 +24,6 @@ use serde_json::json;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -68,6 +55,7 @@ pub struct CreateAccountOutput {
pub async fn create_account(
State(state): State<AppState>,
_rate_limit: RateLimited<AccountCreationLimit>,
headers: HeaderMap,
Json(input): Json<CreateAccountInput>,
) -> Response {
@@ -84,20 +72,9 @@ pub async fn create_account(
} else {
info!("create_account called");
}
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response();
}
let migration_auth = if let Some(extracted) =
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization"))
{
let token = extracted.token;
if is_service_token(&token) {
@@ -143,7 +120,7 @@ pub async fn create_account(
if (is_migration || is_did_web_byod)
&& let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
{
if provided_did != auth_did {
if provided_did != auth_did.as_str() {
info!(
"[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}",
auth_did, provided_did
@@ -164,8 +141,7 @@ pub async fn create_account(
}
}
let hostname_for_validation =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_validation = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_validation);
let validated_short_handle = if !input.handle.contains('.')
@@ -242,8 +218,8 @@ pub async fn create_account(
_ => return ApiError::InvalidVerificationChannel.into_response(),
})
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_endpoint = format!("https://{}", hostname);
let suffix = format!(".{}", hostname_for_handles);
let handle = if input.handle.ends_with(&suffix) {
@@ -308,7 +284,7 @@ pub async fn create_account(
}
if !is_did_web_byod
&& let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await
{
return ApiError::InvalidDid(e).into_response();
}
@@ -322,13 +298,9 @@ pub async fn create_account(
d.clone()
} else if d.starts_with("did:web:") {
if !is_did_web_byod
&& let Err(e) = verify_did_web(
d,
&hostname,
&input.handle,
input.signing_key.as_deref(),
)
.await
&& let Err(e) =
verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref())
.await
{
return ApiError::InvalidDid(e).into_response();
}
@@ -408,8 +380,8 @@ pub async fn create_account(
};
if is_migration {
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: Did::new_unchecked(&did),
new_handle: Handle::new_unchecked(&handle),
did: unsafe { Did::new_unchecked(&did) },
new_handle: unsafe { Handle::new_unchecked(&handle) },
new_email: email.clone(),
};
match state
@@ -463,12 +435,12 @@ pub async fn create_account(
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: Did::new_unchecked(&did),
did: unsafe { Did::new_unchecked(&did) },
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
legacy_login: false,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: None,
controller_did: None,
@@ -478,8 +450,7 @@ pub async fn create_account(
error!("Error creating session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let verification_required = if let Some(ref user_email) = email {
let token =
crate::auth::verification_token::generate_migration_token(&did, user_email);
@@ -491,7 +462,7 @@ pub async fn create_account(
reactivated.user_id,
user_email,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -505,7 +476,7 @@ pub async fn create_account(
axum::http::StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: Did::new_unchecked(&did),
did: unsafe { Did::new_unchecked(&did) },
did_doc: state.did_resolver.resolve_did_document(&did).await,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
@@ -529,7 +500,7 @@ pub async fn create_account(
}
}
let handle_typed = Handle::new_unchecked(&handle);
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
let handle_available = match state
.user_repo
.check_handle_available_for_new_account(&handle_typed)
@@ -613,7 +584,7 @@ pub async fn create_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let did_for_commit = Did::new_unchecked(&did);
let did_for_commit = unsafe { Did::new_unchecked(&did) };
let (commit_bytes, _sig) =
match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) {
Ok(result) => result,
@@ -649,9 +620,9 @@ pub async fn create_account(
};
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
handle: Handle::new_unchecked(&handle),
handle: unsafe { Handle::new_unchecked(&handle) },
email: email.clone(),
did: Did::new_unchecked(&did),
did: unsafe { Did::new_unchecked(&did) },
password_hash,
preferred_comms_channel,
discord_id: input
@@ -701,8 +672,8 @@ pub async fn create_account(
};
let user_id = create_result.user_id;
if !is_migration && !is_did_web_byod {
let did_typed = Did::new_unchecked(&did);
let handle_typed = Handle::new_unchecked(&handle);
let did_typed = unsafe { Did::new_unchecked(&did) };
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
&did_typed,
@@ -712,8 +683,12 @@ pub async fn create_account(
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did_typed,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
@@ -742,8 +717,8 @@ pub async fn create_account(
"$type": "app.bsky.actor.profile",
"displayName": input.handle
});
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
let profile_rkey = Rkey::new_unchecked("self");
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did_typed,
@@ -756,7 +731,7 @@ pub async fn create_account(
warn!("Failed to create default profile for {}: {}", did, e);
}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if !is_migration {
if let Some(ref recipient) = verification_recipient {
let verification_token = crate::auth::verification_token::generate_signup_token(
@@ -772,7 +747,7 @@ pub async fn create_account(
verification_channel,
recipient,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -791,7 +766,7 @@ pub async fn create_account(
user_id,
user_email,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -816,12 +791,12 @@ pub async fn create_account(
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: Did::new_unchecked(&did),
did: unsafe { Did::new_unchecked(&did) },
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
legacy_login: false,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: None,
controller_did: None,
@@ -845,7 +820,7 @@ pub async fn create_account(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: Did::new_unchecked(&did),
did: unsafe { Did::new_unchecked(&did) },
did_doc,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
+27 -31
View File
@@ -1,8 +1,12 @@
use crate::api::{ApiError, DidResponse, EmptyResponse};
use crate::auth::{Auth, NotTakendown};
use crate::plc::signing_key_to_did_key;
use crate::rate_limit::{
HandleUpdateDailyLimit, HandleUpdateLimit, check_user_rate_limit_with_message,
};
use crate::state::AppState;
use crate::types::Handle;
use crate::util::{get_header_str, pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::{Path, Query, State},
@@ -101,20 +105,17 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str
}
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or(&hostname);
let hostname = pds_hostname();
let hostname_without_port = pds_hostname_without_port();
let host_header = get_header_str(&headers, "host").unwrap_or(hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
if host_without_port != hostname_without_port
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
{
let handle = host_without_port
.strip_suffix(&format!(".{}", hostname_without_port))
.unwrap_or(host_without_port);
return serve_subdomain_did_doc(&state, handle, &hostname).await;
return serve_subdomain_did_doc(&state, handle, hostname).await;
}
let did = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
@@ -257,8 +258,8 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let current_handle = format!("{}.{}", handle, hostname_for_handles);
let current_handle_typed: Handle = match current_handle.parse() {
Ok(h) => h,
@@ -531,7 +532,7 @@ pub async fn get_recommended_did_credentials(
ApiError::AuthenticationFailed(Some("OAuth tokens cannot get DID credentials".into()))
})?;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let pds_endpoint = format!("https://{}", hostname);
let signing_key = k256::ecdsa::SigningKey::from_slice(&key_bytes)
.map_err(|_| ApiError::InternalError(None))?;
@@ -585,22 +586,18 @@ pub async fn update_handle(
return Ok(e);
}
let did = auth.did.clone();
if !state
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
.await
{
return Err(ApiError::RateLimitExceeded(Some(
"Too many handle updates. Try again later.".into(),
)));
}
if !state
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
.await
{
return Err(ApiError::RateLimitExceeded(Some(
"Daily handle update limit exceeded.".into(),
)));
}
let _rate_limit = check_user_rate_limit_with_message::<HandleUpdateLimit>(
&state,
&did,
"Too many handle updates. Try again later.",
)
.await?;
let _daily_rate_limit = check_user_rate_limit_with_message::<HandleUpdateDailyLimit>(
&state,
&did,
"Daily handle update limit exceeded.",
)
.await?;
let user_row = state
.user_repo
.get_id_and_handle_by_did(&did)
@@ -639,8 +636,7 @@ pub async fn update_handle(
"Inappropriate language in handle".into(),
)));
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname_for_handles = pds_hostname_without_port();
let suffix = format!(".{}", hostname_for_handles);
let is_service_domain =
crate::handle::is_service_domain_handle(&new_handle, hostname_for_handles);
@@ -656,7 +652,7 @@ pub async fn update_handle(
format!("{}.{}", new_handle, hostname_for_handles)
};
if full_handle == current_handle {
let handle_typed = Handle::new_unchecked(&full_handle);
let handle_typed = unsafe { Handle::new_unchecked(&full_handle) };
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
@@ -679,7 +675,7 @@ pub async fn update_handle(
full_handle
} else {
if new_handle == current_handle {
let handle_typed = Handle::new_unchecked(&new_handle);
let handle_typed = unsafe { Handle::new_unchecked(&new_handle) };
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed))
.await
@@ -772,7 +768,7 @@ pub async fn update_plc_handle(
}
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let host = match headers.get("host").and_then(|h| h.to_str().ok()) {
let host = match crate::util::get_header_str(&headers, "host") {
Some(h) => h,
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
};
@@ -1,13 +1,14 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, Permissive};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
extract::State,
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
use tracing::{error, info, warn};
use tracing::{info, warn};
fn generate_plc_token() -> String {
crate::util::generate_token_code()
@@ -28,10 +29,7 @@ pub async fn request_plc_operation_signature(
.user_repo
.get_id_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
@@ -41,18 +39,15 @@ pub async fn request_plc_operation_signature(
.infra_repo
.insert_plc_token(user_id, &plc_token, expires_at)
.await
.map_err(|e| {
error!("Failed to create PLC token: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("creating PLC token")?;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
&plc_token,
&hostname,
hostname,
)
.await
{
@@ -1,4 +1,5 @@
use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Auth, Permissive};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
@@ -64,20 +65,14 @@ pub async fn sign_plc_operation(
.user_repo
.get_id_by_did(did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let token_expiry = state
.infra_repo
.get_plc_token_expiry(user_id, token)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching PLC token expiry")?
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
if Utc::now() > token_expiry {
@@ -88,10 +83,7 @@ pub async fn sign_plc_operation(
.user_repo
.get_user_key_by_id(user_id)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user key")?
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
@@ -1,8 +1,10 @@
use crate::api::error::DbResultExt;
use crate::api::{ApiError, EmptyResponse};
use crate::auth::{Auth, Permissive};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -40,26 +42,20 @@ pub async fn submit_plc_operation(
.map_err(|e| ApiError::InvalidRequest(format!("Invalid operation: {}", e)))?;
let op = &input.operation;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
.get_id_and_handle_by_did(did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let key_row = state
.user_repo
.get_user_key_by_id(user.id)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user key")?
.ok_or_else(|| ApiError::InternalError(Some("User signing key not found".into())))?;
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
@@ -1,6 +1,7 @@
use crate::api::error::ApiError;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -9,11 +10,12 @@ use axum::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPrefsResponse {
pub preferred_channel: String,
pub preferred_channel: CommsChannel,
pub email: String,
pub discord_id: Option<String>,
pub discord_verified: bool,
@@ -50,9 +52,9 @@ pub async fn get_notification_prefs(
#[serde(rename_all = "camelCase")]
pub struct NotificationHistoryEntry {
pub created_at: String,
pub channel: String,
pub comms_type: String,
pub status: String,
pub channel: CommsChannel,
pub comms_type: CommsType,
pub status: CommsStatus,
pub subject: Option<String>,
pub body: String,
}
@@ -81,30 +83,29 @@ pub async fn get_notification_history(
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
let sensitive_types = [
"email_verification",
"password_reset",
"email_update",
"two_factor_code",
"passkey_recovery",
"migration_verification",
"plc_operation",
"channel_verification",
"signup_verification",
CommsType::EmailVerification,
CommsType::PasswordReset,
CommsType::EmailUpdate,
CommsType::TwoFactorCode,
CommsType::PasskeyRecovery,
CommsType::MigrationVerification,
CommsType::PlcOperation,
CommsType::ChannelVerification,
];
let notifications = rows
.iter()
.map(|row| {
let body = if sensitive_types.contains(&row.comms_type.as_str()) {
let body = if sensitive_types.contains(&row.comms_type) {
"[Code redacted for security]".to_string()
} else {
row.body.clone()
};
NotificationHistoryEntry {
created_at: row.created_at.to_rfc3339(),
channel: row.channel.clone(),
comms_type: row.comms_type.clone(),
status: row.status.clone(),
channel: row.channel,
comms_type: row.comms_type,
status: row.status,
subject: row.subject.clone(),
body,
}
@@ -145,7 +146,7 @@ pub async fn request_channel_verification(
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
if channel == "email" {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let handle_str = handle.unwrap_or("user");
crate::comms::comms_repo::enqueue_email_update(
state.infra_repo.as_ref(),
@@ -153,7 +154,7 @@ pub async fn request_channel_verification(
identifier,
handle_str,
&formatted_token,
&hostname,
hostname,
)
.await
.map_err(|e| format!("Failed to enqueue email notification: {}", e))?;
@@ -200,19 +201,24 @@ pub async fn update_notification_prefs(
let mut verification_required: Vec<String> = Vec::new();
if let Some(ref channel) = input.preferred_channel {
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&channel.as_str()) {
return Err(ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
));
}
if let Some(ref channel_str) = input.preferred_channel {
let channel = match channel_str.as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
"telegram" => CommsChannel::Telegram,
"signal" => CommsChannel::Signal,
_ => {
return Err(ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
));
}
};
state
.user_repo
.update_preferred_comms_channel(&auth.did, channel)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?;
info!(did = %auth.did, channel = %channel, "Updated preferred notification channel");
info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel");
}
if let Some(ref new_email) = input.email {
+4 -7
View File
@@ -3,6 +3,7 @@ use std::convert::Infallible;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use crate::util::get_header_str;
use axum::{
body::Bytes,
extract::{RawQuery, Request, State},
@@ -191,11 +192,7 @@ async fn proxy_handler(
.into_response();
}
let Some(proxy_header) = headers
.get("atproto-proxy")
.and_then(|h| h.to_str().ok())
.map(String::from)
else {
let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else {
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
.into_response();
};
@@ -217,10 +214,10 @@ async fn proxy_handler(
let mut auth_header_val = headers.get("Authorization").cloned();
if let Some(extracted) = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
crate::util::get_header_str(&headers, "Authorization"),
) {
let token = extracted.token;
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
let http_uri = crate::util::build_full_url(&uri.to_string());
match crate::auth::validate_token_with_dpop(
+18 -28
View File
@@ -1,9 +1,9 @@
use crate::api::error::ApiError;
use crate::auth::{Auth, AuthAny, NotTakendown, Permissive};
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, AuthAny, NotTakendown, Permissive, VerifyScope};
use crate::delegation::DelegationActionType;
use crate::state::AppState;
use crate::types::{CidLink, Did};
use crate::util::get_max_blob_size;
use crate::util::{get_header_str, get_max_blob_size};
use axum::body::Body;
use axum::{
Json,
@@ -56,18 +56,16 @@ pub async fn upload_blob(
if user.status.is_takendown() {
return Err(ApiError::AccountTakedown);
}
let mime_type_for_check = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream");
if let Err(e) = crate::auth::scope_check::check_blob_scope(
user.is_oauth(),
user.scope.as_deref(),
mime_type_for_check,
) {
return Ok(e);
}
(user.did.clone(), user.controller_did.clone())
let mime_type_for_check =
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
let scope_proof = match user.verify_blob_upload(mime_type_for_check) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
};
(
scope_proof.principal_did().into_did(),
scope_proof.controller_did().map(|c| c.into_did()),
)
}
};
@@ -80,10 +78,8 @@ pub async fn upload_blob(
return Err(ApiError::Forbidden);
}
let client_mime_hint = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream");
let client_mime_hint =
get_header_str(&headers, "content-type").unwrap_or("application/octet-stream");
let user_id = state
.user_repo
@@ -140,7 +136,7 @@ pub async fn upload_blob(
};
let cid = Cid::new_v1(0x55, multihash);
let cid_str = cid.to_string();
let cid_link: CidLink = CidLink::new_unchecked(&cid_str);
let cid_link: CidLink = unsafe { CidLink::new_unchecked(&cid_str) };
let storage_key = cid_str.clone();
info!(
@@ -232,10 +228,7 @@ pub async fn list_missing_blobs(
.user_repo
.get_by_did(did)
.await
.map_err(|e| {
error!("DB error fetching user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user")?
.ok_or(ApiError::InternalError(None))?;
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
@@ -244,10 +237,7 @@ pub async fn list_missing_blobs(
.blob_repo
.list_missing_blobs(user.id, cursor, limit + 1)
.await
.map_err(|e| {
error!("DB error fetching missing blobs: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching missing blobs")?;
let has_more = missing.len() > limit as usize;
let blobs: Vec<RecordBlob> = missing
+8 -12
View File
@@ -1,5 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::api::repo::record::create_signed_commit;
use crate::auth::{Auth, NotTakendown};
use crate::state::AppState;
@@ -49,10 +49,7 @@ pub async fn import_repo(
.user_repo
.get_by_did(did)
.await
.map_err(|e| {
error!("DB error fetching user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
if user.takedown_ref.is_some() {
return Err(ApiError::AccountTakedown);
@@ -207,10 +204,9 @@ pub async fn import_repo(
let record_uri =
AtUri::from_parts(did.as_str(), &record.collection, &record.rkey);
record.blob_refs.iter().map(move |blob_ref| {
(
record_uri.clone(),
CidLink::new_unchecked(blob_ref.cid.clone()),
)
(record_uri.clone(), unsafe {
CidLink::new_unchecked(blob_ref.cid.clone())
})
})
})
.collect();
@@ -275,7 +271,7 @@ pub async fn import_repo(
error!("Failed to store new commit block: {:?}", e);
ApiError::InternalError(None)
})?;
let new_root_cid_link = CidLink::new_unchecked(new_root_cid.to_string());
let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) };
state
.repo_repo
.update_repo_root(user_id, &new_root_cid_link, &new_rev_str)
@@ -368,8 +364,8 @@ async fn sequence_import_event(
) -> Result<(), tranquil_db::DbError> {
let data = tranquil_db::CommitEventData {
did: did.clone(),
event_type: "commit".to_string(),
commit_cid: Some(CidLink::new_unchecked(commit_cid)),
event_type: tranquil_db::RepoEventType::Commit,
commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }),
prev_cid: None,
ops: Some(serde_json::json!([])),
blobs: Some(vec![]),
+2 -2
View File
@@ -1,6 +1,7 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::AtIdentifier;
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -18,8 +19,7 @@ pub async fn describe_repo(
State(state): State<AppState>,
Query(input): Query<DescribeRepoInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname_for_handles = pds_hostname_without_port();
let user_row = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
+70 -131
View File
@@ -1,7 +1,12 @@
use super::validation::validate_record_with_status;
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
use crate::auth::{Active, Auth};
use crate::auth::{
Active, Auth, WriteOpKind, require_not_migrated, require_verified_or_delegated,
verify_batch_write_scopes,
};
use crate::cid_types::CommitCid;
use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -34,7 +39,7 @@ async fn process_single_write(
write: &WriteOp,
acc: WriteAccumulator,
did: &Did,
validate: Option<bool>,
validate: ValidationMode,
tracking_store: &TrackingBlockStore,
) -> Result<WriteAccumulator, Response> {
let WriteAccumulator {
@@ -51,19 +56,17 @@ async fn process_single_write(
rkey,
value,
} => {
let validation_status = match validate {
Some(false) => None,
_ => {
let require_lexicon = validate == Some(true);
match validate_record_with_status(
value,
collection,
rkey.as_ref(),
require_lexicon,
) {
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
let validation_status = if validate.should_skip() {
None
} else {
match validate_record_with_status(
value,
collection,
rkey.as_ref(),
validate.requires_lexicon(),
) {
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
};
all_blob_cids.extend(extract_blob_cids(value));
@@ -104,19 +107,17 @@ async fn process_single_write(
rkey,
value,
} => {
let validation_status = match validate {
Some(false) => None,
_ => {
let require_lexicon = validate == Some(true);
match validate_record_with_status(
value,
collection,
Some(rkey),
require_lexicon,
) {
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
let validation_status = if validate.should_skip() {
None
} else {
match validate_record_with_status(
value,
collection,
Some(rkey),
validate.requires_lexicon(),
) {
Ok(status) => Some(status),
Err(err_response) => return Err(*err_response),
}
};
all_blob_cids.extend(extract_blob_cids(value));
@@ -181,7 +182,7 @@ async fn process_writes(
writes: &[WriteOp],
initial_mst: Mst<TrackingBlockStore>,
did: &Did,
validate: Option<bool>,
validate: ValidationMode,
tracking_store: &TrackingBlockStore,
) -> Result<WriteAccumulator, Response> {
use futures::stream::{self, TryStreamExt};
@@ -222,7 +223,8 @@ pub enum WriteOp {
#[serde(rename_all = "camelCase")]
pub struct ApplyWritesInput {
pub repo: AtIdentifier,
pub validate: Option<bool>,
#[serde(default, deserialize_with = "deserialize_validation_mode")]
pub validate: ValidationMode,
pub writes: Vec<WriteOp>,
pub swap_commit: Option<String>,
}
@@ -270,36 +272,7 @@ pub async fn apply_writes(
input.repo,
input.writes.len()
);
let did = auth.did.clone();
let is_oauth = auth.is_oauth();
let scope = auth.scope.clone();
let controller_did = auth.controller_did.clone();
if input.repo.as_str() != did {
return Err(ApiError::InvalidRepo(
"Repo does not match authenticated user".into(),
));
}
if state
.user_repo
.is_account_migrated(&did)
.await
.unwrap_or(false)
{
return Err(ApiError::AccountMigrated);
}
let is_verified = state
.user_repo
.has_verified_comms_channel(&did)
.await
.unwrap_or(false);
let is_delegated = state
.delegation_repo
.is_delegated_account(&did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return Err(ApiError::AccountNotVerified);
}
if input.writes.is_empty() {
return Err(ApiError::InvalidRequest("writes array is empty".into()));
}
@@ -310,74 +283,40 @@ pub async fn apply_writes(
)));
}
let has_custom_scope = scope
.as_ref()
.map(|s| s != "com.atproto.access")
.unwrap_or(false);
if is_oauth || has_custom_scope {
use std::collections::HashSet;
let create_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Create { collection, .. } = w {
Some(collection)
} else {
None
}
})
.collect();
let update_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Update { collection, .. } = w {
Some(collection)
} else {
None
}
})
.collect();
let delete_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Delete { collection, .. } = w {
Some(collection)
} else {
None
}
})
.collect();
let batch_proof = match verify_batch_write_scopes(
&auth,
&auth,
&input.writes,
|w| match w {
WriteOp::Create { collection, .. } => collection.as_str(),
WriteOp::Update { collection, .. } => collection.as_str(),
WriteOp::Delete { collection, .. } => collection.as_str(),
},
|w| match w {
WriteOp::Create { .. } => WriteOpKind::Create,
WriteOp::Update { .. } => WriteOpKind::Update,
WriteOp::Delete { .. } => WriteOpKind::Delete,
},
) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
};
let scope_checks = create_collections
.iter()
.map(|c| (crate::oauth::RepoAction::Create, c))
.chain(
update_collections
.iter()
.map(|c| (crate::oauth::RepoAction::Update, c)),
)
.chain(
delete_collections
.iter()
.map(|c| (crate::oauth::RepoAction::Delete, c)),
);
let principal_did = batch_proof.principal_did();
let controller_did = batch_proof.controller_did().map(|c| c.into_did());
if let Some(err) = scope_checks
.filter_map(|(action, collection)| {
crate::auth::scope_check::check_repo_scope(
is_oauth,
scope.as_deref(),
action,
collection,
)
.err()
})
.next()
{
return Ok(err);
}
if input.repo.as_str() != principal_did.as_str() {
return Err(ApiError::InvalidRepo(
"Repo does not match authenticated user".into(),
));
}
let did = principal_did.into_did();
if let Err(e) = require_not_migrated(&state, &did).await {
return Ok(e);
}
if let Err(e) = require_verified_or_delegated(&state, batch_proof.user()).await {
return Ok(e);
}
let user_id: uuid::Uuid = state
@@ -394,16 +333,16 @@ pub async fn apply_writes(
.ok()
.flatten()
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
let current_root_cid = Cid::from_str(&root_cid_str)
let current_root_cid = CommitCid::from_str(&root_cid_str)
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid)
{
return Err(ApiError::InvalidSwap(Some("Repo has been modified".into())));
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store
.get(&current_root_cid)
.get(current_root_cid.as_cid())
.await
.ok()
.flatten()
@@ -471,7 +410,7 @@ pub async fn apply_writes(
} => Some(*cid),
_ => None,
});
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
.chain(
old_mst_blocks
.keys()
@@ -487,7 +426,7 @@ pub async fn apply_writes(
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
current_root_cid: Some(current_root_cid.into_cid()),
prev_data_cid: Some(commit.data),
new_mst_root,
ops,
@@ -1,7 +1,8 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
use crate::auth::{Active, Auth};
use crate::auth::{Active, Auth, VerifyScope};
use crate::cid_types::CommitCid;
use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -43,32 +44,28 @@ pub async fn delete_record(
auth: Auth<Active>,
Json(input): Json<DeleteRecordInput>,
) -> Result<Response, crate::api::error::ApiError> {
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
let scope_proof = match auth.verify_repo_delete(&input.collection) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
};
let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await {
Ok(res) => res,
Err(err_res) => return Ok(err_res),
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
repo_auth.is_oauth,
repo_auth.scope.as_deref(),
crate::oauth::RepoAction::Delete,
&input.collection,
) {
return Ok(e);
}
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let current_root_cid = repo_auth.current_root_cid;
let controller_did = repo_auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid)
{
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
Ok(Some(b)) => b,
_ => {
return Ok(
@@ -159,7 +156,7 @@ pub async fn delete_record(
.into_iter()
.collect();
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
.chain(
old_mst_blocks
.keys()
@@ -173,7 +170,7 @@ pub async fn delete_record(
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
current_root_cid: Some(current_root_cid.into_cid()),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
@@ -1,10 +1,15 @@
pub mod batch;
pub mod delete;
pub mod pagination;
pub mod read;
pub mod utils;
pub mod validation;
pub mod validation_mode;
pub mod write;
pub use pagination::PaginationDirection;
pub use validation_mode::ValidationMode;
pub use batch::apply_writes;
pub use delete::{DeleteRecordInput, delete_record, delete_record_internal};
pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records};
@@ -0,0 +1,31 @@
use serde::{Deserialize, Deserializer};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PaginationDirection {
#[default]
Forward,
Backward,
}
impl PaginationDirection {
pub fn from_optional_bool(value: Option<bool>) -> Self {
match value {
Some(true) => Self::Backward,
Some(false) | None => Self::Forward,
}
}
pub fn is_reverse(&self) -> bool {
matches!(self, Self::Backward)
}
}
pub fn deserialize_pagination_direction<'de, D>(
deserializer: D,
) -> Result<PaginationDirection, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<bool> = Option::deserialize(deserializer)?;
Ok(PaginationDirection::from_optional_bool(opt))
}
@@ -1,6 +1,8 @@
use super::pagination::{PaginationDirection, deserialize_pagination_direction};
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::{AtIdentifier, Nsid, Rkey};
use crate::util::pds_hostname_without_port;
use axum::{
Json,
extract::{Query, State},
@@ -58,8 +60,7 @@ pub async fn get_record(
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname_for_handles = pds_hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -144,7 +145,8 @@ pub struct ListRecordsInput {
pub rkey_start: Option<Rkey>,
#[serde(rename = "rkeyEnd")]
pub rkey_end: Option<Rkey>,
pub reverse: Option<bool>,
#[serde(default, deserialize_with = "deserialize_pagination_direction")]
pub reverse: PaginationDirection,
}
#[derive(Serialize)]
pub struct ListRecordsOutput {
@@ -157,8 +159,7 @@ pub async fn list_records(
State(state): State<AppState>,
Query(input): Query<ListRecordsInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname_for_handles = pds_hostname_without_port();
let user_id_opt = if input.repo.is_did() {
let did: crate::types::Did = match input.repo.as_str().parse() {
Ok(d) => d,
@@ -194,7 +195,6 @@ pub async fn list_records(
}
};
let limit = input.limit.unwrap_or(50).clamp(1, 100);
let reverse = input.reverse.unwrap_or(false);
let limit_i64 = limit as i64;
let cursor_rkey = input
.cursor
@@ -207,7 +207,7 @@ pub async fn list_records(
&input.collection,
cursor_rkey.as_ref(),
limit_i64,
reverse,
input.reverse.is_reverse(),
input.rkey_start.as_ref(),
input.rkey_end.as_ref(),
)
@@ -8,6 +8,7 @@ use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::str::FromStr;
use tranquil_db_traits::SequenceNumber;
use uuid::Uuid;
pub fn extract_blob_cids(record: &Value) -> Vec<String> {
@@ -139,6 +140,7 @@ pub async fn commit_and_log(
) -> Result<CommitResult, String> {
use tranquil_db_traits::{
ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert,
RepoEventType,
};
let CommitParams {
@@ -199,7 +201,7 @@ pub async fn commit_and_log(
upserts.push(RecordUpsert {
collection: collection.clone(),
rkey: rkey.clone(),
cid: crate::types::CidLink::new_unchecked(cid.to_string()),
cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) },
});
}
RecordOp::Delete {
@@ -263,15 +265,15 @@ pub async fn commit_and_log(
let commit_event = CommitEventData {
did: did.clone(),
event_type: "commit".to_string(),
commit_cid: Some(crate::types::CidLink::new_unchecked(
new_root_cid.to_string(),
)),
prev_cid: current_root_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
event_type: RepoEventType::Commit,
commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }),
prev_cid: current_root_cid
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
ops: Some(json!(ops_json)),
blobs: Some(blobs.to_vec()),
blocks_cids: Some(blocks_cids.to_vec()),
prev_data_cid: prev_data_cid.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
prev_data_cid: prev_data_cid
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
rev: Some(rev_str.clone()),
};
@@ -279,8 +281,8 @@ pub async fn commit_and_log(
user_id,
did: did.clone(),
expected_root_cid: current_root_cid
.map(|c| crate::types::CidLink::new_unchecked(c.to_string())),
new_root_cid: crate::types::CidLink::new_unchecked(new_root_cid.to_string()),
.map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }),
new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) },
new_rev: rev_str.clone(),
new_block_cids: all_block_cids,
obsolete_block_cids: obsolete_bytes,
@@ -417,7 +419,7 @@ pub async fn sequence_identity_event(
state: &AppState,
did: &Did,
handle: Option<&Handle>,
) -> Result<i64, String> {
) -> Result<SequenceNumber, String> {
state
.repo_repo
.insert_identity_event(did, handle)
@@ -427,12 +429,11 @@ pub async fn sequence_identity_event(
pub async fn sequence_account_event(
state: &AppState,
did: &Did,
active: bool,
status: Option<&str>,
) -> Result<i64, String> {
status: tranquil_db_traits::AccountStatus,
) -> Result<SequenceNumber, String> {
state
.repo_repo
.insert_account_event(did, active, status)
.insert_account_event(did, status)
.await
.map_err(|e| format!("DB Error (account event): {}", e))
}
@@ -441,8 +442,8 @@ pub async fn sequence_sync_event(
did: &Did,
commit_cid: &str,
rev: Option<&str>,
) -> Result<i64, String> {
let cid_link = crate::types::CidLink::new_unchecked(commit_cid);
) -> Result<SequenceNumber, String> {
let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) };
state
.repo_repo
.insert_sync_event(did, &cid_link, rev)
@@ -456,9 +457,10 @@ pub async fn sequence_genesis_commit(
commit_cid: &Cid,
mst_root_cid: &Cid,
rev: &str,
) -> Result<i64, String> {
let commit_cid_link = crate::types::CidLink::new_unchecked(commit_cid.to_string());
let mst_root_cid_link = crate::types::CidLink::new_unchecked(mst_root_cid.to_string());
) -> Result<SequenceNumber, String> {
let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) };
let mst_root_cid_link =
unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) };
state
.repo_repo
.insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev)
@@ -0,0 +1,35 @@
use serde::{Deserialize, Deserializer};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ValidationMode {
Skip,
#[default]
Infer,
Strict,
}
impl ValidationMode {
pub fn from_optional_bool(value: Option<bool>) -> Self {
match value {
Some(false) => Self::Skip,
Some(true) => Self::Strict,
None => Self::Infer,
}
}
pub fn should_skip(&self) -> bool {
matches!(self, Self::Skip)
}
pub fn requires_lexicon(&self) -> bool {
matches!(self, Self::Strict)
}
}
pub fn deserialize_validation_mode<'de, D>(deserializer: D) -> Result<ValidationMode, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<bool> = Option::deserialize(deserializer)?;
Ok(ValidationMode::from_optional_bool(opt))
}
@@ -1,9 +1,14 @@
use super::validation::validate_record_with_status;
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
};
use crate::auth::{Active, Auth};
use crate::auth::{
Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
require_verified_or_delegated,
};
use crate::cid_types::CommitCid;
use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -26,46 +31,31 @@ use uuid::Uuid;
pub struct RepoWriteAuth {
pub did: Did,
pub user_id: Uuid,
pub current_root_cid: Cid,
pub current_root_cid: CommitCid,
pub is_oauth: bool,
pub scope: Option<String>,
pub controller_did: Option<Did>,
}
pub async fn prepare_repo_write(
pub async fn prepare_repo_write<A: RepoScopeAction>(
state: &AppState,
auth_user: &crate::auth::AuthenticatedUser,
scope_proof: &ScopeVerified<'_, A>,
repo: &AtIdentifier,
) -> Result<RepoWriteAuth, Response> {
if repo.as_str() != auth_user.did.as_str() {
let user = scope_proof.user();
let principal_did = scope_proof.principal_did();
if repo.as_str() != principal_did.as_str() {
return Err(
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
);
}
if state
.user_repo
.is_account_migrated(&auth_user.did)
.await
.unwrap_or(false)
{
return Err(ApiError::AccountMigrated.into_response());
}
let is_verified = state
.user_repo
.has_verified_comms_channel(&auth_user.did)
.await
.unwrap_or(false);
let is_delegated = state
.delegation_repo
.is_delegated_account(&auth_user.did)
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return Err(ApiError::AccountNotVerified.into_response());
}
require_not_migrated(state, principal_did.as_did()).await?;
let _account_verified = require_verified_or_delegated(state, user).await?;
let user_id = state
.user_repo
.get_id_by_did(&auth_user.did)
.get_id_by_did(principal_did.as_did())
.await
.map_err(|e| {
error!("DB error fetching user: {}", e);
@@ -83,16 +73,16 @@ pub async fn prepare_repo_write(
.ok_or_else(|| {
ApiError::InternalError(Some("Repo root not found".into())).into_response()
})?;
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
let current_root_cid = CommitCid::from_str(&root_cid_str).map_err(|_| {
ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
})?;
Ok(RepoWriteAuth {
did: auth_user.did.clone(),
did: principal_did.into_did(),
user_id,
current_root_cid,
is_oauth: auth_user.is_oauth(),
scope: auth_user.scope.clone(),
controller_did: auth_user.controller_did.clone(),
is_oauth: user.is_oauth(),
scope: user.scope.clone(),
controller_did: scope_proof.controller_did().map(|c| c.into_did()),
})
}
#[derive(Deserialize)]
@@ -101,7 +91,8 @@ pub struct CreateRecordInput {
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Option<Rkey>,
pub validate: Option<bool>,
#[serde(default, deserialize_with = "deserialize_validation_mode")]
pub validate: ValidationMode,
pub record: serde_json::Value,
#[serde(rename = "swapCommit")]
pub swap_commit: Option<String>,
@@ -127,40 +118,35 @@ pub async fn create_record(
auth: Auth<Active>,
Json(input): Json<CreateRecordInput>,
) -> Result<Response, crate::api::error::ApiError> {
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
let scope_proof = match auth.verify_repo_create(&input.collection) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
};
let repo_auth = match prepare_repo_write(&state, &scope_proof, &input.repo).await {
Ok(res) => res,
Err(err_res) => return Ok(err_res),
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
repo_auth.is_oauth,
repo_auth.scope.as_deref(),
crate::oauth::RepoAction::Create,
&input.collection,
) {
return Ok(e);
}
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let current_root_cid = repo_auth.current_root_cid;
let controller_did = repo_auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid)
{
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
let validation_status = if input.validate == Some(false) {
let validation_status = if input.validate.should_skip() {
None
} else {
let require_lexicon = input.validate == Some(true);
match validate_record_with_status(
&input.record,
&input.collection,
input.rkey.as_ref(),
require_lexicon,
input.validate.requires_lexicon(),
) {
Ok(status) => Some(status),
Err(err_response) => return Ok(*err_response),
@@ -169,7 +155,7 @@ pub async fn create_record(
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
Ok(Some(b)) => b,
_ => {
return Ok(
@@ -192,7 +178,7 @@ pub async fn create_record(
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
let mut all_old_mst_blocks = std::collections::BTreeMap::new();
if input.validate != Some(false) {
if !input.validate.should_skip() {
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let backlinks = extract_backlinks(&record_uri, &input.record);
@@ -323,7 +309,7 @@ pub async fn create_record(
.collect();
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
let blob_cids = extract_blob_cids(&input.record);
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
.chain(
all_old_mst_blocks
.keys()
@@ -337,7 +323,7 @@ pub async fn create_record(
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
current_root_cid: Some(current_root_cid.into_cid()),
prev_data_cid: Some(initial_mst_root),
new_mst_root,
ops,
@@ -412,7 +398,8 @@ pub struct PutRecordInput {
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Rkey,
pub validate: Option<bool>,
#[serde(default, deserialize_with = "deserialize_validation_mode")]
pub validate: ValidationMode,
pub record: serde_json::Value,
#[serde(rename = "swapCommit")]
pub swap_commit: Option<String>,
@@ -434,40 +421,28 @@ pub async fn put_record(
auth: Auth<Active>,
Json(input): Json<PutRecordInput>,
) -> Result<Response, crate::api::error::ApiError> {
let repo_auth = match prepare_repo_write(&state, &auth, &input.repo).await {
let upsert_proof = match auth.verify_repo_upsert(&input.collection) {
Ok(proof) => proof,
Err(e) => return Ok(e.into_response()),
};
let repo_auth = match prepare_repo_write(&state, &upsert_proof, &input.repo).await {
Ok(res) => res,
Err(err_res) => return Ok(err_res),
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
repo_auth.is_oauth,
repo_auth.scope.as_deref(),
crate::oauth::RepoAction::Create,
&input.collection,
) {
return Ok(e);
}
if let Err(e) = crate::auth::scope_check::check_repo_scope(
repo_auth.is_oauth,
repo_auth.scope.as_deref(),
crate::oauth::RepoAction::Update,
&input.collection,
) {
return Ok(e);
}
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let current_root_cid = repo_auth.current_root_cid;
let controller_did = repo_auth.controller_did;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
&& CommitCid::from_str(swap_commit).ok().as_ref() != Some(&current_root_cid)
{
return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response());
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
let commit_bytes = match tracking_store.get(current_root_cid.as_cid()).await {
Ok(Some(b)) => b,
_ => {
return Ok(
@@ -485,15 +460,14 @@ pub async fn put_record(
};
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let key = format!("{}/{}", input.collection, input.rkey);
let validation_status = if input.validate == Some(false) {
let validation_status = if input.validate.should_skip() {
None
} else {
let require_lexicon = input.validate == Some(true);
match validate_record_with_status(
&input.record,
&input.collection,
Some(&input.rkey),
require_lexicon,
input.validate.requires_lexicon(),
) {
Ok(status) => Some(status),
Err(err_response) => return Ok(*err_response),
@@ -610,7 +584,7 @@ pub async fn put_record(
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
let is_update = existing_cid.is_some();
let blob_cids = extract_blob_cids(&input.record);
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid)
let obsolete_cids: Vec<Cid> = std::iter::once(current_root_cid.into_cid())
.chain(
old_mst_blocks
.keys()
@@ -624,7 +598,7 @@ pub async fn put_record(
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
current_root_cid: Some(current_root_cid.into_cid()),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
@@ -1,10 +1,11 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::{Auth, NotTakendown, Permissive};
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
use crate::cache::Cache;
use crate::plc::PlcClient;
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -130,7 +131,7 @@ async fn assert_valid_did_document_for_service(
did: &crate::types::Did,
with_retry: bool,
) -> Result<(), ApiError> {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let expected_endpoint = format!("https://{}", hostname);
if did.as_str().starts_with("did:plc:") {
@@ -219,10 +220,10 @@ async fn assert_valid_did_document_for_service(
.and_then(|v| v.get("atproto"))
.and_then(|k| k.as_str());
let user_key = user_repo.get_user_key_by_did(did).await.map_err(|e| {
error!("Failed to fetch user key: {:?}", e);
ApiError::InternalError(None)
})?;
let user_key = user_repo
.get_user_key_by_did(did)
.await
.log_db_err("fetching user key")?;
if let Some(key_info) = user_key {
let key_bytes =
@@ -379,8 +380,12 @@ pub async fn activate_account(
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",
did
);
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
warn!(
"[MIGRATION] activateAccount: Failed to sequence account activation event: {}",
@@ -502,8 +507,7 @@ pub async fn deactivate_account(
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
false,
Some("deactivated"),
tranquil_db_traits::AccountStatus::Deactivated,
)
.await
{
@@ -523,20 +527,14 @@ pub async fn request_account_delete(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
let did = &auth.did;
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await {
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
did,
)
.await);
}
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let user_id = state
.user_repo
.get_id_by_did(did)
.get_id_by_did(session_mfa.did())
.await
.ok()
.flatten()
@@ -545,25 +543,22 @@ pub async fn request_account_delete(
let expires_at = Utc::now() + Duration::minutes(15);
state
.infra_repo
.create_deletion_request(&confirmation_token, did, expires_at)
.create_deletion_request(&confirmation_token, session_mfa.did(), expires_at)
.await
.map_err(|e| {
error!("DB error creating deletion token: {:?}", e);
ApiError::InternalError(None)
})?;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
.log_db_err("creating deletion token")?;
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_account_deletion(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
&confirmation_token,
&hostname,
hostname,
)
.await
{
warn!("Failed to enqueue account deletion notification: {:?}", e);
}
info!("Account deletion requested for user {}", did);
info!("Account deletion requested for user {}", session_mfa.did());
Ok(EmptyResponse::ok().into_response())
}
@@ -642,8 +637,12 @@ pub async fn delete_account(
error!("DB error deleting account: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let account_seq =
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await;
let account_seq = crate::api::repo::record::sequence_account_event(
&state,
did,
tranquil_db_traits::AccountStatus::Deleted,
)
.await;
match account_seq {
Ok(seq) => {
if let Err(e) = state.repo_repo.delete_sequences_except(did, seq).await {
@@ -1,17 +1,17 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Auth, NotTakendown, Permissive, generate_app_password};
use crate::delegation::{DelegationActionType, intersect_scopes};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{AppPasswordLimit, RateLimited};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::HeaderMap,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
use tracing::error;
use tranquil_db_traits::AppPasswordCreate;
#[derive(Serialize)]
@@ -39,26 +39,20 @@ pub async fn list_app_passwords(
.user_repo
.get_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error getting user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
let rows = state
.session_repo
.list_app_passwords(user.id)
.await
.map_err(|e| {
error!("DB error listing app passwords: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("listing app passwords")?;
let passwords: Vec<AppPassword> = rows
.iter()
.map(|row| AppPassword {
name: row.name.clone(),
created_at: row.created_at.to_rfc3339(),
privileged: row.privileged,
privileged: row.privilege.is_privileged(),
scopes: row.scopes.clone(),
created_by_controller: row
.created_by_controller_did
@@ -89,27 +83,15 @@ pub struct CreateAppPasswordOutput {
pub async fn create_app_password(
State(state): State<AppState>,
headers: HeaderMap,
_rate_limit: RateLimited<AppPasswordLimit>,
auth: Auth<NotTakendown>,
Json(input): Json<CreateAppPasswordInput>,
) -> Result<Response, ApiError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
.await
{
warn!(ip = %client_ip, "App password creation rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
let user = state
.user_repo
.get_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error getting user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
let name = input.name.trim();
@@ -121,10 +103,7 @@ pub async fn create_app_password(
.session_repo
.get_app_password_by_name(user.id, name)
.await
.map_err(|e| {
error!("DB error checking app password: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("checking app password")?
.is_some()
{
return Err(ApiError::DuplicateAppPassword);
@@ -140,7 +119,7 @@ pub async fn create_app_password(
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
let requested = input.scopes.as_deref().unwrap_or("atproto");
let intersected = intersect_scopes(requested, &granted_scopes);
let intersected = intersect_scopes(requested, granted_scopes.as_str());
if intersected.is_empty() && !granted_scopes.is_empty() {
return Err(ApiError::InsufficientScope(None));
@@ -171,14 +150,15 @@ pub async fn create_app_password(
ApiError::InternalError(None)
})?;
let privileged = input.privileged.unwrap_or(false);
let privilege =
tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false));
let created_at = chrono::Utc::now();
let create_data = AppPasswordCreate {
user_id: user.id,
name: name.to_string(),
password_hash,
privileged,
privilege,
scopes: final_scopes.clone(),
created_by_controller_did: controller_did.clone(),
};
@@ -187,10 +167,7 @@ pub async fn create_app_password(
.session_repo
.create_app_password(&create_data)
.await
.map_err(|e| {
error!("DB error creating app password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("creating app password")?;
if let Some(ref controller) = controller_did {
let _ = state
@@ -214,7 +191,7 @@ pub async fn create_app_password(
name: name.to_string(),
password,
created_at: created_at.to_rfc3339(),
privileged,
privileged: privilege.is_privileged(),
scopes: final_scopes,
})
.into_response())
@@ -234,10 +211,7 @@ pub async fn revoke_app_password(
.user_repo
.get_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error getting user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
let name = input.name.trim();
@@ -255,10 +229,7 @@ pub async fn revoke_app_password(
.session_repo
.delete_sessions_by_app_password(&auth.did, name)
.await
.map_err(|e| {
error!("DB error revoking sessions for app password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking sessions for app password")?;
futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
let cache_key = format!("auth:session:{}:{}", &auth.did, jti);
@@ -273,10 +244,7 @@ pub async fn revoke_app_password(
.session_repo
.delete_app_password(user.id, name)
.await
.map_err(|e| {
error!("DB error revoking app password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking app password")?;
Ok(EmptyResponse::ok().into_response())
}
+21 -92
View File
@@ -1,7 +1,9 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
use crate::auth::{Auth, NotTakendown};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{EmailUpdateLimit, RateLimited, VerificationCheckLimit};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -44,19 +46,10 @@ pub struct RequestEmailUpdateInput {
pub async fn request_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<EmailUpdateLimit>,
auth: Auth<NotTakendown>,
input: Option<Json<RequestEmailUpdateInput>>,
) -> Result<Response, ApiError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
.await
{
warn!(ip = %client_ip, "Email update rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
auth.scope.as_deref(),
@@ -70,10 +63,7 @@ pub async fn request_email_update(
.user_repo
.get_email_info_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting email info")?
.ok_or(ApiError::AccountNotFound)?;
let Some(current_email) = user.email else {
@@ -111,14 +101,14 @@ pub async fn request_email_update(
}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
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,
hostname,
)
.await
{
@@ -139,19 +129,10 @@ pub struct ConfirmEmailInput {
pub async fn confirm_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<EmailUpdateLimit>,
auth: Auth<NotTakendown>,
Json(input): Json<ConfirmEmailInput>,
) -> Result<Response, ApiError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
.await
{
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
auth.scope.as_deref(),
@@ -166,10 +147,7 @@ pub async fn confirm_email(
.user_repo
.get_email_info_by_did(did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting email info")?
.ok_or(ApiError::AccountNotFound)?;
let Some(ref email) = user.email else {
@@ -213,10 +191,7 @@ pub async fn confirm_email(
.user_repo
.set_email_verified(user.id, true)
.await
.map_err(|e| {
error!("DB error confirming email: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("confirming email")?;
info!("Email confirmed for user {}", user.id);
Ok(EmptyResponse::ok().into_response())
@@ -250,10 +225,7 @@ pub async fn update_email(
.user_repo
.get_email_info_by_did(did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting email info")?
.ok_or(ApiError::AccountNotFound)?;
let user_id = user.id;
@@ -325,23 +297,20 @@ pub async fn update_email(
.user_repo
.update_email(user_id, &new_email)
.await
.map_err(|e| {
error!("DB error updating email: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating email")?;
let verification_token =
crate::auth::verification_token::generate_signup_token(did, "email", &new_email);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.infra_repo.as_ref(),
user_id,
"email",
&new_email,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -371,17 +340,9 @@ pub struct CheckEmailVerifiedInput {
pub async fn check_email_verified(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckEmailVerifiedInput>,
) -> 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();
}
match state
.user_repo
.check_email_verified_by_identifier(&input.identifier)
@@ -403,17 +364,9 @@ pub struct AuthorizeEmailUpdateQuery {
pub async fn authorize_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<VerificationCheckLimit>,
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 {
@@ -488,7 +441,7 @@ pub async fn authorize_email_update(
info!(did = %did, "Email update authorized via link click");
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let redirect_url = format!(
"https://{}/app/verify?type=email-authorize-success",
hostname
@@ -499,17 +452,9 @@ pub async fn authorize_email_update(
pub async fn check_email_update_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<VerificationCheckLimit>,
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return Err(ApiError::RateLimitExceeded(None));
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.is_oauth(),
auth.scope.as_deref(),
@@ -549,17 +494,9 @@ pub struct CheckEmailInUseInput {
pub async fn check_email_in_use(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckEmailInUseInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let email = input.email.trim().to_lowercase();
if email.is_empty() {
return ApiError::InvalidRequest("email is required".into()).into_response();
@@ -587,17 +524,9 @@ pub struct CheckCommsChannelInUseInput {
pub async fn check_comms_channel_in_use(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
_rate_limit: RateLimited<VerificationCheckLimit>,
Json(input): Json<CheckCommsChannelInUseInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let channel = match input.channel.to_lowercase().as_str() {
"email" => CommsChannel::Email,
"discord" => CommsChannel::Discord,
+6 -10
View File
@@ -1,7 +1,9 @@
use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Admin, Auth, NotTakendown};
use crate::state::AppState;
use crate::types::Did;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -24,7 +26,7 @@ fn gen_random_token() -> String {
}
fn gen_invite_code() -> String {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let hostname_prefix = hostname.replace('.', "-");
format!("{}-{}", hostname_prefix, gen_random_token())
}
@@ -121,10 +123,7 @@ pub async fn create_invite_codes(
.user_repo
.get_any_admin_user_id()
.await
.map_err(|e| {
error!("DB error looking up admin user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("looking up admin user")?
.ok_or_else(|| {
error!("No admin user found to create invite codes");
ApiError::InternalError(None)
@@ -202,14 +201,11 @@ pub async fn get_account_invite_codes(
.infra_repo
.get_invite_codes_for_account(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching invite codes: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching invite codes")?;
let filtered_codes: Vec<_> = codes_info
.into_iter()
.filter(|info| !info.disabled)
.filter(|info| info.state.is_active())
.collect();
let codes = futures::future::join_all(filtered_codes.into_iter().map(|info| {
+1 -1
View File
@@ -21,7 +21,7 @@ pub async fn get_logo(State(state): State<AppState>) -> Response {
Some(c) if !c.is_empty() => c,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let cid = crate::types::CidLink::new_unchecked(&cid_str);
let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) };
let metadata = match state.blob_repo.get_blob_metadata(&cid).await {
Ok(Some(m)) => m,
+3 -2
View File
@@ -1,4 +1,5 @@
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde_json::json;
@@ -30,9 +31,9 @@ pub fn is_self_hosted_did_web_enabled() -> bool {
}
pub async fn describe_server() -> impl IntoResponse {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_hostname = pds_hostname();
let domains_str =
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone());
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string());
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
@@ -1,6 +1,8 @@
use crate::api::ApiError;
use crate::api::error::DbResultExt;
use crate::auth::{Active, Auth};
use crate::state::AppState;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -49,10 +51,7 @@ pub async fn update_did_document(
.user_repo
.get_user_for_did_doc(&auth.did)
.await
.map_err(|e| {
tracing::error!("DB error getting user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting user")?
.ok_or(ApiError::AccountNotFound)?;
if let Some(ref methods) = input.verification_methods {
@@ -107,10 +106,7 @@ pub async fn update_did_document(
.user_repo
.upsert_did_web_overrides(user.id, verification_methods_json, also_known_as)
.await
.map_err(|e| {
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("upserting did_web_overrides")?;
if let Some(ref endpoint) = input.service_endpoint {
let endpoint_clean = endpoint.trim().trim_end_matches('/');
@@ -118,10 +114,7 @@ pub async fn update_did_document(
.user_repo
.update_migrated_to_pds(&auth.did, endpoint_clean)
.await
.map_err(|e| {
tracing::error!("DB error updating service endpoint: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating service endpoint")?;
}
let did_doc = build_did_document(&state, &auth.did).await;
@@ -154,7 +147,7 @@ pub async fn get_did_document(
}
async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_json::Value {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let user = match state.user_repo.get_user_for_did_doc_build(did).await {
Ok(Some(row)) => row,
@@ -1,5 +1,6 @@
use crate::api::SuccessResponse;
use crate::api::error::ApiError;
use crate::auth::NormalizedLoginIdentifier;
use axum::{
Json,
extract::State,
@@ -19,25 +20,12 @@ use uuid::Uuid;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited};
use crate::state::AppState;
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
fn generate_setup_token() -> String {
let mut rng = rand::thread_rng();
(0..32)
@@ -80,23 +68,12 @@ pub struct CreatePasskeyAccountResponse {
pub async fn create_passkey_account(
State(state): State<AppState>,
_rate_limit: RateLimited<AccountCreationLimit>,
headers: HeaderMap,
Json(input): Json<CreatePasskeyAccountInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response();
}
let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
crate::util::get_header_str(&headers, "Authorization"),
) {
let token = extracted.token;
if is_service_token(&token) {
@@ -135,8 +112,8 @@ pub async fn create_passkey_account(
.map(|d| d.starts_with("did:web:"))
.unwrap_or(false);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname);
let hostname = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let pds_suffix = format!(".{}", hostname_for_handles);
let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
@@ -169,15 +146,10 @@ pub async fn create_passkey_account(
return ApiError::InvalidEmail.into_response();
}
if let Some(ref code) = input.invite_code {
let valid = state
.infra_repo
.is_invite_code_valid(code)
.await
.unwrap_or(false);
if !valid {
return ApiError::InvalidInviteCode.into_response();
let _validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
@@ -186,7 +158,8 @@ pub async fn create_passkey_account(
if invite_required {
return ApiError::InviteCodeRequired.into_response();
}
}
None
};
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let verification_recipient = match verification_channel {
@@ -268,7 +241,7 @@ pub async fn create_passkey_account(
}
if is_byod_did_web {
if let Some(ref auth_did) = byod_auth
&& d != auth_did
&& d != auth_did.as_str()
{
return ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
@@ -280,7 +253,7 @@ pub async fn create_passkey_account(
} else {
if let Err(e) = crate::api::identity::did::verify_did_web(
d,
&hostname,
hostname,
&input.handle,
input.signing_key.as_deref(),
)
@@ -296,7 +269,7 @@ pub async fn create_passkey_account(
if let Some(ref auth_did) = byod_auth {
if let Some(ref provided_did) = input.did {
if provided_did.starts_with("did:plc:") {
if provided_did != auth_did {
if provided_did != auth_did.as_str() {
return ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, provided_did
@@ -389,7 +362,7 @@ pub async fn create_passkey_account(
}
};
let rev = Tid::now(LimitedU32::MIN);
let did_typed = Did::new_unchecked(&did);
let did_typed = unsafe { Did::new_unchecked(&did) };
let (commit_bytes, _sig) =
match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) {
Ok(result) => result,
@@ -422,7 +395,7 @@ pub async fn create_passkey_account(
_ => tranquil_db_traits::CommsChannel::Email,
};
let handle_typed = Handle::new_unchecked(&handle);
let handle_typed = unsafe { Handle::new_unchecked(&handle) };
let create_input = tranquil_db_traits::CreatePasskeyAccountInput {
handle: handle_typed.clone(),
email: email.clone().unwrap_or_default(),
@@ -484,8 +457,12 @@ pub async fn create_passkey_account(
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did_typed,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
@@ -493,8 +470,8 @@ pub async fn create_passkey_account(
"$type": "app.bsky.actor.profile",
"displayName": handle
});
let profile_collection = Nsid::new_unchecked("app.bsky.actor.profile");
let profile_rkey = Rkey::new_unchecked("self");
let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") };
let profile_rkey = unsafe { Rkey::new_unchecked("self") };
if let Err(e) = crate::api::repo::record::create_record_internal(
&state,
&did_typed,
@@ -521,7 +498,7 @@ pub async fn create_passkey_account(
verification_channel,
&verification_recipient,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -541,7 +518,7 @@ pub async fn create_passkey_account(
refresh_jti,
access_expires_at: token_meta.expires_at,
refresh_expires_at: refresh_expires,
legacy_login: false,
login_type: tranquil_db::LoginType::Modern,
mfa_verified: false,
scope: None,
controller_did: None,
@@ -626,14 +603,7 @@ pub async fn complete_passkey_setup(
return ApiError::InvalidToken(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let webauthn = &state.webauthn_config;
let reg_state = match state
.user_repo
@@ -768,14 +738,7 @@ pub async fn start_passkey_registration_for_setup(
return ApiError::InvalidToken(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let webauthn = &state.webauthn_config;
let existing_passkeys = state
.user_repo
@@ -840,30 +803,18 @@ pub struct RequestPasskeyRecoveryInput {
pub async fn request_passkey_recovery(
State(state): State<AppState>,
headers: HeaderMap,
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasskeyRecoveryInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
let hostname_for_handles = pds_hostname_without_port();
let identifier = input.email.trim().to_lowercase();
let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
identifier.to_string()
} else {
format!("{}.{}", identifier, hostname_for_handles)
};
let normalized_handle =
NormalizedLoginIdentifier::normalize(&input.email, hostname_for_handles);
let user = match state
.user_repo
.get_user_for_passkey_recovery(identifier, &normalized_handle)
.get_user_for_passkey_recovery(identifier, normalized_handle.as_str())
.await
{
Ok(Some(u)) if !u.password_required => u,
@@ -890,7 +841,7 @@ pub async fn request_passkey_recovery(
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let recovery_url = format!(
"https://{}/app/recover-passkey?did={}&token={}",
hostname,
@@ -903,7 +854,7 @@ pub async fn request_passkey_recovery(
state.infra_repo.as_ref(),
user.id,
&recovery_url,
&hostname,
hostname,
)
.await;
+20 -56
View File
@@ -1,7 +1,6 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::webauthn::WebAuthnConfig;
use crate::auth::{Active, Auth};
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{Active, Auth, require_legacy_session_mfa, require_reauth_window};
use crate::state::AppState;
use axum::{
Json,
@@ -12,14 +11,6 @@ use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use webauthn_rs::prelude::*;
fn get_webauthn() -> Result<WebAuthnConfig, ApiError> {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
WebAuthnConfig::new(&hostname).map_err(|e| {
error!("Failed to create WebAuthn config: {}", e);
ApiError::InternalError(Some("WebAuthn configuration failed".into()))
})
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartRegistrationInput {
@@ -37,26 +28,20 @@ pub async fn start_passkey_registration(
auth: Auth<Active>,
Json(input): Json<StartRegistrationInput>,
) -> Result<Response, ApiError> {
let webauthn = get_webauthn()?;
let webauthn = &state.webauthn_config;
let handle = state
.user_repo
.get_handle_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching user: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let existing_passkeys = state
.user_repo
.get_passkeys_for_user(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching existing passkeys: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching existing passkeys")?;
let exclude_credentials: Vec<CredentialID> = existing_passkeys
.iter()
@@ -81,10 +66,7 @@ pub async fn start_passkey_registration(
.user_repo
.save_webauthn_challenge(&auth.did, "registration", &state_json)
.await
.map_err(|e| {
error!("Failed to save registration state: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("saving registration state")?;
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
@@ -112,16 +94,13 @@ pub async fn finish_passkey_registration(
auth: Auth<Active>,
Json(input): Json<FinishRegistrationInput>,
) -> Result<Response, ApiError> {
let webauthn = get_webauthn()?;
let webauthn = &state.webauthn_config;
let reg_state_json = state
.user_repo
.load_webauthn_challenge(&auth.did, "registration")
.await
.map_err(|e| {
error!("DB error loading registration state: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("loading registration state")?
.ok_or(ApiError::NoRegistrationInProgress)?;
let reg_state: SecurityKeyRegistration =
@@ -157,10 +136,7 @@ pub async fn finish_passkey_registration(
input.friendly_name.as_deref(),
)
.await
.map_err(|e| {
error!("Failed to save passkey: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("saving passkey")?;
if let Err(e) = state
.user_repo
@@ -208,10 +184,7 @@ pub async fn list_passkeys(
.user_repo
.get_passkeys_for_user(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching passkeys: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching passkeys")?;
let passkey_infos: Vec<PasskeyInfo> = passkeys
.into_iter()
@@ -241,30 +214,21 @@ pub async fn delete_passkey(
auth: Auth<Active>,
Json(input): Json<DeletePasskeyInput>,
) -> Result<Response, ApiError> {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
{
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
return Ok(crate::api::server::reauth::reauth_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let reauth_mfa = match require_reauth_window(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?;
match state.user_repo.delete_passkey(id, &auth.did).await {
match state.user_repo.delete_passkey(id, reauth_mfa.did()).await {
Ok(true) => {
info!(did = %auth.did, passkey_id = %id, "Passkey deleted");
info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted");
Ok(EmptyResponse::ok().into_response())
}
Ok(false) => Err(ApiError::PasskeyNotFound),
+64 -170
View File
@@ -1,16 +1,20 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
use crate::auth::{Active, Auth};
use crate::state::{AppState, RateLimitKind};
use crate::auth::{
Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window,
require_reauth_window_if_available,
};
use crate::rate_limit::{PasswordResetLimit, RateLimited, ResetPasswordLimit};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::{pds_hostname, pds_hostname_without_port};
use crate::validation::validate_password;
use axum::{
Json,
extract::State,
http::HeaderMap,
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash, verify};
use bcrypt::{DEFAULT_COST, hash};
use chrono::{Duration, Utc};
use serde::Deserialize;
use tracing::{error, info, warn};
@@ -18,20 +22,6 @@ use tracing::{error, info, warn};
fn generate_reset_code() -> String {
crate::util::generate_token_code()
}
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
#[derive(Deserialize)]
pub struct RequestPasswordResetInput {
@@ -41,31 +31,18 @@ pub struct RequestPasswordResetInput {
pub async fn request_password_reset(
State(state): State<AppState>,
headers: HeaderMap,
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasswordResetInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
.await
{
warn!(ip = %client_ip, "Password reset rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let identifier = input.email.trim();
if identifier.is_empty() {
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
let hostname_for_handles = pds_hostname_without_port();
let normalized = identifier.to_lowercase();
let normalized = normalized.strip_prefix('@').unwrap_or(&normalized);
let is_email_lookup = normalized.contains('@');
let normalized_handle = if normalized.contains('@') || normalized.contains('.') {
normalized.to_string()
} else {
format!("{}.{}", normalized, hostname_for_handles)
};
let normalized_handle = NormalizedLoginIdentifier::normalize(identifier, hostname_for_handles);
let multiple_accounts_warning = if is_email_lookup {
match state.user_repo.count_accounts_by_email(normalized).await {
@@ -78,7 +55,7 @@ pub async fn request_password_reset(
let user_id = match state
.user_repo
.get_id_by_email_or_handle(normalized, &normalized_handle)
.get_id_by_email_or_handle(normalized, normalized_handle.as_str())
.await
{
Ok(Some(id)) => id,
@@ -101,13 +78,13 @@ pub async fn request_password_reset(
error!("DB error setting reset code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_password_reset(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
&code,
&hostname,
hostname,
)
.await
{
@@ -135,17 +112,9 @@ pub struct ResetPasswordInput {
pub async fn reset_password(
State(state): State<AppState>,
headers: HeaderMap,
_rate_limit: RateLimited<ResetPasswordLimit>,
Json(input): Json<ResetPasswordInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::ResetPassword, &client_ip)
.await
{
warn!(ip = %client_ip, "Reset password rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let token = input.token.trim();
let password = &input.password;
if token.is_empty() {
@@ -230,50 +199,35 @@ pub async fn change_password(
auth: Auth<Active>,
Json(input): Json<ChangePasswordInput>,
) -> Result<Response, ApiError> {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
{
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
use crate::auth::verify_password_mfa;
let current_password = &input.current_password;
let new_password = &input.new_password;
if current_password.is_empty() {
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
if input.current_password.is_empty() {
return Err(ApiError::InvalidRequest(
"currentPassword is required".into(),
));
}
if new_password.is_empty() {
if input.new_password.is_empty() {
return Err(ApiError::InvalidRequest("newPassword is required".into()));
}
if let Err(e) = validate_password(new_password) {
if let Err(e) = validate_password(&input.new_password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let password_mfa = verify_password_mfa(&state, &auth, &input.current_password).await?;
let user = state
.user_repo
.get_id_and_password_hash_by_did(&auth.did)
.get_id_and_password_hash_by_did(password_mfa.did())
.await
.map_err(|e| {
error!("DB error in change_password: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("in change_password")?
.ok_or(ApiError::AccountNotFound)?;
let (user_id, password_hash) = (user.id, user.password_hash);
let valid = verify(current_password, &password_hash).map_err(|e| {
error!("Password verification error: {:?}", e);
ApiError::InternalError(None)
})?;
if !valid {
return Err(ApiError::InvalidPassword(
"Current password is incorrect".into(),
));
}
let new_password_clone = new_password.to_string();
let new_password_clone = input.new_password.to_string();
let new_hash = tokio::task::spawn_blocking(move || hash(new_password_clone, DEFAULT_COST))
.await
.map_err(|e| {
@@ -287,14 +241,11 @@ pub async fn change_password(
state
.user_repo
.update_password_hash(user_id, &new_hash)
.update_password_hash(user.id, &new_hash)
.await
.map_err(|e| {
error!("DB error updating password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating password")?;
info!(did = %&auth.did, "Password changed successfully");
info!(did = %session_mfa.did(), "Password changed successfully");
Ok(EmptyResponse::ok().into_response())
}
@@ -302,48 +253,32 @@ pub async fn get_password_status(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
match state.user_repo.has_password_by_did(&auth.did).await {
Ok(Some(has)) => Ok(HasPasswordResponse::response(has).into_response()),
Ok(None) => Err(ApiError::AccountNotFound),
Err(e) => {
error!("DB error: {:?}", e);
Err(ApiError::InternalError(None))
}
}
let has = state
.user_repo
.has_password_by_did(&auth.did)
.await
.log_db_err("checking password status")?
.ok_or(ApiError::AccountNotFound)?;
Ok(HasPasswordResponse::response(has).into_response())
}
pub async fn remove_password(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
{
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
if crate::api::server::reauth::check_reauth_required_cached(
&*state.session_repo,
&state.cache,
&auth.did,
)
.await
{
return Ok(crate::api::server::reauth::reauth_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let reauth_mfa = match require_reauth_window(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let has_passkeys = state
.user_repo
.has_passkeys(&auth.did)
.has_passkeys(reauth_mfa.did())
.await
.unwrap_or(false);
if !has_passkeys {
@@ -354,12 +289,9 @@ pub async fn remove_password(
let user = state
.user_repo
.get_password_info_by_did(&auth.did)
.get_password_info_by_did(reauth_mfa.did())
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting password info")?
.ok_or(ApiError::AccountNotFound)?;
if user.password_hash.is_none() {
@@ -372,12 +304,9 @@ pub async fn remove_password(
.user_repo
.remove_user_password(user.id)
.await
.map_err(|e| {
error!("DB error removing password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("removing password")?;
info!(did = %&auth.did, "Password removed - account is now passkey-only");
info!(did = %session_mfa.did(), "Password removed - account is now passkey-only");
Ok(SuccessResponse::ok().into_response())
}
@@ -392,41 +321,10 @@ pub async fn set_password(
auth: Auth<Active>,
Json(input): Json<SetPasswordInput>,
) -> Result<Response, ApiError> {
let has_password = state
.user_repo
.has_password_by_did(&auth.did)
.await
.ok()
.flatten()
.unwrap_or(false);
let has_passkeys = state
.user_repo
.has_passkeys(&auth.did)
.await
.unwrap_or(false);
let has_totp = state
.user_repo
.has_totp_enabled(&auth.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.did,
)
.await
{
return Ok(crate::api::server::reauth::reauth_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let new_password = &input.new_password;
if new_password.is_empty() {
@@ -436,14 +334,13 @@ pub async fn set_password(
return Err(ApiError::InvalidRequest(e.to_string()));
}
let did = reauth_mfa.as_ref().map(|m| m.did()).unwrap_or(&auth.did);
let user = state
.user_repo
.get_password_info_by_did(&auth.did)
.get_password_info_by_did(did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting password info")?
.ok_or(ApiError::AccountNotFound)?;
if user.password_hash.is_some() {
@@ -468,11 +365,8 @@ pub async fn set_password(
.user_repo
.set_new_user_password(user.id, &new_hash)
.await
.map_err(|e| {
error!("DB error setting password: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("setting password")?;
info!(did = %&auth.did, "Password set for passkey-only account");
info!(did = %did, "Password set for passkey-only account");
Ok(SuccessResponse::ok().into_response())
}
+22 -59
View File
@@ -1,4 +1,4 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use axum::{
Json,
extract::State,
@@ -11,10 +11,11 @@ use tracing::{error, info, warn};
use tranquil_db_traits::{SessionRepository, UserRepository};
use crate::auth::{Active, Auth};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use crate::state::AppState;
use crate::types::PlainPassword;
const REAUTH_WINDOW_SECONDS: i64 = 300;
pub const REAUTH_WINDOW_SECONDS: i64 = 300;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -32,10 +33,7 @@ pub async fn get_reauth_status(
.session_repo
.get_last_reauth_at(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("getting last reauth")?;
let reauth_required = is_reauth_required(last_reauth_at);
let available_methods =
@@ -70,10 +68,7 @@ pub async fn reauth_password(
.user_repo
.get_password_hash_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching password hash")?
.ok_or(ApiError::AccountNotFound)?;
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
@@ -97,10 +92,7 @@ pub async fn reauth_password(
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
.await
.map_err(|e| {
error!("DB error updating reauth: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating reauth")?;
info!(did = %&auth.did, "Re-auth successful via password");
Ok(Json(ReauthResponse { reauthed_at }).into_response())
@@ -117,15 +109,12 @@ pub async fn reauth_totp(
auth: Auth<Active>,
Json(input): Json<TotpReauthInput>,
) -> Result<Response, ApiError> {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
.await
{
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
return Err(ApiError::RateLimitExceeded(Some(
"Too many verification attempts. Please try again in a few minutes.".into(),
)));
}
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
&auth.did,
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
let valid =
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.did, &input.code)
@@ -140,10 +129,7 @@ pub async fn reauth_totp(
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
.await
.map_err(|e| {
error!("DB error updating reauth: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating reauth")?;
info!(did = %&auth.did, "Re-auth successful via TOTP");
Ok(Json(ReauthResponse { reauthed_at }).into_response())
@@ -159,16 +145,11 @@ pub async fn reauth_passkey_start(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let stored_passkeys = state
.user_repo
.get_passkeys_for_user(&auth.did)
.await
.map_err(|e| {
error!("Failed to get passkeys: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("getting passkeys")?;
if stored_passkeys.is_empty() {
return Err(ApiError::NoPasskeys);
@@ -185,10 +166,7 @@ pub async fn reauth_passkey_start(
)));
}
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
error!("Failed to create WebAuthn config: {:?}", e);
ApiError::InternalError(None)
})?;
let webauthn = &state.webauthn_config;
let (rcr, auth_state) = webauthn.start_authentication(passkeys).map_err(|e| {
error!("Failed to start passkey authentication: {:?}", e);
@@ -204,10 +182,7 @@ pub async fn reauth_passkey_start(
.user_repo
.save_webauthn_challenge(&auth.did, "authentication", &state_json)
.await
.map_err(|e| {
error!("Failed to save authentication state: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("saving authentication state")?;
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Ok(Json(PasskeyReauthStartResponse { options }).into_response())
@@ -224,16 +199,11 @@ pub async fn reauth_passkey_finish(
auth: Auth<Active>,
Json(input): Json<PasskeyReauthFinishInput>,
) -> Result<Response, ApiError> {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let auth_state_json = state
.user_repo
.load_webauthn_challenge(&auth.did, "authentication")
.await
.map_err(|e| {
error!("Failed to load authentication state: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("loading authentication state")?
.ok_or(ApiError::NoChallengeInProgress)?;
let auth_state: webauthn_rs::prelude::SecurityKeyAuthentication =
@@ -248,12 +218,8 @@ pub async fn reauth_passkey_finish(
ApiError::InvalidCredential
})?;
let webauthn = crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname).map_err(|e| {
error!("Failed to create WebAuthn config: {:?}", e);
ApiError::InternalError(None)
})?;
let auth_result = webauthn
let auth_result = state
.webauthn_config
.finish_authentication(&credential, &auth_state)
.map_err(|e| {
warn!(did = %&auth.did, "Passkey re-auth failed: {:?}", e);
@@ -287,10 +253,7 @@ pub async fn reauth_passkey_finish(
let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did)
.await
.map_err(|e| {
error!("DB error updating reauth: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating reauth")?;
info!(did = %&auth.did, "Re-auth successful via passkey");
Ok(Json(ReauthResponse { reauthed_at }).into_response())
@@ -418,7 +381,7 @@ pub async fn check_legacy_session_mfa(
) -> bool {
match session_repo.get_session_mfa_status(did).await {
Ok(Some(status)) => {
if !status.legacy_login {
if status.login_type.is_modern() {
return true;
}
if status.mfa_verified {
@@ -51,8 +51,8 @@ pub async fn get_service_auth(
headers: axum::http::HeaderMap,
Query(params): Query<GetServiceAuthParams>,
) -> Response {
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let auth_header = crate::util::get_header_str(&headers, "Authorization");
let dpop_proof = crate::util::get_header_str(&headers, "DPoP");
info!(
has_auth_header = auth_header.is_some(),
has_dpop_proof = dpop_proof.is_some(),
@@ -94,7 +94,7 @@ pub async fn get_service_auth(
.await
{
Ok(result) => crate::auth::AuthenticatedUser {
did: Did::new_unchecked(result.did),
did: unsafe { Did::new_unchecked(result.did) },
is_admin: false,
status: AccountStatus::Active,
scope: result.scope,
+88 -161
View File
@@ -1,8 +1,13 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::api::{EmptyResponse, SuccessResponse};
use crate::auth::{Active, Auth, Permissive};
use crate::state::{AppState, RateLimitKind};
use crate::auth::{
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
require_reauth_window,
};
use crate::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
use crate::state::AppState;
use crate::types::{AccountState, Did, Handle, PlainPassword};
use crate::util::{pds_hostname, pds_hostname_without_port};
use axum::{
Json,
extract::State,
@@ -13,34 +18,9 @@ use bcrypt::verify;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use tranquil_db_traits::{SessionId, TokenFamilyId};
use tranquil_types::TokenId;
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
let identifier = identifier.trim();
if identifier.contains('@') || identifier.starts_with("did:") {
identifier.to_string()
} else if !identifier.contains('.') {
format!("{}.{}", identifier.to_lowercase(), pds_hostname)
} else {
identifier.to_lowercase()
}
}
fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
stored_handle.to_string()
}
@@ -75,31 +55,25 @@ pub struct CreateSessionOutput {
pub async fn create_session(
State(state): State<AppState>,
headers: HeaderMap,
rate_limit: RateLimited<LoginLimit>,
Json(input): Json<CreateSessionInput>,
) -> Response {
let client_ip = rate_limit.client_ip();
info!(
"create_session called with identifier: {}",
input.identifier
);
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::Login, &client_ip)
.await
{
warn!(ip = %client_ip, "Login rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname_for_handles = pds_hostname.split(':').next().unwrap_or(&pds_hostname);
let normalized_identifier = normalize_handle(&input.identifier, hostname_for_handles);
let pds_host = pds_hostname();
let hostname_for_handles = pds_hostname_without_port();
let normalized_identifier =
NormalizedLoginIdentifier::normalize(&input.identifier, hostname_for_handles);
info!(
"Normalized identifier: {} -> {}",
input.identifier, normalized_identifier
);
let row = match state
.user_repo
.get_login_full_by_identifier(&normalized_identifier)
.get_login_full_by_identifier(normalized_identifier.as_str())
.await
{
Ok(Some(row)) => row,
@@ -165,8 +139,7 @@ pub async fn create_session(
warn!("Login attempt for takendown account: {}", row.did);
return ApiError::AccountTakedown.into_response();
}
let is_verified =
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
let is_verified = row.channel_verification.has_any_verified();
let is_delegated = state
.delegation_repo
.is_delegated_account(&row.did)
@@ -226,7 +199,7 @@ pub async fn create_session(
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
legacy_login: is_legacy_login,
login_type: tranquil_db_traits::LoginType::from(is_legacy_login),
mfa_verified: false,
scope: app_password_scopes.clone(),
controller_did: app_password_controller.clone(),
@@ -246,13 +219,13 @@ pub async fn create_session(
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_legacy_login(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
row.id,
&hostname,
&client_ip,
hostname,
client_ip,
row.preferred_comms_channel,
)
.await
@@ -260,7 +233,7 @@ pub async fn create_session(
error!("Failed to queue legacy login notification: {:?}", e);
}
}
let handle = full_handle(&row.handle, &pds_hostname);
let handle = full_handle(&row.handle, pds_host);
let is_active = account_state.is_active();
let status = account_state.status_for_session().map(String::from);
Json(CreateSessionOutput {
@@ -270,7 +243,7 @@ pub async fn create_session(
did: row.did,
did_doc,
email: row.email,
email_confirmed: Some(row.email_verified),
email_confirmed: Some(row.channel_verification.email),
active: Some(is_active),
status,
})
@@ -292,16 +265,17 @@ pub async fn get_session(
);
match db_result {
Ok(Some(row)) => {
let (preferred_channel, preferred_channel_verified) = match row.preferred_comms_channel
{
tranquil_db_traits::CommsChannel::Email => ("email", row.email_verified),
tranquil_db_traits::CommsChannel::Discord => ("discord", row.discord_verified),
tranquil_db_traits::CommsChannel::Telegram => ("telegram", row.telegram_verified),
tranquil_db_traits::CommsChannel::Signal => ("signal", row.signal_verified),
let preferred_channel = match row.preferred_comms_channel {
tranquil_db_traits::CommsChannel::Email => "email",
tranquil_db_traits::CommsChannel::Discord => "discord",
tranquil_db_traits::CommsChannel::Telegram => "telegram",
tranquil_db_traits::CommsChannel::Signal => "signal",
};
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&row.handle, &pds_hostname);
let preferred_channel_verified = row
.channel_verification
.is_verified(row.preferred_comms_channel);
let pds_hostname = pds_hostname();
let handle = full_handle(&row.handle, pds_hostname);
let account_state = AccountState::from_db_fields(
row.deactivated_at,
row.takedown_ref.clone(),
@@ -313,7 +287,7 @@ pub async fn get_session(
} else {
None
};
let email_confirmed_value = can_read_email && row.email_verified;
let email_confirmed_value = can_read_email && row.channel_verification.email;
let mut response = json!({
"handle": handle,
"did": &auth.did,
@@ -352,9 +326,10 @@ pub async fn delete_session(
headers: axum::http::HeaderMap,
_auth: Auth<Active>,
) -> Result<Response, ApiError> {
let extracted = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
&headers,
"Authorization",
))
.ok_or(ApiError::AuthenticationRequired)?;
let jti = crate::auth::get_jti_from_token(&extracted.token)
.map_err(|_| ApiError::AuthenticationFailed(None))?;
@@ -374,19 +349,13 @@ pub async fn delete_session(
pub async fn refresh_session(
State(state): State<AppState>,
_rate_limit: RateLimited<RefreshSessionLimit>,
headers: axum::http::HeaderMap,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::RefreshSession, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str(
&headers,
"Authorization",
)) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
@@ -503,15 +472,17 @@ pub async fn refresh_session(
);
match db_result {
Ok(Some(u)) => {
let (preferred_channel, preferred_channel_verified) = match u.preferred_comms_channel {
tranquil_db_traits::CommsChannel::Email => ("email", u.email_verified),
tranquil_db_traits::CommsChannel::Discord => ("discord", u.discord_verified),
tranquil_db_traits::CommsChannel::Telegram => ("telegram", u.telegram_verified),
tranquil_db_traits::CommsChannel::Signal => ("signal", u.signal_verified),
let preferred_channel = match u.preferred_comms_channel {
tranquil_db_traits::CommsChannel::Email => "email",
tranquil_db_traits::CommsChannel::Discord => "discord",
tranquil_db_traits::CommsChannel::Telegram => "telegram",
tranquil_db_traits::CommsChannel::Signal => "signal",
};
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&u.handle, &pds_hostname);
let preferred_channel_verified = u
.channel_verification
.is_verified(u.preferred_comms_channel);
let pds_hostname = pds_hostname();
let handle = full_handle(&u.handle, pds_hostname);
let account_state =
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
let mut response = json!({
@@ -520,7 +491,7 @@ pub async fn refresh_session(
"handle": handle,
"did": session_row.did,
"email": u.email,
"emailConfirmed": u.email_verified,
"emailConfirmed": u.channel_verification.email,
"preferredChannel": preferred_channel,
"preferredChannelVerified": preferred_channel_verified,
"preferredLocale": u.preferred_locale,
@@ -664,7 +635,7 @@ pub async fn confirm_signup(
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
legacy_login: false,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: None,
controller_did: None,
@@ -675,12 +646,12 @@ pub async fn confirm_signup(
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_welcome(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
row.id,
&hostname,
hostname,
)
.await
{
@@ -731,8 +702,7 @@ pub async fn resend_verification(
return ApiError::InternalError(None).into_response();
}
};
let is_verified =
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
let is_verified = row.channel_verification.has_any_verified();
if is_verified {
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
}
@@ -756,14 +726,14 @@ pub async fn resend_verification(
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification(
state.infra_repo.as_ref(),
row.id,
channel_str,
&recipient,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -804,19 +774,13 @@ pub async fn list_sessions(
.session_repo
.list_sessions_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching JWT sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching JWT sessions")?;
let oauth_rows = state
.oauth_repo
.list_sessions_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching OAuth sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("fetching OAuth sessions")?;
let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo {
id: format!("jwt:{}", row.id),
@@ -869,43 +833,36 @@ pub async fn revoke_session(
Json(input): Json<RevokeSessionInput>,
) -> Result<Response, ApiError> {
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
let session_id: i32 = jwt_id
.parse()
let session_id = jwt_id
.parse::<i32>()
.map(SessionId::new)
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
let access_jti = state
.session_repo
.get_session_access_jti_by_id(session_id, &auth.did)
.await
.map_err(|e| {
error!("DB error in revoke_session: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("in revoke_session")?
.ok_or(ApiError::SessionNotFound)?;
state
.session_repo
.delete_session_by_id(session_id)
.await
.map_err(|e| {
error!("DB error deleting session: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting session")?;
let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache: {:?}", e);
}
info!(did = %&auth.did, session_id = %session_id, "JWT session revoked");
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
let session_id: i32 = oauth_id
.parse()
let session_id = oauth_id
.parse::<i32>()
.map(TokenFamilyId::new)
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
let deleted = state
.oauth_repo
.delete_session_by_id(session_id, &auth.did)
.await
.map_err(|e| {
error!("DB error deleting OAuth session: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting OAuth session")?;
if deleted == 0 {
return Err(ApiError::SessionNotFound);
}
@@ -932,36 +889,24 @@ pub async fn revoke_all_sessions(
.session_repo
.delete_sessions_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error revoking JWT sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking JWT sessions")?;
let jti_typed = TokenId::from(jti.clone());
state
.oauth_repo
.delete_sessions_by_did_except(&auth.did, &jti_typed)
.await
.map_err(|e| {
error!("DB error revoking OAuth sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking OAuth sessions")?;
} else {
state
.session_repo
.delete_sessions_by_did_except_jti(&auth.did, &jti)
.await
.map_err(|e| {
error!("DB error revoking JWT sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking JWT sessions")?;
state
.oauth_repo
.delete_sessions_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error revoking OAuth sessions: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking OAuth sessions")?;
}
info!(did = %&auth.did, "All other sessions revoked");
@@ -983,10 +928,7 @@ pub async fn get_legacy_login_preference(
.user_repo
.get_legacy_login_pref(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("getting legacy login pref")?
.ok_or(ApiError::AccountNotFound)?;
Ok(Json(LegacyLoginPreferenceOutput {
allow_legacy_login: pref.allow_legacy_login,
@@ -1006,38 +948,26 @@ pub async fn update_legacy_login_preference(
auth: Auth<Active>,
Json(input): Json<UpdateLegacyLoginInput>,
) -> Result<Response, ApiError> {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
{
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
if crate::api::server::reauth::check_reauth_required(&*state.session_repo, &auth.did).await {
return Ok(crate::api::server::reauth::reauth_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
let reauth_mfa = match require_reauth_window(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let updated = state
.user_repo
.update_legacy_login(&auth.did, input.allow_legacy_login)
.update_legacy_login(reauth_mfa.did(), input.allow_legacy_login)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating legacy login")?;
if !updated {
return Err(ApiError::AccountNotFound);
}
info!(
did = %&auth.did,
did = %session_mfa.did(),
allow_legacy_login = input.allow_legacy_login,
"Legacy login preference updated"
);
@@ -1071,10 +1001,7 @@ pub async fn update_locale(
.user_repo
.update_locale(&auth.did, &input.preferred_locale)
.await
.map_err(|e| {
error!("DB error updating locale: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating locale")?;
if !updated {
return Err(ApiError::AccountNotFound);
}
+68 -166
View File
@@ -1,13 +1,15 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::{Active, Auth};
use crate::api::error::{ApiError, DbResultExt};
use crate::auth::{
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
verify_backup_code, verify_totp_code,
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
verify_totp_code, verify_totp_mfa,
};
use crate::state::{AppState, RateLimitKind};
use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use crate::state::AppState;
use crate::types::PlainPassword;
use crate::util::pds_hostname;
use axum::{
Json,
extract::State,
@@ -30,9 +32,11 @@ pub async fn create_totp_secret(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
match state.user_repo.get_totp_record(&auth.did).await {
Ok(Some(record)) if record.verified => return Err(ApiError::TotpAlreadyEnabled),
Ok(_) => {}
use tranquil_db_traits::TotpRecordState;
match state.user_repo.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => {}
Err(e) => {
error!("DB error checking TOTP: {:?}", e);
return Err(ApiError::InternalError(None));
@@ -45,16 +49,13 @@ pub async fn create_totp_secret(
.user_repo
.get_handle_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching handle: {:?}", e);
ApiError::InternalError(None)
})?
.log_db_err("fetching handle")?
.ok_or(ApiError::AccountNotFound)?;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let uri = generate_totp_uri(&secret, &handle, &hostname);
let hostname = pds_hostname();
let uri = generate_totp_uri(&secret, &handle, hostname);
let qr_code = generate_qr_png_base64(&secret, &handle, &hostname).map_err(|e| {
let qr_code = generate_qr_png_base64(&secret, &handle, hostname).map_err(|e| {
error!("Failed to generate QR code: {:?}", e);
ApiError::InternalError(Some("Failed to generate QR code".into()))
})?;
@@ -68,10 +69,7 @@ pub async fn create_totp_secret(
.user_repo
.upsert_totp_secret(&auth.did, &encrypted_secret, ENCRYPTION_VERSION)
.await
.map_err(|e| {
error!("Failed to store TOTP secret: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("storing TOTP secret")?;
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
@@ -101,16 +99,18 @@ pub async fn enable_totp(
auth: Auth<Active>,
Json(input): Json<EnableTotpInput>,
) -> Result<Response, ApiError> {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
.await
{
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
use tranquil_db_traits::TotpRecordState;
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
Ok(Some(row)) => row,
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
&auth.did,
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
let unverified_record = match state.user_repo.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Unverified(record))) => record,
Ok(Some(TotpRecordState::Verified(_))) => return Err(ApiError::TotpAlreadyEnabled),
Ok(None) => return Err(ApiError::TotpNotEnabled),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
@@ -118,13 +118,9 @@ pub async fn enable_totp(
}
};
if totp_record.verified {
return Err(ApiError::TotpAlreadyEnabled);
}
let secret = decrypt_totp_secret(
&totp_record.secret_encrypted,
totp_record.encryption_version,
&unverified_record.secret_encrypted,
unverified_record.encryption_version,
)
.map_err(|e| {
error!("Failed to decrypt TOTP secret: {:?}", e);
@@ -152,10 +148,7 @@ pub async fn enable_totp(
.user_repo
.enable_totp_with_backup_codes(&auth.did, &backup_hashes)
.await
.map_err(|e| {
error!("Failed to enable TOTP: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("enabling TOTP")?;
info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len());
@@ -173,79 +166,28 @@ pub async fn disable_totp(
auth: Auth<Active>,
Json(input): Json<DisableTotpInput>,
) -> Result<Response, ApiError> {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &auth.did).await
{
return Ok(crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&auth.did,
)
.await);
}
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
.await
{
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
let password_hash = state
.user_repo
.get_password_hash_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching user: {:?}", e);
ApiError::InternalError(None)
})?
.ok_or(ApiError::AccountNotFound)?;
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
if !password_valid {
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
}
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return Err(ApiError::InternalError(None));
}
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let code = input.code.trim();
let code_valid = if is_backup_code_format(code) {
verify_backup_code_for_user(&state, &auth.did, code).await
} else {
let secret = decrypt_totp_secret(
&totp_record.secret_encrypted,
totp_record.encryption_version,
)
.map_err(|e| {
error!("Failed to decrypt TOTP secret: {:?}", e);
ApiError::InternalError(None)
})?;
verify_totp_code(&secret, code)
};
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
session_mfa.did(),
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
if !code_valid {
return Err(ApiError::InvalidCode(Some(
"Invalid verification code".into(),
)));
}
let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?;
let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?;
state
.user_repo
.delete_totp_and_backup_codes(&auth.did)
.delete_totp_and_backup_codes(totp_mfa.did())
.await
.map_err(|e| {
error!("Failed to delete TOTP: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("deleting TOTP")?;
info!(did = %&auth.did, "TOTP disabled");
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
Ok(EmptyResponse::ok().into_response())
}
@@ -262,9 +204,11 @@ pub async fn get_totp_status(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let enabled = match state.user_repo.get_totp_record(&auth.did).await {
Ok(Some(row)) => row.verified,
Ok(None) => false,
use tranquil_db_traits::TotpRecordState;
let enabled = match state.user_repo.get_totp_record_state(&auth.did).await {
Ok(Some(TotpRecordState::Verified(_))) => true,
Ok(Some(TotpRecordState::Unverified(_))) | Ok(None) => false,
Err(e) => {
error!("DB error fetching TOTP status: {:?}", e);
return Err(ApiError::InternalError(None));
@@ -275,10 +219,7 @@ pub async fn get_totp_status(
.user_repo
.count_unused_backup_codes(&auth.did)
.await
.map_err(|e| {
error!("DB error counting backup codes: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("counting backup codes")?;
Ok(Json(GetTotpStatusResponse {
enabled,
@@ -305,53 +246,15 @@ pub async fn regenerate_backup_codes(
auth: Auth<Active>,
Json(input): Json<RegenerateBackupCodesInput>,
) -> Result<Response, ApiError> {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.did)
.await
{
warn!(did = %&auth.did, "TOTP verification rate limit exceeded");
return Err(ApiError::RateLimitExceeded(None));
}
let password_hash = state
.user_repo
.get_password_hash_by_did(&auth.did)
.await
.map_err(|e| {
error!("DB error fetching user: {:?}", e);
ApiError::InternalError(None)
})?
.ok_or(ApiError::AccountNotFound)?;
let password_valid = bcrypt::verify(&input.password, &password_hash).unwrap_or(false);
if !password_valid {
return Err(ApiError::InvalidPassword("Password is incorrect".into()));
}
let totp_record = match state.user_repo.get_totp_record(&auth.did).await {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => return Err(ApiError::TotpNotEnabled),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
let secret = decrypt_totp_secret(
&totp_record.secret_encrypted,
totp_record.encryption_version,
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
&auth.did,
"Too many verification attempts. Please try again in a few minutes.",
)
.map_err(|e| {
error!("Failed to decrypt TOTP secret: {:?}", e);
ApiError::InternalError(None)
})?;
.await?;
let code = input.code.trim();
if !verify_totp_code(&secret, code) {
return Err(ApiError::InvalidCode(Some(
"Invalid verification code".into(),
)));
}
let password_mfa = verify_password_mfa(&state, &auth, &input.password).await?;
let totp_mfa = verify_totp_mfa(&state, &auth, &input.code).await?;
let backup_codes = generate_backup_codes();
let backup_hashes: Vec<_> = backup_codes
@@ -365,14 +268,11 @@ pub async fn regenerate_backup_codes(
state
.user_repo
.replace_backup_codes(&auth.did, &backup_hashes)
.replace_backup_codes(totp_mfa.did(), &backup_hashes)
.await
.map_err(|e| {
error!("Failed to regenerate backup codes: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("replacing backup codes")?;
info!(did = %&auth.did, "Backup codes regenerated");
info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method());
Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response())
}
@@ -410,20 +310,22 @@ pub async fn verify_totp_or_backup_for_user(
did: &crate::types::Did,
code: &str,
) -> bool {
use tranquil_db_traits::TotpRecordState;
let code = code.trim();
if is_backup_code_format(code) {
return verify_backup_code_for_user(state, did, code).await;
}
let totp_record = match state.user_repo.get_totp_record(did).await {
Ok(Some(row)) if row.verified => row,
let verified_record = match state.user_repo.get_totp_record_state(did).await {
Ok(Some(TotpRecordState::Verified(record))) => record,
_ => return false,
};
let secret = match decrypt_totp_secret(
&totp_record.secret_encrypted,
totp_record.encryption_version,
&verified_record.secret_encrypted,
verified_record.encryption_version,
) {
Ok(s) => s,
Err(_) => return false,
@@ -1,5 +1,5 @@
use crate::api::SuccessResponse;
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use axum::{
Json,
extract::State,
@@ -79,10 +79,7 @@ pub async fn list_trusted_devices(
.oauth_repo
.list_trusted_devices(&auth.did)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("listing trusted devices")?;
let devices = rows
.into_iter()
@@ -134,10 +131,7 @@ pub async fn revoke_trusted_device(
.oauth_repo
.revoke_device_trust(&device_id)
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("revoking device trust")?;
info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked");
Ok(SuccessResponse::ok().into_response())
@@ -175,10 +169,7 @@ pub async fn update_trusted_device(
.oauth_repo
.update_device_friendly_name(&device_id, input.friendly_name.as_deref())
.await
.map_err(|e| {
error!("DB error: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating device friendly name")?;
info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated");
Ok(SuccessResponse::ok().into_response())
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::state::AppState;
use crate::util::pds_hostname;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -70,7 +71,7 @@ pub async fn resend_migration_verification(
return Ok(Json(ResendMigrationVerificationOutput { sent: true }));
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname = pds_hostname();
let token = crate::auth::verification_token::generate_migration_token(&user.did, &email);
let formatted_token = crate::auth::verification_token::format_token_for_display(&token);
@@ -80,7 +81,7 @@ pub async fn resend_migration_verification(
user.id,
&email,
&formatted_token,
&hostname,
hostname,
)
.await
{
@@ -1,8 +1,8 @@
use crate::api::error::ApiError;
use crate::api::error::{ApiError, DbResultExt};
use crate::types::Did;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use tracing::{info, warn};
use crate::auth::verification_token::{
VerificationPurpose, normalize_token_input, verify_token_signature,
@@ -81,25 +81,19 @@ async fn handle_migration_verification(
.user_repo
.get_verification_info(&did_typed)
.await
.map_err(|e| {
warn!(error = ?e, "Database error during migration verification");
ApiError::InternalError(None)
})?
.log_db_err("during migration verification")?
.ok_or(ApiError::AccountNotFound)?;
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err(ApiError::IdentifierMismatch);
}
if !user.email_verified {
if !user.channel_verification.email {
state
.user_repo
.set_email_verified_flag(user.id)
.await
.map_err(|e| {
warn!(error = ?e, "Failed to update email_verified status");
ApiError::InternalError(None)
})?;
.log_db_err("updating email_verified status")?;
}
info!(did = %did, "Migration email verified successfully");
@@ -125,7 +119,7 @@ async fn handle_channel_update(
.user_repo
.get_id_by_did(&did_typed)
.await
.map_err(|_| ApiError::InternalError(None))?
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
match channel {
@@ -134,10 +128,7 @@ async fn handle_channel_update(
.user_repo
.verify_email_channel(user_id, identifier)
.await
.map_err(|e| {
error!("Failed to update email channel: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating email channel")?;
if !success {
return Err(ApiError::EmailTaken);
}
@@ -147,30 +138,21 @@ async fn handle_channel_update(
.user_repo
.verify_discord_channel(user_id, identifier)
.await
.map_err(|e| {
error!("Failed to update discord channel: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating discord channel")?;
}
"telegram" => {
state
.user_repo
.verify_telegram_channel(user_id, identifier)
.await
.map_err(|e| {
error!("Failed to update telegram channel: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating telegram channel")?;
}
"signal" => {
state
.user_repo
.verify_signal_channel(user_id, identifier)
.await
.map_err(|e| {
error!("Failed to update signal channel: {:?}", e);
ApiError::InternalError(None)
})?;
.log_db_err("updating signal channel")?;
}
_ => {
return Err(ApiError::InvalidChannel);
@@ -200,16 +182,10 @@ async fn handle_signup_verification(
.user_repo
.get_verification_info(&did_typed)
.await
.map_err(|e| {
warn!(error = ?e, "Database error during signup verification");
ApiError::InternalError(None)
})?
.log_db_err("during signup verification")?
.ok_or(ApiError::AccountNotFound)?;
let is_verified = user.email_verified
|| user.discord_verified
|| user.telegram_verified
|| user.signal_verified;
let is_verified = user.channel_verification.has_any_verified();
if is_verified {
info!(did = %did, "Account already verified");
return Ok(Json(VerifyTokenOutput {
@@ -226,40 +202,28 @@ async fn handle_signup_verification(
.user_repo
.set_email_verified_flag(user.id)
.await
.map_err(|e| {
warn!(error = ?e, "Failed to update email verified status");
ApiError::InternalError(None)
})?;
.log_db_err("updating email verified status")?;
}
"discord" => {
state
.user_repo
.set_discord_verified_flag(user.id)
.await
.map_err(|e| {
warn!(error = ?e, "Failed to update discord verified status");
ApiError::InternalError(None)
})?;
.log_db_err("updating discord verified status")?;
}
"telegram" => {
state
.user_repo
.set_telegram_verified_flag(user.id)
.await
.map_err(|e| {
warn!(error = ?e, "Failed to update telegram verified status");
ApiError::InternalError(None)
})?;
.log_db_err("updating telegram verified status")?;
}
"signal" => {
state
.user_repo
.set_signal_verified_flag(user.id)
.await
.map_err(|e| {
warn!(error = ?e, "Failed to update signal verified status");
ApiError::InternalError(None)
})?;
.log_db_err("updating signal verified status")?;
}
_ => {
return Err(ApiError::InvalidChannel);
@@ -0,0 +1,61 @@
use axum::response::{IntoResponse, Response};
use super::AuthenticatedUser;
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::Did;
pub struct AccountVerified<'a> {
user: &'a AuthenticatedUser,
}
impl<'a> AccountVerified<'a> {
pub fn did(&self) -> &Did {
&self.user.did
}
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
}
pub async fn require_verified_or_delegated<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<AccountVerified<'a>, Response> {
let is_verified = state
.user_repo
.has_verified_comms_channel(&user.did)
.await
.unwrap_or(false);
if is_verified {
return Ok(AccountVerified { user });
}
let is_delegated = state
.delegation_repo
.is_delegated_account(&user.did)
.await
.unwrap_or(false);
if is_delegated {
return Ok(AccountVerified { user });
}
Err(ApiError::AccountNotVerified.into_response())
}
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> {
match state.user_repo.is_account_migrated(did).await {
Ok(true) => Err(ApiError::AccountMigrated.into_response()),
Ok(false) => Ok(()),
Err(e) => {
tracing::error!("Failed to check migration status: {:?}", e);
Err(
ApiError::InternalError(Some("Failed to verify migration status".into()))
.into_response(),
)
}
}
}
@@ -1,547 +0,0 @@
mod common;
mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client, create_account_and_login, pds_endpoint};
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(code_verifier, code_challenge)
}
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
let mock_server = MockServer::start().await;
let metadata = json!({
"client_id": mock_server.uri(),
"client_name": "Auth Extractor Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": dpop_bound
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
mock_server
}
async fn get_oauth_session(
http_client: &reqwest::Client,
url: &str,
dpop_bound: bool,
) -> (String, String, String, String) {
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("ae{}", suffix);
let password = "AuthExtract123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(http_client, &did).await;
let redirect_uri = "https://example.com/auth-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
(
token_body["access_token"].as_str().unwrap().to_string(),
token_body["refresh_token"].as_str().unwrap().to_string(),
client_id,
did,
)
}
#[tokio::test]
async fn test_oauth_token_works_with_bearer_auth() {
let url = base_url().await;
let http_client = client();
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&access_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with RequiredAuth extractor");
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_session_token_still_works() {
let url = base_url().await;
let http_client = client();
let (jwt, did) = create_account_and_login(&http_client).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&jwt)
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "Session token should still work");
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("adm{}", suffix);
let password = "AdminOAuth123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(&http_client, &did).await;
let pool = common::get_test_db_pool().await;
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
.execute(pool)
.await
.expect("Failed to mark user as admin");
let redirect_uri = "https://example.com/admin-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let access_token = token_body["access_token"].as_str().unwrap();
let res = http_client
.get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did))
.bearer_auth(access_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"OAuth token for admin user should work with admin endpoint"
);
}
#[tokio::test]
async fn test_expired_oauth_token_returns_proper_error() {
let url = base_url().await;
let http_client = client();
let now = Utc::now().timestamp();
let header = json!({"alg": "HS256", "typ": "at+jwt"});
let payload = json!({
"iss": url,
"sub": "did:plc:test123",
"aud": url,
"iat": now - 7200,
"exp": now - 3600,
"jti": "expired-token",
"sid": "expired-session",
"scope": "atproto",
"client_id": "https://example.com"
});
let fake_token = format!(
"{}.{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
URL_SAFE_NO_PAD.encode([1u8; 32])
);
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&fake_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"Expired token should be rejected"
);
}
#[tokio::test]
async fn test_dpop_nonce_error_has_proper_headers() {
let url = base_url().await;
let pds_url = pds_endpoint();
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("dpop{}", suffix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@test.com", handle),
"password": "DpopTest123!"
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap();
verify_new_account(&http_client, did).await;
let redirect_uri = "https://example.com/dpop-callback";
let mock_server = MockServer::start().await;
let client_id = mock_server.uri();
let metadata = json!({
"client_id": &client_id,
"client_name": "DPoP Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": "DpopTest123!",
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_endpoint = format!("{}/oauth/token", pds_url);
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
let token_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let token_status = token_res.status();
let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string());
let token_body: Value = token_res.json().await.unwrap();
let access_token = if token_status == StatusCode::OK {
token_body["access_token"].as_str().unwrap().to_string()
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
let retry_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof_with_nonce)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let retry_body: Value = retry_res.json().await.unwrap();
retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string()
} else {
panic!("Token exchange failed unexpectedly: {:?}", token_body);
};
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("DPoP {}", access_token))
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail");
let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap());
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
assert!(
www_auth.unwrap().contains("use_dpop_nonce"),
"WWW-Authenticate should indicate dpop nonce required"
);
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
}
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
use p256::ecdsa::{SigningKey, signature::Signer};
use p256::elliptic_curve::rand_core::OsRng;
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y
});
let header = {
let h = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": jwk.clone()
});
h
};
let mut payload = json!({
"jti": uuid::Uuid::new_v4().to_string(),
"htm": method,
"htu": uri,
"iat": Utc::now().timestamp()
});
if let Some(n) = nonce {
payload["nonce"] = json!(n);
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let proof = format!("{}.{}", signing_input, sig_b64);
(jwk, proof)
}
+22 -18
View File
@@ -9,7 +9,7 @@ use tracing::{debug, error, info};
use super::{
AccountStatus, AuthSource, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier,
is_service_token, validate_bearer_token_for_service_auth,
is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth,
};
use crate::api::error::ApiError;
use crate::oauth::scopes::{RepoAction, ScopePermissions};
@@ -293,7 +293,7 @@ async fn extract_auth_internal(
return Ok(ExtractedAuth::Service(claims));
}
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let dpop_proof = crate::util::get_header_str(&parts.headers, "DPoP");
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
@@ -358,6 +358,22 @@ impl<P: AuthPolicy> std::ops::Deref for Auth<P> {
}
}
impl<P: AuthPolicy> AsRef<AuthenticatedUser> for Auth<P> {
fn as_ref(&self) -> &AuthenticatedUser {
&self.0
}
}
impl<P: AuthPolicy> VerifyScope for Auth<P> {
fn needs_scope_check(&self) -> bool {
self.0.is_oauth()
}
fn permissions(&self) -> ScopePermissions {
self.0.permissions()
}
}
impl<P: AuthPolicy> FromRequestParts<AppState> for Auth<P> {
type Rejection = AuthError;
@@ -418,10 +434,7 @@ impl FromRequestParts<AppState> for ServiceAuth {
) -> Result<Self, Self::Rejection> {
match extract_auth_internal(parts, state).await? {
ExtractedAuth::Service(claims) => {
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
let did = claims.iss.clone();
Ok(ServiceAuth { did, claims })
}
ExtractedAuth::User(_) => Err(AuthError::AuthenticationFailed),
@@ -438,10 +451,7 @@ impl OptionalFromRequestParts<AppState> for ServiceAuth {
) -> Result<Option<Self>, Self::Rejection> {
match extract_auth_internal(parts, state).await {
Ok(ExtractedAuth::Service(claims)) => {
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
let did = claims.iss.clone();
Ok(Some(ServiceAuth { did, claims }))
}
Ok(ExtractedAuth::User(_)) => Err(AuthError::AuthenticationFailed),
@@ -503,10 +513,7 @@ impl<P: AuthPolicy> FromRequestParts<AppState> for AuthAny<P> {
Ok(AuthAny::User(Auth(user, PhantomData)))
}
ExtractedAuth::Service(claims) => {
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
let did = claims.iss.clone();
Ok(AuthAny::Service(ServiceAuth { did, claims }))
}
}
@@ -526,10 +533,7 @@ impl<P: AuthPolicy> OptionalFromRequestParts<AppState> for AuthAny<P> {
Ok(Some(AuthAny::User(Auth(user, PhantomData))))
}
Ok(ExtractedAuth::Service(claims)) => {
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
let did = claims.iss.clone();
Ok(Some(AuthAny::Service(ServiceAuth { did, claims })))
}
Err(AuthError::MissingToken) => Ok(None),
@@ -0,0 +1,135 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizedLoginIdentifier(String);
impl NormalizedLoginIdentifier {
pub fn normalize(identifier: &str, pds_hostname: &str) -> Self {
let trimmed = identifier.trim();
let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed);
let normalized = match () {
_ if stripped.starts_with("did:") => stripped.to_string(),
_ if stripped.contains('@') => stripped.to_string(),
_ if !stripped.contains('.') => {
format!("{}.{}", stripped.to_lowercase(), pds_hostname)
}
_ => stripped.to_lowercase(),
};
Self(normalized)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for NormalizedLoginIdentifier {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for NormalizedLoginIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BareLoginIdentifier(String);
impl BareLoginIdentifier {
pub fn from_identifier(identifier: &str, pds_hostname: &str) -> Self {
let trimmed = identifier.trim();
let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed);
let suffix = format!(".{}", pds_hostname);
let bare = stripped.strip_suffix(&suffix).unwrap_or(stripped);
Self(bare.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for BareLoginIdentifier {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for BareLoginIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalized_identifier_handles_did() {
let id = NormalizedLoginIdentifier::normalize("did:plc:abc123", "example.com");
assert_eq!(id.as_str(), "did:plc:abc123");
}
#[test]
fn normalized_identifier_handles_email() {
let id = NormalizedLoginIdentifier::normalize("user@example.org", "pds.example.com");
assert_eq!(id.as_str(), "user@example.org");
}
#[test]
fn normalized_identifier_handles_bare_handle() {
let id = NormalizedLoginIdentifier::normalize("alice", "pds.example.com");
assert_eq!(id.as_str(), "alice.pds.example.com");
}
#[test]
fn normalized_identifier_handles_bare_handle_with_at_prefix() {
let id = NormalizedLoginIdentifier::normalize("@alice", "pds.example.com");
assert_eq!(id.as_str(), "alice.pds.example.com");
}
#[test]
fn normalized_identifier_handles_full_handle() {
let id = NormalizedLoginIdentifier::normalize("alice.bsky.social", "pds.example.com");
assert_eq!(id.as_str(), "alice.bsky.social");
}
#[test]
fn normalized_identifier_handles_uppercase() {
let id = NormalizedLoginIdentifier::normalize("ALICE", "pds.example.com");
assert_eq!(id.as_str(), "alice.pds.example.com");
let id2 = NormalizedLoginIdentifier::normalize("ALICE.BSKY.SOCIAL", "pds.example.com");
assert_eq!(id2.as_str(), "alice.bsky.social");
}
#[test]
fn normalized_identifier_trims_whitespace() {
let id = NormalizedLoginIdentifier::normalize(" alice ", "pds.example.com");
assert_eq!(id.as_str(), "alice.pds.example.com");
}
#[test]
fn bare_identifier_strips_hostname_suffix() {
let id = BareLoginIdentifier::from_identifier("alice.pds.example.com", "pds.example.com");
assert_eq!(id.as_str(), "alice");
}
#[test]
fn bare_identifier_preserves_non_matching() {
let id = BareLoginIdentifier::from_identifier("alice.bsky.social", "pds.example.com");
assert_eq!(id.as_str(), "alice.bsky.social");
}
#[test]
fn bare_identifier_strips_at_prefix() {
let id = BareLoginIdentifier::from_identifier("@alice.pds.example.com", "pds.example.com");
assert_eq!(id.as_str(), "alice");
}
}
@@ -0,0 +1,234 @@
use axum::response::Response;
use super::AuthenticatedUser;
use crate::state::AppState;
use crate::types::Did;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MfaMethod {
Totp,
Passkey,
Password,
RecoveryCode,
SessionReauth,
}
impl MfaMethod {
pub fn as_str(&self) -> &'static str {
match self {
Self::Totp => "totp",
Self::Passkey => "passkey",
Self::Password => "password",
Self::RecoveryCode => "recovery_code",
Self::SessionReauth => "session_reauth",
}
}
}
impl std::fmt::Display for MfaMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
pub struct MfaVerified<'a> {
user: &'a AuthenticatedUser,
method: MfaMethod,
}
impl<'a> MfaVerified<'a> {
fn new(user: &'a AuthenticatedUser, method: MfaMethod) -> Self {
Self { user, method }
}
pub(crate) fn from_totp(user: &'a AuthenticatedUser) -> Self {
Self::new(user, MfaMethod::Totp)
}
pub(crate) fn from_password(user: &'a AuthenticatedUser) -> Self {
Self::new(user, MfaMethod::Password)
}
pub(crate) fn from_recovery_code(user: &'a AuthenticatedUser) -> Self {
Self::new(user, MfaMethod::RecoveryCode)
}
pub(crate) fn from_session_reauth(user: &'a AuthenticatedUser) -> Self {
Self::new(user, MfaMethod::SessionReauth)
}
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
pub fn did(&self) -> &Did {
&self.user.did
}
pub fn method(&self) -> MfaMethod {
self.method
}
}
pub async fn require_legacy_session_mfa<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<MfaVerified<'a>, Response> {
use crate::api::server::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
if check_legacy_session_mfa(&*state.session_repo, &user.did).await {
Ok(MfaVerified::from_session_reauth(user))
} else {
Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
}
}
pub async fn require_reauth_window<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<MfaVerified<'a>, Response> {
use crate::api::server::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
use chrono::Utc;
let status = state
.session_repo
.get_session_mfa_status(&user.did)
.await
.ok()
.flatten();
match status {
Some(s) => {
if let Some(last_reauth) = s.last_reauth_at {
let elapsed = Utc::now().signed_duration_since(last_reauth);
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
return Ok(MfaVerified::from_session_reauth(user));
}
}
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
}
None => {
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
}
}
}
pub async fn require_reauth_window_if_available<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<Option<MfaVerified<'a>>, Response> {
use crate::api::server::reauth::{check_reauth_required_cached, reauth_required_response};
let has_password = state
.user_repo
.has_password_by_did(&user.did)
.await
.ok()
.flatten()
.unwrap_or(false);
let has_passkeys = state
.user_repo
.has_passkeys(&user.did)
.await
.unwrap_or(false);
let has_totp = state
.user_repo
.has_totp_enabled(&user.did)
.await
.unwrap_or(false);
let has_any_reauth_method = has_password || has_passkeys || has_totp;
if !has_any_reauth_method {
return Ok(None);
}
if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await {
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
} else {
Ok(Some(MfaVerified::from_session_reauth(user)))
}
}
pub async fn verify_password_mfa<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
password: &str,
) -> Result<MfaVerified<'a>, crate::api::error::ApiError> {
let hash = state
.user_repo
.get_password_hash_by_did(&user.did)
.await
.ok()
.flatten();
match hash {
Some(h) => {
if bcrypt::verify(password, &h).unwrap_or(false) {
Ok(MfaVerified::from_password(user))
} else {
Err(crate::api::error::ApiError::InvalidPassword(
"Password is incorrect".into(),
))
}
}
None => Err(crate::api::error::ApiError::AccountNotFound),
}
}
pub async fn verify_totp_mfa<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
code: &str,
) -> Result<MfaVerified<'a>, crate::api::error::ApiError> {
use crate::auth::{decrypt_totp_secret, is_backup_code_format, verify_totp_code};
use tranquil_db_traits::TotpRecordState;
let code = code.trim();
if is_backup_code_format(code) {
let backup_codes = state
.user_repo
.get_unused_backup_codes(&user.did)
.await
.ok()
.unwrap_or_default();
let code_upper = code.to_uppercase();
let matched = backup_codes
.iter()
.find(|row| crate::auth::verify_backup_code(&code_upper, &row.code_hash));
return match matched {
Some(row) => {
let _ = state.user_repo.mark_backup_code_used(row.id).await;
Ok(MfaVerified::from_recovery_code(user))
}
None => Err(crate::api::error::ApiError::InvalidCode(Some(
"Invalid backup code".into(),
))),
};
}
let verified_record = match state.user_repo.get_totp_record_state(&user.did).await {
Ok(Some(TotpRecordState::Verified(record))) => record,
_ => {
return Err(crate::api::error::ApiError::TotpNotEnabled);
}
};
let secret = decrypt_totp_secret(
&verified_record.secret_encrypted,
verified_record.encryption_version,
)
.map_err(|_| crate::api::error::ApiError::InternalError(None))?;
if verify_totp_code(&secret, code) {
let _ = state.user_repo.update_totp_last_used(&user.did).await;
Ok(MfaVerified::from_totp(user))
} else {
Err(crate::api::error::ApiError::InvalidCode(Some(
"Invalid verification code".into(),
)))
}
}
+23 -4
View File
@@ -10,16 +10,33 @@ use crate::types::Did;
use tranquil_db::UserRepository;
use tranquil_db_traits::OAuthRepository;
pub mod account_verified;
pub mod extractor;
pub mod login_identifier;
pub mod mfa_verified;
pub mod scope_check;
pub mod scope_verified;
pub mod service;
pub mod verification_token;
pub mod webauthn;
pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier};
pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated};
pub use extractor::{
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown,
Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header,
};
pub use mfa_verified::{
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
require_reauth_window_if_available, verify_password_mfa, verify_totp_mfa,
};
pub use scope_verified::{
AccountManage, AccountRead, BatchWriteScopes, BlobScopeAction, BlobUpload, ControllerDid,
IdentityAccess, PrincipalDid, RepoCreate, RepoDelete, RepoScopeAction, RepoUpdate, RepoUpsert,
RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind,
verify_batch_write_scopes,
};
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use tranquil_auth::{
@@ -409,7 +426,7 @@ async fn validate_bearer_token_with_options_internal(
.claims
.act
.as_ref()
.map(|a| Did::new_unchecked(a.sub.clone()));
.map(|a| unsafe { Did::new_unchecked(a.sub.clone()) });
let status =
AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at);
return Ok(AuthenticatedUser {
@@ -461,12 +478,14 @@ async fn validate_bearer_token_with_options_internal(
None
};
return Ok(AuthenticatedUser {
did: Did::new_unchecked(oauth_token.did),
did: unsafe { Did::new_unchecked(oauth_token.did) },
key_bytes,
is_admin: oauth_token.is_admin,
status,
scope: oauth_info.scope,
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
controller_did: oauth_info
.controller_did
.map(|d| unsafe { Did::new_unchecked(d) }),
auth_source: AuthSource::OAuth,
});
} else {
@@ -545,7 +564,7 @@ pub async fn validate_token_with_dpop(
None
};
Ok(AuthenticatedUser {
did: Did::new_unchecked(result.did),
did: unsafe { Did::new_unchecked(result.did) },
key_bytes,
is_admin: user_info.is_admin,
status,
@@ -0,0 +1,502 @@
use std::marker::PhantomData;
use std::ops::Deref;
use axum::response::{IntoResponse, Response};
use crate::api::error::ApiError;
use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
use crate::types::Did;
use super::AuthenticatedUser;
#[derive(Debug, Clone)]
pub struct PrincipalDid(Did);
impl PrincipalDid {
pub fn as_did(&self) -> &Did {
&self.0
}
pub fn into_did(self) -> Did {
self.0
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl Deref for PrincipalDid {
type Target = Did;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<Did> for PrincipalDid {
fn as_ref(&self) -> &Did {
&self.0
}
}
impl std::fmt::Display for PrincipalDid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone)]
pub struct ControllerDid(Did);
impl ControllerDid {
pub fn as_did(&self) -> &Did {
&self.0
}
pub fn into_did(self) -> Did {
self.0
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl Deref for ControllerDid {
type Target = Did;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<Did> for ControllerDid {
fn as_ref(&self) -> &Did {
&self.0
}
}
impl std::fmt::Display for ControllerDid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug)]
pub struct ScopeVerificationError {
message: String,
}
impl ScopeVerificationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl std::fmt::Display for ScopeVerificationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for ScopeVerificationError {}
impl IntoResponse for ScopeVerificationError {
fn into_response(self) -> Response {
ApiError::InsufficientScope(Some(self.message)).into_response()
}
}
mod private {
pub trait Sealed {}
pub trait RepoScopeSealed {}
pub trait BlobScopeSealed {}
}
pub trait ScopeAction: private::Sealed {}
pub trait RepoScopeAction: ScopeAction + private::RepoScopeSealed {}
pub trait BlobScopeAction: ScopeAction + private::BlobScopeSealed {}
pub struct RepoCreate;
pub struct RepoUpdate;
pub struct RepoDelete;
pub struct RepoUpsert;
pub struct BlobUpload;
pub struct RpcCall;
pub struct AccountRead;
pub struct AccountManage;
pub struct IdentityAccess;
impl private::Sealed for RepoCreate {}
impl private::Sealed for RepoUpdate {}
impl private::Sealed for RepoDelete {}
impl private::Sealed for RepoUpsert {}
impl private::Sealed for BlobUpload {}
impl private::Sealed for RpcCall {}
impl private::Sealed for AccountRead {}
impl private::Sealed for AccountManage {}
impl private::Sealed for IdentityAccess {}
impl private::RepoScopeSealed for RepoCreate {}
impl private::RepoScopeSealed for RepoUpdate {}
impl private::RepoScopeSealed for RepoDelete {}
impl private::RepoScopeSealed for RepoUpsert {}
impl private::BlobScopeSealed for BlobUpload {}
impl ScopeAction for RepoCreate {}
impl ScopeAction for RepoUpdate {}
impl ScopeAction for RepoDelete {}
impl ScopeAction for RepoUpsert {}
impl ScopeAction for BlobUpload {}
impl ScopeAction for RpcCall {}
impl ScopeAction for AccountRead {}
impl ScopeAction for AccountManage {}
impl ScopeAction for IdentityAccess {}
impl RepoScopeAction for RepoCreate {}
impl RepoScopeAction for RepoUpdate {}
impl RepoScopeAction for RepoDelete {}
impl RepoScopeAction for RepoUpsert {}
impl BlobScopeAction for BlobUpload {}
pub struct ScopeVerified<'a, A: ScopeAction> {
user: &'a AuthenticatedUser,
_action: PhantomData<A>,
}
impl<'a, A: ScopeAction> ScopeVerified<'a, A> {
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
pub fn principal_did(&self) -> PrincipalDid {
PrincipalDid(self.user.did.clone())
}
pub fn controller_did(&self) -> Option<ControllerDid> {
self.user.controller_did.clone().map(ControllerDid)
}
pub fn is_admin(&self) -> bool {
self.user.is_admin
}
}
pub struct BatchWriteScopes<'a> {
user: &'a AuthenticatedUser,
has_creates: bool,
has_updates: bool,
has_deletes: bool,
}
impl<'a> BatchWriteScopes<'a> {
pub fn principal_did(&self) -> PrincipalDid {
PrincipalDid(self.user.did.clone())
}
pub fn controller_did(&self) -> Option<ControllerDid> {
self.user.controller_did.clone().map(ControllerDid)
}
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
pub fn has_creates(&self) -> bool {
self.has_creates
}
pub fn has_updates(&self) -> bool {
self.has_updates
}
pub fn has_deletes(&self) -> bool {
self.has_deletes
}
}
pub fn verify_batch_write_scopes<'a, T, C, F>(
auth: &'a impl VerifyScope,
user: &'a AuthenticatedUser,
writes: &[T],
get_collection: F,
classify: C,
) -> Result<BatchWriteScopes<'a>, ScopeVerificationError>
where
F: Fn(&T) -> &str,
C: Fn(&T) -> WriteOpKind,
{
use std::collections::HashSet;
let create_collections: HashSet<&str> = writes
.iter()
.filter(|w| matches!(classify(w), WriteOpKind::Create))
.map(&get_collection)
.collect();
let update_collections: HashSet<&str> = writes
.iter()
.filter(|w| matches!(classify(w), WriteOpKind::Update))
.map(&get_collection)
.collect();
let delete_collections: HashSet<&str> = writes
.iter()
.filter(|w| matches!(classify(w), WriteOpKind::Delete))
.map(&get_collection)
.collect();
if auth.needs_scope_check() {
create_collections.iter().try_for_each(|c| {
auth.permissions()
.assert_repo(RepoAction::Create, c)
.map_err(|e| ScopeVerificationError::new(e.to_string()))
})?;
update_collections.iter().try_for_each(|c| {
auth.permissions()
.assert_repo(RepoAction::Update, c)
.map_err(|e| ScopeVerificationError::new(e.to_string()))
})?;
delete_collections.iter().try_for_each(|c| {
auth.permissions()
.assert_repo(RepoAction::Delete, c)
.map_err(|e| ScopeVerificationError::new(e.to_string()))
})?;
}
Ok(BatchWriteScopes {
user,
has_creates: !create_collections.is_empty(),
has_updates: !update_collections.is_empty(),
has_deletes: !delete_collections.is_empty(),
})
}
#[derive(Clone, Copy)]
pub enum WriteOpKind {
Create,
Update,
Delete,
}
pub trait VerifyScope {
fn needs_scope_check(&self) -> bool;
fn permissions(&self) -> ScopePermissions;
fn verify_repo_create<'a>(
&'a self,
collection: &str,
) -> Result<ScopeVerified<'a, RepoCreate>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_repo(RepoAction::Create, collection)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_repo_update<'a>(
&'a self,
collection: &str,
) -> Result<ScopeVerified<'a, RepoUpdate>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_repo(RepoAction::Update, collection)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_repo_delete<'a>(
&'a self,
collection: &str,
) -> Result<ScopeVerified<'a, RepoDelete>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_repo(RepoAction::Delete, collection)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_repo_upsert<'a>(
&'a self,
collection: &str,
) -> Result<ScopeVerified<'a, RepoUpsert>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_repo(RepoAction::Create, collection)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
self.permissions()
.assert_repo(RepoAction::Update, collection)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_blob_upload<'a>(
&'a self,
mime_type: &str,
) -> Result<ScopeVerified<'a, BlobUpload>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_blob(mime_type)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_rpc<'a>(
&'a self,
aud: &str,
lxm: &str,
) -> Result<ScopeVerified<'a, RpcCall>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_rpc(aud, lxm)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_account_read<'a>(
&'a self,
attr: AccountAttr,
) -> Result<ScopeVerified<'a, AccountRead>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_account(attr, AccountAction::Read)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_account_manage<'a>(
&'a self,
attr: AccountAttr,
) -> Result<ScopeVerified<'a, AccountManage>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_account(attr, AccountAction::Manage)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
fn verify_identity<'a>(
&'a self,
attr: IdentityAttr,
) -> Result<ScopeVerified<'a, IdentityAccess>, ScopeVerificationError>
where
Self: AsRef<AuthenticatedUser>,
{
if !self.needs_scope_check() {
return Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
});
}
self.permissions()
.assert_identity(attr)
.map_err(|e| ScopeVerificationError::new(e.to_string()))?;
Ok(ScopeVerified {
user: self.as_ref(),
_action: PhantomData,
})
}
}
+10 -9
View File
@@ -1,3 +1,5 @@
use crate::types::Did;
use crate::util::pds_hostname;
use anyhow::{Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -42,10 +44,10 @@ pub struct DidService {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceTokenClaims {
pub iss: String,
pub iss: Did,
#[serde(default)]
pub sub: Option<String>,
pub aud: String,
pub sub: Option<Did>,
pub aud: Did,
pub exp: usize,
#[serde(default)]
pub iat: Option<usize>,
@@ -56,8 +58,8 @@ pub struct ServiceTokenClaims {
}
impl ServiceTokenClaims {
pub fn subject(&self) -> &str {
self.sub.as_deref().unwrap_or(&self.iss)
pub fn subject(&self) -> &Did {
self.sub.as_ref().unwrap_or(&self.iss)
}
}
@@ -78,8 +80,7 @@ impl ServiceTokenVerifier {
let plc_directory_url = std::env::var("PLC_DIRECTORY_URL")
.unwrap_or_else(|_| "https://plc.directory".to_string());
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_hostname = pds_hostname();
let pds_did = format!("did:web:{}", pds_hostname);
let client = Client::builder()
@@ -130,7 +131,7 @@ impl ServiceTokenVerifier {
return Err(anyhow!("Token expired"));
}
if claims.aud != self.pds_did {
if claims.aud.as_str() != self.pds_did {
return Err(anyhow!(
"Invalid audience: expected {}, got {}",
self.pds_did,
@@ -154,7 +155,7 @@ impl ServiceTokenVerifier {
}
}
let did = &claims.iss;
let did = claims.iss.as_str();
let public_key = self.resolve_signing_key(did).await?;
let signature_bytes = URL_SAFE_NO_PAD
+1 -1
View File
@@ -7,7 +7,7 @@ pub struct WebAuthnConfig {
impl WebAuthnConfig {
pub fn new(hostname: &str) -> Result<Self, String> {
let rp_id = hostname.to_string();
let rp_id = hostname.split(':').next().unwrap_or(hostname).to_string();
let rp_origin = Url::parse(&format!("https://{}", hostname))
.map_err(|e| format!("Invalid origin URL: {}", e))?;
+101
View File
@@ -0,0 +1,101 @@
use cid::Cid;
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CommitCid(Cid);
impl CommitCid {
pub fn new(cid: Cid) -> Self {
Self(cid)
}
pub fn as_cid(&self) -> &Cid {
&self.0
}
pub fn into_cid(self) -> Cid {
self.0
}
}
impl From<Cid> for CommitCid {
fn from(cid: Cid) -> Self {
Self(cid)
}
}
impl From<CommitCid> for Cid {
fn from(commit_cid: CommitCid) -> Self {
commit_cid.0
}
}
impl FromStr for CommitCid {
type Err = cid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Cid::from_str(s).map(Self)
}
}
impl fmt::Display for CommitCid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<Cid> for CommitCid {
fn as_ref(&self) -> &Cid {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RecordCid(Cid);
impl RecordCid {
pub fn new(cid: Cid) -> Self {
Self(cid)
}
pub fn as_cid(&self) -> &Cid {
&self.0
}
pub fn into_cid(self) -> Cid {
self.0
}
}
impl From<Cid> for RecordCid {
fn from(cid: Cid) -> Self {
Self(cid)
}
}
impl From<RecordCid> for Cid {
fn from(record_cid: RecordCid) -> Self {
record_cid.0
}
}
impl FromStr for RecordCid {
type Err = cid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Cid::from_str(s).map(Self)
}
}
impl fmt::Display for RecordCid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<Cid> for RecordCid {
fn as_ref(&self) -> &Cid {
&self.0
}
}
+11 -13
View File
@@ -3,8 +3,8 @@ use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use tokio::sync::watch;
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use tranquil_comms::{
CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message,
@@ -96,7 +96,7 @@ impl CommsService {
!self.senders.is_empty()
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
pub async fn run(self, shutdown: CancellationToken) {
if self.senders.is_empty() {
warn!(
"Comms service starting with no senders configured. Messages will be queued but not delivered until senders are configured."
@@ -116,11 +116,9 @@ impl CommsService {
error!(error = %e, "Failed to process comms batch");
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Comms service shutting down");
break;
}
_ = shutdown.cancelled() => {
info!("Comms service shutting down");
break;
}
}
}
@@ -278,7 +276,7 @@ pub mod repo {
&[("hostname", hostname), ("handle", &prefs.handle)],
);
let subject = format_message(strings.welcome_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
@@ -309,7 +307,7 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.password_reset_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
@@ -422,7 +420,7 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.account_deletion_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
@@ -453,7 +451,7 @@ pub mod repo {
&[("handle", &prefs.handle), ("token", token)],
);
let subject = format_message(strings.plc_operation_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
@@ -484,7 +482,7 @@ pub mod repo {
&[("handle", &prefs.handle), ("url", recovery_url)],
);
let subject = format_message(strings.passkey_recovery_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
@@ -614,7 +612,7 @@ pub mod repo {
&[("handle", &prefs.handle), ("code", code)],
);
let subject = format_message(strings.two_factor_code_subject, &[("hostname", hostname)]);
let channel = channel_from_str(&prefs.preferred_channel);
let channel = prefs.preferred_channel;
infra_repo
.enqueue_comms(
Some(user_id),
+14 -10
View File
@@ -1,11 +1,14 @@
use crate::circuit_breaker::CircuitBreaker;
use crate::sync::firehose::SequencedEvent;
use crate::util::pds_hostname;
use reqwest::Client;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::RepoEventType;
const NOTIFY_THRESHOLD_SECS: u64 = 20 * 60;
@@ -40,7 +43,10 @@ impl Crawlers {
}
pub fn from_env() -> Option<Self> {
let hostname = std::env::var("PDS_HOSTNAME").ok()?;
let hostname = pds_hostname();
if hostname == "localhost" {
return None;
}
let crawler_urls: Vec<String> = std::env::var("CRAWLERS")
.unwrap_or_default()
@@ -53,7 +59,7 @@ impl Crawlers {
return None;
}
Some(Self::new(hostname, crawler_urls))
Some(Self::new(hostname.to_string(), crawler_urls))
}
fn should_notify(&self) -> bool {
@@ -143,7 +149,7 @@ impl Crawlers {
pub async fn start_crawlers_service(
crawlers: Arc<Crawlers>,
mut firehose_rx: broadcast::Receiver<SequencedEvent>,
mut shutdown: watch::Receiver<bool>,
shutdown: CancellationToken,
) {
info!(
hostname = %crawlers.hostname,
@@ -157,7 +163,7 @@ pub async fn start_crawlers_service(
result = firehose_rx.recv() => {
match result {
Ok(event) => {
if event.event_type == "commit" {
if event.event_type == RepoEventType::Commit {
crawlers.notify_of_update().await;
}
}
@@ -171,11 +177,9 @@ pub async fn start_crawlers_service(
}
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Crawlers service shutting down");
break;
}
_ = shutdown.cancelled() => {
info!("Crawlers service shutting down");
break;
}
}
}
+9 -1
View File
@@ -1,4 +1,12 @@
pub mod roles;
pub mod scopes;
pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes};
pub use roles::{
CanAddControllers, CanBeController, CanControlAccounts, verify_can_add_controllers,
verify_can_be_controller, verify_can_control_accounts,
};
pub use scopes::{
InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope,
intersect_scopes, validate_delegation_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
+108
View File
@@ -0,0 +1,108 @@
use axum::response::{IntoResponse, Response};
use crate::api::error::ApiError;
use crate::auth::AuthenticatedUser;
use crate::state::AppState;
use crate::types::Did;
pub struct CanAddControllers<'a> {
user: &'a AuthenticatedUser,
}
pub struct CanControlAccounts<'a> {
user: &'a AuthenticatedUser,
}
pub struct CanBeController<'a> {
controller_did: &'a Did,
}
impl<'a> CanAddControllers<'a> {
pub fn did(&self) -> &Did {
&self.user.did
}
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
}
impl<'a> CanControlAccounts<'a> {
pub fn did(&self) -> &Did {
&self.user.did
}
pub fn user(&self) -> &AuthenticatedUser {
self.user
}
}
impl<'a> CanBeController<'a> {
pub fn did(&self) -> &Did {
self.controller_did
}
}
pub async fn verify_can_add_controllers<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<CanAddControllers<'a>, Response> {
match state.delegation_repo.controls_any_accounts(&user.did).await {
Ok(true) => Err(ApiError::InvalidDelegation(
"Cannot add controllers to an account that controls other accounts".into(),
)
.into_response()),
Ok(false) => Ok(CanAddControllers { user }),
Err(e) => {
tracing::error!("Failed to check delegation status: {:?}", e);
Err(
ApiError::InternalError(Some("Failed to verify delegation status".into()))
.into_response(),
)
}
}
}
pub async fn verify_can_control_accounts<'a>(
state: &AppState,
user: &'a AuthenticatedUser,
) -> Result<CanControlAccounts<'a>, Response> {
match state.delegation_repo.has_any_controllers(&user.did).await {
Ok(true) => Err(ApiError::InvalidDelegation(
"Cannot create delegated accounts from a controlled account".into(),
)
.into_response()),
Ok(false) => Ok(CanControlAccounts { user }),
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
Err(
ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response(),
)
}
}
}
pub async fn verify_can_be_controller<'a>(
state: &AppState,
controller_did: &'a Did,
) -> Result<CanBeController<'a>, Response> {
match state
.delegation_repo
.has_any_controllers(controller_did)
.await
{
Ok(true) => Err(ApiError::InvalidDelegation(
"Cannot add a controlled account as a controller".into(),
)
.into_response()),
Ok(false) => Ok(CanBeController { controller_did }),
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
Err(
ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response(),
)
}
}
}
+7 -29
View File
@@ -1,5 +1,9 @@
use std::collections::HashSet;
pub use tranquil_db_traits::{
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
};
pub struct ScopePreset {
pub name: &'static str,
pub label: &'static str,
@@ -107,35 +111,9 @@ fn split_scope(scope: &str) -> (&str, Option<&str>) {
}
}
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> {
if scopes.is_empty() {
return Ok(());
}
scopes.split_whitespace().try_for_each(|scope| {
let (base, _) = split_scope(scope);
if is_valid_scope_prefix(base) {
Ok(())
} else {
Err(format!("Invalid scope: {}", scope))
}
})
}
fn is_valid_scope_prefix(base: &str) -> bool {
const VALID_PREFIXES: [&str; 7] = [
"atproto",
"repo:",
"blob:",
"rpc:",
"account:",
"identity:",
"transition:",
];
VALID_PREFIXES
.iter()
.any(|prefix| base == prefix.trim_end_matches(':') || base.starts_with(prefix))
pub fn validate_delegation_scopes(scopes: &str) -> Result<(), InvalidDelegationScopeError> {
ValidatedDelegationScope::new(scopes)?;
Ok(())
}
#[cfg(test)]
+2 -1
View File
@@ -2,6 +2,7 @@ pub mod api;
pub mod appview;
pub mod auth;
pub mod cache;
pub mod cid_types;
pub mod circuit_breaker;
pub mod comms;
pub mod config;
@@ -35,9 +36,9 @@ use axum::{
use http::StatusCode;
use serde_json::json;
use state::AppState;
pub use sync::util::AccountStatus;
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
pub use tranquil_db_traits::AccountStatus;
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
pub fn app(state: AppState) -> Router {
+52 -39
View File
@@ -1,7 +1,7 @@
use std::net::SocketAddr;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
@@ -34,10 +34,20 @@ async fn main() -> ExitCode {
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let state = AppState::new().await?;
tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await;
let shutdown = CancellationToken::new();
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let shutdown_for_panic = shutdown.clone();
let default_panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
error!("PANIC: {}", info);
shutdown_for_panic.cancel();
default_panic_hook(info);
}));
spawn_signal_handler(shutdown.clone());
let state = AppState::new(shutdown.clone()).await?;
tranquil_pds::sync::listener::start_sequencer_listener(state.clone()).await;
let backfill_repo_repo = state.repo_repo.clone();
let backfill_block_store = state.block_store.clone();
@@ -77,7 +87,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(signal_sender);
}
let comms_handle = tokio::spawn(comms_service.run(shutdown_rx.clone()));
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
let crawlers = Arc::new(
@@ -88,7 +98,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Some(tokio::spawn(start_crawlers_service(
crawlers,
firehose_rx,
shutdown_rx.clone(),
shutdown.clone(),
)))
} else {
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
@@ -102,7 +112,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
state.backup_repo.clone(),
state.block_store.clone(),
backup_storage,
shutdown_rx.clone(),
shutdown.clone(),
)))
} else {
warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)");
@@ -114,7 +124,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
state.blob_repo.clone(),
state.blob_store.clone(),
state.sso_repo.clone(),
shutdown_rx,
shutdown.clone(),
));
let app = tranquil_pds::app(state);
@@ -136,7 +146,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
let server_result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(shutdown_tx))
.with_graceful_shutdown(shutdown.clone().cancelled_owned())
.await;
comms_handle.await.ok();
@@ -158,37 +168,40 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn shutdown_signal(shutdown_tx: watch::Sender<bool>) {
let ctrl_c = async {
match tokio::signal::ctrl_c().await {
Ok(()) => {}
Err(e) => {
error!("Failed to install Ctrl+C handler: {}", e);
fn spawn_signal_handler(shutdown: CancellationToken) {
tokio::spawn(async move {
let ctrl_c = async {
match tokio::signal::ctrl_c().await {
Ok(()) => {}
Err(e) => {
error!("Failed to install Ctrl+C handler: {}", e);
std::future::pending::<()>().await;
}
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(e) => {
error!("Failed to install SIGTERM handler: {}", e);
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(e) => {
error!("Failed to install SIGTERM handler: {}", e);
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("Shutdown signal received, stopping services...");
shutdown_tx.send(true).ok();
info!("Shutdown signal received, stopping services...");
shutdown.cancel();
});
}

Some files were not shown because too many files have changed in this diff Show More