this repo has no description

Attempt at better byod did:web registration

lewis d8ac571e 41feec8d

+1
frontend/src/lib/api.ts
··· 266 266 inviteCodeRequired: boolean 267 267 links?: { privacyPolicy?: string; termsOfService?: string } 268 268 version?: string 269 + availableCommsChannels?: string[] 269 270 }> { 270 271 return xrpc('com.atproto.server.describeServer') 271 272 },
+2
frontend/src/locales/en.json
··· 96 96 "signalNumber": "Signal Phone Number", 97 97 "signalNumberPlaceholder": "+1234567890", 98 98 "signalNumberHint": "Include country code (e.g., +1 for US)", 99 + "notConfigured": "not configured", 99 100 "inviteCode": "Invite Code", 100 101 "inviteCodePlaceholder": "Enter your invite code", 101 102 "inviteCodeRequired": "required", ··· 388 389 "telegramVia": "Receive messages via Telegram", 389 390 "signalVia": "Receive messages via Signal", 390 391 "configureToEnable": "Configure below to enable", 392 + "notConfiguredOnServer": "Not configured on this server", 391 393 "emailManagedInSettings": "Your email is managed in Account Settings", 392 394 "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", 393 395 "telegramHint": "Your Telegram username without the @ symbol",
+2
frontend/src/locales/fi.json
··· 96 96 "signalNumber": "Signal-puhelinnumero", 97 97 "signalNumberPlaceholder": "+358401234567", 98 98 "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 99 + "notConfigured": "ei määritetty", 99 100 "inviteCode": "Kutsukoodi", 100 101 "inviteCodePlaceholder": "Syötä kutsukoodisi", 101 102 "inviteCodeRequired": "vaaditaan", ··· 388 389 "telegramVia": "Vastaanota viestejä Telegramissa", 389 390 "signalVia": "Vastaanota viestejä Signalissa", 390 391 "configureToEnable": "Määritä alla ottaaksesi käyttöön", 392 + "notConfiguredOnServer": "Ei määritetty tällä palvelimella", 391 393 "emailManagedInSettings": "Sähköpostisi hallinnoidaan Tilin asetuksissa", 392 394 "discordIdHint": "Discord-käyttäjätunnuksesi (ei käyttäjänimi). Ota Kehittäjätila käyttöön Discordissa kopioidaksesi sen.", 393 395 "telegramHint": "Telegram-käyttäjänimesi ilman @-merkkiä",
+2
frontend/src/locales/ja.json
··· 96 96 "signalNumber": "Signal 電話番号", 97 97 "signalNumberPlaceholder": "+81XXXXXXXXXX", 98 98 "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 99 + "notConfigured": "未設定", 99 100 "inviteCode": "招待コード", 100 101 "inviteCodePlaceholder": "招待コードを入力", 101 102 "inviteCodeRequired": "必須", ··· 388 389 "telegramVia": "Telegram でメッセージを受信", 389 390 "signalVia": "Signal でメッセージを受信", 390 391 "configureToEnable": "有効にするには下記で設定", 392 + "notConfiguredOnServer": "このサーバーでは設定されていません", 391 393 "emailManagedInSettings": "メールはアカウント設定で管理されています", 392 394 "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", 393 395 "telegramHint": "@ 記号なしの Telegram ユーザー名",
+2
frontend/src/locales/ko.json
··· 96 96 "signalNumber": "Signal 전화번호", 97 97 "signalNumberPlaceholder": "+821012345678", 98 98 "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 99 + "notConfigured": "구성되지 않음", 99 100 "inviteCode": "초대 코드", 100 101 "inviteCodePlaceholder": "초대 코드 입력", 101 102 "inviteCodeRequired": "필수", ··· 388 389 "telegramVia": "Telegram으로 메시지 받기", 389 390 "signalVia": "Signal로 메시지 받기", 390 391 "configureToEnable": "활성화하려면 아래에서 설정", 392 + "notConfiguredOnServer": "이 서버에서 설정되지 않음", 391 393 "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", 392 394 "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", 393 395 "telegramHint": "@ 기호 없이 Telegram 사용자 이름",
+2
frontend/src/locales/sv.json
··· 96 96 "signalNumber": "Signal-telefonnummer", 97 97 "signalNumberPlaceholder": "+46701234567", 98 98 "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 99 + "notConfigured": "ej konfigurerad", 99 100 "inviteCode": "Inbjudningskod", 100 101 "inviteCodePlaceholder": "Ange din inbjudningskod", 101 102 "inviteCodeRequired": "krävs", ··· 388 389 "telegramVia": "Ta emot meddelanden via Telegram", 389 390 "signalVia": "Ta emot meddelanden via Signal", 390 391 "configureToEnable": "Konfigurera nedan för att aktivera", 392 + "notConfiguredOnServer": "Inte konfigurerat på denna server", 391 393 "emailManagedInSettings": "Din e-post hanteras i Kontoinställningar", 392 394 "discordIdHint": "Ditt Discord användar-ID (inte användarnamn). Aktivera Utvecklarläge i Discord för att kopiera det.", 393 395 "telegramHint": "Ditt Telegram-användarnamn utan @-symbolen",
+2
frontend/src/locales/zh.json
··· 96 96 "signalNumber": "Signal 电话号码", 97 97 "signalNumberPlaceholder": "+1234567890", 98 98 "signalNumberHint": "包含国家代码(例如中国为 +86)", 99 + "notConfigured": "未配置", 99 100 "inviteCode": "邀请码", 100 101 "inviteCodePlaceholder": "输入您的邀请码", 101 102 "inviteCodeRequired": "必填", ··· 388 389 "telegramVia": "通过 Telegram 接收消息", 389 390 "signalVia": "通过 Signal 接收消息", 390 391 "configureToEnable": "请先在下方配置", 392 + "notConfiguredOnServer": "此服务器未配置", 391 393 "emailManagedInSettings": "邮箱在账户设置中管理", 392 394 "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", 393 395 "telegramHint": "您的 Telegram 用户名,不含 @ 符号",
+47 -12
frontend/src/routes/Comms.svelte
··· 10 10 let error = $state<string | null>(null) 11 11 let success = $state<string | null>(null) 12 12 let preferredChannel = $state('email') 13 + let availableCommsChannels = $state<string[]>(['email']) 13 14 let email = $state('') 14 15 let discordId = $state('') 15 16 let discordVerified = $state(false) ··· 47 48 loading = true 48 49 error = null 49 50 try { 50 - const prefs = await api.getNotificationPrefs(auth.session.accessJwt) 51 + const [prefs, serverInfo] = await Promise.all([ 52 + api.getNotificationPrefs(auth.session.accessJwt), 53 + api.describeServer() 54 + ]) 51 55 preferredChannel = prefs.preferredChannel 52 56 email = prefs.email 53 57 discordId = prefs.discordId ?? '' ··· 56 60 telegramVerified = prefs.telegramVerified 57 61 signalNumber = prefs.signalNumber ?? '' 58 62 signalVerified = prefs.signalVerified 63 + availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 59 64 } catch (e) { 60 65 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' 61 66 } finally { ··· 135 140 default: return '' 136 141 } 137 142 } 143 + function isChannelAvailableOnServer(channelId: string): boolean { 144 + return availableCommsChannels.includes(channelId) 145 + } 138 146 function canSelectChannel(channelId: string): boolean { 147 + if (!isChannelAvailableOnServer(channelId)) return false 139 148 if (channelId === 'email') return true 140 149 if (channelId === 'discord') return !!discordId 141 150 if (channelId === 'telegram') return !!telegramUsername ··· 174 183 </p> 175 184 <div class="channel-options"> 176 185 {#each channels as channelId} 177 - <label class="channel-option" class:disabled={!canSelectChannel(channelId)}> 186 + <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 178 187 <input 179 188 type="radio" 180 189 name="preferredChannel" ··· 185 194 <div class="channel-info"> 186 195 <span class="channel-name">{getChannelName(channelId)}</span> 187 196 <span class="channel-description">{getChannelDescription(channelId)}</span> 188 - {#if channelId !== 'email' && !canSelectChannel(channelId)} 197 + {#if !isChannelAvailableOnServer(channelId)} 198 + <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 199 + {:else if channelId !== 'email' && !canSelectChannel(channelId)} 189 200 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 190 201 {/if} 191 202 </div> ··· 210 221 </div> 211 222 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 212 223 </div> 213 - <div class="config-item"> 224 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 214 225 <label for="discord">{$_('register.discordId')}</label> 215 226 <div class="config-input"> 216 227 <input ··· 218 229 type="text" 219 230 bind:value={discordId} 220 231 placeholder={$_('register.discordIdPlaceholder')} 221 - disabled={saving} 232 + disabled={saving || !isChannelAvailableOnServer('discord')} 222 233 /> 223 - {#if discordId} 234 + {#if !isChannelAvailableOnServer('discord')} 235 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 236 + {:else if discordId} 224 237 {#if discordVerified} 225 238 <span class="status verified">{$_('comms.verified')}</span> 226 239 {:else} ··· 243 256 </div> 244 257 {/if} 245 258 </div> 246 - <div class="config-item"> 259 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 247 260 <label for="telegram">{$_('register.telegramUsername')}</label> 248 261 <div class="config-input"> 249 262 <input ··· 251 264 type="text" 252 265 bind:value={telegramUsername} 253 266 placeholder={$_('register.telegramUsernamePlaceholder')} 254 - disabled={saving} 267 + disabled={saving || !isChannelAvailableOnServer('telegram')} 255 268 /> 256 - {#if telegramUsername} 269 + {#if !isChannelAvailableOnServer('telegram')} 270 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 271 + {:else if telegramUsername} 257 272 {#if telegramVerified} 258 273 <span class="status verified">{$_('comms.verified')}</span> 259 274 {:else} ··· 276 291 </div> 277 292 {/if} 278 293 </div> 279 - <div class="config-item"> 294 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 280 295 <label for="signal">{$_('register.signalNumber')}</label> 281 296 <div class="config-input"> 282 297 <input ··· 284 299 type="tel" 285 300 bind:value={signalNumber} 286 301 placeholder={$_('register.signalNumberPlaceholder')} 287 - disabled={saving} 302 + disabled={saving || !isChannelAvailableOnServer('signal')} 288 303 /> 289 - {#if signalNumber} 304 + {#if !isChannelAvailableOnServer('signal')} 305 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 306 + {:else if signalNumber} 290 307 {#if signalVerified} 291 308 <span class="status verified">{$_('comms.verified')}</span> 292 309 {:else} ··· 439 456 cursor: not-allowed; 440 457 } 441 458 459 + .channel-option.unavailable { 460 + opacity: 0.5; 461 + background: var(--bg-input-disabled); 462 + } 463 + 442 464 .channel-option input[type="radio"] { 443 465 flex-shrink: 0; 444 466 width: 16px; ··· 467 489 font-size: var(--text-xs); 468 490 color: var(--text-muted); 469 491 font-style: italic; 492 + } 493 + 494 + .channel-hint.server-unavailable { 495 + color: var(--warning-text); 470 496 } 471 497 472 498 .channel-config { ··· 481 507 gap: var(--space-1); 482 508 } 483 509 510 + .config-item.unavailable { 511 + opacity: 0.6; 512 + } 513 + 484 514 .config-item label { 485 515 font-size: var(--text-sm); 486 516 font-weight: var(--font-medium); ··· 516 546 .status.unverified { 517 547 background: var(--warning-bg); 518 548 color: var(--warning-text); 549 + } 550 + 551 + .status.unavailable { 552 + background: var(--bg-input-disabled); 553 + color: var(--text-muted); 519 554 } 520 555 521 556 .config-hint {
+3 -3
frontend/src/routes/Home.svelte
··· 153 153 154 154 <h2>Works with everything</h2> 155 155 156 - <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients, tools, and bots just work.</p> 156 + <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p> 157 157 158 158 <h2>Ready to try it?</h2> 159 159 ··· 170 170 </section> 171 171 172 172 <footer class="site-footer"> 173 - <span>Open Source</span> 174 - <span>Made with patience</span> 173 + <span>Made by people who don't take themselves too seriously</span> 174 + <span>Open Source: issues & PRs welcome</span> 175 175 </footer> 176 176 </div> 177 177
+1 -1
frontend/src/routes/Login.svelte
··· 144 144 </button> 145 145 146 146 <p class="link-text"> 147 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAcount')}</a> 147 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 148 148 </p> 149 149 150 150 {:else}
+15 -3
frontend/src/routes/Register.svelte
··· 22 22 let serverInfo = $state<{ 23 23 availableUserDomains: string[] 24 24 inviteCodeRequired: boolean 25 + availableCommsChannels?: string[] 25 26 } | null>(null) 26 27 let loadingServerInfo = $state(true) 27 28 let serverInfoLoaded = false ··· 46 47 } 47 48 48 49 let handleHasDot = $derived(handle.includes('.')) 50 + 51 + function isChannelAvailable(channel: string): boolean { 52 + const available = serverInfo?.availableCommsChannels ?? ['email'] 53 + return available.includes(channel) 54 + } 49 55 50 56 function validateForm(): string | null { 51 57 if (!handle.trim()) return $_('register.validation.handleRequired') ··· 262 268 <label for="verification-channel">{$_('register.verificationMethod')}</label> 263 269 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 264 270 <option value="email">{$_('register.email')}</option> 265 - <option value="discord">{$_('register.discord')}</option> 266 - <option value="telegram">{$_('register.telegram')}</option> 267 - <option value="signal">{$_('register.signal')}</option> 271 + <option value="discord" disabled={!isChannelAvailable('discord')}> 272 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 273 + </option> 274 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 275 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 276 + </option> 277 + <option value="signal" disabled={!isChannelAvailable('signal')}> 278 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 279 + </option> 268 280 </select> 269 281 </div> 270 282
+15 -4
frontend/src/routes/RegisterPasskey.svelte
··· 19 19 let passkeyName = $state('') 20 20 let submitting = $state(false) 21 21 let error = $state<string | null>(null) 22 - let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) 22 + let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null) 23 23 let loadingServerInfo = $state(true) 24 24 let serverInfoLoaded = false 25 25 ··· 289 289 } 290 290 } 291 291 292 + function isChannelAvailable(ch: string): boolean { 293 + const available = serverInfo?.availableCommsChannels ?? ['email'] 294 + return available.includes(ch) 295 + } 296 + 292 297 function goToLogin() { 293 298 navigate('/login') 294 299 } ··· 363 368 <label for="verification-channel">Verification Method</label> 364 369 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 365 370 <option value="email">Email</option> 366 - <option value="discord">Discord</option> 367 - <option value="telegram">Telegram</option> 368 - <option value="signal">Signal</option> 371 + <option value="discord" disabled={!isChannelAvailable('discord')}> 372 + Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 373 + </option> 374 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 375 + Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 376 + </option> 377 + <option value="signal" disabled={!isChannelAvailable('signal')}> 378 + Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 379 + </option> 369 380 </select> 370 381 </div> 371 382 {#if verificationChannel === 'email'}
+34 -19
src/api/identity/account.rs
··· 122 122 && input 123 123 .did 124 124 .as_ref() 125 - .map(|d| d.starts_with("did:plc:")) 125 + .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) 126 + .unwrap_or(false); 127 + 128 + let is_did_web_byod = migration_auth.is_some() 129 + && input 130 + .did 131 + .as_ref() 132 + .map(|d| d.starts_with("did:web:")) 126 133 .unwrap_or(false); 127 134 128 135 if is_migration { ··· 138 145 ) 139 146 .into_response(); 140 147 } 141 - info!(did = %migration_did, "Processing account migration"); 148 + if is_did_web_byod { 149 + info!(did = %migration_did, "Processing did:web BYOD account creation"); 150 + } else { 151 + info!(did = %migration_did, "Processing account migration"); 152 + } 142 153 } 143 154 } 144 155 ··· 337 348 ) 338 349 .into_response(); 339 350 } 340 - if let Err(e) = 341 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 342 - { 343 - return ( 344 - StatusCode::BAD_REQUEST, 345 - Json(json!({"error": "InvalidDid", "message": e})), 346 - ) 347 - .into_response(); 351 + if !is_did_web_byod { 352 + if let Err(e) = 353 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 354 + { 355 + return ( 356 + StatusCode::BAD_REQUEST, 357 + Json(json!({"error": "InvalidDid", "message": e})), 358 + ) 359 + .into_response(); 360 + } 348 361 } 349 362 info!(did = %d, "Creating external did:web account"); 350 363 d.clone() ··· 355 368 info!(did = %d, "Migration with existing did:plc"); 356 369 d.clone() 357 370 } else if d.starts_with("did:web:") { 358 - if let Err(e) = 359 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 360 - .await 361 - { 362 - return ( 363 - StatusCode::BAD_REQUEST, 364 - Json(json!({"error": "InvalidDid", "message": e})), 365 - ) 366 - .into_response(); 371 + if !is_did_web_byod { 372 + if let Err(e) = 373 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 374 + .await 375 + { 376 + return ( 377 + StatusCode::BAD_REQUEST, 378 + Json(json!({"error": "InvalidDid", "message": e})), 379 + ) 380 + .into_response(); 381 + } 367 382 } 368 383 d.clone() 369 384 } else if !d.trim().is_empty() {
+17 -1
src/api/server/meta.rs
··· 2 2 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 3 3 use serde_json::json; 4 4 use tracing::error; 5 + 6 + fn get_available_comms_channels() -> Vec<&'static str> { 7 + let mut channels = vec!["email"]; 8 + if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { 9 + channels.push("discord"); 10 + } 11 + if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { 12 + channels.push("telegram"); 13 + } 14 + if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { 15 + channels.push("signal"); 16 + } 17 + channels 18 + } 19 + 5 20 pub async fn robots_txt() -> impl IntoResponse { 6 21 ( 7 22 StatusCode::OK, ··· 21 36 "availableUserDomains": domains, 22 37 "inviteCodeRequired": invite_code_required, 23 38 "did": format!("did:web:{}", pds_hostname), 24 - "version": env!("CARGO_PKG_VERSION") 39 + "version": env!("CARGO_PKG_VERSION"), 40 + "availableCommsChannels": get_available_comms_channels() 25 41 })) 26 42 } 27 43 pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+9 -8
src/auth/service.rs
··· 229 229 .strip_prefix("did:web:") 230 230 .ok_or_else(|| anyhow!("Invalid did:web format"))?; 231 231 232 - let decoded_host = host.replace("%3A", ":"); 233 - let (host_part, path_part) = if let Some(idx) = decoded_host.find('/') { 234 - (&decoded_host[..idx], &decoded_host[idx..]) 235 - } else { 236 - (decoded_host.as_str(), "") 237 - }; 232 + let parts: Vec<&str> = host.split(':').collect(); 233 + if parts.is_empty() { 234 + return Err(anyhow!("Invalid did:web format - no host")); 235 + } 236 + 237 + let host_part = parts[0].replace("%3A", ":"); 238 238 239 239 let scheme = if host_part.starts_with("localhost") 240 240 || host_part.starts_with("127.0.0.1") ··· 245 245 "https" 246 246 }; 247 247 248 - let url = if path_part.is_empty() { 248 + let url = if parts.len() == 1 { 249 249 format!("{}://{}/.well-known/did.json", scheme, host_part) 250 250 } else { 251 - format!("{}://{}{}/did.json", scheme, host_part, path_part) 251 + let path = parts[1..].join("/"); 252 + format!("{}://{}/{}/did.json", scheme, host_part, path) 252 253 }; 253 254 254 255 debug!("Resolving did:web {} via {}", did, url);
+193
tests/did_web.rs
··· 1 1 mod common; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use base64::Engine; 2 4 use common::*; 5 + use k256::ecdsa::{SigningKey, signature::Signer}; 3 6 use reqwest::StatusCode; 4 7 use serde_json::{Value, json}; 5 8 use wiremock::matchers::{method, path}; ··· 348 351 body 349 352 ); 350 353 } 354 + 355 + fn signing_key_to_multibase(signing_key: &SigningKey) -> String { 356 + let verifying_key = signing_key.verifying_key(); 357 + let compressed = verifying_key.to_sec1_bytes(); 358 + let mut multicodec = vec![0xe7, 0x01]; 359 + multicodec.extend_from_slice(&compressed); 360 + multibase::encode(multibase::Base::Base58Btc, &multicodec) 361 + } 362 + 363 + fn create_service_jwt(signing_key: &SigningKey, did: &str, aud: &str) -> String { 364 + let header = json!({"alg": "ES256K", "typ": "jwt"}); 365 + let now = chrono::Utc::now().timestamp() as usize; 366 + let claims = json!({ 367 + "iss": did, 368 + "sub": did, 369 + "aud": aud, 370 + "exp": now + 300, 371 + "iat": now, 372 + "lxm": "com.atproto.server.createAccount", 373 + "jti": uuid::Uuid::new_v4().to_string() 374 + }); 375 + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); 376 + let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string()); 377 + let message = format!("{}.{}", header_b64, claims_b64); 378 + let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes()); 379 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 380 + format!("{}.{}", message, sig_b64) 381 + } 382 + 383 + #[tokio::test] 384 + async fn test_did_web_byod_flow() { 385 + let client = client(); 386 + let mock_server = MockServer::start().await; 387 + let mock_uri = mock_server.uri(); 388 + let mock_addr = mock_uri.trim_start_matches("http://"); 389 + let unique_id = uuid::Uuid::new_v4().to_string().replace("-", ""); 390 + let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id); 391 + let handle = format!("byod_{}", uuid::Uuid::new_v4()); 392 + let pds_endpoint = base_url().await.replace("http://", "https://"); 393 + let pds_did = format!( 394 + "did:web:{}", 395 + pds_endpoint.trim_start_matches("https://") 396 + ); 397 + 398 + let temp_key = SigningKey::random(&mut rand::thread_rng()); 399 + let public_key_multibase = signing_key_to_multibase(&temp_key); 400 + 401 + let did_doc = json!({ 402 + "@context": ["https://www.w3.org/ns/did/v1"], 403 + "id": did, 404 + "verificationMethod": [{ 405 + "id": format!("{}#atproto", did), 406 + "type": "Multikey", 407 + "controller": did, 408 + "publicKeyMultibase": public_key_multibase 409 + }], 410 + "service": [{ 411 + "id": "#atproto_pds", 412 + "type": "AtprotoPersonalDataServer", 413 + "serviceEndpoint": pds_endpoint 414 + }] 415 + }); 416 + Mock::given(method("GET")) 417 + .and(path(format!("/byod/{}/did.json", unique_id))) 418 + .respond_with(ResponseTemplate::new(200).set_body_json(&did_doc)) 419 + .mount(&mock_server) 420 + .await; 421 + 422 + let service_jwt = create_service_jwt(&temp_key, &did, &pds_did); 423 + let payload = json!({ 424 + "handle": handle, 425 + "email": format!("{}@example.com", handle), 426 + "password": "Testpass123!", 427 + "did": did 428 + }); 429 + let res = client 430 + .post(format!( 431 + "{}/xrpc/com.atproto.server.createAccount", 432 + base_url().await 433 + )) 434 + .header("Authorization", format!("Bearer {}", service_jwt)) 435 + .json(&payload) 436 + .send() 437 + .await 438 + .expect("Failed to send request"); 439 + if res.status() != StatusCode::OK { 440 + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); 441 + panic!("createAccount BYOD failed: {:?}", body); 442 + } 443 + let body: Value = res.json().await.expect("Response was not JSON"); 444 + let returned_did = body["did"].as_str().expect("No DID in response"); 445 + assert_eq!(returned_did, did, "Returned DID should match requested DID"); 446 + let access_jwt = body["accessJwt"] 447 + .as_str() 448 + .expect("No accessJwt in response"); 449 + 450 + let res = client 451 + .get(format!( 452 + "{}/xrpc/com.atproto.server.checkAccountStatus", 453 + base_url().await 454 + )) 455 + .bearer_auth(access_jwt) 456 + .send() 457 + .await 458 + .expect("Failed to check account status"); 459 + assert_eq!(res.status(), StatusCode::OK); 460 + let status: Value = res.json().await.expect("Response was not JSON"); 461 + assert_eq!( 462 + status["activated"], false, 463 + "BYOD account should be deactivated initially" 464 + ); 465 + 466 + let res = client 467 + .get(format!( 468 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 469 + base_url().await 470 + )) 471 + .bearer_auth(access_jwt) 472 + .send() 473 + .await 474 + .expect("Failed to get recommended credentials"); 475 + assert_eq!(res.status(), StatusCode::OK); 476 + let creds: Value = res.json().await.expect("Response was not JSON"); 477 + assert!( 478 + creds["verificationMethods"]["atproto"].is_string(), 479 + "Should return PDS signing key" 480 + ); 481 + let pds_signing_key = creds["verificationMethods"]["atproto"] 482 + .as_str() 483 + .expect("No atproto verification method"); 484 + assert!( 485 + pds_signing_key.starts_with("did:key:"), 486 + "PDS signing key should be did:key format" 487 + ); 488 + 489 + let res = client 490 + .post(format!( 491 + "{}/xrpc/com.atproto.server.activateAccount", 492 + base_url().await 493 + )) 494 + .bearer_auth(access_jwt) 495 + .send() 496 + .await 497 + .expect("Failed to activate account"); 498 + assert_eq!( 499 + res.status(), 500 + StatusCode::OK, 501 + "activateAccount should succeed" 502 + ); 503 + 504 + let res = client 505 + .get(format!( 506 + "{}/xrpc/com.atproto.server.checkAccountStatus", 507 + base_url().await 508 + )) 509 + .bearer_auth(access_jwt) 510 + .send() 511 + .await 512 + .expect("Failed to check account status"); 513 + assert_eq!(res.status(), StatusCode::OK); 514 + let status: Value = res.json().await.expect("Response was not JSON"); 515 + assert_eq!( 516 + status["activated"], true, 517 + "Account should be activated after activateAccount call" 518 + ); 519 + 520 + let res = client 521 + .post(format!( 522 + "{}/xrpc/com.atproto.repo.createRecord", 523 + base_url().await 524 + )) 525 + .bearer_auth(access_jwt) 526 + .json(&json!({ 527 + "repo": did, 528 + "collection": "app.bsky.feed.post", 529 + "record": { 530 + "$type": "app.bsky.feed.post", 531 + "text": "Hello from BYOD did:web!", 532 + "createdAt": chrono::Utc::now().to_rfc3339() 533 + } 534 + })) 535 + .send() 536 + .await 537 + .expect("Failed to create post"); 538 + assert_eq!( 539 + res.status(), 540 + StatusCode::OK, 541 + "Activated BYOD account should be able to create records" 542 + ); 543 + }