Security improvements

This commit is contained in:
lewis
2025-12-22 21:09:09 +02:00
parent fa6c4cc177
commit d80247ba43
61 changed files with 1470 additions and 298 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": [
false
]
},
"hash": "14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09"
}
@@ -39,7 +39,8 @@
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery"
"passkey_recovery",
"legacy_login_alert"
]
}
}
@@ -47,7 +47,8 @@
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery"
"passkey_recovery",
"legacy_login_alert"
]
}
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e"
}
@@ -39,7 +39,8 @@
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery"
"passkey_recovery",
"legacy_login_alert"
]
}
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.allow_legacy_login,\n (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR\n EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as \"has_mfa!\"\n FROM users u WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "allow_legacy_login",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "has_mfa!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null
]
},
"hash": "5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "legacy_login",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "mfa_verified",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "last_reauth_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
"query": "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
"hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979"
"hash": "e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f"
}
@@ -42,7 +42,8 @@
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery"
"passkey_recovery",
"legacy_login_alert"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
"describe": {
"columns": [
{
@@ -45,13 +45,40 @@
},
{
"ordinal": 8,
"name": "allow_legacy_login",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "preferred_comms_channel: crate::comms::CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 10,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 9,
"ordinal": 11,
"name": "encryption_version",
"type_info": "Int4"
},
{
"ordinal": 12,
"name": "totp_enabled",
"type_info": "Bool"
}
],
"parameters": {
@@ -69,8 +96,11 @@
false,
false,
false,
true
false,
false,
true,
null
]
},
"hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590"
"hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38"
}
+2 -15
View File
@@ -9,21 +9,6 @@ So like... make the thing unique, make it cool.
- [ ] Unique "brand" style both unauthed and authed
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
### Passkeys and 2FA
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
- [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
- [x] user_totp table (did, secret_encrypted, verified, created_at, last_used)
- [x] WebAuthn registration challenge generation and attestation verification
- [x] TOTP secret generation with QR code setup flow
- [x] Backup codes (hashed, one-time use) with recovery flow
- [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
- [ ] Passkey-only account creation (no password)
- [x] Settings UI for managing passkeys, TOTP, backup codes
- [ ] Trusted devices option (remember this browser)
- [x] Rate limit 2FA attempts
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
### Delegated accounts
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
@@ -103,3 +88,5 @@ Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic
Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection.
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
@@ -29,6 +29,9 @@
activeMethod = 'totp'
} else if (availableMethods.includes('passkey')) {
activeMethod = 'passkey'
if (availableMethods.length === 1) {
handlePasskeyAuth()
}
}
}
})
+22 -1
View File
@@ -37,7 +37,7 @@ async function xrpc<T>(method: string, options?: {
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods)
}
return res.json()
}
@@ -331,9 +331,23 @@ export const api = {
return xrpc('com.tranquil.account.getPasswordStatus', { token })
},
async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
return xrpc('com.tranquil.account.getLegacyLoginPreference', { token })
},
async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> {
return xrpc('com.tranquil.account.updateLegacyLoginPreference', {
method: 'POST',
token,
body: { allowLegacyLogin },
})
},
async listSessions(token: string): Promise<{
sessions: Array<{
id: string
sessionType: string
clientName: string | null
createdAt: string
expiresAt: string
isCurrent: boolean
@@ -350,6 +364,13 @@ export const api = {
})
},
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
return xrpc('com.tranquil.account.revokeAllSessions', {
method: 'POST',
token,
})
},
async searchAccounts(token: string, options?: {
handle?: string
cursor?: string
-6
View File
@@ -27,12 +27,6 @@
const auth = getAuthState()
$effect(() => {
if (auth.session) {
navigate('/dashboard')
}
})
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
@@ -30,12 +30,6 @@
let resendingCode = $state(false)
let resendMessage = $state<string | null>(null)
$effect(() => {
if (auth.session) {
navigate('/dashboard')
}
})
$effect(() => {
if (!serverInfoLoaded) {
serverInfoLoaded = true
+219 -3
View File
@@ -44,6 +44,11 @@
let showRemovePasswordForm = $state(false)
let removePasswordLoading = $state(false)
let allowLegacyLogin = $state(true)
let hasMfa = $state(false)
let legacyLoginLoading = $state(true)
let legacyLoginUpdating = $state(false)
let showReauthModal = $state(false)
let reauthMethods = $state<string[]>(['password'])
let pendingAction = $state<(() => Promise<void>) | null>(null)
@@ -59,6 +64,7 @@
loadTotpStatus()
loadPasskeys()
loadPasswordStatus()
loadLegacyLoginPreference()
}
})
@@ -75,6 +81,47 @@
}
}
async function loadLegacyLoginPreference() {
if (!auth.session) return
legacyLoginLoading = true
try {
const pref = await api.getLegacyLoginPreference(auth.session.accessJwt)
allowLegacyLogin = pref.allowLegacyLogin
hasMfa = pref.hasMfa
} catch {
allowLegacyLogin = true
hasMfa = false
} finally {
legacyLoginLoading = false
}
}
async function handleToggleLegacyLogin() {
if (!auth.session) return
legacyLoginUpdating = true
try {
const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin)
allowLegacyLogin = result.allowLegacyLogin
showMessage('success', allowLegacyLogin
? 'Legacy app login enabled'
: 'Legacy app login disabled - only OAuth apps can sign in')
} catch (e) {
if (e instanceof ApiError) {
if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') {
reauthMethods = e.reauthMethods || ['password']
pendingAction = handleToggleLegacyLogin
showReauthModal = true
} else {
showMessage('error', e.message)
}
} else {
showMessage('error', 'Failed to update preference')
}
} finally {
legacyLoginUpdating = false
}
}
async function handleRemovePassword() {
if (!auth.session) return
removePasswordLoading = true
@@ -572,9 +619,11 @@
<button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
Rename
</button>
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
Delete
</button>
{#if hasPassword || passkeys.length > 1}
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
Delete
</button>
{/if}
</div>
{/if}
</div>
@@ -670,6 +719,63 @@
Manage Trusted Devices &rarr;
</a>
</section>
{#if hasMfa}
<section>
<h2>App Compatibility</h2>
<p class="description">
Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account.
</p>
{#if legacyLoginLoading}
<div class="loading">Loading...</div>
{:else}
<div class="toggle-row">
<div class="toggle-info">
<span class="toggle-label">Allow legacy app login</span>
<span class="toggle-description">
{#if allowLegacyLogin}
Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification.
{:else}
Only OAuth-compatible apps can sign in. Legacy apps will be blocked.
{/if}
</span>
</div>
<button
type="button"
class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
onclick={handleToggleLegacyLogin}
disabled={legacyLoginUpdating}
>
<span class="toggle-slider"></span>
</button>
</div>
{#if totpEnabled}
<div class="warning-box">
<strong>Important: Password changes in Bluesky app will fail</strong>
<p>
With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked.
To change your password, you have two options:
</p>
<ol>
<li><strong>Change it here:</strong> Use this website's <a href="#/settings">Settings page</a> where you can verify with your authenticator app.</li>
<li><strong>Verify your session first:</strong> Use the <a href="#/settings">re-authenticate option</a> to verify your Bluesky session with TOTP, then password changes will work temporarily.</li>
</ol>
</div>
{/if}
<div class="info-box-inline">
<strong>What are legacy apps?</strong>
<p>
Some apps (like the official Bluesky app) use older authentication that only requires your password.
When you have MFA enabled, these apps bypass your second factor.
Disabling legacy login forces all apps to use OAuth, which properly enforces MFA.
</p>
</div>
{/if}
</section>
{/if}
{/if}
</div>
@@ -1076,4 +1182,114 @@
.info-box-inline li {
margin-bottom: 0.25rem;
}
.info-box-inline p {
margin: 0;
color: var(--text-secondary);
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color-light);
border-radius: 6px;
margin-bottom: 1rem;
}
.toggle-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toggle-label {
font-weight: 500;
}
.toggle-description {
font-size: 0.875rem;
color: var(--text-secondary);
}
.toggle-button {
position: relative;
width: 50px;
height: 26px;
padding: 0;
border: none;
border-radius: 13px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-button.on {
background: var(--success-text);
}
.toggle-button.off {
background: var(--text-secondary);
}
.toggle-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-slider {
position: absolute;
top: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: left 0.2s;
}
.toggle-button.on .toggle-slider {
left: 27px;
}
.toggle-button.off .toggle-slider {
left: 3px;
}
.warning-box {
background: var(--warning-bg);
border: 1px solid var(--warning-border, var(--border-color));
border-left: 4px solid var(--warning-text);
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.warning-box strong {
display: block;
margin-bottom: 0.5rem;
color: var(--warning-text);
}
.warning-box p {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
color: var(--text-primary);
}
.warning-box ol {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
}
.warning-box li {
margin-bottom: 0.5rem;
}
.warning-box a {
color: var(--accent);
}
</style>
+63 -7
View File
@@ -7,6 +7,8 @@
let error = $state<string | null>(null)
let sessions = $state<Array<{
id: string
sessionType: string
clientName: string | null
createdAt: string
expiresAt: string
isCurrent: boolean
@@ -51,6 +53,21 @@
error = e instanceof ApiError ? e.message : 'Failed to revoke session'
}
}
async function revokeAllSessions() {
if (!auth.session) return
const otherCount = sessions.filter(s => !s.isCurrent).length
if (otherCount === 0) {
error = 'No other sessions to revoke'
return
}
if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return
try {
await api.revokeAllSessions(auth.session.accessJwt)
sessions = sessions.filter(s => s.isCurrent)
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to revoke sessions'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
@@ -87,9 +104,13 @@
<div class="session-info">
<div class="session-header">
{#if session.isCurrent}
<span class="badge current">Current Session</span>
{:else}
<span class="session-label">Session</span>
<span class="badge current">Current</span>
{/if}
<span class="badge type" class:oauth={session.sessionType === 'oauth'}>
{session.sessionType === 'oauth' ? 'OAuth' : 'Session'}
</span>
{#if session.clientName}
<span class="client-name">{session.clientName}</span>
{/if}
</div>
<div class="session-details">
@@ -115,7 +136,12 @@
</div>
{/each}
</div>
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
<div class="actions-bar">
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
{#if sessions.filter(s => !s.isCurrent).length > 0}
<button class="revoke-all-btn" onclick={revokeAllSessions}>Revoke All Other Sessions</button>
{/if}
</div>
{/if}
{/if}
</div>
@@ -174,10 +200,14 @@
}
.session-header {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.session-label {
.client-name {
font-weight: 500;
color: var(--text-secondary);
color: var(--text-primary);
}
.badge {
display: inline-block;
@@ -190,6 +220,16 @@
background: var(--accent);
color: white;
}
.badge.type {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.badge.type.oauth {
background: #e6f4ea;
color: #1e7e34;
border-color: #b8d9c5;
}
.session-details {
display: flex;
flex-direction: column;
@@ -224,8 +264,13 @@
.revoke-btn.danger:hover {
background: var(--error-bg);
}
.refresh-btn {
.actions-bar {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.refresh-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border-color);
@@ -237,4 +282,15 @@
background: var(--bg-card);
border-color: var(--accent);
}
.revoke-all-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--error-text);
border-radius: 4px;
cursor: pointer;
color: var(--error-text);
}
.revoke-all-btn:hover {
background: var(--error-bg);
}
</style>
@@ -0,0 +1,8 @@
ALTER TABLE users ADD COLUMN allow_legacy_login BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE session_tokens ADD COLUMN mfa_verified BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE session_tokens ADD COLUMN legacy_login BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX idx_session_tokens_legacy ON session_tokens(did, legacy_login) WHERE legacy_login = TRUE;
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'legacy_login_alert';
+35 -32
View File
@@ -4,10 +4,11 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Serialize;
use std::borrow::Cow;
#[derive(Debug, Serialize)]
struct ErrorBody {
error: &'static str,
struct ErrorBody<'a> {
error: Cow<'a, str>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
@@ -90,41 +91,43 @@ impl ApiError {
| Self::InvalidSwap => StatusCode::BAD_REQUEST,
}
}
fn error_name(&self) -> &'static str {
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError | Self::DatabaseError => "InternalError",
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => "UpstreamFailure",
Self::UpstreamTimeout => "UpstreamTimeout",
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
Self::UpstreamError { error, .. } => {
if let Some(e) = error {
return Box::leak(e.clone().into_boxed_str());
return Cow::Owned(e.clone());
}
"UpstreamError"
Cow::Borrowed("UpstreamError")
}
Self::AuthenticationRequired => "AuthenticationRequired",
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed",
Self::InvalidToken => "InvalidToken",
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken",
Self::TokenRequired => "TokenRequired",
Self::AccountDeactivated => "AccountDeactivated",
Self::AccountTakedown => "AccountTakedown",
Self::Forbidden => "Forbidden",
Self::InvitesDisabled => "InvitesDisabled",
Self::AccountNotFound => "AccountNotFound",
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound",
Self::RecordNotFound => "RecordNotFound",
Self::BlobNotFound => "BlobNotFound",
Self::AppPasswordNotFound => "AppPasswordNotFound",
Self::InvalidRequest(_) => "InvalidRequest",
Self::InvalidHandle => "InvalidHandle",
Self::HandleNotAvailable => "HandleNotAvailable",
Self::HandleTaken => "HandleTaken",
Self::InvalidEmail => "InvalidEmail",
Self::EmailTaken => "EmailTaken",
Self::InvalidInviteCode => "InvalidInviteCode",
Self::DuplicateCreate => "DuplicateCreate",
Self::DuplicateAppPassword => "DuplicateAppPassword",
Self::InvalidSwap => "InvalidSwap",
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => {
Cow::Borrowed("AuthenticationFailed")
}
Self::InvalidToken => Cow::Borrowed("InvalidToken"),
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"),
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
Self::Forbidden => Cow::Borrowed("Forbidden"),
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
Self::RecordNotFound => Cow::Borrowed("RecordNotFound"),
Self::BlobNotFound => Cow::Borrowed("BlobNotFound"),
Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"),
Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"),
Self::InvalidHandle => Cow::Borrowed("InvalidHandle"),
Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"),
Self::HandleTaken => Cow::Borrowed("HandleTaken"),
Self::InvalidEmail => Cow::Borrowed("InvalidEmail"),
Self::EmailTaken => Cow::Borrowed("EmailTaken"),
Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"),
Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"),
Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"),
Self::InvalidSwap => Cow::Borrowed("InvalidSwap"),
}
}
fn message(&self) -> Option<String> {
+25 -12
View File
@@ -2,6 +2,7 @@ use super::did::verify_did_web;
use crate::auth::{ServiceTokenVerifier, extract_bearer_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::validation::validate_password;
use axum::{
Json,
extract::State,
@@ -124,19 +125,20 @@ pub async fn create_account(
.unwrap_or(false);
if is_migration {
let migration_did = input.did.as_ref().unwrap();
let auth_did = migration_auth.as_ref().unwrap();
if migration_did != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
})),
)
.into_response();
if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
{
if migration_did != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
})),
)
.into_response();
}
info!(did = %migration_did, "Processing account migration");
}
info!(did = %migration_did, "Processing account migration");
}
let hostname_for_validation =
@@ -670,6 +672,17 @@ pub async fn create_account(
}
}
}
if let Err(e) = validate_password(&input.password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
}
let password_hash = match hash(&input.password, DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
+8 -2
View File
@@ -304,7 +304,7 @@ pub async fn request_account_delete(
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
let validated = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
extracted.is_dpop,
@@ -315,9 +315,15 @@ pub async fn request_account_delete(
)
.await
{
Ok(user) => user.did,
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = validated.did.clone();
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await;
}
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
+6 -4
View File
@@ -33,13 +33,15 @@ pub use password::{
change_password, get_password_status, remove_password, request_password_reset, reset_password,
};
pub use reauth::{
check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start,
reauth_password, reauth_required_response, reauth_totp,
check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response,
reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response,
reauth_totp, update_mfa_verified,
};
pub use service_auth::get_service_auth;
pub use session::{
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
resend_verification, revoke_session,
confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session,
list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session,
update_legacy_login_preference,
};
pub use signing_key::reserve_signing_key;
pub use totp::{
+6 -2
View File
@@ -16,6 +16,7 @@ use tracing::{error, info, warn};
use uuid::Uuid;
use crate::state::{AppState, RateLimitKind};
use crate::validation::validate_password;
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
@@ -1108,10 +1109,13 @@ pub async fn recover_passkey_account(
State(state): State<AppState>,
Json(input): Json<RecoverPasskeyAccountInput>,
) -> Response {
if input.new_password.len() < 8 {
if let Err(e) = validate_password(&input.new_password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})),
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
}
+9
View File
@@ -294,6 +294,15 @@ pub async fn delete_passkey(
auth: BearerAuth,
Json(input): Json<DeletePasskeyInput>,
) -> Response {
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
.await;
}
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
let id: uuid::Uuid = match input.id.parse() {
Ok(id) => id,
Err(_) => {
+26 -2
View File
@@ -1,5 +1,6 @@
use crate::auth::BearerAuth;
use crate::state::{AppState, RateLimitKind};
use crate::validation::validate_password;
use axum::{
Json,
extract::State,
@@ -164,6 +165,16 @@ pub async fn reset_password(
)
.into_response();
}
if let Err(e) = validate_password(password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
}
let user = sqlx::query!(
"SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
token
@@ -326,6 +337,11 @@ pub async fn change_password(
auth: BearerAuth,
Json(input): Json<ChangePasswordInput>,
) -> Response {
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
.await;
}
let current_password = &input.current_password;
let new_password = &input.new_password;
if current_password.is_empty() {
@@ -342,10 +358,13 @@ pub async fn change_password(
)
.into_response();
}
if new_password.len() < 8 {
if let Err(e) = validate_password(new_password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})),
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
}
@@ -447,6 +466,11 @@ pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth
}
pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
.await;
}
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
+85 -18
View File
@@ -11,7 +11,7 @@ use sqlx::PgPool;
use tracing::{error, info, warn};
use crate::auth::BearerAuth;
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
const REAUTH_WINDOW_SECONDS: i64 = 300;
@@ -155,6 +155,21 @@ pub async fn reauth_totp(
auth: BearerAuth,
Json(input): Json<TotpReauthInput>,
) -> Response {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
}
let valid =
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
.await;
@@ -352,14 +367,29 @@ pub async fn reauth_passkey_finish(
};
let cred_id_bytes = auth_result.cred_id().as_ref();
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
match crate::auth::webauthn::update_passkey_counter(
&state.db,
cred_id_bytes,
auth_result.counter(),
)
.await
{
error!("Failed to update passkey counter: {:?}", e);
Ok(false) => {
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "PasskeyCounterAnomaly",
"message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
})),
)
.into_response();
}
Err(e) => {
error!("Failed to update passkey counter: {:?}", e);
}
Ok(true) => {}
}
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
@@ -383,7 +413,7 @@ pub async fn reauth_passkey_finish(
async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> {
let now = Utc::now();
sqlx::query!(
"UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
"UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
now,
did
)
@@ -419,20 +449,6 @@ async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
methods.push("password".to_string());
}
let has_app_password = sqlx::query_scalar!(
"SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
.is_some();
if has_app_password && !methods.contains(&"password".to_string()) {
methods.push("password".to_string());
}
let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
if has_totp {
methods.push("totp".to_string());
@@ -480,3 +496,54 @@ pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
)
.into_response()
}
pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool {
let session = sqlx::query!(
"SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
did
)
.fetch_optional(db)
.await;
match session {
Ok(Some(row)) => {
if !row.legacy_login {
return true;
}
if row.mfa_verified {
return true;
}
if let Some(last_reauth) = row.last_reauth_at {
let elapsed = chrono::Utc::now().signed_duration_since(last_reauth);
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
return true;
}
}
false
}
_ => true,
}
}
pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
did
)
.execute(db)
.await?;
Ok(())
}
pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
let methods = get_available_reauth_methods(db, did).await;
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "MfaVerificationRequired",
"message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
"reauthMethods": methods
})),
)
.into_response()
}
+389 -59
View File
@@ -92,7 +92,10 @@ pub async fn create_session(
r#"SELECT
u.id, u.did, u.handle, u.password_hash,
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
k.key_bytes, k.encryption_version
u.allow_legacy_login,
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
k.key_bytes, k.encryption_version,
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
@@ -161,6 +164,23 @@ pub async fn create_session(
)
.into_response();
}
let has_totp = row.totp_enabled.unwrap_or(false);
let is_legacy_login = has_totp;
if has_totp && !row.allow_legacy_login {
warn!(
"Legacy login blocked for TOTP-enabled account: {}",
row.did
);
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "MfaRequired",
"message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.",
"did": row.did
})),
)
.into_response();
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
Ok(m) => m,
Err(e) => {
@@ -176,12 +196,14 @@ pub async fn create_session(
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at
refresh_meta.expires_at,
is_legacy_login,
false
)
.execute(&state.db)
.await
@@ -189,6 +211,25 @@ pub async fn create_session(
error!("Failed to insert session: {:?}", e);
return ApiError::InternalError.into_response();
}
if is_legacy_login {
warn!(
did = %row.did,
ip = %client_ip,
"Legacy login on TOTP-enabled account - sending notification"
);
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::queue_legacy_login_notification(
&state.db,
row.id,
&hostname,
&client_ip,
row.preferred_comms_channel,
)
.await
{
error!("Failed to queue legacy login notification: {:?}", e);
}
}
let handle = full_handle(&row.handle, &pds_hostname);
Json(CreateSessionOutput {
access_jwt: access_meta.token,
@@ -617,12 +658,14 @@ pub async fn confirm_signup(
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at
refresh_meta.expires_at,
false,
false
)
.execute(&state.db)
.await
@@ -746,6 +789,8 @@ pub async fn resend_verification(
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub id: String,
pub session_type: String,
pub client_name: Option<String>,
pub created_at: String,
pub expires_at: String,
pub is_current: bool,
@@ -767,7 +812,10 @@ pub async fn list_sessions(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
let result = sqlx::query_as::<
let mut sessions: Vec<SessionInfo> = Vec::new();
let jwt_result = sqlx::query_as::<
_,
(
i32,
@@ -786,28 +834,90 @@ pub async fn list_sessions(
.bind(&auth.0.did)
.fetch_all(&state.db)
.await;
match result {
match jwt_result {
Ok(rows) => {
let sessions: Vec<SessionInfo> = rows
.into_iter()
.map(|(id, access_jti, created_at, expires_at)| SessionInfo {
id: id.to_string(),
for (id, access_jti, created_at, expires_at) in rows {
sessions.push(SessionInfo {
id: format!("jwt:{}", id),
session_type: "legacy".to_string(),
client_name: None,
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: current_jti.as_ref() == Some(&access_jti),
})
.collect();
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
});
}
}
Err(e) => {
error!("DB error in list_sessions: {:?}", e);
(
error!("DB error fetching JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
.into_response();
}
}
let oauth_result = sqlx::query_as::<
_,
(
i32,
String,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
String,
),
>(
r#"
SELECT id, token_id, created_at, expires_at, client_id
FROM oauth_token
WHERE did = $1 AND expires_at > NOW()
ORDER BY created_at DESC
"#,
)
.bind(&auth.0.did)
.fetch_all(&state.db)
.await;
match oauth_result {
Ok(rows) => {
for (id, token_id, created_at, expires_at, client_id) in rows {
let client_name = extract_client_name(&client_id);
let is_current_oauth = auth.0.is_oauth
&& current_jti.as_ref() == Some(&token_id);
sessions.push(SessionInfo {
id: format!("oauth:{}", id),
session_type: "oauth".to_string(),
client_name: Some(client_name),
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: is_current_oauth,
});
}
}
Err(e) => {
error!("DB error fetching OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
}
fn extract_client_name(client_id: &str) -> String {
if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") {
"Localhost App".to_string()
} else if let Ok(parsed) = reqwest::Url::parse(client_id) {
parsed.host_str().unwrap_or("Unknown App").to_string()
} else {
client_id.to_string()
}
}
#[derive(Deserialize)]
@@ -821,57 +931,277 @@ pub async fn revoke_session(
auth: BearerAuth,
Json(input): Json<RevokeSessionInput>,
) -> Response {
let session_id: i32 = match input.session_id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
)
.into_response();
}
};
let session = sqlx::query_as::<_, (String,)>(
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
)
.bind(session_id)
.bind(&auth.0.did)
.fetch_optional(&state.db)
.await;
let access_jti = match session {
Ok(Some((jti,))) => jti,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error in revoke_session: {:?}", e);
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
let session_id: i32 = match jwt_id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
)
.into_response();
}
};
let session = sqlx::query_as::<_, (String,)>(
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
)
.bind(session_id)
.bind(&auth.0.did)
.fetch_optional(&state.db)
.await;
let access_jti = match session {
Ok(Some((jti,))) => jti,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error in revoke_session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
.bind(session_id)
.execute(&state.db)
.await
{
error!("DB error deleting session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
.bind(session_id)
.execute(&state.db)
.await
{
error!("DB error deleting session: {:?}", e);
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache: {:?}", e);
}
info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked");
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
let session_id: i32 = match oauth_id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
)
.into_response();
}
};
let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2")
.bind(session_id)
.bind(&auth.0.did)
.execute(&state.db)
.await;
match result {
Ok(r) if r.rows_affected() == 0 => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error deleting OAuth session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
_ => {}
}
info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked");
} else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})),
)
.into_response();
}
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache: {:?}", e);
}
info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
(StatusCode::OK, Json(json!({}))).into_response()
}
pub async fn revoke_all_sessions(
State(state): State<AppState>,
headers: HeaderMap,
auth: BearerAuth,
) -> Response {
let current_jti = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
if let Some(ref jti) = current_jti {
if auth.0.is_oauth {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
} else {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})),
)
.into_response();
}
info!(did = %auth.0.did, "All other sessions revoked");
(StatusCode::OK, Json(json!({"success": true}))).into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyLoginPreferenceOutput {
pub allow_legacy_login: bool,
pub has_mfa: bool,
}
pub async fn get_legacy_login_preference(
State(state): State<AppState>,
auth: BearerAuth,
) -> Response {
let result = sqlx::query!(
r#"SELECT
u.allow_legacy_login,
(EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR
EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!"
FROM users u WHERE u.did = $1"#,
auth.0.did
)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(row)) => Json(LegacyLoginPreferenceOutput {
allow_legacy_login: row.allow_legacy_login,
has_mfa: row.has_mfa,
})
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateLegacyLoginInput {
pub allow_legacy_login: bool,
}
pub async fn update_legacy_login_preference(
State(state): State<AppState>,
auth: BearerAuth,
Json(input): Json<UpdateLegacyLoginInput>,
) -> Response {
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
.await;
}
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
}
let result = sqlx::query!(
"UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
input.allow_legacy_login,
auth.0.did
)
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(_)) => {
info!(
did = %auth.0.did,
allow_legacy_login = input.allow_legacy_login,
"Legacy login preference updated"
);
Json(json!({
"allowLegacyLogin": input.allow_legacy_login
}))
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
}
}
}
+51 -1
View File
@@ -4,7 +4,7 @@ use crate::auth::totp::{
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
verify_backup_code, verify_totp_code,
};
use crate::state::AppState;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
@@ -149,6 +149,21 @@ pub async fn enable_totp(
auth: BearerAuth,
Json(input): Json<EnableTotpInput>,
) -> Response {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
@@ -309,6 +324,26 @@ pub async fn disable_totp(
auth: BearerAuth,
Json(input): Json<DisableTotpInput>,
) -> Response {
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
.await;
}
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
@@ -516,6 +551,21 @@ pub async fn regenerate_backup_codes(
auth: BearerAuth,
Json(input): Json<RegenerateBackupCodesInput>,
) -> Response {
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
.fetch_optional(&state.db)
.await;
+8 -6
View File
@@ -64,14 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
return Err(HandleValidationError::TooLong);
}
let first_char = handle.chars().next().unwrap();
if first_char == '-' || first_char == '_' {
return Err(HandleValidationError::StartsWithInvalidChar);
if let Some(first_char) = handle.chars().next() {
if first_char == '-' || first_char == '_' {
return Err(HandleValidationError::StartsWithInvalidChar);
}
}
let last_char = handle.chars().last().unwrap();
if last_char == '-' || last_char == '_' {
return Err(HandleValidationError::EndsWithInvalidChar);
if let Some(last_char) = handle.chars().last() {
if last_char == '-' || last_char == '_' {
return Err(HandleValidationError::EndsWithInvalidChar);
}
}
for c in handle.chars() {
+17 -2
View File
@@ -341,7 +341,22 @@ pub async fn update_passkey_counter(
pool: &PgPool,
credential_id: &[u8],
new_counter: u32,
) -> Result<(), sqlx::Error> {
) -> Result<bool, sqlx::Error> {
let stored = get_passkey_by_credential_id(pool, credential_id).await?;
let Some(stored) = stored else {
return Err(sqlx::Error::RowNotFound);
};
if new_counter > 0 && new_counter <= stored.sign_count as u32 {
tracing::warn!(
credential_id = ?credential_id,
stored_counter = stored.sign_count,
new_counter = new_counter,
"Passkey counter did not increment - possible cloned key!"
);
return Ok(false);
}
sqlx::query!(
"UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
new_counter as i32,
@@ -349,7 +364,7 @@ pub async fn update_passkey_counter(
)
.execute(pool)
.await?;
Ok(())
Ok(true)
}
pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
+1
View File
@@ -11,6 +11,7 @@ pub use service::{
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
queue_legacy_login_notification,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
View File
@@ -527,3 +527,41 @@ pub async fn enqueue_signup_verification(
)
.await
}
pub async fn queue_legacy_login_notification(
db: &PgPool,
user_id: Uuid,
hostname: &str,
client_ip: &str,
channel: CommsChannel,
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let body = format!(
"Hello @{},\n\n\
A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\
Details:\n\
- Time: {}\n\
- IP Address: {}\n\n\
Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\
If this wasn't you, please:\n\
1. Change your password immediately\n\
2. Review your active sessions\n\
3. Consider disabling legacy app logins in your security settings\n\n\
Stay safe,\n\
{}",
prefs.handle, timestamp, client_ip, hostname
);
enqueue_comms(
db,
NewComms::new(
user_id,
channel,
super::types::CommsType::LegacyLoginAlert,
prefs.email.clone().unwrap_or_default(),
Some(format!("Security Alert: Legacy Login Detected - {}", hostname)),
body,
),
)
.await
}
+1
View File
@@ -33,6 +33,7 @@ pub enum CommsType {
PlcOperation,
TwoFactorCode,
PasskeyRecovery,
LegacyLoginAlert,
}
#[derive(Debug, Clone, FromRow)]
+67
View File
@@ -19,6 +19,7 @@ pub struct AuthConfig {
pub signing_key_x: String,
pub signing_key_y: String,
key_encryption_key: [u8; 32],
device_cookie_key: [u8; 32],
}
impl AuthConfig {
@@ -112,6 +113,10 @@ impl AuthConfig {
hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key)
.expect("HKDF expansion failed");
let mut device_cookie_key = [0u8; 32];
hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key)
.expect("HKDF expansion failed");
AuthConfig {
jwt_secret,
dpop_secret,
@@ -120,6 +125,7 @@ impl AuthConfig {
signing_key_x,
signing_key_y,
key_encryption_key,
device_cookie_key,
}
})
}
@@ -138,6 +144,67 @@ impl AuthConfig {
&self.dpop_secret
}
pub fn sign_device_cookie(&self, device_id: &str) -> String {
use hmac::Mac;
type HmacSha256 = hmac::Hmac<Sha256>;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let message = format!("{}:{}", device_id, timestamp);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key)
.expect("HMAC key size is valid");
mac.update(message.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{}.{}.{}", device_id, timestamp, signature)
}
pub fn verify_device_cookie(&self, cookie_value: &str) -> Option<String> {
use hmac::Mac;
type HmacSha256 = hmac::Hmac<Sha256>;
let parts: Vec<&str> = cookie_value.splitn(3, '.').collect();
if parts.len() != 3 {
return None;
}
let device_id = parts[0];
let timestamp_str = parts[1];
let provided_signature = parts[2];
let timestamp: u64 = timestamp_str.parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let max_age_days = 400;
if now.saturating_sub(timestamp) > max_age_days * 24 * 60 * 60 {
return None;
}
let message = format!("{}:{}", device_id, timestamp);
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key)
.expect("HMAC key size is valid");
mac.update(message.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_signature
.as_bytes()
.ct_eq(expected_signature.as_bytes())
.into()
{
Some(device_id.to_string())
} else {
None
}
}
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
use rand::RngCore;
+12
View File
@@ -59,6 +59,10 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.revokeSession",
post(api::server::revoke_session),
)
.route(
"/xrpc/com.tranquil.account.revokeAllSessions",
post(api::server::revoke_all_sessions),
)
.route(
"/xrpc/com.atproto.server.deleteSession",
post(api::server::delete_session),
@@ -230,6 +234,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
post(api::server::reauth_passkey_finish),
)
.route(
"/xrpc/com.tranquil.account.getLegacyLoginPreference",
get(api::server::get_legacy_login_preference),
)
.route(
"/xrpc/com.tranquil.account.updateLegacyLoginPreference",
post(api::server::update_legacy_login_preference),
)
.route(
"/xrpc/com.tranquil.account.listTrustedDevices",
get(api::server::list_trusted_devices),
+49 -8
View File
@@ -39,7 +39,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
for cookie in cookie_str.split(';') {
let cookie = cookie.trim();
if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) {
return Some(value.to_string());
return crate::config::AuthConfig::get().verify_device_cookie(value);
}
}
None
@@ -69,9 +69,10 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
}
fn make_device_cookie(device_id: &str) -> String {
let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id);
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
DEVICE_COOKIE_NAME, device_id
DEVICE_COOKIE_NAME, signed_value
)
}
@@ -1511,6 +1512,17 @@ pub async fn authorize_2fa_post(
"No 2FA challenge found. Please start over.",
);
}
if !state
.check_rate_limit(RateLimitKind::TotpVerify, &did)
.await
{
tracing::warn!(did = %did, "TOTP verification rate limit exceeded");
return json_error(
StatusCode::TOO_MANY_REQUESTS,
"RateLimitExceeded",
"Too many verification attempts. Please try again in a few minutes.",
);
}
let totp_valid =
crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
if !totp_valid {
@@ -2067,15 +2079,30 @@ pub async fn passkey_finish(
tracing::warn!(error = %e, "Failed to delete authentication state");
}
if auth_result.needs_update()
&& let Err(e) = crate::auth::webauthn::update_passkey_counter(
if auth_result.needs_update() {
match crate::auth::webauthn::update_passkey_counter(
&state.db,
auth_result.cred_id(),
auth_result.counter(),
)
.await
{
tracing::warn!(error = %e, "Failed to update passkey counter");
{
Ok(false) => {
tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key");
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Security key counter anomaly detected. This may indicate a cloned key."
})),
)
.into_response();
}
Err(e) => {
tracing::warn!(error = %e, "Failed to update passkey counter");
}
Ok(true) => {}
}
}
tracing::info!(did = %did, "Passkey authentication successful");
@@ -2469,14 +2496,28 @@ pub async fn authorize_passkey_finish(
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await;
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
match crate::auth::webauthn::update_passkey_counter(
&state.db,
credential.id.as_ref(),
auth_result.counter(),
)
.await
{
tracing::warn!("Failed to update passkey counter: {:?}", e);
Ok(false) => {
tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key");
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Security key counter anomaly detected. This may indicate a cloned key."
})),
)
.into_response();
}
Err(e) => {
tracing::warn!("Failed to update passkey counter: {:?}", e);
}
Ok(true) => {}
}
let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
+5
View File
@@ -29,6 +29,7 @@ pub struct RateLimiters {
pub oauth_introspect: Arc<KeyedRateLimiter>,
pub app_password: Arc<KeyedRateLimiter>,
pub email_update: Arc<KeyedRateLimiter>,
pub totp_verify: Arc<KeyedRateLimiter>,
}
impl Default for RateLimiters {
@@ -73,6 +74,10 @@ impl RateLimiters {
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
))),
totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60))
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
)),
}
}
+4
View File
@@ -35,6 +35,7 @@ pub enum RateLimitKind {
OAuthIntrospect,
AppPassword,
EmailUpdate,
TotpVerify,
}
impl RateLimitKind {
@@ -51,6 +52,7 @@ impl RateLimitKind {
Self::OAuthIntrospect => "oauth_introspect",
Self::AppPassword => "app_password",
Self::EmailUpdate => "email_update",
Self::TotpVerify => "totp_verify",
}
}
@@ -67,6 +69,7 @@ impl RateLimitKind {
Self::OAuthIntrospect => (30, 60_000),
Self::AppPassword => (10, 60_000),
Self::EmailUpdate => (5, 3_600_000),
Self::TotpVerify => (5, 300_000),
}
}
}
@@ -142,6 +145,7 @@ impl AppState {
RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect,
RateLimitKind::AppPassword => &self.rate_limiters.app_password,
RateLimitKind::EmailUpdate => &self.rate_limiters.email_update,
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
};
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+68
View File
@@ -409,6 +409,74 @@ pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError>
Ok(())
}
#[derive(Debug)]
pub struct PasswordValidationError {
pub errors: Vec<String>,
}
impl std::fmt::Display for PasswordValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.errors.join("; "))
}
}
impl std::error::Error for PasswordValidationError {}
pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> {
let mut errors = Vec::new();
if password.len() < 8 {
errors.push("Password must be at least 8 characters".to_string());
}
if password.len() > 256 {
errors.push("Password must be at most 256 characters".to_string());
}
if !password.chars().any(|c| c.is_ascii_lowercase()) {
errors.push("Password must contain at least one lowercase letter".to_string());
}
if !password.chars().any(|c| c.is_ascii_uppercase()) {
errors.push("Password must contain at least one uppercase letter".to_string());
}
if !password.chars().any(|c| c.is_ascii_digit()) {
errors.push("Password must contain at least one number".to_string());
}
if is_common_password(password) {
errors.push("Password is too common, please choose a different one".to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(PasswordValidationError { errors })
}
}
fn is_common_password(password: &str) -> bool {
const COMMON_PASSWORDS: &[&str] = &[
"password", "Password1", "Password123", "Passw0rd", "Passw0rd!",
"12345678", "123456789", "1234567890",
"qwerty123", "Qwerty123", "qwertyui", "Qwertyui",
"letmein1", "Letmein1", "welcome1", "Welcome1",
"admin123", "Admin123", "password1", "Password1!",
"iloveyou", "Iloveyou1", "monkey123", "Monkey123",
"dragon12", "Dragon123", "master12", "Master123",
"login123", "Login123", "abc12345", "Abc12345",
"football", "Football1", "baseball", "Baseball1",
"trustno1", "Trustno1", "sunshine", "Sunshine1",
"princess", "Princess1", "computer", "Computer1",
"whatever", "Whatever1", "nintendo", "Nintendo1",
"bluesky1", "Bluesky1", "Bluesky123",
];
let lower = password.to_lowercase();
COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -63,7 +63,7 @@ async fn test_search_accounts_with_handle_filter() {
let create_payload = serde_json::json!({
"handle": unique_handle,
"email": format!("unique-{}@searchtest.com", ts),
"password": "test-password-123"
"password": "Testpass123!"
});
let create_res = client
.post(format!(
+9 -9
View File
@@ -11,8 +11,8 @@ async fn test_change_password_success() {
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("change-pw-{}.test", ts);
let email = format!("change-pw-{}@test.com", ts);
let old_password = "old-password-123";
let new_password = "new-password-456";
let old_password = "Oldpass123!";
let new_password = "Newpass456!";
let create_payload = json!({
"handle": handle,
"email": email,
@@ -92,8 +92,8 @@ async fn test_change_password_wrong_current() {
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "wrong-password",
"newPassword": "new-password-123"
"currentPassword": "Wrongpass999!",
"newPassword": "Newpass123!"
}))
.send()
.await
@@ -109,7 +109,7 @@ async fn test_change_password_too_short() {
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("change-pw-short-{}.test", ts);
let email = format!("change-pw-short-{}@test.com", ts);
let password = "correct-password";
let password = "Correct123!";
let create_payload = json!({
"handle": handle,
"email": email,
@@ -158,7 +158,7 @@ async fn test_change_password_empty_current() {
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "",
"newPassword": "new-password-123"
"newPassword": "Newpass123!"
}))
.send()
.await
@@ -177,7 +177,7 @@ async fn test_change_password_empty_new() {
))
.bearer_auth(&jwt)
.json(&json!({
"currentPassword": "e2e-password-123",
"currentPassword": "E2epass123!",
"newPassword": ""
}))
.send()
@@ -195,8 +195,8 @@ async fn test_change_password_requires_auth() {
base_url().await
))
.json(&json!({
"currentPassword": "old",
"newPassword": "new-password-123"
"currentPassword": "Oldpass123!",
"newPassword": "Newpass123!"
}))
.send()
.await
+1 -1
View File
@@ -418,7 +418,7 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) ->
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password"
"password": "Testpass123!"
});
let res = match client
.post(format!(
+7 -7
View File
@@ -49,7 +49,7 @@ async fn test_delete_account_full_flow() {
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-test-{}.test", ts);
let email = format!("delete-test-{}@test.com", ts);
let password = "delete-password-123";
let password = "Delete123pass!";
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
@@ -106,7 +106,7 @@ async fn test_delete_account_wrong_password() {
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-wrongpw-{}.test", ts);
let email = format!("delete-wrongpw-{}@test.com", ts);
let password = "correct-password";
let password = "Correct123!";
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
@@ -153,7 +153,7 @@ async fn test_delete_account_invalid_token() {
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-badtoken-{}.test", ts);
let email = format!("delete-badtoken-{}@test.com", ts);
let password = "delete-password";
let password = "Delete123!";
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
@@ -196,7 +196,7 @@ async fn test_delete_account_expired_token() {
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-expired-{}.test", ts);
let email = format!("delete-expired-{}@test.com", ts);
let password = "delete-password";
let password = "Delete123!";
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
let request_delete_res = client
.post(format!(
@@ -250,12 +250,12 @@ async fn test_delete_account_token_mismatch() {
let ts = Utc::now().timestamp_millis();
let handle1 = format!("delete-user1-{}.test", ts);
let email1 = format!("delete-user1-{}@test.com", ts);
let password1 = "user1-password";
let password1 = "User1pass123!";
let (did1, jwt1) =
create_verified_account(&client, &base_url, &handle1, &email1, password1).await;
let handle2 = format!("delete-user2-{}.test", ts);
let email2 = format!("delete-user2-{}@test.com", ts);
let password2 = "user2-password";
let password2 = "User2pass123!";
let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await;
let request_delete_res = client
.post(format!(
@@ -302,7 +302,7 @@ async fn test_delete_account_with_app_password() {
let ts = Utc::now().timestamp_millis();
let handle = format!("delete-apppw-{}.test", ts);
let email = format!("delete-apppw-{}@test.com", ts);
let main_password = "main-password-123";
let main_password = "Mainpass123!";
let (did, jwt) =
create_verified_account(&client, &base_url, &handle, &email, main_password).await;
let app_password_res = client
+6 -6
View File
@@ -12,7 +12,7 @@ async fn test_create_self_hosted_did_web() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "web"
});
let res = client
@@ -139,7 +139,7 @@ async fn test_external_did_web_no_local_doc() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "web-external",
"did": did,
"signingKey": signing_key
@@ -181,7 +181,7 @@ async fn test_plc_operations_blocked_for_did_web() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "web"
});
let res = client
@@ -246,7 +246,7 @@ async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "web"
});
let res = client
@@ -295,7 +295,7 @@ async fn test_did_plc_still_works_with_did_type_param() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "plc"
});
let res = client
@@ -324,7 +324,7 @@ async fn test_external_did_web_requires_did_field() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"didType": "web-external"
});
let res = client
+1 -1
View File
@@ -26,7 +26,7 @@ async fn create_verified_account(
.json(&json!({
"handle": handle,
"email": email,
"password": "password"
"password": "Testpass123!"
}))
.send()
.await
+1 -1
View File
@@ -10,7 +10,7 @@ pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
let ts = Utc::now().timestamp_millis();
let handle = format!("{}-{}.test", handle_prefix, ts);
let email = format!("{}-{}@test.com", handle_prefix, ts);
let password = "e2e-password-123";
let password = "E2epass123!";
let create_account_payload = json!({
"handle": handle,
"email": email,
+4 -4
View File
@@ -12,7 +12,7 @@ async fn test_resolve_handle_success() {
let payload = json!({
"handle": short_handle,
"email": format!("{}@example.com", short_handle),
"password": "password"
"password": "Testpass123!"
});
let res = client
.post(format!(
@@ -142,7 +142,7 @@ async fn test_create_did_web_account_and_resolve() {
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"did": did,
"signingKey": signing_key
});
@@ -188,7 +188,7 @@ async fn test_create_account_duplicate_handle() {
let payload = json!({
"handle": handle,
"email": email,
"password": "password"
"password": "Testpass123!"
});
let res = client
.post(format!(
@@ -266,7 +266,7 @@ async fn test_did_web_lifecycle() {
let create_payload = json!({
"handle": handle,
"email": email,
"password": "password",
"password": "Testpass123!",
"did": did,
"signingKey": signing_key
});
+1 -1
View File
@@ -675,7 +675,7 @@ async fn test_refresh_token_replay_protection() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": "test-password-123" }))
.json(&json!({ "handle": handle, "email": email, "password": "Testpass123!" }))
.send()
.await
.unwrap();
+4 -4
View File
@@ -36,7 +36,7 @@ async fn test_session_lifecycle_multiple_sessions() {
let ts = Utc::now().timestamp_millis();
let handle = format!("multi-session-{}.test", ts);
let email = format!("multi-session-{}@test.com", ts);
let password = "multi-session-pw";
let password = "Multisession123!";
let create_payload = json!({
"handle": handle,
"email": email,
@@ -112,7 +112,7 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
let ts = Utc::now().timestamp_millis();
let handle = format!("refresh-inv-{}.test", ts);
let email = format!("refresh-inv-{}@test.com", ts);
let password = "refresh-inv-pw";
let password = "Refresh123inv!";
let create_payload = json!({
"handle": handle,
"email": email,
@@ -180,7 +180,7 @@ async fn test_app_password_lifecycle() {
let ts = Utc::now().timestamp_millis();
let handle = format!("apppass-{}.test", ts);
let email = format!("apppass-{}@test.com", ts);
let password = "apppass-password";
let password = "Apppass123!";
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
@@ -291,7 +291,7 @@ async fn test_account_deactivation_lifecycle() {
let ts = Utc::now().timestamp_millis();
let handle = format!("deactivate-{}.test", ts);
let email = format!("deactivate-{}@test.com", ts);
let password = "deactivate-password";
let password = "Deactivate123!";
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
+1 -1
View File
@@ -176,7 +176,7 @@ async fn test_account_to_post_full_lifecycle() {
let ts = Utc::now().timestamp_millis();
let handle = format!("fullcycle-{}.test", ts);
let email = format!("fullcycle-{}@test.com", ts);
let password = "fullcycle-password";
let password = "Fullcycle123!";
let create_account_res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
+7 -7
View File
@@ -194,7 +194,7 @@ async fn test_full_oauth_flow() {
let ts = Utc::now().timestamp_millis();
let handle = format!("oauth-test-{}", ts);
let email = format!("oauth-test-{}@example.com", ts);
let password = "oauth-test-password";
let password = "Oauthtest123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
@@ -354,7 +354,7 @@ async fn test_oauth_error_cases() {
let email = format!("wrong-creds-{}@example.com", ts);
http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": "correct-password" }))
.json(&json!({ "handle": handle, "email": email, "password": "Correct123!" }))
.send()
.await
.unwrap();
@@ -438,7 +438,7 @@ async fn test_oauth_2fa_flow() {
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-test-{}", ts);
let email = format!("2fa-test-{}@example.com", ts);
let password = "2fa-test-password";
let password = "Twofa123test!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
@@ -565,7 +565,7 @@ async fn test_oauth_2fa_lockout() {
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-lockout-{}", ts);
let email = format!("2fa-lockout-{}@example.com", ts);
let password = "2fa-test-password";
let password = "Twofa123test!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
@@ -662,7 +662,7 @@ async fn test_account_selector_with_2fa() {
let ts = Utc::now().timestamp_millis();
let handle = format!("selector-2fa-{}", ts);
let email = format!("selector-2fa-{}@example.com", ts);
let password = "selector-2fa-password";
let password = "Selector2fa123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
@@ -853,7 +853,7 @@ async fn test_oauth_state_encoding() {
let ts = Utc::now().timestamp_millis();
let handle = format!("state-special-{}", ts);
let email = format!("state-special-{}@example.com", ts);
let password = "state-special-password";
let password = "State123special!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
@@ -932,7 +932,7 @@ async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) {
let ts = Utc::now().timestamp_millis();
let handle = format!("scope-test-{}", ts);
let email = format!("scope-test-{}@example.com", ts);
let password = "scope-test-password";
let password = "Scopetest123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
+2 -2
View File
@@ -57,7 +57,7 @@ async fn create_user_and_oauth_session(
let ts = Utc::now().timestamp_millis();
let handle = format!("{}-{}", handle_prefix, ts);
let email = format!("{}-{}@example.com", handle_prefix, ts);
let password = format!("{}-password", handle_prefix);
let password = format!("{}Pass123!", handle_prefix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
@@ -577,7 +577,7 @@ async fn test_oauth_multiple_clients_same_user() {
let ts = Utc::now().timestamp_millis();
let handle = format!("multi-client-{}", ts);
let email = format!("multi-client-{}@example.com", ts);
let password = "multi-client-password";
let password = "MultiClient123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
+4 -4
View File
@@ -61,7 +61,7 @@ async fn create_user_and_oauth_session_with_scope(
let ts = Utc::now().timestamp_millis();
let handle = format!("{}-{}", handle_prefix, ts);
let email = format!("{}-{}@example.com", handle_prefix, ts);
let password = format!("{}-password", handle_prefix);
let password = format!("{}Pass123!", handle_prefix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
@@ -383,7 +383,7 @@ async fn test_consent_endpoint_returns_scope_info() {
let ts = Utc::now().timestamp_millis();
let handle = format!("consent-test-{}", ts);
let email = format!("consent-{}@example.com", ts);
let password = "consent-password";
let password = "Consent123!";
let redirect_uri = "https://consent-test.example.com/callback";
let create_res = http_client
@@ -479,7 +479,7 @@ async fn test_consent_post_generates_code() {
let ts = Utc::now().timestamp_millis();
let handle = format!("consent-post-{}", ts);
let email = format!("consent-post-{}@example.com", ts);
let password = "consent-post-password";
let password = "ConsentPost123!";
let redirect_uri = "https://consent-post.example.com/callback";
let create_res = http_client
@@ -593,7 +593,7 @@ async fn test_consent_post_requires_atproto_scope() {
let ts = Utc::now().timestamp_millis();
let handle = format!("consent-req-{}", ts);
let email = format!("consent-req-{}@example.com", ts);
let password = "consent-req-password";
let password = "ConsentReq123!";
let redirect_uri = "https://consent-req.example.com/callback";
let create_res = http_client
+10 -10
View File
@@ -44,7 +44,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String,
let ts = Utc::now().timestamp_millis();
let handle = format!("sec-test-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" }))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" }))
.send().await.unwrap();
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap();
@@ -72,7 +72,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String,
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": "security-test-password", "remember_device": false}))
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "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();
@@ -258,7 +258,7 @@ async fn test_pkce_security() {
let ts = Utc::now().timestamp_millis();
let handle = format!("pkce-attack-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" }))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" }))
.send().await.unwrap();
let account: Value = create_res.json().await.unwrap();
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
@@ -283,7 +283,7 @@ async fn test_pkce_security() {
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": "pkce-password", "remember_device": false}))
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false}))
.send().await.unwrap();
assert_eq!(auth_res.status(), StatusCode::OK);
let auth_body: Value = auth_res.json().await.unwrap();
@@ -329,7 +329,7 @@ async fn test_replay_attacks() {
let ts = Utc::now().timestamp_millis();
let handle = format!("replay-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" }))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" }))
.send().await.unwrap();
let account: Value = create_res.json().await.unwrap();
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
@@ -356,7 +356,7 @@ async fn test_replay_attacks() {
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": "replay-password", "remember_device": false}))
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false}))
.send().await.unwrap();
assert_eq!(auth_res.status(), StatusCode::OK);
let auth_body: Value = auth_res.json().await.unwrap();
@@ -495,7 +495,7 @@ async fn test_oauth_security_boundaries() {
let ts = Utc::now().timestamp_millis();
let handle = format!("deact-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" }))
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" }))
.send().await.unwrap();
let account: Value = create_res.json().await.unwrap();
let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
@@ -524,7 +524,7 @@ async fn test_oauth_security_boundaries() {
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false}))
.json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false}))
.send().await.unwrap();
assert_eq!(
auth_res.status(),
@@ -539,7 +539,7 @@ async fn test_oauth_security_boundaries() {
let ts2 = Utc::now().timestamp_millis();
let handle2 = format!("cross-{}", ts2);
let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" }))
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" }))
.send().await.unwrap();
let account2: Value = create_res2.json().await.unwrap();
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
@@ -563,7 +563,7 @@ async fn test_oauth_security_boundaries() {
let auth_a = http_client.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false}))
.json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false}))
.send().await.unwrap();
assert_eq!(auth_a.status(), StatusCode::OK);
let auth_body_a: Value = auth_a.json().await.unwrap();
+9 -9
View File
@@ -24,7 +24,7 @@ async fn test_request_password_reset_creates_code() {
let payload = json!({
"handle": handle,
"email": email,
"password": "oldpassword"
"password": "Oldpass123!"
});
let res = client
.post(format!(
@@ -83,8 +83,8 @@ async fn test_reset_password_with_valid_token() {
let pool = get_pool().await;
let handle = format!("pwreset2_{}", uuid::Uuid::new_v4());
let email = format!("{}@example.com", handle);
let old_password = "oldpassword";
let new_password = "newpassword123";
let old_password = "Oldpass123!";
let new_password = "Newpass456!";
let payload = json!({
"handle": handle,
"email": email,
@@ -182,7 +182,7 @@ async fn test_reset_password_with_invalid_token() {
))
.json(&json!({
"token": "invalid-token",
"password": "newpassword"
"password": "Newpass123!"
}))
.send()
.await
@@ -202,7 +202,7 @@ async fn test_reset_password_with_expired_token() {
let payload = json!({
"handle": handle,
"email": email,
"password": "oldpassword"
"password": "Oldpass123!"
});
let res = client
.post(format!(
@@ -246,7 +246,7 @@ async fn test_reset_password_with_expired_token() {
))
.json(&json!({
"token": token,
"password": "newpassword"
"password": "Newpass123!"
}))
.send()
.await
@@ -266,7 +266,7 @@ async fn test_reset_password_invalidates_sessions() {
let payload = json!({
"handle": handle,
"email": email,
"password": "oldpassword"
"password": "Oldpass123!"
});
let res = client
.post(format!(
@@ -313,7 +313,7 @@ async fn test_reset_password_invalidates_sessions() {
))
.json(&json!({
"token": token,
"password": "newpassword123"
"password": "Newpass123!"
}))
.send()
.await
@@ -356,7 +356,7 @@ async fn test_reset_password_creates_notification() {
let payload = json!({
"handle": handle,
"email": email,
"password": "oldpassword"
"password": "Oldpass123!"
});
let res = client
.post(format!(
+1 -1
View File
@@ -93,7 +93,7 @@ async fn test_account_creation_rate_limiting() {
let payload = json!({
"handle": format!("ratelimit_{}_{}", i, unique_id),
"email": format!("ratelimit_{}_{}@example.com", i, unique_id),
"password": "testpassword123"
"password": "Testpass123!"
});
let res = client
.post(&url)
+4 -4
View File
@@ -27,7 +27,7 @@ async fn test_account_and_session_lifecycle() {
let client = client();
let base = base_url().await;
let handle = format!("user_{}", uuid::Uuid::new_v4());
let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "password" });
let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" });
let create_res = client
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&payload)
@@ -40,7 +40,7 @@ async fn test_account_and_session_lifecycle() {
let _ = verify_new_account(&client, did).await;
let login = client
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
.json(&json!({ "identifier": handle, "password": "password" }))
.json(&json!({ "identifier": handle, "password": "Testpass123!" }))
.send()
.await
.unwrap();
@@ -61,7 +61,7 @@ async fn test_account_and_session_lifecycle() {
assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt);
let missing_id = client
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
.json(&json!({ "password": "password" }))
.json(&json!({ "password": "Testpass123!" }))
.send()
.await
.unwrap();
@@ -70,7 +70,7 @@ async fn test_account_and_session_lifecycle() {
|| missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY
);
let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
.json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "password" }))
.json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" }))
.send().await.unwrap();
assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST);
let unauth_session = client
+2 -2
View File
@@ -47,7 +47,7 @@ async fn test_list_sessions_multiple_sessions() {
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("multi-list-{}.test", ts);
let email = format!("multi-list-{}@test.com", ts);
let password = "test-password-123";
let password = "Testpass123!";
let create_payload = json!({
"handle": handle,
"email": email,
@@ -122,7 +122,7 @@ async fn test_revoke_session_success() {
let ts = chrono::Utc::now().timestamp_millis();
let handle = format!("revoke-sess-{}.test", ts);
let email = format!("revoke-sess-{}@test.com", ts);
let password = "test-password-123";
let password = "Testpass123!";
let create_payload = json!({
"handle": handle,
"email": email,
+5 -5
View File
@@ -183,7 +183,7 @@ async fn test_create_account_with_reserved_signing_key() {
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"signingKey": signing_key
}))
.send()
@@ -221,7 +221,7 @@ async fn test_create_account_with_invalid_signing_key() {
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"signingKey": "did:key:zNonExistentKey12345"
}))
.send()
@@ -257,7 +257,7 @@ async fn test_create_account_cannot_reuse_signing_key() {
.json(&json!({
"handle": handle1,
"email": format!("{}@example.com", handle1),
"password": "password",
"password": "Testpass123!",
"signingKey": signing_key
}))
.send()
@@ -273,7 +273,7 @@ async fn test_create_account_cannot_reuse_signing_key() {
.json(&json!({
"handle": handle2,
"email": format!("{}@example.com", handle2),
"password": "password",
"password": "Testpass123!",
"signingKey": signing_key
}))
.send()
@@ -310,7 +310,7 @@ async fn test_reserved_key_tokens_work() {
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"password": "Testpass123!",
"signingKey": signing_key
}))
.send()