Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Better handles

+408 -346
+8 -8
TODO.md
··· 12 12 ### Passkeys and 2FA 13 13 Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 14 15 - - [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 - - [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 - - [ ] WebAuthn registration challenge generation and attestation verification 18 - - [ ] TOTP secret generation with QR code setup flow 19 - - [ ] Backup codes (hashed, one-time use) with recovery flow 20 - - [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 15 + - [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 + - [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 + - [x] WebAuthn registration challenge generation and attestation verification 18 + - [x] TOTP secret generation with QR code setup flow 19 + - [x] Backup codes (hashed, one-time use) with recovery flow 20 + - [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 21 - [ ] Passkey-only account creation (no password) 22 - - [ ] Settings UI for managing passkeys, TOTP, backup codes 22 + - [x] Settings UI for managing passkeys, TOTP, backup codes 23 23 - [ ] Trusted devices option (remember this browser) 24 - - [ ] Rate limit 2FA attempts 24 + - [x] Rate limit 2FA attempts 25 25 - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 26 27 27 ### Delegated accounts
+6 -4
frontend/src/routes/OAuthLogin.svelte
··· 341 341 /> 342 342 </div> 343 343 344 - {#if securityStatusChecked && passkeySupported} 344 + {#if passkeySupported && username.length >= 3} 345 345 <button 346 346 type="button" 347 347 class="passkey-btn" 348 - class:passkey-unavailable={!hasPasskeys} 348 + class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 349 349 onclick={handlePasskeyLogin} 350 - disabled={submitting || !hasPasskeys || !username} 351 - title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 350 + disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 351 + title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 352 > 353 353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 358 358 <span class="passkey-text"> 359 359 {#if submitting} 360 360 Authenticating... 361 + {:else if checkingSecurityStatus || !securityStatusChecked} 362 + Checking passkey... 361 363 {:else if hasPasskeys} 362 364 Sign in with passkey 363 365 {:else}
+9 -1
frontend/src/routes/Register.svelte
··· 50 50 } 51 51 } 52 52 53 + let handleHasDot = $derived(handle.includes('.')) 54 + 53 55 function validateForm(): string | null { 54 56 if (!handle.trim()) return 'Handle is required' 57 + if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 55 58 if (!password) return 'Password is required' 56 59 if (password.length < 8) return 'Password must be at least 8 characters' 57 60 if (password !== confirmPassword) return 'Passwords do not match' ··· 152 155 disabled={submitting} 153 156 required 154 157 /> 155 - {#if fullHandle()} 158 + {#if handleHasDot} 159 + <p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p> 160 + {:else if fullHandle()} 156 161 <p class="hint">Your full handle will be: @{fullHandle()}</p> 157 162 {/if} 158 163 </div> ··· 389 394 font-size: 0.75rem; 390 395 color: var(--text-secondary); 391 396 margin: 0.25rem 0 0 0; 397 + } 398 + .hint.warning { 399 + color: var(--warning-text, #856404); 392 400 } 393 401 .verification-section { 394 402 border: 1px solid var(--border-color-light);
+90 -19
migrations/20251211_initial_schema.sql
··· 1 - CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 - CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 - CREATE TYPE notification_type AS ENUM ( 1 + CREATE TYPE comms_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 + CREATE TYPE comms_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 + CREATE TYPE comms_type AS ENUM ( 4 4 'welcome', 5 5 'email_verification', 6 6 'password_reset', ··· 8 8 'account_deletion', 9 9 'admin_email', 10 10 'plc_operation', 11 - 'two_factor_code' 11 + 'two_factor_code', 12 + 'channel_verification' 12 13 ); 13 14 CREATE TABLE IF NOT EXISTS users ( 14 15 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ··· 21 22 deactivated_at TIMESTAMPTZ, 22 23 invites_disabled BOOLEAN DEFAULT FALSE, 23 24 takedown_ref TEXT, 24 - preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', 25 + preferred_comms_channel comms_channel NOT NULL DEFAULT 'email', 25 26 password_reset_code TEXT, 26 27 password_reset_code_expires_at TIMESTAMPTZ, 27 - email_pending_verification TEXT, 28 - email_confirmation_code TEXT, 29 - email_confirmation_code_expires_at TIMESTAMPTZ, 30 - email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, 28 + email_verified BOOLEAN NOT NULL DEFAULT FALSE, 31 29 two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, 32 30 discord_id TEXT, 33 31 discord_verified BOOLEAN NOT NULL DEFAULT FALSE, 34 32 telegram_username TEXT, 35 33 telegram_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 34 signal_number TEXT, 37 - signal_verified BOOLEAN NOT NULL DEFAULT FALSE 35 + signal_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 + is_admin BOOLEAN NOT NULL DEFAULT FALSE, 37 + migrated_to_pds TEXT, 38 + migrated_at TIMESTAMPTZ 38 39 ); 39 40 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 40 - CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; 41 41 CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL; 42 42 CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL; 43 43 CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL; 44 + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; 44 45 CREATE TABLE IF NOT EXISTS invite_codes ( 45 46 code TEXT PRIMARY KEY, 46 47 available_uses INT NOT NULL DEFAULT 1, ··· 48 49 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 49 50 disabled BOOLEAN DEFAULT FALSE 50 51 ); 52 + CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by ON invite_codes(created_by_user); 51 53 CREATE TABLE IF NOT EXISTS invite_code_uses ( 52 54 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 53 55 code TEXT NOT NULL REFERENCES invite_codes(code), ··· 86 88 UNIQUE(repo_id, collection, rkey) 87 89 ); 88 90 CREATE INDEX idx_records_repo_rev ON records(repo_rev); 91 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection ON records(repo_id, collection); 92 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created ON records(repo_id, collection, created_at DESC); 89 93 CREATE TABLE IF NOT EXISTS blobs ( 90 94 cid TEXT PRIMARY KEY, 91 95 mime_type TEXT NOT NULL, ··· 95 99 takedown_ref TEXT, 96 100 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 97 101 ); 102 + CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user ON blobs(created_by_user, created_at DESC); 98 103 CREATE TABLE IF NOT EXISTS app_passwords ( 99 104 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 100 105 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, ··· 104 109 privileged BOOLEAN NOT NULL DEFAULT FALSE, 105 110 UNIQUE(user_id, name) 106 111 ); 112 + CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id ON app_passwords(user_id); 107 113 CREATE TABLE reports ( 108 114 id BIGINT PRIMARY KEY, 109 115 reason_type TEXT NOT NULL, ··· 118 124 expires_at TIMESTAMPTZ NOT NULL, 119 125 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 120 126 ); 121 - CREATE TABLE IF NOT EXISTS notification_queue ( 127 + CREATE TABLE IF NOT EXISTS comms_queue ( 122 128 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 123 129 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 124 - channel notification_channel NOT NULL DEFAULT 'email', 125 - notification_type notification_type NOT NULL, 126 - status notification_status NOT NULL DEFAULT 'pending', 130 + channel comms_channel NOT NULL DEFAULT 'email', 131 + comms_type comms_type NOT NULL, 132 + status comms_status NOT NULL DEFAULT 'pending', 127 133 recipient TEXT NOT NULL, 128 134 subject TEXT, 129 135 body TEXT NOT NULL, ··· 136 142 scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 137 143 processed_at TIMESTAMPTZ 138 144 ); 139 - CREATE INDEX idx_notification_queue_status_scheduled 140 - ON notification_queue(status, scheduled_for) 145 + CREATE INDEX idx_comms_queue_status_scheduled 146 + ON comms_queue(status, scheduled_for) 141 147 WHERE status = 'pending'; 142 - CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); 148 + CREATE INDEX idx_comms_queue_user_id ON comms_queue(user_id); 143 149 CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 144 150 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 145 151 did TEXT, ··· 160 166 prev_cid TEXT, 161 167 ops JSONB, 162 168 blobs TEXT[], 163 - blocks_cids TEXT[] 169 + blocks_cids TEXT[], 170 + prev_data_cid TEXT, 171 + handle TEXT, 172 + active BOOLEAN, 173 + status TEXT 164 174 ); 165 175 CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 166 176 CREATE INDEX idx_repo_seq_did ON repo_seq(did); 177 + CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq ON repo_seq(did, seq DESC); 167 178 CREATE TABLE IF NOT EXISTS session_tokens ( 168 179 id SERIAL PRIMARY KEY, 169 180 did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, ··· 275 286 ); 276 287 CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); 277 288 CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at); 289 + CREATE TABLE IF NOT EXISTS channel_verifications ( 290 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 291 + channel comms_channel NOT NULL, 292 + code TEXT NOT NULL, 293 + pending_identifier TEXT, 294 + expires_at TIMESTAMPTZ NOT NULL, 295 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 296 + PRIMARY KEY (user_id, channel) 297 + ); 298 + CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at); 299 + CREATE TABLE oauth_scope_preference ( 300 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 301 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 302 + client_id TEXT NOT NULL, 303 + scope TEXT NOT NULL, 304 + granted BOOLEAN NOT NULL DEFAULT TRUE, 305 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 306 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 307 + UNIQUE(did, client_id, scope) 308 + ); 309 + CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id); 310 + CREATE TABLE user_totp ( 311 + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 312 + secret_encrypted BYTEA NOT NULL, 313 + encryption_version INTEGER NOT NULL DEFAULT 1, 314 + verified BOOLEAN NOT NULL DEFAULT FALSE, 315 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 316 + last_used TIMESTAMPTZ 317 + ); 318 + CREATE TABLE backup_codes ( 319 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 320 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 321 + code_hash TEXT NOT NULL, 322 + used_at TIMESTAMPTZ, 323 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 324 + ); 325 + CREATE INDEX idx_backup_codes_did ON backup_codes(did); 326 + CREATE TABLE passkeys ( 327 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 328 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 329 + credential_id BYTEA NOT NULL UNIQUE, 330 + public_key BYTEA NOT NULL, 331 + sign_count INTEGER NOT NULL DEFAULT 0, 332 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 333 + last_used TIMESTAMPTZ, 334 + friendly_name TEXT, 335 + aaguid BYTEA, 336 + transports TEXT[] 337 + ); 338 + CREATE INDEX idx_passkeys_did ON passkeys(did); 339 + CREATE TABLE webauthn_challenges ( 340 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 341 + did TEXT NOT NULL, 342 + challenge BYTEA NOT NULL, 343 + challenge_type TEXT NOT NULL, 344 + state_json TEXT NOT NULL, 345 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 346 + expires_at TIMESTAMPTZ NOT NULL 347 + ); 348 + CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
-15
migrations/20251213_performance_indexes.sql
··· 1 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection 2 - ON records(repo_id, collection); 3 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created 4 - ON records(repo_id, collection, created_at DESC); 5 - CREATE INDEX IF NOT EXISTS idx_users_email 6 - ON users(email) 7 - WHERE email IS NOT NULL; 8 - CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user 9 - ON blobs(created_by_user, created_at DESC); 10 - CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq 11 - ON repo_seq(did, seq DESC); 12 - CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id 13 - ON app_passwords(user_id); 14 - CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by 15 - ON invite_codes(created_by_user);
-1
migrations/20251214_add_prev_data_cid.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
-3
migrations/20251215_add_identity_account_fields.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS handle TEXT; 2 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS active BOOLEAN; 3 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS status TEXT;
-12
migrations/20251216_add_channel_verification.sql
··· 1 - ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification'; 2 - 3 - CREATE TABLE IF NOT EXISTS channel_verifications ( 4 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 5 - channel notification_channel NOT NULL, 6 - code TEXT NOT NULL, 7 - expires_at TIMESTAMPTZ NOT NULL, 8 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - PRIMARY KEY (user_id, channel) 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
-11
migrations/20251217_migrate_email_to_channel_verifications.sql
··· 1 - ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT; 2 - 3 - INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 4 - SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at 5 - FROM users 6 - WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL; 7 - 8 - ALTER TABLE users 9 - DROP COLUMN email_confirmation_code, 10 - DROP COLUMN email_confirmation_code_expires_at, 11 - DROP COLUMN email_pending_verification;
-1
migrations/20251218_add_is_admin.sql
··· 1 - ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-6
migrations/20251219_rename_email_confirmed.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN 4 - ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified; 5 - END IF; 6 - END $$;
-27
migrations/20251220_rename_notifications_to_comms.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN 4 - ALTER TYPE notification_channel RENAME TO comms_channel; 5 - END IF; 6 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN 7 - ALTER TYPE notification_status RENAME TO comms_status; 8 - END IF; 9 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN 10 - ALTER TYPE notification_type RENAME TO comms_type; 11 - END IF; 12 - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN 13 - ALTER TABLE notification_queue RENAME TO comms_queue; 14 - END IF; 15 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN 16 - ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type; 17 - END IF; 18 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN 19 - ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled; 20 - END IF; 21 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN 22 - ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id; 23 - END IF; 24 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN 25 - ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel; 26 - END IF; 27 - END $$;
-12
migrations/20251221_oauth_scope_preferences.sql
··· 1 - CREATE TABLE oauth_scope_preference ( 2 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 4 - client_id TEXT NOT NULL, 5 - scope TEXT NOT NULL, 6 - granted BOOLEAN NOT NULL DEFAULT TRUE, 7 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - UNIQUE(did, client_id, scope) 10 - ); 11 - 12 - CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
-2
migrations/20251222_add_did_web_migration_tracking.sql
··· 1 - ALTER TABLE users ADD COLUMN migrated_to_pds TEXT; 2 - ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
-42
migrations/20251223_add_passkeys_totp.sql
··· 1 - CREATE TABLE user_totp ( 2 - did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 3 - secret_encrypted BYTEA NOT NULL, 4 - encryption_version INTEGER NOT NULL DEFAULT 1, 5 - verified BOOLEAN NOT NULL DEFAULT FALSE, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 - last_used TIMESTAMPTZ 8 - ); 9 - 10 - CREATE TABLE backup_codes ( 11 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 12 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 13 - code_hash TEXT NOT NULL, 14 - used_at TIMESTAMPTZ, 15 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 16 - ); 17 - CREATE INDEX idx_backup_codes_did ON backup_codes(did); 18 - 19 - CREATE TABLE passkeys ( 20 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 21 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 22 - credential_id BYTEA NOT NULL UNIQUE, 23 - public_key BYTEA NOT NULL, 24 - sign_count INTEGER NOT NULL DEFAULT 0, 25 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 - last_used TIMESTAMPTZ, 27 - friendly_name TEXT, 28 - aaguid BYTEA, 29 - transports TEXT[] 30 - ); 31 - CREATE INDEX idx_passkeys_did ON passkeys(did); 32 - 33 - CREATE TABLE webauthn_challenges ( 34 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 35 - did TEXT NOT NULL, 36 - challenge BYTEA NOT NULL, 37 - challenge_type TEXT NOT NULL, 38 - state_json TEXT NOT NULL, 39 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 40 - expires_at TIMESTAMPTZ NOT NULL 41 - ); 42 - CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
+9 -3
src/api/admin/account/update.rs
··· 67 67 Json(input): Json<UpdateAccountHandleInput>, 68 68 ) -> Response { 69 69 let did = input.did.trim(); 70 - let handle = input.handle.trim(); 71 - if did.is_empty() || handle.is_empty() { 70 + let input_handle = input.handle.trim(); 71 + if did.is_empty() || input_handle.is_empty() { 72 72 return ( 73 73 StatusCode::BAD_REQUEST, 74 74 Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), 75 75 ) 76 76 .into_response(); 77 77 } 78 - if !handle 78 + if !input_handle 79 79 .chars() 80 80 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 81 81 { ··· 87 87 ) 88 88 .into_response(); 89 89 } 90 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 91 + let handle = if !input_handle.contains('.') { 92 + format!("{}.{}", input_handle, hostname) 93 + } else { 94 + input_handle.to_string() 95 + }; 90 96 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 91 97 .fetch_optional(&state.db) 92 98 .await
+45 -24
src/api/identity/account.rs
··· 139 139 info!(did = %migration_did, "Processing account migration"); 140 140 } 141 141 142 - if input.handle.contains('!') || input.handle.contains('@') { 143 - return ( 144 - StatusCode::BAD_REQUEST, 145 - Json( 146 - json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), 147 - ), 148 - ) 149 - .into_response(); 150 - } 142 + let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 143 + let pds_suffix = format!(".{}", hostname_for_validation); 144 + 145 + let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 146 + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 147 + input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle) 148 + } else { 149 + &input.handle 150 + }; 151 + match crate::api::validation::validate_short_handle(handle_to_validate) { 152 + Ok(h) => h, 153 + Err(e) => { 154 + return ( 155 + StatusCode::BAD_REQUEST, 156 + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), 157 + ) 158 + .into_response(); 159 + } 160 + } 161 + } else { 162 + if input.handle.contains(' ') || input.handle.contains('\t') { 163 + return ( 164 + StatusCode::BAD_REQUEST, 165 + Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})), 166 + ) 167 + .into_response(); 168 + } 169 + input.handle.to_lowercase() 170 + }; 151 171 let email: Option<String> = input 152 172 .email 153 173 .as_ref() ··· 212 232 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 213 233 let pds_endpoint = format!("https://{}", hostname); 214 234 let suffix = format!(".{}", hostname); 215 - let short_handle = if input.handle.ends_with(&suffix) { 216 - input.handle.strip_suffix(&suffix).unwrap_or(&input.handle) 235 + let handle = if input.handle.ends_with(&suffix) { 236 + format!("{}.{}", validated_short_handle, hostname) 237 + } else if input.handle.contains('.') { 238 + validated_short_handle.clone() 217 239 } else { 218 - &input.handle 240 + format!("{}.{}", validated_short_handle, hostname) 219 241 }; 220 - let full_handle = format!("{}.{}", short_handle, hostname); 221 242 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 222 243 if let Some(signing_key_did) = &input.signing_key { 223 244 let reserved = sqlx::query!( ··· 298 319 ) 299 320 .into_response(); 300 321 } 301 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 322 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 302 323 return ( 303 324 StatusCode::BAD_REQUEST, 304 325 Json(json!({"error": "InvalidDid", "message": e})), ··· 314 335 info!(did = %d, "Migration with existing did:plc"); 315 336 d.clone() 316 337 } else if d.starts_with("did:web:") { 317 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 338 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 318 339 return ( 319 340 StatusCode::BAD_REQUEST, 320 341 Json(json!({"error": "InvalidDid", "message": e})), ··· 334 355 let genesis_result = match create_genesis_operation( 335 356 &signing_key, 336 357 &rotation_key, 337 - &full_handle, 358 + &handle, 338 359 &pds_endpoint, 339 360 ) { 340 361 Ok(r) => r, ··· 371 392 let genesis_result = match create_genesis_operation( 372 393 &signing_key, 373 394 &rotation_key, 374 - &full_handle, 395 + &handle, 375 396 &pds_endpoint, 376 397 ) { 377 398 Ok(r) => r, ··· 424 445 .unwrap_or(None); 425 446 if let Some((account_id, old_handle, deactivated_at)) = existing_account { 426 447 if deactivated_at.is_some() { 427 - info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); 448 + info!(did = %did, old_handle = %old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); 428 449 let update_result: Result<_, sqlx::Error> = 429 450 sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 430 - .bind(short_handle) 451 + .bind(&handle) 431 452 .bind(account_id) 432 453 .execute(&mut *tx) 433 454 .await; ··· 536 557 return ( 537 558 StatusCode::OK, 538 559 Json(CreateAccountOutput { 539 - handle: full_handle.clone(), 560 + handle: handle.clone(), 540 561 did, 541 562 access_jwt: Some(access_meta.token), 542 563 refresh_jwt: Some(refresh_meta.token), ··· 556 577 } 557 578 let exists_result: Option<(i32,)> = 558 579 sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") 559 - .bind(short_handle) 580 + .bind(&handle) 560 581 .fetch_optional(&mut *tx) 561 582 .await 562 583 .unwrap_or(None); ··· 660 681 is_admin, deactivated_at, email_verified 661 682 ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#, 662 683 ) 663 - .bind(short_handle) 684 + .bind(&handle) 664 685 .bind(&email) 665 686 .bind(&did) 666 687 .bind(&password_hash) ··· 898 919 } 899 920 if !is_migration { 900 921 if let Err(e) = 901 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 922 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 902 923 .await 903 924 { 904 925 warn!("Failed to sequence identity event for {}: {}", did, e); ··· 991 1012 ( 992 1013 StatusCode::OK, 993 1014 Json(CreateAccountOutput { 994 - handle: full_handle.clone(), 1015 + handle: handle.clone(), 995 1016 did, 996 1017 access_jwt, 997 1018 refresh_jwt,
+64 -56
src/api/identity/did.rs
··· 36 36 if let Some(did) = state.cache.get(&cache_key).await { 37 37 return (StatusCode::OK, Json(json!({ "did": did }))).into_response(); 38 38 } 39 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 40 - let suffix = format!(".{}", hostname); 41 - let short_handle = if handle.ends_with(&suffix) { 42 - handle.strip_suffix(&suffix).unwrap_or(handle) 43 - } else { 44 - handle 45 - }; 46 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 39 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 47 40 .fetch_optional(&state.db) 48 41 .await; 49 42 match user { ··· 139 132 } 140 133 141 134 async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 135 + let full_handle = format!("{}.{}", handle, hostname); 142 136 let user = sqlx::query!( 143 137 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 144 - handle 138 + full_handle 145 139 ) 146 140 .fetch_optional(&state.db) 147 141 .await; ··· 212 206 .into_response(); 213 207 } 214 208 }; 215 - let full_handle = if handle.contains('.') { 216 - handle.to_string() 217 - } else { 218 - format!("{}.{}", handle, hostname) 219 - }; 220 209 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 221 210 Json(json!({ 222 211 "@context": [ ··· 225 214 "https://w3id.org/security/suites/secp256k1-2019/v1" 226 215 ], 227 216 "id": did, 228 - "alsoKnownAs": [format!("at://{}", full_handle)], 217 + "alsoKnownAs": [format!("at://{}", handle)], 229 218 "verificationMethod": [{ 230 219 "id": format!("{}#atproto", did), 231 220 "type": "Multikey", ··· 243 232 244 233 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 245 234 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 235 + let full_handle = format!("{}.{}", handle, hostname); 246 236 let user = sqlx::query!( 247 237 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 248 - handle 238 + full_handle 249 239 ) 250 240 .fetch_optional(&state.db) 251 241 .await; ··· 318 308 .into_response(); 319 309 } 320 310 }; 321 - let full_handle = if handle.contains('.') { 322 - handle.clone() 323 - } else { 324 - format!("{}.{}", handle, hostname) 325 - }; 326 311 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 327 312 Json(json!({ 328 313 "@context": [ ··· 331 316 "https://w3id.org/security/suites/secp256k1-2019/v1" 332 317 ], 333 318 "id": did, 334 - "alsoKnownAs": [format!("at://{}", full_handle)], 319 + "alsoKnownAs": [format!("at://{}", handle)], 335 320 "verificationMethod": [{ 336 321 "id": format!("{}#atproto", did), 337 322 "type": "Multikey", ··· 347 332 .into_response() 348 333 } 349 334 350 - pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> { 335 + pub async fn verify_did_web( 336 + did: &str, 337 + hostname: &str, 338 + handle: &str, 339 + expected_signing_key: Option<&str>, 340 + ) -> Result<(), String> { 351 341 let subdomain_host = format!("{}.{}", handle, hostname); 352 342 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 353 343 let expected_subdomain_did = format!("did:web:{}", encoded_subdomain); ··· 371 361 )); 372 362 } 373 363 } 364 + let expected_signing_key = expected_signing_key.ok_or_else(|| { 365 + "External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string() 366 + })?; 374 367 let parts: Vec<&str> = did.split(':').collect(); 375 368 if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 376 369 return Err("Invalid did:web format".into()); ··· 411 404 let has_valid_service = services 412 405 .iter() 413 406 .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); 414 - if has_valid_service { 415 - Ok(()) 416 - } else { 417 - Err(format!( 407 + if !has_valid_service { 408 + return Err(format!( 418 409 "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 419 410 pds_endpoint 420 - )) 411 + )); 421 412 } 413 + let verification_methods = doc["verificationMethod"] 414 + .as_array() 415 + .ok_or("No verificationMethod found in DID doc")?; 416 + let expected_multibase = expected_signing_key 417 + .strip_prefix("did:key:") 418 + .ok_or("Invalid signing key format")?; 419 + let has_matching_key = verification_methods.iter().any(|vm| { 420 + vm["publicKeyMultibase"] 421 + .as_str() 422 + .map(|pk| pk == expected_multibase) 423 + .unwrap_or(false) 424 + }); 425 + if !has_matching_key { 426 + return Err(format!( 427 + "DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}", 428 + expected_multibase 429 + )); 430 + } 431 + Ok(()) 422 432 } 423 433 424 434 #[derive(serde::Serialize)] ··· 492 502 }; 493 503 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 494 504 let pds_endpoint = format!("https://{}", hostname); 495 - let full_handle = if user.handle.contains('.') { 496 - user.handle.clone() 497 - } else { 498 - format!("{}.{}", user.handle, hostname) 499 - }; 500 505 let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { 501 506 Ok(k) => k, 502 507 Err(_) => return ApiError::InternalError.into_response(), ··· 511 516 StatusCode::OK, 512 517 Json(GetRecommendedDidCredentialsOutput { 513 518 rotation_keys, 514 - also_known_as: vec![format!("at://{}", full_handle)], 519 + also_known_as: vec![format!("at://{}", user.handle)], 515 520 verification_methods: VerificationMethods { atproto: did_key }, 516 521 services: Services { 517 522 atproto_pds: AtprotoPds { ··· 577 582 .into_response(); 578 583 } 579 584 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 585 + let suffix = format!(".{}", hostname); 580 586 let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); 581 - let (handle_to_store, full_handle) = if is_service_domain { 582 - let suffix = format!(".{}", hostname); 583 - let short_handle = if new_handle.ends_with(&suffix) { 587 + let handle = if is_service_domain { 588 + let short_part = if new_handle.ends_with(&suffix) { 584 589 new_handle.strip_suffix(&suffix).unwrap_or(new_handle) 585 590 } else { 586 591 new_handle 587 592 }; 588 - ( 589 - short_handle.to_string(), 590 - format!("{}.{}", short_handle, hostname), 591 - ) 593 + if short_part.contains('.') { 594 + return ( 595 + StatusCode::BAD_REQUEST, 596 + Json(json!({ 597 + "error": "InvalidHandle", 598 + "message": "Nested subdomains are not allowed. Use a simple handle without dots." 599 + })), 600 + ) 601 + .into_response(); 602 + } 603 + if new_handle.ends_with(&suffix) { 604 + new_handle.to_string() 605 + } else { 606 + format!("{}.{}", new_handle, hostname) 607 + } 592 608 } else { 593 609 match crate::handle::verify_handle_ownership(new_handle, &did).await { 594 610 Ok(()) => {} ··· 625 641 .into_response(); 626 642 } 627 643 } 628 - (new_handle.to_string(), new_handle.to_string()) 644 + new_handle.to_string() 629 645 }; 630 646 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) 631 647 .fetch_optional(&state.db) ··· 634 650 .flatten(); 635 651 let existing = sqlx::query!( 636 652 "SELECT id FROM users WHERE handle = $1 AND id != $2", 637 - handle_to_store, 653 + handle, 638 654 user_id 639 655 ) 640 656 .fetch_optional(&state.db) ··· 648 664 } 649 665 let result = sqlx::query!( 650 666 "UPDATE users SET handle = $1 WHERE id = $2", 651 - handle_to_store, 667 + handle, 652 668 user_id 653 669 ) 654 670 .execute(&state.db) ··· 660 676 } 661 677 let _ = state 662 678 .cache 663 - .delete(&format!("handle:{}", handle_to_store)) 679 + .delete(&format!("handle:{}", handle)) 664 680 .await; 665 - let _ = state.cache.delete(&format!("handle:{}", full_handle)).await; 666 681 if let Err(e) = 667 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 682 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 668 683 .await 669 684 { 670 685 warn!("Failed to sequence identity event for handle update: {}", e); 671 686 } 672 - if let Err(e) = update_plc_handle(&state, &did, &full_handle).await { 687 + if let Err(e) = update_plc_handle(&state, &did, &handle).await { 673 688 warn!("Failed to update PLC handle: {}", e); 674 689 } 675 690 (StatusCode::OK, Json(json!({}))).into_response() ··· 723 738 Some(h) => h, 724 739 None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), 725 740 }; 726 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 727 - let suffix = format!(".{}", hostname); 728 741 let handle = host.split(':').next().unwrap_or(host); 729 - let short_handle = if handle.ends_with(&suffix) { 730 - handle.strip_suffix(&suffix).unwrap_or(handle) 731 - } else { 732 - return (StatusCode::NOT_FOUND, "Handle not found").into_response(); 733 - }; 734 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 742 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 735 743 .fetch_optional(&state.db) 736 744 .await; 737 745 match user {
+2 -2
src/api/repo/blob.rs
··· 16 16 use std::str::FromStr; 17 17 use tracing::{debug, error}; 18 18 19 - const MAX_BLOB_SIZE: usize = 1_000_000; 20 - const MAX_VIDEO_BLOB_SIZE: usize = 100_000_000; 19 + const MAX_BLOB_SIZE: usize = 10_000_000_000; 20 + const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000; 21 21 22 22 pub async fn upload_blob( 23 23 State(state): State<AppState>,
-24
src/api/repo/import.rs
··· 318 318 records.len(), 319 319 did 320 320 ); 321 - if is_migration { 322 - if let Err(e) = 323 - sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) 324 - .execute(&state.db) 325 - .await 326 - { 327 - error!("Failed to reactivate account after import: {:?}", e); 328 - } 329 - let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; 330 - if let Err(e) = crate::api::repo::record::sequence_identity_event( 331 - &state, 332 - did, 333 - Some(&user.handle), 334 - ) 335 - .await 336 - { 337 - warn!("Failed to sequence identity event after import: {:?}", e); 338 - } 339 - if let Err(e) = 340 - crate::api::repo::record::sequence_account_event(&state, did, true, None).await 341 - { 342 - warn!("Failed to sequence account event after import: {:?}", e); 343 - } 344 - } 345 321 if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 346 322 warn!("Failed to sequence import event: {:?}", e); 347 323 }
+7 -1
src/api/repo/meta.rs
··· 17 17 State(state): State<AppState>, 18 18 Query(input): Query<DescribeRepoInput>, 19 19 ) -> Response { 20 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 20 21 let user_row = if input.repo.starts_with("did:") { 21 22 sqlx::query!( 22 23 "SELECT id, handle, did FROM users WHERE did = $1", ··· 26 27 .await 27 28 .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) 28 29 } else { 30 + let handle = if !input.repo.contains('.') { 31 + format!("{}.{}", input.repo, hostname) 32 + } else { 33 + input.repo.clone() 34 + }; 29 35 sqlx::query!( 30 36 "SELECT id, handle, did FROM users WHERE handle = $1", 31 - input.repo 37 + handle 32 38 ) 33 39 .fetch_optional(&state.db) 34 40 .await
+8 -10
src/api/repo/record/read.rs
··· 34 34 .await 35 35 .map(|opt| opt.map(|r| r.id)) 36 36 } else { 37 - let suffix = format!(".{}", hostname); 38 - let short_handle = if input.repo.ends_with(&suffix) { 39 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 37 + let handle = if !input.repo.contains('.') { 38 + format!("{}.{}", input.repo, hostname) 40 39 } else { 41 - &input.repo 40 + input.repo.clone() 42 41 }; 43 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 42 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 44 43 .fetch_optional(&state.db) 45 44 .await 46 45 .map(|opt| opt.map(|r| r.id)) ··· 212 211 .await 213 212 .map(|opt| opt.map(|r| r.id)) 214 213 } else { 215 - let suffix = format!(".{}", hostname); 216 - let short_handle = if input.repo.ends_with(&suffix) { 217 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 214 + let handle = if !input.repo.contains('.') { 215 + format!("{}.{}", input.repo, hostname) 218 216 } else { 219 - &input.repo 217 + input.repo.clone() 220 218 }; 221 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 219 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 222 220 .fetch_optional(&state.db) 223 221 .await 224 222 .map(|opt| opt.map(|r| r.id))
+10 -12
src/api/server/session.rs
··· 29 29 } 30 30 31 31 fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 32 - let suffix = format!(".{}", pds_hostname); 33 - if identifier.ends_with(&suffix) { 34 - identifier[..identifier.len() - suffix.len()].to_string() 32 + let identifier = identifier.trim(); 33 + if identifier.contains('@') || identifier.starts_with("did:") { 34 + identifier.to_string() 35 + } else if !identifier.contains('.') { 36 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 35 37 } else { 36 - identifier.to_string() 38 + identifier.to_lowercase() 37 39 } 38 40 } 39 41 40 - fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { 41 - let suffix = format!(".{}", pds_hostname); 42 - if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) { 43 - stored_handle.to_string() 44 - } else { 45 - format!("{}.{}", stored_handle, pds_hostname) 46 - } 42 + fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 43 + stored_handle.to_string() 47 44 } 48 45 49 46 #[derive(Deserialize)] ··· 66 63 headers: HeaderMap, 67 64 Json(input): Json<CreateSessionInput>, 68 65 ) -> Response { 69 - info!("create_session called"); 66 + info!("create_session called with identifier: {}", input.identifier); 70 67 let client_ip = extract_client_ip(&headers); 71 68 if !state 72 69 .check_rate_limit(RateLimitKind::Login, &client_ip) ··· 84 81 } 85 82 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 86 83 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 84 + info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier); 87 85 let row = match sqlx::query!( 88 86 r#"SELECT 89 87 u.id, u.did, u.handle, u.password_hash,
+100
src/api/validation.rs
··· 4 4 pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; 5 5 const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; 6 6 7 + pub const MIN_HANDLE_LENGTH: usize = 3; 8 + pub const MAX_HANDLE_LENGTH: usize = 253; 9 + 10 + #[derive(Debug, PartialEq)] 11 + pub enum HandleValidationError { 12 + Empty, 13 + TooShort, 14 + TooLong, 15 + InvalidCharacters, 16 + StartsWithInvalidChar, 17 + EndsWithInvalidChar, 18 + ContainsSpaces, 19 + } 20 + 21 + impl std::fmt::Display for HandleValidationError { 22 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 + match self { 24 + Self::Empty => write!(f, "Handle cannot be empty"), 25 + Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH), 26 + Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH), 27 + Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"), 28 + Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"), 29 + Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), 30 + Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 31 + } 32 + } 33 + } 34 + 35 + pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 36 + let handle = handle.trim(); 37 + 38 + if handle.is_empty() { 39 + return Err(HandleValidationError::Empty); 40 + } 41 + 42 + if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 43 + return Err(HandleValidationError::ContainsSpaces); 44 + } 45 + 46 + if handle.len() < MIN_HANDLE_LENGTH { 47 + return Err(HandleValidationError::TooShort); 48 + } 49 + 50 + if handle.len() > MAX_HANDLE_LENGTH { 51 + return Err(HandleValidationError::TooLong); 52 + } 53 + 54 + let first_char = handle.chars().next().unwrap(); 55 + if first_char == '-' || first_char == '_' { 56 + return Err(HandleValidationError::StartsWithInvalidChar); 57 + } 58 + 59 + let last_char = handle.chars().last().unwrap(); 60 + if last_char == '-' || last_char == '_' { 61 + return Err(HandleValidationError::EndsWithInvalidChar); 62 + } 63 + 64 + for c in handle.chars() { 65 + if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { 66 + return Err(HandleValidationError::InvalidCharacters); 67 + } 68 + } 69 + 70 + Ok(handle.to_lowercase()) 71 + } 72 + 7 73 pub fn is_valid_email(email: &str) -> bool { 8 74 let email = email.trim(); 9 75 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { ··· 54 120 #[cfg(test)] 55 121 mod tests { 56 122 use super::*; 123 + 124 + #[test] 125 + fn test_valid_handles() { 126 + assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 127 + assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 128 + assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string())); 129 + assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string())); 130 + assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string())); 131 + assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string())); 132 + assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 133 + } 134 + 135 + #[test] 136 + fn test_invalid_handles() { 137 + assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 138 + assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty)); 139 + assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort)); 140 + assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort)); 141 + assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces)); 142 + assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces)); 143 + assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar)); 144 + assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar)); 145 + assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar)); 146 + assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar)); 147 + assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters)); 148 + assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters)); 149 + assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters)); 150 + } 151 + 152 + #[test] 153 + fn test_handle_trimming() { 154 + assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); 155 + } 156 + 57 157 #[test] 58 158 fn test_valid_emails() { 59 159 assert!(is_valid_email("user@example.com"));
+15 -17
src/oauth/endpoints/authorize.rs
··· 426 426 let normalized_username = normalized_username 427 427 .strip_prefix('@') 428 428 .unwrap_or(normalized_username); 429 - let normalized_username = if let Some(bare_handle) = 430 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 431 - { 432 - bare_handle.to_string() 429 + let normalized_username = if normalized_username.contains('@') { 430 + normalized_username.to_string() 431 + } else if !normalized_username.contains('.') { 432 + format!("{}.{}", normalized_username, pds_hostname) 433 433 } else { 434 434 normalized_username.to_string() 435 435 }; ··· 1585 1585 Query(query): Query<CheckPasskeysQuery>, 1586 1586 ) -> Response { 1587 1587 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1588 - let normalized_identifier = query.identifier.trim(); 1589 - let normalized_identifier = normalized_identifier 1590 - .strip_prefix('@') 1591 - .unwrap_or(normalized_identifier); 1592 - let normalized_identifier = if let Some(bare_handle) = 1593 - normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) 1594 - { 1595 - bare_handle.to_string() 1588 + let identifier = query.identifier.trim(); 1589 + let identifier = identifier.strip_prefix('@').unwrap_or(identifier); 1590 + let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") { 1591 + identifier.to_string() 1592 + } else if !identifier.contains('.') { 1593 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 1596 1594 } else { 1597 - normalized_identifier.to_string() 1595 + identifier.to_lowercase() 1598 1596 }; 1599 1597 1600 1598 let user = sqlx::query!( ··· 1695 1693 let normalized_username = normalized_username 1696 1694 .strip_prefix('@') 1697 1695 .unwrap_or(normalized_username); 1698 - let normalized_username = if let Some(bare_handle) = 1699 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 1700 - { 1701 - bare_handle.to_string() 1696 + let normalized_username = if normalized_username.contains('@') { 1697 + normalized_username.to_string() 1698 + } else if !normalized_username.contains('.') { 1699 + format!("{}.{}", normalized_username, pds_hostname) 1702 1700 } else { 1703 1701 normalized_username.to_string() 1704 1702 };
+30 -29
tests/email_update.rs
··· 17 17 base_url: &str, 18 18 handle: &str, 19 19 email: &str, 20 - ) -> String { 20 + ) -> (String, String) { 21 21 let res = client 22 22 .post(format!( 23 23 "{}/xrpc/com.atproto.server.createAccount", ··· 33 33 .expect("Failed to create account"); 34 34 assert_eq!(res.status(), StatusCode::OK); 35 35 let body: Value = res.json().await.expect("Invalid JSON"); 36 - let did = body["did"].as_str().expect("No did"); 37 - common::verify_new_account(client, did).await 36 + let did = body["did"].as_str().expect("No did").to_string(); 37 + let jwt = common::verify_new_account(client, &did).await; 38 + (jwt, did) 38 39 } 39 40 40 41 #[tokio::test] ··· 44 45 let pool = get_pool().await; 45 46 let handle = format!("emailup_{}", uuid::Uuid::new_v4()); 46 47 let email = format!("{}@example.com", handle); 47 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 48 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 48 49 let new_email = format!("new_{}@example.com", handle); 49 50 let res = client 50 51 .post(format!( ··· 61 62 assert_eq!(body["tokenRequired"], true); 62 63 63 64 let verification = sqlx::query!( 64 - "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 65 - handle 65 + "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 66 + did 66 67 ) 67 68 .fetch_one(&pool) 68 69 .await ··· 84 85 .await 85 86 .expect("Failed to confirm email"); 86 87 assert_eq!(res.status(), StatusCode::OK); 87 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 88 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 88 89 .fetch_one(&pool) 89 90 .await 90 91 .expect("User not found"); 91 92 assert_eq!(user.email, Some(new_email)); 92 93 93 94 let verification = sqlx::query!( 94 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 95 - handle 95 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 96 + did 96 97 ) 97 98 .fetch_optional(&pool) 98 99 .await ··· 106 107 let base_url = common::base_url().await; 107 108 let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); 108 109 let email1 = format!("{}@example.com", handle1); 109 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 110 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 110 111 let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); 111 112 let email2 = format!("{}@example.com", handle2); 112 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 113 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 113 114 let res = client 114 115 .post(format!( 115 116 "{}/xrpc/com.atproto.server.requestEmailUpdate", ··· 131 132 let base_url = common::base_url().await; 132 133 let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); 133 134 let email = format!("{}@example.com", handle); 134 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 135 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 135 136 let new_email = format!("new_{}@example.com", handle); 136 137 let res = client 137 138 .post(format!( ··· 166 167 let pool = get_pool().await; 167 168 let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); 168 169 let email = format!("{}@example.com", handle); 169 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 170 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 170 171 let new_email = format!("new_{}@example.com", handle); 171 172 let res = client 172 173 .post(format!( ··· 180 181 .expect("Failed to request email update"); 181 182 assert_eq!(res.status(), StatusCode::OK); 182 183 let verification = sqlx::query!( 183 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 184 - handle 184 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 185 + did 185 186 ) 186 187 .fetch_one(&pool) 187 188 .await ··· 209 210 let pool = get_pool().await; 210 211 let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); 211 212 let email = format!("{}@example.com", handle); 212 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 213 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 213 214 let new_email = format!("direct_{}@example.com", handle); 214 215 let res = client 215 216 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) ··· 219 220 .await 220 221 .expect("Failed to update email"); 221 222 assert_eq!(res.status(), StatusCode::OK); 222 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 223 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 223 224 .fetch_one(&pool) 224 225 .await 225 226 .expect("User not found"); ··· 232 233 let base_url = common::base_url().await; 233 234 let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); 234 235 let email = format!("{}@example.com", handle); 235 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 236 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 236 237 let res = client 237 238 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 238 239 .bearer_auth(&access_jwt) ··· 253 254 let base_url = common::base_url().await; 254 255 let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); 255 256 let email = format!("{}@example.com", handle); 256 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 257 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 257 258 let new_email = format!("pending_{}@example.com", handle); 258 259 let res = client 259 260 .post(format!( ··· 285 286 let pool = get_pool().await; 286 287 let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); 287 288 let email = format!("{}@example.com", handle); 288 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 289 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 289 290 let new_email = format!("valid_{}@example.com", handle); 290 291 let res = client 291 292 .post(format!( ··· 299 300 .expect("Failed to request email update"); 300 301 assert_eq!(res.status(), StatusCode::OK); 301 302 let verification = sqlx::query!( 302 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 303 - handle 303 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 304 + did 304 305 ) 305 306 .fetch_one(&pool) 306 307 .await ··· 317 318 .await 318 319 .expect("Failed to update email"); 319 320 assert_eq!(res.status(), StatusCode::OK); 320 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 321 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 321 322 .fetch_one(&pool) 322 323 .await 323 324 .expect("User not found"); 324 325 assert_eq!(user.email, Some(new_email)); 325 326 let verification = sqlx::query!( 326 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 327 - handle 327 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 328 + did 328 329 ) 329 330 .fetch_optional(&pool) 330 331 .await ··· 338 339 let base_url = common::base_url().await; 339 340 let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); 340 341 let email = format!("{}@example.com", handle); 341 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 342 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 342 343 let new_email = format!("badtok_{}@example.com", handle); 343 344 let res = client 344 345 .post(format!( ··· 372 373 let base_url = common::base_url().await; 373 374 let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); 374 375 let email1 = format!("{}@example.com", handle1); 375 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 376 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 376 377 let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); 377 378 let email2 = format!("{}@example.com", handle2); 378 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 379 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 379 380 let res = client 380 381 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 381 382 .bearer_auth(&access_jwt2) ··· 412 413 let base_url = common::base_url().await; 413 414 let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); 414 415 let email = format!("{}@example.com", handle); 415 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 416 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 416 417 let res = client 417 418 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 418 419 .bearer_auth(&access_jwt)
+5 -4
tests/identity.rs
··· 8 8 #[tokio::test] 9 9 async fn test_resolve_handle_success() { 10 10 let client = client(); 11 - let handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 11 + let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 12 12 let payload = json!({ 13 - "handle": handle, 14 - "email": format!("{}@example.com", handle), 13 + "handle": short_handle, 14 + "email": format!("{}@example.com", short_handle), 15 15 "password": "password" 16 16 }); 17 17 let res = client ··· 26 26 assert_eq!(res.status(), StatusCode::OK); 27 27 let body: Value = res.json().await.expect("Invalid JSON"); 28 28 let did = body["did"].as_str().expect("No DID").to_string(); 29 - let params = [("handle", handle.as_str())]; 29 + let full_handle = body["handle"].as_str().expect("No handle in response").to_string(); 30 + let params = [("handle", full_handle.as_str())]; 30 31 let res = client 31 32 .get(format!( 32 33 "{}/xrpc/com.atproto.identity.resolveHandle",