OAuth scopes full impl.

This commit is contained in:
lewis
2025-12-20 13:05:43 +02:00
parent bfa7fb243e
commit faea661917
114 changed files with 9805 additions and 2808 deletions
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth_scope_preference (did, client_id, scope, granted, created_at, updated_at)\n VALUES ($1, $2, $3, $4, NOW(), NOW())\n ON CONFLICT (did, client_id, scope) DO UPDATE SET granted = $4, updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT scope, granted FROM oauth_scope_preference\n WHERE did = $1 AND client_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scope",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "granted",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "repo_root_cid",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "seq",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Jsonb",
"TextArray",
"TextArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, deactivated_at, takedown_ref FROM users WHERE did = $1",
"query": "SELECT id, handle, deactivated_at, takedown_ref FROM users WHERE did = $1",
"describe": {
"columns": [
{
@@ -10,11 +10,16 @@
},
{
"ordinal": 1,
"name": "handle",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"ordinal": 3,
"name": "takedown_ref",
"type_info": "Text"
}
@@ -25,10 +30,11 @@
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002"
"hash": "2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING seq\n ",
"query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)\n VALUES ($1, 'commit', $2, $2, $3, $4, $5)\n RETURNING seq\n ",
"describe": {
"columns": [
{
@@ -11,19 +11,16 @@
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Jsonb",
"TextArray",
"TextArray",
"Text"
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f"
"hash": "53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_authorization_request\n SET parameters = jsonb_set(parameters, '{scope}', to_jsonb($2::text))\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM oauth_scope_preference\n WHERE did = $1 AND client_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT preferred_comms_channel as \"channel: CommsChannel\" FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_authorization_request\n SET did = $2, device_id = $3\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT deactivated_at IS NULL FROM users WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM oauth_token WHERE did = $1 AND client_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as one FROM users WHERE handle = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d"
}
Generated
+1
View File
@@ -6207,6 +6207,7 @@ dependencies = [
"serde_bytes",
"serde_ipld_dagcbor",
"serde_json",
"serde_urlencoded",
"sha2",
"sqlx",
"subtle",
+1
View File
@@ -34,6 +34,7 @@ serde_bytes = "0.11.14"
serde_ipld_dagcbor = "0.6.4"
ipld-core = "0.4.2"
serde_json = "1.0.145"
serde_urlencoded = "0.7"
sha2 = "0.10.9"
subtle = "2.5"
p256 = { version = "0.13", features = ["ecdsa"] }
+3 -13
View File
@@ -2,18 +2,6 @@
## Active development
### OAuth scope authorization UI
Display and manage OAuth scopes during authorization flows.
- [ ] Parse and display requested scopes from authorization request
- [ ] Human-readable scope descriptions (e.g., "Read your posts" not "app.bsky.feed.read")
- [ ] Group scopes by category (read, write, admin, etc.)
- [ ] Allow users to uncheck optional scopes before authorizing
- [ ] Distinguish required vs optional scopes in UI
- [ ] Remember scope preferences per client (don't ask again for same scopes)
- [ ] Token endpoint respects user's scope selections
- [ ] Protected endpoints check token scopes before allowing operations
### Frontend
So like... make the thing unique, make it cool.
@@ -90,10 +78,12 @@ Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, app
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
OAuth Scope Enforcement: Full granular scope system with consent UI, human-readable scope descriptions, per-client scope preferences, scope parsing (repo/blob/rpc/account/identity), endpoint-level scope checks, DPoP token support in auth extractors, token revocation on re-authorization, response_mode support (query/fragment).
App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback).
Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening.
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.
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.
+15
View File
@@ -13,6 +13,11 @@
import Notifications from './routes/Notifications.svelte'
import RepoExplorer from './routes/RepoExplorer.svelte'
import Admin from './routes/Admin.svelte'
import OAuthConsent from './routes/OAuthConsent.svelte'
import OAuthLogin from './routes/OAuthLogin.svelte'
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthError from './routes/OAuthError.svelte'
const auth = getAuthState()
@@ -46,6 +51,16 @@
return RepoExplorer
case '/admin':
return Admin
case '/oauth/consent':
return OAuthConsent
case '/oauth/login':
return OAuthLogin
case '/oauth/accounts':
return OAuthAccounts
case '/oauth/2fa':
return OAuth2FA
case '/oauth/error':
return OAuthError
default:
return auth.session ? Dashboard : Login
}
+7 -2
View File
@@ -1,7 +1,12 @@
let currentPath = $state(window.location.hash.slice(1) || '/')
let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/'))
function getPathWithoutQuery(hash: string): string {
const queryIndex = hash.indexOf('?')
return queryIndex === -1 ? hash : hash.slice(0, queryIndex)
}
window.addEventListener('hashchange', () => {
currentPath = window.location.hash.slice(1) || '/'
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/')
})
export function navigate(path: string) {
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
let code = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
function getChannel(): string {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('channel') || 'email'
}
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize/2fa', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
code: code.trim()
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || 'Verification failed'
submitting = false
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = 'Unexpected response from server'
submitting = false
} catch {
error = 'Failed to connect to server'
submitting = false
}
}
function handleCancel() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
} else {
window.history.back()
}
}
let channel = $derived(getChannel())
</script>
<div class="oauth-2fa-container">
<h1>Two-Factor Authentication</h1>
<p class="subtitle">
A verification code has been sent to your {channel}.
Enter the code below to continue.
</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="code">Verification Code</label>
<input
id="code"
type="text"
bind:value={code}
placeholder="Enter 6-digit code"
disabled={submitting}
required
maxlength="6"
pattern="[0-9]{6}"
autocomplete="one-time-code"
inputmode="numeric"
/>
</div>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
Cancel
</button>
<button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}>
{submitting ? 'Verifying...' : 'Verify'}
</button>
</div>
</form>
</div>
<style>
.oauth-2fa-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
input {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1.5rem;
letter-spacing: 0.5em;
text-align: center;
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.actions button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.cancel-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.submit-btn {
background: var(--accent);
color: white;
}
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
</style>
+264
View File
@@ -0,0 +1,264 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
interface AccountInfo {
did: string
handle: string
email: string
}
let loading = $state(true)
let error = $state<string | null>(null)
let submitting = $state(false)
let accounts = $state<AccountInfo[]>([])
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
async function fetchAccounts() {
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
loading = false
return
}
try {
const response = await fetch(`/oauth/authorize/accounts?request_uri=${encodeURIComponent(requestUri)}`)
if (!response.ok) {
const data = await response.json()
error = data.error_description || data.error || 'Failed to load accounts'
loading = false
return
}
const data = await response.json()
accounts = data.accounts || []
} catch {
error = 'Failed to connect to server'
} finally {
loading = false
}
}
async function handleSelectAccount(did: string) {
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize/select', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
did
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || 'Selection failed'
submitting = false
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = 'Unexpected response from server'
submitting = false
} catch {
error = 'Failed to connect to server'
submitting = false
}
}
function handleDifferentAccount() {
const requestUri = getRequestUri()
if (requestUri) {
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
} else {
navigate('/oauth/login')
}
}
$effect(() => {
fetchAccounts()
})
</script>
<div class="oauth-accounts-container">
{#if loading}
<div class="loading">
<p>Loading accounts...</p>
</div>
{:else if error}
<div class="error-container">
<h1>Error</h1>
<div class="error">{error}</div>
<button type="button" onclick={handleDifferentAccount}>
Sign in with different account
</button>
</div>
{:else}
<h1>Choose an Account</h1>
<p class="subtitle">Select an account to continue</p>
<div class="accounts-list">
{#each accounts as account}
<button
type="button"
class="account-item"
class:disabled={submitting}
onclick={() => !submitting && handleSelectAccount(account.did)}
>
<div class="account-info">
<span class="account-handle">@{account.handle}</span>
<span class="account-email">{account.email}</span>
</div>
</button>
{/each}
</div>
<button type="button" class="secondary different-account" onclick={handleDifferentAccount}>
Sign in to different account
</button>
{/if}
</div>
<style>
.oauth-accounts-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-secondary);
}
.error-container {
text-align: center;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.account-item {
display: flex;
align-items: center;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
}
.account-item:hover:not(.disabled) {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
}
.account-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.account-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.account-handle {
font-weight: 500;
color: var(--text-primary);
}
.account-email {
font-size: 0.875rem;
color: var(--text-secondary);
}
button {
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
width: 100%;
}
button.secondary:hover:not(:disabled) {
background: var(--accent);
color: white;
}
.different-account {
margin-top: 1rem;
}
</style>
+451
View File
@@ -0,0 +1,451 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
interface ScopeInfo {
scope: string
category: string
required: boolean
description: string
display_name: string
granted: boolean | null
}
interface ConsentData {
request_uri: string
client_id: string
client_name: string | null
client_uri: string | null
logo_uri: string | null
scopes: ScopeInfo[]
show_consent: boolean
did: string
}
let loading = $state(true)
let error = $state<string | null>(null)
let submitting = $state(false)
let consentData = $state<ConsentData | null>(null)
let scopeSelections = $state<Record<string, boolean>>({})
let rememberChoice = $state(false)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
async function fetchConsentData() {
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
loading = false
return
}
try {
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
if (!response.ok) {
const data = await response.json()
error = data.error_description || data.error || 'Failed to load consent data'
loading = false
return
}
const data: ConsentData = await response.json()
consentData = data
for (const scope of data.scopes) {
if (scope.required) {
scopeSelections[scope.scope] = true
} else if (scope.granted !== null) {
scopeSelections[scope.scope] = scope.granted
} else {
scopeSelections[scope.scope] = true
}
}
if (!data.show_consent) {
await submitConsent()
}
} catch {
error = 'Failed to connect to server'
} finally {
loading = false
}
}
async function submitConsent() {
if (!consentData) return
submitting = true
const approvedScopes = Object.entries(scopeSelections)
.filter(([_, approved]) => approved)
.map(([scope]) => scope)
try {
const response = await fetch('/oauth/authorize/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_uri: consentData.request_uri,
approved_scopes: approvedScopes,
remember: rememberChoice
})
})
if (!response.ok) {
const data = await response.json()
error = data.error_description || data.error || 'Authorization failed'
submitting = false
return
}
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
}
} catch {
error = 'Failed to complete authorization'
submitting = false
}
}
async function handleDeny() {
if (!consentData) return
submitting = true
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `request_uri=${encodeURIComponent(consentData.request_uri)}`
})
if (response.redirected) {
window.location.href = response.url
}
} catch {
error = 'Failed to deny authorization'
submitting = false
}
}
function handleScopeToggle(scope: string) {
const scopeInfo = consentData?.scopes.find(s => s.scope === scope)
if (scopeInfo?.required) return
scopeSelections[scope] = !scopeSelections[scope]
}
function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
const groups: Record<string, ScopeInfo[]> = {}
for (const scope of scopes) {
if (!groups[scope.category]) {
groups[scope.category] = []
}
groups[scope.category].push(scope)
}
return groups
}
$effect(() => {
fetchConsentData()
})
let scopeGroups = $derived(consentData ? groupScopesByCategory(consentData.scopes) : {})
</script>
<div class="consent-container">
{#if loading}
<div class="loading">
<p>Loading...</p>
</div>
{:else if error}
<div class="error-container">
<h1>Authorization Error</h1>
<div class="error">{error}</div>
<button type="button" onclick={() => navigate('/login')}>
Return to Login
</button>
</div>
{:else if consentData}
<div class="client-info">
{#if consentData.logo_uri}
<img src={consentData.logo_uri} alt="" class="client-logo" />
{/if}
<h1>{consentData.client_name || 'Application'}</h1>
<p class="subtitle">wants to access your account</p>
{#if consentData.client_uri}
<a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
{consentData.client_uri}
</a>
{/if}
</div>
<div class="account-info">
<span class="label">Signing in as:</span>
<span class="did">{consentData.did}</span>
</div>
<div class="scopes-section">
<h2>Permissions Requested</h2>
{#each Object.entries(scopeGroups) as [category, scopes]}
<div class="scope-group">
<h3 class="category-title">{category}</h3>
{#each scopes as scope}
<label class="scope-item" class:required={scope.required}>
<input
type="checkbox"
checked={scopeSelections[scope.scope]}
disabled={scope.required || submitting}
onchange={() => handleScopeToggle(scope.scope)}
/>
<div class="scope-info">
<span class="scope-name">{scope.display_name}</span>
<span class="scope-description">{scope.description}</span>
{#if scope.required}
<span class="required-badge">Required</span>
{/if}
</div>
</label>
{/each}
</div>
{/each}
</div>
<label class="remember-choice">
<input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
<span>Remember my choice for this application</span>
</label>
<div class="actions">
<button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
Deny
</button>
<button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}>
{submitting ? 'Authorizing...' : 'Authorize'}
</button>
</div>
{/if}
</div>
<style>
.consent-container {
max-width: 480px;
margin: 2rem auto;
padding: 2rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-secondary);
}
.error-container {
text-align: center;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.client-info {
text-align: center;
margin-bottom: 1.5rem;
}
.client-logo {
width: 64px;
height: 64px;
border-radius: 12px;
margin-bottom: 1rem;
}
.client-info h1 {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
}
.subtitle {
color: var(--text-secondary);
margin: 0;
}
.client-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--accent);
text-decoration: none;
}
.client-link:hover {
text-decoration: underline;
}
.account-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.account-info .label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.account-info .did {
font-family: monospace;
font-size: 0.875rem;
color: var(--text-primary);
word-break: break-all;
}
.scopes-section {
margin-bottom: 1.5rem;
}
.scopes-section h2 {
font-size: 1rem;
margin: 0 0 1rem 0;
color: var(--text-secondary);
}
.scope-group {
margin-bottom: 1rem;
}
.category-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-color);
}
.scope-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: border-color 0.15s;
}
.scope-item:hover:not(.required) {
border-color: var(--accent);
}
.scope-item.required {
background: var(--bg-secondary);
}
.scope-item input[type="checkbox"] {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 2px;
}
.scope-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.scope-name {
font-weight: 500;
color: var(--text-primary);
}
.scope-description {
font-size: 0.875rem;
color: var(--text-secondary);
}
.required-badge {
display: inline-block;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--warning-bg);
color: var(--warning-text);
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
width: fit-content;
}
.remember-choice {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
}
.remember-choice input {
width: 16px;
height: 16px;
}
.actions {
display: flex;
gap: 1rem;
}
.actions button {
flex: 1;
padding: 0.875rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.deny-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.deny-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.approve-btn {
background: var(--accent);
color: white;
}
.approve-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
function getError(): string {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('error') || 'Unknown error'
}
function getErrorDescription(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('error_description')
}
function handleBack() {
window.history.back()
}
let error = $derived(getError())
let errorDescription = $derived(getErrorDescription())
</script>
<div class="oauth-error-container">
<h1>Authorization Error</h1>
<div class="error-box">
<div class="error-code">{error}</div>
{#if errorDescription}
<div class="error-description">{errorDescription}</div>
{/if}
</div>
<button type="button" onclick={handleBack}>
Go Back
</button>
</div>
<style>
.oauth-error-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
text-align: center;
}
h1 {
margin: 0 0 1.5rem 0;
color: var(--error-text);
}
.error-box {
padding: 1.5rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.error-code {
font-family: monospace;
font-size: 1rem;
color: var(--error-text);
margin-bottom: 0.5rem;
}
.error-description {
color: var(--text-secondary);
font-size: 0.875rem;
}
button {
padding: 0.75rem 1.5rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover {
background: var(--accent-hover);
}
</style>
+269
View File
@@ -0,0 +1,269 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte'
let username = $state('')
let password = $state('')
let rememberDevice = $state(false)
let submitting = $state(false)
let error = $state<string | null>(null)
function getRequestUri(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('request_uri')
}
function getErrorFromUrl(): string | null {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
return params.get('error')
}
$effect(() => {
const urlError = getErrorFromUrl()
if (urlError) {
error = urlError
}
})
async function handleSubmit(e: Event) {
e.preventDefault()
const requestUri = getRequestUri()
if (!requestUri) {
error = 'Missing request_uri parameter'
return
}
submitting = true
error = null
try {
const response = await fetch('/oauth/authorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
username,
password,
remember_device: rememberDevice
})
})
const data = await response.json()
if (!response.ok) {
error = data.error_description || data.error || 'Login failed'
submitting = false
return
}
if (data.needs_2fa) {
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
return
}
if (data.redirect_uri) {
window.location.href = data.redirect_uri
return
}
error = 'Unexpected response from server'
submitting = false
} catch {
error = 'Failed to connect to server'
submitting = false
}
}
async function handleCancel() {
const requestUri = getRequestUri()
if (!requestUri) {
window.history.back()
return
}
submitting = true
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ request_uri: requestUri })
})
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
}
} catch {
window.history.back()
}
}
</script>
<div class="oauth-login-container">
<h1>Sign In</h1>
<p class="subtitle">Sign in to continue to the application</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form onsubmit={handleSubmit}>
<div class="field">
<label for="username">Handle or Email</label>
<input
id="username"
type="text"
bind:value={username}
placeholder="you@example.com or handle"
disabled={submitting}
required
autocomplete="username"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>Remember this device</span>
</label>
<div class="actions">
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
Cancel
</button>
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</div>
</form>
</div>
<style>
.oauth-login-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
}
input[type="text"],
input[type="password"] {
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1rem;
background: var(--bg-input);
color: var(--text-primary);
}
input:focus {
outline: none;
border-color: var(--accent);
}
.remember-device {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
}
.remember-device input {
width: 16px;
height: 16px;
}
.error {
padding: 0.75rem;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 4px;
color: var(--error-text);
margin-bottom: 1rem;
}
.actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.actions button {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s;
}
.actions button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.cancel-btn:hover:not(:disabled) {
background: var(--error-bg);
border-color: var(--error-border);
color: var(--error-text);
}
.submit-btn {
background: var(--accent);
color: white;
}
.submit-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
</style>
@@ -0,0 +1,12 @@
CREATE TABLE oauth_scope_preference (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
granted BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(did, client_id, scope)
);
CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
+33 -29
View File
@@ -32,16 +32,17 @@ pub async fn get_preferences(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
@@ -109,30 +110,33 @@ pub async fn put_preferences(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let (user_id, is_migration): (uuid::Uuid, bool) =
match sqlx::query!("SELECT id, deactivated_at FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => (row.id, row.deactivated_at.is_some()),
_ => {
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
};
let (user_id, is_migration): (uuid::Uuid, bool) = match sqlx::query!(
"SELECT id, deactivated_at FROM users WHERE did = $1",
auth_user.did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => (row.id, row.deactivated_at.is_some()),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
};
if input.preferences.len() > MAX_PREFERENCES_COUNT {
return (
StatusCode::BAD_REQUEST,
+2 -3
View File
@@ -93,9 +93,8 @@ fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec<String> {
.map(|q| {
q.split('&')
.filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
let k = parts.next()?;
let v = parts.next()?;
let (k, v) = pair.split_once('=')?;
if k == key {
Some(urlencoding::decode(v).ok()?.into_owned())
} else {
+27 -13
View File
@@ -54,7 +54,17 @@ pub async fn search_accounts(
let limit = params.limit.clamp(1, 100);
let cursor_did = params.cursor.as_deref().unwrap_or("");
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
let result = sqlx::query_as::<_, (String, String, Option<String>, chrono::DateTime<chrono::Utc>, bool, Option<chrono::DateTime<chrono::Utc>>)>(
let result = sqlx::query_as::<
_,
(
String,
String,
Option<String>,
chrono::DateTime<chrono::Utc>,
bool,
Option<chrono::DateTime<chrono::Utc>>,
),
>(
r#"
SELECT did, handle, email, created_at, email_verified, deactivated_at
FROM users
@@ -74,19 +84,23 @@ pub async fn search_accounts(
let accounts: Vec<AccountView> = rows
.into_iter()
.take(limit as usize)
.map(|(did, handle, email, created_at, email_verified, deactivated_at)| AccountView {
did: did.clone(),
handle,
email,
indexed_at: created_at.to_rfc3339(),
email_verified_at: if email_verified {
Some(created_at.to_rfc3339())
} else {
None
.map(
|(did, handle, email, created_at, email_verified, deactivated_at)| {
AccountView {
did: did.clone(),
handle,
email,
indexed_at: created_at.to_rfc3339(),
email_verified_at: if email_verified {
Some(created_at.to_rfc3339())
} else {
None
},
deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()),
invites_disabled: None,
}
},
deactivated_at: deactivated_at.map(|dt| dt.to_rfc3339()),
invites_disabled: None,
})
)
.collect();
let next_cursor = if has_more {
accounts.last().map(|a| a.did.clone())
+10 -12
View File
@@ -16,10 +16,7 @@ pub struct ServerStatsResponse {
pub blob_storage_bytes: i64,
}
pub async fn get_server_stats(
State(state): State<AppState>,
_auth: BearerAuthAdmin,
) -> Response {
pub async fn get_server_stats(State(state): State<AppState>, _auth: BearerAuthAdmin) -> Response {
let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users")
.fetch_one(&state.db)
.await
@@ -47,14 +44,15 @@ pub async fn get_server_stats(
Err(_) => 0,
};
let blob_storage_bytes: i64 = match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs")
.fetch_one(&state.db)
.await
{
Ok(Some(bytes)) => bytes,
Ok(None) => 0,
Err(_) => 0,
};
let blob_storage_bytes: i64 =
match sqlx::query_scalar!("SELECT COALESCE(SUM(size_bytes), 0)::BIGINT FROM blobs")
.fetch_one(&state.db)
.await
{
Ok(Some(bytes)) => bytes,
Ok(None) => 0,
Err(_) => 0,
};
Json(ServerStatsResponse {
user_count,
+164 -147
View File
@@ -21,13 +21,15 @@ use tracing::{debug, error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
@@ -114,7 +116,11 @@ pub async fn create_account(
};
let is_migration = migration_auth.is_some()
&& input.did.as_ref().map(|d| d.starts_with("did:plc:")).unwrap_or(false);
&& input
.did
.as_ref()
.map(|d| d.starts_with("did:plc:"))
.unwrap_or(false);
if is_migration {
let migration_did = input.did.as_ref().unwrap();
@@ -147,13 +153,14 @@ pub async fn create_account(
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty());
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
}
&& !crate::api::validation::is_valid_email(email)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
}
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&verification_channel) && !is_migration {
@@ -366,32 +373,32 @@ pub async fn create_account(
};
if is_migration {
let existing_account: Option<(uuid::Uuid, String, Option<chrono::DateTime<chrono::Utc>>)> =
sqlx::query_as(
"SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE",
)
.bind(&did)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
sqlx::query_as("SELECT id, handle, deactivated_at FROM users WHERE did = $1 FOR UPDATE")
.bind(&did)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
if let Some((account_id, old_handle, deactivated_at)) = existing_account {
if deactivated_at.is_some() {
info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration");
let update_result: Result<_, sqlx::Error> = sqlx::query(
"UPDATE users SET handle = $1 WHERE id = $2",
)
.bind(short_handle)
.bind(account_id)
.execute(&mut *tx)
.await;
let update_result: Result<_, sqlx::Error> =
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
.bind(short_handle)
.bind(account_id)
.execute(&mut *tx)
.await;
if let Err(e) = update_result {
if let Some(db_err) = e.as_database_error() {
if db_err.constraint().map(|c| c.contains("handle")).unwrap_or(false) {
return (
if let Some(db_err) = e.as_database_error()
&& db_err
.constraint()
.map(|c| c.contains("handle"))
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})),
)
.into_response();
}
}
error!("Error reactivating account: {:?}", e);
return (
@@ -438,18 +445,22 @@ pub async fn create_account(
.into_response();
}
};
let access_meta = match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Error creating access token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) {
let access_meta =
match crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Error creating access token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(
&did,
&secret_key_bytes,
) {
Ok(m) => m,
Err(e) => {
error!("Error creating refresh token: {:?}", e);
@@ -499,13 +510,12 @@ pub async fn create_account(
}
}
}
let exists_result: Option<(i32,)> = sqlx::query_as(
"SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL",
)
.bind(short_handle)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
let exists_result: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
.bind(short_handle)
.fetch_optional(&mut *tx)
.await
.unwrap_or(None);
if exists_result.is_some() {
return (
StatusCode::BAD_REQUEST,
@@ -516,50 +526,41 @@ pub async fn create_account(
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if invite_code_required && input.invite_code.as_ref().map(|c| c.trim().is_empty()).unwrap_or(true) {
if invite_code_required
&& input
.invite_code
.as_ref()
.map(|c| c.trim().is_empty())
.unwrap_or(true)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})),
)
.into_response();
}
if let Some(code) = &input.invite_code {
if !code.trim().is_empty() {
let invite_query = sqlx::query!(
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
code
)
.fetch_optional(&mut *tx)
.await;
match invite_query {
Ok(Some(row)) => {
if row.available_uses <= 0 {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
}
let update_invite = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
if let Err(e) = update_invite {
error!("Error updating invite code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let invite_query = sqlx::query!(
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
code
)
.fetch_optional(&mut *tx)
.await;
match invite_query {
Ok(Some(row)) => {
if row.available_uses <= 0 {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
}
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
)
.into_response();
}
Err(e) => {
error!("Error checking invite code: {:?}", e);
let update_invite = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
if let Err(e) = update_invite {
error!("Error updating invite code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -567,6 +568,21 @@ pub async fn create_account(
.into_response();
}
}
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
)
.into_response();
}
Err(e) => {
error!("Error checking invite code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
}
let password_hash = match hash(&input.password, DEFAULT_COST) {
@@ -635,37 +651,38 @@ pub async fn create_account(
Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("23505") {
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") || constraint.contains("users_handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": "Handle already taken"
})),
)
.into_response();
} else if constraint.contains("email") || constraint.contains("users_email") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidEmail",
"message": "Email already registered"
})),
)
.into_response();
} else if constraint.contains("did") || constraint.contains("users_did") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "AccountAlreadyExists",
"message": "An account with this DID already exists"
})),
)
.into_response();
}
&& db_err.code().as_deref() == Some("23505")
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") || constraint.contains("users_handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": "Handle already taken"
})),
)
.into_response();
} else if constraint.contains("email") || constraint.contains("users_email") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidEmail",
"message": "Email already registered"
})),
)
.into_response();
} else if constraint.contains("did") || constraint.contains("users_did") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "AccountAlreadyExists",
"message": "An account with this DID already exists"
})),
)
.into_response();
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -675,8 +692,8 @@ pub async fn create_account(
}
};
if !is_migration {
if let Err(e) = sqlx::query!(
if !is_migration
&& let Err(e) = sqlx::query!(
"INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
user_id,
verification_code,
@@ -692,7 +709,6 @@ pub async fn create_account(
)
.into_response();
}
}
let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
Ok(enc) => enc,
Err(e) => {
@@ -809,23 +825,23 @@ pub async fn create_account(
)
.into_response();
}
if let Some(code) = &input.invite_code {
if !code.trim().is_empty() {
let use_insert = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let use_insert = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
if let Err(e) = use_insert {
error!("Error recording invite usage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.execute(&mut *tx)
.await;
if let Err(e) = use_insert {
error!("Error recording invite usage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
.into_response();
}
}
if let Err(e) = tx.commit().await {
@@ -838,11 +854,13 @@ pub async fn create_account(
}
if !is_migration {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
.await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
@@ -861,8 +879,8 @@ pub async fn create_account(
{
warn!("Failed to create default profile for {}: {}", did, e);
}
if let Some(ref recipient) = verification_recipient {
if let Err(e) = crate::comms::enqueue_signup_verification(
if let Some(ref recipient) = verification_recipient
&& let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
user_id,
verification_channel,
@@ -870,12 +888,11 @@ pub async fn create_account(
&verification_code,
)
.await
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
}
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
}
}
+37 -25
View File
@@ -54,22 +54,20 @@ pub async fn resolve_handle(
.await;
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
}
Ok(None) => {
match crate::handle::resolve_handle(handle).await {
Ok(did) => {
let _ = state
.cache
.set(&cache_key, &did, std::time::Duration::from_secs(300))
.await;
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
}
Err(_) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
)
.into_response(),
Ok(None) => match crate::handle::resolve_handle(handle).await {
Ok(did) => {
let _ = state
.cache
.set(&cache_key, &did, std::time::Duration::from_secs(300))
.await;
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
}
}
Err(_) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
)
.into_response(),
},
Err(e) => {
error!("DB error resolving handle: {:?}", e);
(
@@ -310,10 +308,11 @@ pub async fn get_recommended_did_credentials(
.into_response();
}
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let user = match sqlx::query!(
"SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
auth_user.did
@@ -378,10 +377,19 @@ pub async fn update_handle(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Handle,
) {
return e;
}
let did = auth_user.did;
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -414,7 +422,10 @@ pub async fn update_handle(
} else {
new_handle
};
(short_handle.to_string(), format!("{}.{}", short_handle, hostname))
(
short_handle.to_string(),
format!("{}.{}", short_handle, hostname),
)
} else {
match crate::handle::verify_handle_ownership(new_handle, &did).await {
Ok(()) => {}
@@ -537,7 +548,8 @@ async fn update_plc_handle(
let plc_client = crate::plc::PlcClient::new(None);
let last_op = plc_client.get_last_op(did).await?;
let new_also_known_as = vec![format!("at://{}", new_handle)];
let update_op = crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?;
let update_op =
crate::plc::create_update_op(&last_op, None, None, Some(new_also_known_as), None)?;
let signed_op = crate::plc::sign_operation(&update_op, &signing_key)?;
plc_client.send_operation(did, &signed_op).await?;
Ok(())
+12 -4
View File
@@ -24,10 +24,18 @@ pub async fn request_plc_operation_signature(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
return e;
}
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
+12 -4
View File
@@ -50,10 +50,18 @@ pub async fn sign_plc_operation(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
return e;
}
let did = &auth_user.did;
let token = match &input.token {
Some(t) => t,
+71 -58
View File
@@ -29,10 +29,18 @@ pub async fn submit_plc_operation(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &bearer).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_identity_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::IdentityAttr::Wildcard,
) {
return e;
}
let did = &auth_user.did;
if let Err(e) = validate_plc_operation(&input.operation) {
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
@@ -40,9 +48,12 @@ pub async fn submit_plc_operation(
let op = &input.operation;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let public_url = format!("https://{}", hostname);
let user = match sqlx::query!("SELECT id, handle, deactivated_at FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
let user = match sqlx::query!(
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
@@ -94,63 +105,65 @@ pub async fn submit_plc_operation(
}
};
let user_did_key = signing_key_to_did_key(&signing_key);
if !is_migration {
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
let server_rotation_key =
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
let has_server_key = rotation_keys
.iter()
.any(|k| k.as_str() == Some(&server_rotation_key));
if !has_server_key {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Rotation keys do not include server's rotation key"
})),
)
.into_response();
}
if !is_migration && let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array())
{
let server_rotation_key =
std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone());
let has_server_key = rotation_keys
.iter()
.any(|k| k.as_str() == Some(&server_rotation_key));
if !has_server_key {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Rotation keys do not include server's rotation key"
})),
)
.into_response();
}
}
if let Some(services) = op.get("services").and_then(|v| v.as_object())
&& let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) {
let service_type = pds.get("type").and_then(|v| v.as_str());
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
if service_type != Some("AtprotoPersonalDataServer") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect type on atproto_pds service"
})),
)
.into_response();
}
if endpoint != Some(&public_url) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect endpoint on atproto_pds service"
})),
)
.into_response();
}
&& let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object())
{
let service_type = pds.get("type").and_then(|v| v.as_str());
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
if service_type != Some("AtprotoPersonalDataServer") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect type on atproto_pds service"
})),
)
.into_response();
}
if endpoint != Some(&public_url) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect endpoint on atproto_pds service"
})),
)
.into_response();
}
}
if !is_migration {
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
if let Some(verification_methods) =
op.get("verificationMethods").and_then(|v| v.as_object())
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
&& atproto_key != user_did_key {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect signing key in verificationMethods"
})),
)
.into_response();
}
&& atproto_key != user_did_key
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect signing key in verificationMethods"
})),
)
.into_response();
}
if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) {
let expected_handle = format!("at://{}", user.handle);
let first_aka = also_known_as.first().and_then(|v| v.as_str());
+72 -46
View File
@@ -147,20 +147,24 @@ pub async fn get_notification_history(
}
};
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
.fetch_one(&state.db)
.await
{
Ok(id) => id,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response(),
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
.fetch_one(&state.db)
.await
{
Ok(id) => id,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
};
let rows = match sqlx::query!(
r#"
let rows =
match sqlx::query!(
r#"
SELECT
created_at,
channel as "channel: String",
@@ -173,29 +177,32 @@ pub async fn get_notification_history(
ORDER BY created_at DESC
LIMIT 50
"#,
user_id
)
.fetch_all(&state.db)
.await
{
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
user_id
)
.into_response(),
};
.fetch_all(&state.db)
.await
{
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
};
let notifications = rows.iter().map(|row| {
NotificationHistoryEntry {
let notifications = rows
.iter()
.map(|row| NotificationHistoryEntry {
created_at: row.created_at.to_rfc3339(),
channel: row.channel.clone(),
comms_type: row.comms_type.clone(),
status: row.status.clone(),
subject: row.subject.clone(),
body: row.body.clone(),
}
}).collect();
})
.collect();
Json(GetNotificationHistoryResponse { notifications }).into_response()
}
@@ -297,20 +304,23 @@ pub async fn update_notification_prefs(
}
};
let user_row = match sqlx::query!(
"SELECT id, handle, email FROM users WHERE did = $1",
user.did
)
.fetch_one(&state.db)
.await
{
Ok(row) => row,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
let user_row =
match sqlx::query!(
"SELECT id, handle, email FROM users WHERE did = $1",
user.did
)
.into_response(),
};
.fetch_one(&state.db)
.await
{
Ok(row) => row,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
};
let user_id = user_row.id;
let handle = user_row.handle;
@@ -384,7 +394,15 @@ pub async fn update_notification_prefs(
.into_response();
}
if let Err(e) = request_channel_verification(&state.db, user_id, "email", &email_clean, Some(&handle)).await {
if let Err(e) = request_channel_verification(
&state.db,
user_id,
"email",
&email_clean,
Some(&handle),
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
@@ -419,7 +437,9 @@ pub async fn update_notification_prefs(
.await;
info!(did = %user.did, "Cleared Discord ID");
} else {
if let Err(e) = request_channel_verification(&state.db, user_id, "discord", discord_id, None).await {
if let Err(e) =
request_channel_verification(&state.db, user_id, "discord", discord_id, None).await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
@@ -455,7 +475,10 @@ pub async fn update_notification_prefs(
.await;
info!(did = %user.did, "Cleared Telegram username");
} else {
if let Err(e) = request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None).await {
if let Err(e) =
request_channel_verification(&state.db, user_id, "telegram", telegram_clean, None)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
@@ -490,7 +513,9 @@ pub async fn update_notification_prefs(
.await;
info!(did = %user.did, "Cleared Signal number");
} else {
if let Err(e) = request_channel_verification(&state.db, user_id, "signal", signal, None).await {
if let Err(e) =
request_channel_verification(&state.db, user_id, "signal", signal, None).await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
@@ -505,5 +530,6 @@ pub async fn update_notification_prefs(
Json(UpdateNotificationPrefsResponse {
success: true,
verification_required,
}).into_response()
})
.into_response()
}
+10 -4
View File
@@ -18,10 +18,7 @@ pub async fn proxy_handler(
RawQuery(query): RawQuery,
body: Bytes,
) -> Response {
let proxy_header = match headers
.get("atproto-proxy")
.and_then(|h| h.to_str().ok())
{
let proxy_header = match headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) {
Some(h) => h.to_string(),
None => {
return (
@@ -66,6 +63,15 @@ pub async fn proxy_handler(
) {
match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(auth_user) => {
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
&resolved.did,
&method,
) {
return e;
}
if let Some(key_bytes) = auth_user.key_bytes {
match crate::auth::create_service_token(
&auth_user.did,
+31 -20
View File
@@ -62,6 +62,17 @@ pub async fn upload_blob(
} else {
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => {
let mime_type_for_check = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream");
if let Err(e) = crate::auth::scope_check::check_blob_scope(
user.is_oauth,
user.scope.as_deref(),
mime_type_for_check,
) {
return e;
}
let deactivated = sqlx::query_scalar!(
"SELECT deactivated_at FROM users WHERE did = $1",
user.did
@@ -171,23 +182,22 @@ pub async fn upload_blob(
.blob_store
.put_bytes(&storage_key, bytes::Bytes::from(data))
.await
{
error!("Failed to upload blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
}
{
error!("Failed to upload blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit blob transaction: {:?}", e);
if was_inserted
&& let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
error!(
"Failed to cleanup orphaned blob {}: {:?}",
storage_key, cleanup_err
);
}
if was_inserted && let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
error!(
"Failed to cleanup orphaned blob {}: {:?}",
storage_key, cleanup_err
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -231,11 +241,12 @@ fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
if let Some(obj) = val.as_object() {
if let Some(type_val) = obj.get("$type")
&& type_val == "blob"
&& let Some(r) = obj.get("ref")
&& let Some(link) = r.get("$link")
&& let Some(s) = link.as_str() {
blobs.push(s.to_string());
}
&& let Some(r) = obj.get("ref")
&& let Some(link) = r.get("$link")
&& let Some(s) = link.as_str()
{
blobs.push(s.to_string());
}
for (_, v) in obj {
find_blobs(v, blobs);
}
+30 -5
View File
@@ -53,13 +53,14 @@ pub async fn import_repo(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = &auth_user.did;
let user = match sqlx::query!(
"SELECT id, deactivated_at, takedown_ref FROM users WHERE did = $1",
"SELECT id, handle, deactivated_at, takedown_ref FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
@@ -317,6 +318,30 @@ pub async fn import_repo(
records.len(),
did
);
if is_migration {
if let Err(e) =
sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
.execute(&state.db)
.await
{
error!("Failed to reactivate account after import: {:?}", e);
}
let _ = state.cache.delete(&format!("handle:{}", user.handle)).await;
if let Err(e) = crate::api::repo::record::sequence_identity_event(
&state,
did,
Some(&user.handle),
)
.await
{
warn!("Failed to sequence identity event after import: {:?}", e);
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did, true, None).await
{
warn!("Failed to sequence account event after import: {:?}", e);
}
}
if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
warn!("Failed to sequence import event: {:?}", e);
}
+93 -15
View File
@@ -101,7 +101,9 @@ pub async fn apply_writes(
.into_response();
}
};
let did = auth_user.did;
let did = auth_user.did.clone();
let is_oauth = auth_user.is_oauth;
let scope = auth_user.scope;
if input.repo != did {
return (
StatusCode::FORBIDDEN,
@@ -144,6 +146,75 @@ pub async fn apply_writes(
)
.into_response();
}
if is_oauth {
use std::collections::HashSet;
let create_collections: HashSet<&str> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Create { collection, .. } = w {
Some(collection.as_str())
} else {
None
}
})
.collect();
let update_collections: HashSet<&str> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Update { collection, .. } = w {
Some(collection.as_str())
} else {
None
}
})
.collect();
let delete_collections: HashSet<&str> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Delete { collection, .. } = w {
Some(collection.as_str())
} else {
None
}
})
.collect();
for collection in create_collections {
if let Err(e) = crate::auth::scope_check::check_repo_scope(
is_oauth,
scope.as_deref(),
crate::oauth::RepoAction::Create,
collection,
) {
return e;
}
}
for collection in update_collections {
if let Err(e) = crate::auth::scope_check::check_repo_scope(
is_oauth,
scope.as_deref(),
crate::oauth::RepoAction::Update,
collection,
) {
return e;
}
}
for collection in delete_collections {
if let Err(e) = crate::auth::scope_check::check_repo_scope(
is_oauth,
scope.as_deref(),
crate::oauth::RepoAction::Delete,
collection,
) {
return e;
}
}
}
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -184,13 +255,14 @@ pub async fn apply_writes(
}
};
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
@@ -225,9 +297,10 @@ pub async fn apply_writes(
value,
} => {
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(value, collection) {
return *err_response;
}
&& let Err(err_response) = validate_record(value, collection)
{
return *err_response;
}
let rkey = rkey
.clone()
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
@@ -276,9 +349,10 @@ pub async fn apply_writes(
value,
} => {
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(value, collection) {
return *err_response;
}
&& let Err(err_response) = validate_record(value, collection)
{
return *err_response;
}
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
@@ -353,7 +427,11 @@ pub async fn apply_writes(
};
let mut relevant_blocks = std::collections::BTreeMap::new();
for key in &modified_keys {
if mst.blocks_for_path(key, &mut relevant_blocks).await.is_err() {
if mst
.blocks_for_path(key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if original_mst
+33 -10
View File
@@ -34,19 +34,34 @@ pub async fn delete_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<DeleteRecordInput>,
) -> Response {
let (did, user_id, current_root_cid) =
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
auth.scope.as_deref(),
crate::oauth::RepoAction::Delete,
&input.collection,
) {
return e;
}
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
@@ -115,10 +130,18 @@ pub async fn delete_record(
prev: prev_record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if new_mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
let mut written_cids = tracking_store.get_all_relevant_cids();
+19 -19
View File
@@ -48,10 +48,7 @@ pub async fn get_record(
let user_id: uuid::Uuid = match user_id_opt {
Ok(Some(id)) => id,
Ok(None) => {
if let Some(proxy_header) = headers
.get("atproto-proxy")
.and_then(|h| h.to_str().ok())
{
if let Some(proxy_header) = headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) {
let did = proxy_header.split('#').next().unwrap_or(proxy_header);
if let Some(resolved) = state.did_resolver.resolve_did(did).await {
let mut url = format!(
@@ -84,7 +81,8 @@ pub async fn get_record(
.header("content-type", "application/json")
.body(axum::body::Body::from(body))
.unwrap_or_else(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error")
.into_response()
});
}
Err(e) => {
@@ -138,13 +136,14 @@ pub async fn get_record(
}
};
if let Some(expected_cid) = &input.cid
&& &record_cid_str != expected_cid {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
)
.into_response();
}
&& &record_cid_str != expected_cid
{
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
)
.into_response();
}
let cid = match Cid::from_str(&record_cid_str) {
Ok(c) => c,
Err(_) => {
@@ -326,13 +325,14 @@ pub async fn list_records(
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
if let Some(block) = block_opt
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
&& let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
records.push(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
"cid": cid_str,
"value": value
}));
}
&& let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block)
{
records.push(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
"cid": cid_str,
"value": value
}));
}
}
Json(ListRecordsOutput {
cursor: last_rkey,
+84 -37
View File
@@ -151,27 +151,36 @@ pub async fn commit_and_log(
match lock_result {
Err(e) => {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("55P03") {
return Err(
"ConcurrentModification: Another request is modifying this repo"
.to_string(),
);
}
&& db_err.code().as_deref() == Some("55P03")
{
return Err(
"ConcurrentModification: Another request is modifying this repo".to_string(),
);
}
return Err(format!("Failed to acquire repo lock: {}", e));
}
Ok(Some(row)) => {
if let Some(expected_root) = &current_root_cid
&& row.repo_root_cid != expected_root.to_string() {
return Err(
"ConcurrentModification: Repo has been modified since last read"
.to_string(),
);
}
&& row.repo_root_cid != expected_root.to_string()
{
return Err(
"ConcurrentModification: Repo has been modified since last read".to_string(),
);
}
}
Ok(None) => {
return Err("Repo not found".to_string());
}
}
let is_account_active = sqlx::query_scalar!(
"SELECT deactivated_at IS NULL FROM users WHERE id = $1",
user_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| format!("Failed to check account status: {}", e))?
.flatten()
.unwrap_or(false);
sqlx::query!(
"UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2",
new_root_cid.to_string(),
@@ -289,35 +298,39 @@ pub async fn commit_and_log(
}
})
.collect::<Vec<_>>();
let event_type = "commit";
let prev_cid_str = current_root_cid.map(|c| c.to_string());
let prev_data_cid_str = prev_data_cid.map(|c| c.to_string());
let seq_row = sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING seq
"#,
did,
event_type,
new_root_cid.to_string(),
prev_cid_str,
json!(ops_json),
&[] as &[String],
blocks_cids,
prev_data_cid_str,
)
.fetch_one(&mut *tx)
.await
.map_err(|e| format!("DB Error (repo_seq): {}", e))?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&mut *tx)
if is_account_active {
let event_type = "commit";
let prev_cid_str = current_root_cid.map(|c| c.to_string());
let prev_data_cid_str = prev_data_cid.map(|c| c.to_string());
let seq_row = sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING seq
"#,
did,
event_type,
new_root_cid.to_string(),
prev_cid_str,
json!(ops_json),
&[] as &[String],
blocks_cids,
prev_data_cid_str,
)
.fetch_one(&mut *tx)
.await
.map_err(|e| format!("DB Error (notify): {}", e))?;
.map_err(|e| format!("DB Error (repo_seq): {}", e))?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (notify): {}", e))?;
}
tx.commit()
.await
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await;
if is_account_active {
let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await;
}
Ok(CommitResult {
commit_cid: new_root_cid,
rev: rev_str,
@@ -482,3 +495,37 @@ pub async fn sequence_sync_event(
.map_err(|e| format!("DB Error (notify): {}", e))?;
Ok(seq_row.seq)
}
pub async fn sequence_empty_commit_event(state: &AppState, did: &str) -> Result<i64, String> {
let repo_root = sqlx::query_scalar!(
"SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
did
)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("DB Error fetching repo root: {}", e))?
.ok_or_else(|| "Repo not found".to_string())?;
let ops = serde_json::json!([]);
let blobs: Vec<String> = vec![];
let blocks_cids: Vec<String> = vec![];
let seq_row = sqlx::query!(
r#"
INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids)
VALUES ($1, 'commit', $2, $2, $3, $4, $5)
RETURNING seq
"#,
did,
repo_root,
ops,
&blobs,
&blocks_cids
)
.fetch_one(&state.db)
.await
.map_err(|e| format!("DB Error (repo_seq empty commit): {}", e))?;
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&state.db)
.await
.map_err(|e| format!("DB Error (notify): {}", e))?;
Ok(seq_row.seq)
}
+100 -35
View File
@@ -22,10 +22,7 @@ use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
pub async fn has_verified_comms_channel(
db: &PgPool,
did: &str,
) -> Result<bool, sqlx::Error> {
pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT
@@ -52,13 +49,21 @@ pub async fn has_verified_comms_channel(
}
}
pub struct RepoWriteAuth {
pub did: String,
pub user_id: Uuid,
pub current_root_cid: Cid,
pub is_oauth: bool,
pub scope: Option<String>,
}
pub async fn prepare_repo_write(
state: &AppState,
headers: &HeaderMap,
repo_did: &str,
http_method: &str,
http_uri: &str,
) -> Result<(String, Uuid, Cid), Response> {
) -> Result<RepoWriteAuth, Response> {
let extracted = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
@@ -69,9 +74,7 @@ pub async fn prepare_repo_write(
)
.into_response()
})?;
let dpop_proof = headers
.get("DPoP")
.and_then(|h| h.to_str().ok());
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let auth_user = crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
@@ -163,7 +166,13 @@ pub async fn prepare_repo_write(
)
.into_response()
})?;
Ok((auth_user.did, user_id, current_root_cid))
Ok(RepoWriteAuth {
did: auth_user.did,
user_id,
current_root_cid,
is_oauth: auth_user.is_oauth,
scope: auth_user.scope,
})
}
#[derive(Deserialize)]
#[allow(dead_code)]
@@ -188,19 +197,34 @@ pub async fn create_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<CreateRecordInput>,
) -> Response {
let (did, user_id, current_root_cid) =
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
auth.scope.as_deref(),
crate::oauth::RepoAction::Create,
&input.collection,
) {
return e;
}
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
@@ -234,9 +258,10 @@ pub async fn create_record(
}
};
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
return *err_response;
}
&& let Err(err_response) = validate_record(&input.record, &input.collection)
{
return *err_response;
}
let rkey = input
.rkey
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
@@ -285,10 +310,18 @@ pub async fn create_record(
cid: record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if new_mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
@@ -356,19 +389,42 @@ pub async fn put_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json<PutRecordInput>,
) -> Response {
let (did, user_id, current_root_cid) =
let auth =
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
auth.scope.as_deref(),
crate::oauth::RepoAction::Create,
&input.collection,
) {
return e;
}
if let Err(e) = crate::auth::scope_check::check_repo_scope(
auth.is_oauth,
auth.scope.as_deref(),
crate::oauth::RepoAction::Update,
&input.collection,
) {
return e;
}
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
@@ -403,9 +459,10 @@ pub async fn put_record(
};
let key = format!("{}/{}", collection_nsid, input.rkey);
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
return *err_response;
}
&& let Err(err_response) = validate_record(&input.record, &input.collection)
{
return *err_response;
}
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
@@ -480,10 +537,18 @@ pub async fn put_record(
}
};
let mut relevant_blocks = std::collections::BTreeMap::new();
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if new_mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
+57 -13
View File
@@ -133,7 +133,7 @@ pub async fn activate_account(
"https://{}/xrpc/com.atproto.server.activateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
let auth_user = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
extracted.is_dpop,
@@ -144,9 +144,20 @@ pub async fn activate_account(
)
.await
{
Ok(user) => user.did,
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Repo,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -171,6 +182,14 @@ pub async fn activate_account(
{
warn!("Failed to sequence identity event for activation: {}", e);
}
if let Err(e) =
crate::api::repo::record::sequence_empty_commit_event(&state, &did).await
{
warn!(
"Failed to sequence empty commit event for activation: {}",
e
);
}
(StatusCode::OK, Json(json!({}))).into_response()
}
Err(e) => {
@@ -206,7 +225,7 @@ pub async fn deactivate_account(
"https://{}/xrpc/com.atproto.server.deactivateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
let auth_user = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
extracted.is_dpop,
@@ -217,9 +236,20 @@ pub async fn deactivate_account(
)
.await
{
Ok(user) => user.did,
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Repo,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -236,8 +266,13 @@ pub async fn deactivate_account(
if let Some(ref h) = handle {
let _ = state.cache.delete(&format!("handle:{}", h)).await;
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, false, Some("deactivated")).await
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
false,
Some("deactivated"),
)
.await
{
warn!("Failed to sequence account deactivation event: {}", e);
}
@@ -315,13 +350,9 @@ pub async fn request_account_delete(
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::enqueue_account_deletion(
&state.db,
user_id,
&confirmation_token,
&hostname,
)
.await
if let Err(e) =
crate::comms::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname)
.await
{
warn!("Failed to enqueue account deletion notification: {:?}", e);
}
@@ -502,6 +533,19 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
did,
false,
Some("deleted"),
)
.await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
did, e
);
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
info!("Account {} deleted successfully", did);
(StatusCode::OK, Json(json!({}))).into_response()
+41 -14
View File
@@ -52,11 +52,21 @@ pub async fn request_email_update(
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -167,11 +177,21 @@ pub async fn confirm_email(
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_one(&state.db)
.await
@@ -274,7 +294,7 @@ pub async fn confirm_email(
return ApiError::InternalError.into_response();
}
if let Err(_) = tx.commit().await {
if tx.commit().await.is_err() {
return ApiError::InternalError.into_response();
}
@@ -310,17 +330,24 @@ pub async fn update_email(
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let did = match auth_result {
Ok(user) => user.did,
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let user = match sqlx::query!(
"SELECT id, email FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
@@ -451,7 +478,7 @@ pub async fn update_email(
.execute(&mut *tx)
.await;
if let Err(_) = tx.commit().await {
if tx.commit().await.is_err() {
return ApiError::InternalError.into_response();
}
+15 -15
View File
@@ -8,10 +8,10 @@ use axum::{
};
use bcrypt::{DEFAULT_COST, hash, verify};
use chrono::{Duration, Utc};
use uuid::Uuid;
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
use uuid::Uuid;
fn generate_reset_code() -> String {
crate::util::generate_token_code()
@@ -19,13 +19,15 @@ fn generate_reset_code() -> String {
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
@@ -99,8 +101,7 @@ pub async fn request_password_reset(
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
{
warn!("Failed to enqueue password reset notification: {:?}", e);
}
@@ -335,12 +336,11 @@ pub async fn change_password(
)
.into_response();
}
let user = sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, password_hash FROM users WHERE did = $1",
)
.bind(&auth.0.did)
.fetch_optional(&state.db)
.await;
let user =
sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1")
.bind(&auth.0.did)
.fetch_optional(&state.db)
.await;
let (user_id, password_hash) = match user {
Ok(Some(row)) => row,
Ok(None) => {
+50 -22
View File
@@ -55,12 +55,13 @@ pub async fn get_service_auth(
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let auth_user = match crate::auth::validate_bearer_token_for_service_auth(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let key_bytes = match auth_user.key_bytes {
Some(kb) => kb,
let auth_user =
match crate::auth::validate_bearer_token_for_service_auth(&state.db, &token).await {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let key_bytes = match &auth_user.key_bytes {
Some(kb) => kb.clone(),
None => {
return ApiError::AuthenticationFailedMsg(
"OAuth tokens cannot create service auth".into(),
@@ -72,6 +73,29 @@ pub async fn get_service_auth(
let lxm = params.lxm.as_deref();
let lxm_for_token = lxm.unwrap_or("*");
if let Some(method) = lxm {
if let Err(e) = crate::auth::scope_check::check_rpc_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
&params.aud,
method,
) {
return e;
}
} else if auth_user.is_oauth {
let permissions = auth_user.permissions();
if !permissions.has_full_access() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "OAuth tokens with granular scopes must specify an lxm parameter"
})),
)
.into_response();
}
}
let user_status = sqlx::query!(
"SELECT takedown_ref FROM users WHERE did = $1",
auth_user.did
@@ -95,9 +119,10 @@ pub async fn get_service_auth(
.into_response();
}
if let Some(method) = lxm {
if PROTECTED_METHODS.contains(&method) {
return (
if let Some(method) = lxm
&& PROTECTED_METHODS.contains(&method)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
@@ -105,7 +130,6 @@ pub async fn get_service_auth(
})),
)
.into_response();
}
}
if let Some(exp) = params.exp {
@@ -146,18 +170,22 @@ pub async fn get_service_auth(
}
}
let service_token =
match crate::auth::create_service_token(&auth_user.did, &params.aud, lxm_for_token, &key_bytes) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let service_token = match crate::auth::create_service_token(
&auth_user.did,
&params.aud,
lxm_for_token,
&key_bytes,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
(
StatusCode::OK,
Json(GetServiceAuthOutput {
+49 -36
View File
@@ -16,13 +16,15 @@ use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
@@ -36,7 +38,8 @@ fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
}
fn full_handle(stored_handle: &str, pds_hostname: &str) -> String {
if stored_handle.contains('.') {
let suffix = format!(".{}", pds_hostname);
if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) {
stored_handle.to_string()
} else {
format!("{}.{}", stored_handle, pds_hostname)
@@ -191,6 +194,9 @@ pub async fn get_session(
State(state): State<AppState>,
BearerAuthAllowDeactivated(auth_user): BearerAuthAllowDeactivated,
) -> Response {
let permissions = auth_user.permissions();
let can_read_email = permissions.allows_email_read();
match sqlx::query!(
r#"SELECT
handle, email, email_verified, is_admin, deactivated_at,
@@ -209,21 +215,29 @@ pub async fn get_session(
crate::comms::CommsChannel::Telegram => ("telegram", row.telegram_verified),
crate::comms::CommsChannel::Signal => ("signal", row.signal_verified),
};
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&row.handle, &pds_hostname);
let is_active = row.deactivated_at.is_none();
let email_value = if can_read_email {
row.email.clone()
} else {
None
};
let email_verified_value = can_read_email && row.email_verified;
Json(json!({
"handle": handle,
"did": auth_user.did,
"email": row.email,
"emailVerified": row.email_verified,
"email": email_value,
"emailVerified": email_verified_value,
"preferredChannel": preferred_channel,
"preferredChannelVerified": preferred_channel_verified,
"isAdmin": row.is_admin,
"active": is_active,
"status": if is_active { "active" } else { "deactivated" },
"didDoc": {}
})).into_response()
}))
.into_response()
}
Ok(None) => ApiError::AuthenticationFailed.into_response(),
Err(e) => {
@@ -433,7 +447,8 @@ pub async fn refresh_session(
crate::comms::CommsChannel::Telegram => ("telegram", u.telegram_verified),
crate::comms::CommsChannel::Signal => ("signal", u.signal_verified),
};
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&u.handle, &pds_hostname);
Json(json!({
"accessJwt": new_access_meta.token,
@@ -446,7 +461,8 @@ pub async fn refresh_session(
"preferredChannelVerified": preferred_channel_verified,
"isAdmin": u.is_admin,
"active": true
})).into_response()
}))
.into_response()
}
Ok(None) => {
error!("User not found for existing session: {}", session_row.did);
@@ -500,7 +516,8 @@ pub async fn confirm_signup(
Ok(Some(row)) => row,
Ok(None) => {
warn!("User not found for confirm_signup: {}", input.did);
return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
return ApiError::InvalidRequest("Invalid DID or verification code".into())
.into_response();
}
Err(e) => {
error!("Database error in confirm_signup: {:?}", e);
@@ -532,8 +549,7 @@ pub async fn confirm_signup(
}
if verification.expires_at < Utc::now() {
warn!("Verification code expired for user: {}", input.did);
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
.into_response();
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
}
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
@@ -549,10 +565,7 @@ pub async fn confirm_signup(
crate::comms::CommsChannel::Telegram => "telegram_verified",
crate::comms::CommsChannel::Signal => "signal_verified",
};
let update_query = format!(
"UPDATE users SET {} = TRUE WHERE did = $1",
verified_column
);
let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column);
if let Err(e) = sqlx::query(&update_query)
.bind(&input.did)
.execute(&state.db)
@@ -567,7 +580,8 @@ pub async fn confirm_signup(
row.id
)
.execute(&state.db)
.await {
.await
{
error!("Failed to delete verification record: {:?}", e);
}
@@ -603,10 +617,7 @@ pub async fn confirm_signup(
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
warn!("Failed to enqueue welcome notification: {:?}", e);
}
let email_verified = matches!(
row.channel,
crate::comms::CommsChannel::Email
);
let email_verified = matches!(row.channel, crate::comms::CommsChannel::Email);
let preferred_channel = match row.channel {
crate::comms::CommsChannel::Email => "email",
crate::comms::CommsChannel::Discord => "discord",
@@ -688,18 +699,12 @@ pub async fn resend_verification(
return ApiError::InternalError.into_response();
}
let (channel_str, recipient) = match row.channel {
crate::comms::CommsChannel::Email => {
("email", row.email.unwrap_or_default())
}
crate::comms::CommsChannel::Discord => {
("discord", row.discord_id.unwrap_or_default())
}
crate::comms::CommsChannel::Email => ("email", row.email.unwrap_or_default()),
crate::comms::CommsChannel::Discord => ("discord", row.discord_id.unwrap_or_default()),
crate::comms::CommsChannel::Telegram => {
("telegram", row.telegram_username.unwrap_or_default())
}
crate::comms::CommsChannel::Signal => {
("signal", row.signal_number.unwrap_or_default())
}
crate::comms::CommsChannel::Signal => ("signal", row.signal_number.unwrap_or_default()),
};
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
@@ -740,7 +745,15 @@ 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::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
let result = sqlx::query_as::<
_,
(
i32,
String,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
),
>(
r#"
SELECT id, access_jti, created_at, refresh_expires_at
FROM session_tokens
@@ -759,7 +772,7 @@ pub async fn list_sessions(
id: id.to_string(),
created_at: created_at.to_rfc3339(),
expires_at: expires_at.to_rfc3339(),
is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti),
is_current: current_jti.as_ref() == Some(&access_jti),
})
.collect();
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
+123 -11
View File
@@ -6,8 +6,11 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde::Serialize;
use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -23,16 +26,17 @@ pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMa
if let Some(token) =
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
&& let Ok(user) = validate_bearer_token(&state.db, &token).await
&& user.is_oauth {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "Forbidden",
"message": "OAuth credentials are not supported for this endpoint"
})),
)
.into_response();
}
&& user.is_oauth
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "Forbidden",
"message": "OAuth credentials are not supported for this endpoint"
})),
)
.into_response();
}
Json(CheckSignupQueueOutput {
activated: true,
place_in_queue: None,
@@ -40,3 +44,111 @@ pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMa
})
.into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DereferenceScopeInput {
pub scope: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DereferenceScopeOutput {
pub scope: String,
}
pub async fn dereference_scope(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<DereferenceScopeInput>,
) -> Response {
let token = match extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
if validate_bearer_token(&state.db, &token).await.is_err() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
let scope_parts: Vec<&str> = input.scope.split_whitespace().collect();
let mut resolved_scopes: Vec<String> = Vec::new();
for part in scope_parts {
if let Some(cid_str) = part.strip_prefix("ref:") {
let cache_key = format!("scope_ref:{}", cid_str);
if let Some(cached) = state.cache.get(&cache_key).await {
for s in cached.split_whitespace() {
if !resolved_scopes.contains(&s.to_string()) {
resolved_scopes.push(s.to_string());
}
}
continue;
}
let cid = match Cid::from_str(cid_str) {
Ok(c) => c,
Err(_) => {
tracing::warn!("Invalid CID in scope ref: {}", cid_str);
continue;
}
};
let block_bytes = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
Ok(None) => {
tracing::warn!("Scope ref block not found: {}", cid_str);
continue;
}
Err(e) => {
tracing::warn!("Error fetching scope ref block {}: {:?}", cid_str, e);
continue;
}
};
let scope_record: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block_bytes)
{
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to decode scope ref block {}: {:?}", cid_str, e);
continue;
}
};
if let Some(scope_value) = scope_record.get("scope").and_then(|v| v.as_str()) {
let _ = state
.cache
.set(
&cache_key,
scope_value,
std::time::Duration::from_secs(3600),
)
.await;
for s in scope_value.split_whitespace() {
if !resolved_scopes.contains(&s.to_string()) {
resolved_scopes.push(s.to_string());
}
}
}
} else if !resolved_scopes.contains(&part.to_string()) {
resolved_scopes.push(part.to_string());
}
}
Json(DereferenceScopeOutput {
scope: resolved_scopes.join(" "),
})
.into_response()
}
+31 -21
View File
@@ -49,11 +49,13 @@ pub async fn confirm_channel_verification(
.await
{
Ok(id) => id,
Err(_) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response(),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
};
let channel_str = input.channel.as_str();
@@ -88,14 +90,15 @@ pub async fn confirm_channel_verification(
.into_response(),
};
let pending_identifier = match record.pending_identifier {
Some(p) => p,
None => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
)
.into_response(),
};
let pending_identifier =
match record.pending_identifier {
Some(p) => p,
None => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending identifier found"})),
)
.into_response(),
};
if record.expires_at < Utc::now() {
return (
@@ -115,11 +118,13 @@ pub async fn confirm_channel_verification(
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response(),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let update_result = match channel_str {
@@ -148,7 +153,11 @@ pub async fn confirm_channel_verification(
if let Err(e) = update_result {
error!("Failed to update user channel: {:?}", e);
if channel_str == "email" && e.as_database_error().map(|db| db.is_unique_violation()).unwrap_or(false) {
if channel_str == "email"
&& e.as_database_error()
.map(|db| db.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
@@ -168,7 +177,8 @@ pub async fn confirm_channel_verification(
channel_str as _
)
.execute(&mut *tx)
.await {
.await
{
error!("Failed to delete verification record: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -177,7 +187,7 @@ pub async fn confirm_channel_verification(
.into_response();
}
if let Err(_) = tx.commit().await {
if tx.commit().await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
+18 -18
View File
@@ -83,13 +83,13 @@ impl DidResolver {
pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> {
{
let cache = self.did_cache.read().await;
if let Some(cached) = cache.get(did) {
if cached.resolved_at.elapsed() < self.cache_ttl {
return Some(ResolvedService {
url: cached.url.clone(),
did: cached.did.clone(),
});
}
if let Some(cached) = cache.get(did)
&& cached.resolved_at.elapsed() < self.cache_ttl
{
return Some(ResolvedService {
url: cached.url.clone(),
did: cached.did.clone(),
});
}
}
@@ -240,17 +240,17 @@ impl DidResolver {
}
}
if let Some(service) = doc.service.first() {
if service.service_endpoint.starts_with("http") {
warn!(
"No explicit AppView service found for {}, using first service: {}",
doc.id, service.service_endpoint
);
return Some(ResolvedService {
url: service.service_endpoint.clone(),
did: doc.id.clone(),
});
}
if let Some(service) = doc.service.first()
&& service.service_endpoint.starts_with("http")
{
warn!(
"No explicit AppView service found for {}, using first service: {}",
doc.id, service.service_endpoint
);
return Some(ResolvedService {
url: service.service_endpoint.clone(),
did: doc.id.clone(),
});
}
if doc.id.starts_with("did:web:") {
+107 -21
View File
@@ -8,7 +8,7 @@ use serde_json::json;
use super::{
AuthenticatedUser, TokenValidationError, validate_bearer_token_cached,
validate_bearer_token_cached_allow_deactivated,
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
};
use crate::state::AppState;
@@ -63,6 +63,7 @@ impl IntoResponse for AuthError {
}
}
#[cfg(test)]
fn extract_bearer_token(auth_header: &str) -> Result<&str, AuthError> {
let auth_header = auth_header.trim();
@@ -151,13 +152,37 @@ impl FromRequestParts<AppState> for BearerAuth {
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
match validate_bearer_token_cached(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = parts.uri.to_string();
match validate_token_with_dpop(
&state.db,
&extracted.token,
true,
dpop_proof,
method,
&uri,
false,
)
.await
{
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
}
}
}
}
@@ -178,12 +203,41 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await {
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = parts.uri.to_string();
match validate_token_with_dpop(
&state.db,
&extracted.token,
true,
dpop_proof,
method,
&uri,
true,
)
.await
{
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached_allow_deactivated(
&state.db,
&state.cache,
&extracted.token,
)
.await
{
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
}
}
}
}
@@ -204,19 +258,51 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = extract_bearer_token(auth_header)?;
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
match validate_bearer_token_cached(&state.db, &state.cache, token).await {
Ok(user) => {
if !user.is_admin {
return Err(AuthError::AdminRequired);
let user = if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = parts.uri.to_string();
match validate_token_with_dpop(
&state.db,
&extracted.token,
true,
dpop_proof,
method,
&uri,
false,
)
.await
{
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
Ok(BearerAuthAdmin(user))
Err(TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(_) => return Err(AuthError::AuthenticationFailed),
}
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(_) => Err(AuthError::AuthenticationFailed),
} else {
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
Err(TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(_) => return Err(AuthError::AuthenticationFailed),
}
};
if !user.is_admin {
return Err(AuthError::AdminRequired);
}
Ok(BearerAuthAdmin(user))
}
}
+67 -40
View File
@@ -5,8 +5,10 @@ use std::sync::Arc;
use std::time::Duration;
use crate::cache::Cache;
use crate::oauth::scopes::ScopePermissions;
pub mod extractor;
pub mod scope_check;
pub mod service;
pub mod token;
pub mod verify;
@@ -15,6 +17,7 @@ pub use extractor::{
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
extract_auth_token_from_header, extract_bearer_token_from_header,
};
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
@@ -24,7 +27,6 @@ pub use token::{
pub use verify::{
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, verify_token,
};
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
const KEY_CACHE_TTL_SECS: u64 = 300;
const SESSION_CACHE_TTL_SECS: u64 = 60;
@@ -53,6 +55,16 @@ pub struct AuthenticatedUser {
pub key_bytes: Option<Vec<u8>>,
pub is_oauth: bool,
pub is_admin: bool,
pub scope: Option<String>,
}
impl AuthenticatedUser {
pub fn permissions(&self) -> ScopePermissions {
if !self.is_oauth {
return ScopePermissions::from_scope_string(Some("atproto"));
}
ScopePermissions::from_scope_string(self.scope.as_deref())
}
}
pub async fn validate_bearer_token(
@@ -114,7 +126,8 @@ async fn validate_bearer_token_with_options_internal(
}
}
let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key {
let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key
{
let user_status = sqlx::query!(
"SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1",
did
@@ -125,7 +138,12 @@ async fn validate_bearer_token_with_options_internal(
.flatten();
match user_status {
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref, status.is_admin),
Some(status) => (
Some(key),
status.deactivated_at,
status.takedown_ref,
status.is_admin,
),
None => (None, None, None, false),
}
} else if let Some(user) = sqlx::query!(
@@ -153,7 +171,12 @@ async fn validate_bearer_token_with_options_internal(
.await;
}
(Some(key), user.deactivated_at, user.takedown_ref, user.is_admin)
(
Some(key),
user.deactivated_at,
user.takedown_ref,
user.is_admin,
)
} else {
(None, None, None, false)
};
@@ -194,16 +217,15 @@ async fn validate_bearer_token_with_options_internal(
session_valid = session_exists.is_some();
if session_valid
&& let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
}
if session_valid && let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
}
}
if session_valid {
@@ -212,6 +234,7 @@ async fn validate_bearer_token_with_options_internal(
key_bytes: Some(decrypted_key),
is_oauth: false,
is_admin,
scope: None,
});
}
}
@@ -232,33 +255,34 @@ async fn validate_bearer_token_with_options_internal(
.await
.ok()
.flatten()
{
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
return Err(TokenValidationError::AccountDeactivated);
}
if oauth_token.takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
let now = chrono::Utc::now();
if oauth_token.expires_at > now {
let key_bytes = if let (Some(kb), Some(ev)) =
(&oauth_token.key_bytes, oauth_token.encryption_version)
{
crate::config::decrypt_key(kb, Some(ev)).ok()
} else {
None
};
return Ok(AuthenticatedUser {
did: oauth_token.did,
key_bytes,
is_oauth: true,
is_admin: oauth_token.is_admin,
});
}
{
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
return Err(TokenValidationError::AccountDeactivated);
}
if oauth_token.takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
let now = chrono::Utc::now();
if oauth_token.expires_at > now {
let key_bytes = if let (Some(kb), Some(ev)) =
(&oauth_token.key_bytes, oauth_token.encryption_version)
{
crate::config::decrypt_key(kb, Some(ev)).ok()
} else {
None
};
return Ok(AuthenticatedUser {
did: oauth_token.did,
key_bytes,
is_oauth: true,
is_admin: oauth_token.is_admin,
scope: oauth_info.scope,
});
}
}
Err(TokenValidationError::AuthenticationFailed)
}
@@ -314,7 +338,9 @@ pub async fn validate_token_with_dpop(
if user_info.takedown_ref.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
let key_bytes = if let (Some(kb), Some(ev)) = (&user_info.key_bytes, user_info.encryption_version) {
let key_bytes = if let (Some(kb), Some(ev)) =
(&user_info.key_bytes, user_info.encryption_version)
{
crate::config::decrypt_key(kb, Some(ev)).ok()
} else {
None
@@ -324,6 +350,7 @@ pub async fn validate_token_with_dpop(
key_bytes,
is_oauth: true,
is_admin: user_info.is_admin,
scope: result.scope,
})
}
Err(_) => Err(TokenValidationError::AuthenticationFailed),
+118
View File
@@ -0,0 +1,118 @@
#![allow(clippy::result_large_err)]
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
pub fn check_repo_scope(
is_oauth: bool,
scope: Option<&str>,
action: RepoAction,
collection: &str,
) -> Result<(), Response> {
if !is_oauth {
return Ok(());
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_repo(action, collection).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
}
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
if !is_oauth {
return Ok(());
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_blob(mime).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
}
pub fn check_rpc_scope(
is_oauth: bool,
scope: Option<&str>,
aud: &str,
lxm: &str,
) -> Result<(), Response> {
if !is_oauth {
return Ok(());
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_rpc(aud, lxm).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
}
pub fn check_account_scope(
is_oauth: bool,
scope: Option<&str>,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), Response> {
if !is_oauth {
return Ok(());
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_account(attr, action).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
}
pub fn check_identity_scope(
is_oauth: bool,
scope: Option<&str>,
attr: IdentityAttr,
) -> Result<(), Response> {
if !is_oauth {
return Ok(());
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_identity(attr).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
}
+6 -5
View File
@@ -278,11 +278,13 @@ impl Default for ServiceTokenVerifier {
fn parse_did_key_multibase(multibase: &str) -> Result<VerifyingKey> {
if !multibase.starts_with('z') {
return Err(anyhow!("Expected base58btc multibase encoding (starts with 'z')"));
return Err(anyhow!(
"Expected base58btc multibase encoding (starts with 'z')"
));
}
let (_, decoded) = multibase::decode(multibase)
.map_err(|e| anyhow!("Failed to decode multibase: {}", e))?;
let (_, decoded) =
multibase::decode(multibase).map_err(|e| anyhow!("Failed to decode multibase: {}", e))?;
if decoded.len() < 2 {
return Err(anyhow!("Invalid multicodec data"));
@@ -302,8 +304,7 @@ fn parse_did_key_multibase(multibase: &str) -> Result<VerifyingKey> {
return Err(anyhow!("Only secp256k1 keys are supported"));
}
VerifyingKey::from_sec1_bytes(key_bytes)
.map_err(|e| anyhow!("Invalid public key: {}", e))
VerifyingKey::from_sec1_bytes(key_bytes).map_err(|e| anyhow!("Invalid public key: {}", e))
}
pub fn is_service_token(token: &str) -> bool {
+16 -14
View File
@@ -113,13 +113,14 @@ fn verify_token_internal(
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if let Some(expected) = expected_typ
&& header.typ != expected {
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
&& header.typ != expected
{
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
@@ -185,13 +186,14 @@ fn verify_token_hs256_internal(
}
if let Some(expected) = expected_typ
&& header.typ != expected {
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
&& header.typ != expected
{
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
+2 -2
View File
@@ -8,8 +8,8 @@ pub use sender::{
};
pub use service::{
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
enqueue_email_update, enqueue_email_verification, enqueue_password_reset,
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
};
+2 -1
View File
@@ -87,7 +87,8 @@ impl EmailSender {
pub fn from_env() -> Option<Self> {
let from_address = std::env::var("MAIL_FROM_ADDRESS").ok()?;
let from_name = std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string());
let from_name =
std::env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Tranquil PDS".to_string());
Some(Self::new(from_address, from_name))
}
+1 -1
View File
@@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::sender::{CommsSender, SendError};
use super::types::{NewComms, CommsChannel, CommsStatus, QueuedComms};
use super::types::{CommsChannel, CommsStatus, NewComms, QueuedComms};
pub struct CommsService {
db: PgPool,
+9 -3
View File
@@ -46,11 +46,15 @@ impl AuthConfig {
}
});
if jwt_secret.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
if jwt_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("JWT_SECRET must be at least 32 characters");
}
if dpop_secret.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
if dpop_secret.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("DPOP_SECRET must be at least 32 characters");
}
@@ -97,7 +101,9 @@ impl AuthConfig {
}
});
if master_key.len() < 32 && std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err() {
if master_key.len() < 32
&& std::env::var("TRANQUIL_PDS_ALLOW_INSECURE_SECRETS").is_err()
{
panic!("MASTER_KEY must be at least 32 characters");
}
+5 -4
View File
@@ -79,10 +79,11 @@ impl Crawlers {
}
if let Some(cb) = &self.circuit_breaker
&& !cb.can_execute().await {
debug!("Skipping crawler notification due to circuit breaker open");
return;
}
&& !cb.can_execute().await
{
debug!("Skipping crawler notification due to circuit breaker open");
return;
}
self.mark_notified();
let circuit_breaker = self.circuit_breaker.clone();
+1 -1
View File
@@ -1,5 +1,5 @@
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use std::time::Duration;
use thiserror::Error;
+17 -1
View File
@@ -3,12 +3,12 @@ pub mod appview;
pub mod auth;
pub mod cache;
pub mod circuit_breaker;
pub mod comms;
pub mod config;
pub mod crawlers;
pub mod handle;
pub mod image;
pub mod metrics;
pub mod comms;
pub mod oauth;
pub mod plc;
pub mod rate_limit;
@@ -343,6 +343,10 @@ pub fn app(state: AppState) -> Router {
)
.route("/oauth/authorize", get(oauth::endpoints::authorize_get))
.route("/oauth/authorize", post(oauth::endpoints::authorize_post))
.route(
"/oauth/authorize/accounts",
get(oauth::endpoints::authorize_accounts),
)
.route(
"/oauth/authorize/select",
post(oauth::endpoints::authorize_select),
@@ -359,6 +363,14 @@ pub fn app(state: AppState) -> Router {
"/oauth/authorize/deny",
post(oauth::endpoints::authorize_deny),
)
.route(
"/oauth/authorize/consent",
get(oauth::endpoints::consent_get),
)
.route(
"/oauth/authorize/consent",
post(oauth::endpoints::consent_post),
)
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
.route(
@@ -369,6 +381,10 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.temp.checkSignupQueue",
get(api::temp::check_signup_queue),
)
.route(
"/xrpc/com.atproto.temp.dereferenceScope",
post(api::temp::dereference_scope),
)
.route(
"/xrpc/com.tranquil.account.getNotificationPrefs",
get(api::notification_prefs::get_notification_prefs),
+3 -3
View File
@@ -1,11 +1,11 @@
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
use tranquil_pds::state::AppState;
use std::net::SocketAddr;
use std::process::ExitCode;
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info, warn};
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
use tranquil_pds::state::AppState;
#[tokio::main]
async fn main() -> ExitCode {
+20 -10
View File
@@ -24,7 +24,10 @@ pub fn init_metrics() -> PrometheusHandle {
}
fn describe_metrics() {
metrics::describe_counter!("tranquil_pds_http_requests_total", "Total number of HTTP requests");
metrics::describe_counter!(
"tranquil_pds_http_requests_total",
"Total number of HTTP requests"
);
metrics::describe_histogram!(
"tranquil_pds_http_request_duration_seconds",
"HTTP request duration in seconds"
@@ -61,7 +64,10 @@ fn describe_metrics() {
"tranquil_pds_rate_limit_rejections_total",
"Total number of rate limit rejections"
);
metrics::describe_counter!("tranquil_pds_db_queries_total", "Total number of database queries");
metrics::describe_counter!(
"tranquil_pds_db_queries_total",
"Total number of database queries"
);
metrics::describe_histogram!(
"tranquil_pds_db_query_duration_seconds",
"Database query duration in seconds"
@@ -116,12 +122,13 @@ pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response
fn normalize_path(path: &str) -> String {
if path.starts_with("/xrpc/")
&& let Some(method) = path.strip_prefix("/xrpc/") {
if let Some(q) = method.find('?') {
return format!("/xrpc/{}", &method[..q]);
}
return path.to_string();
&& let Some(method) = path.strip_prefix("/xrpc/")
{
if let Some(q) = method.find('?') {
return format!("/xrpc/{}", &method[..q]);
}
return path.to_string();
}
if path.starts_with("/u/") && path.ends_with("/did.json") {
return "/u/{handle}/did.json".to_string();
@@ -135,11 +142,13 @@ fn normalize_path(path: &str) -> String {
}
pub fn record_auth_cache_hit(cache_type: &str) {
counter!("tranquil_pds_auth_cache_hits_total", "cache_type" => cache_type.to_string()).increment(1);
counter!("tranquil_pds_auth_cache_hits_total", "cache_type" => cache_type.to_string())
.increment(1);
}
pub fn record_auth_cache_miss(cache_type: &str) {
counter!("tranquil_pds_auth_cache_misses_total", "cache_type" => cache_type.to_string()).increment(1);
counter!("tranquil_pds_auth_cache_misses_total", "cache_type" => cache_type.to_string())
.increment(1);
}
pub fn set_firehose_subscribers(count: usize) {
@@ -172,7 +181,8 @@ pub fn set_comms_queue_size(size: usize) {
}
pub fn record_rate_limit_rejection(limiter: &str) {
counter!("tranquil_pds_rate_limit_rejections_total", "limiter" => limiter.to_string()).increment(1);
counter!("tranquil_pds_rate_limit_rejections_total", "limiter" => limiter.to_string())
.increment(1);
}
pub fn record_db_query(query_type: &str, duration_seconds: f64) {
+36 -32
View File
@@ -135,9 +135,10 @@ impl ClientMetadataCache {
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(client_id)
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
return Ok(cached.metadata.clone());
}
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
{
return Ok(cached.metadata.clone());
}
}
let metadata = self.fetch_metadata(client_id).await?;
{
@@ -168,9 +169,10 @@ impl ClientMetadataCache {
{
let cache = self.jwks_cache.read().await;
if let Some(cached) = cache.get(jwks_uri)
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
return Ok(cached.jwks.clone());
}
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
{
return Ok(cached.jwks.clone());
}
}
let jwks = self.fetch_jwks(jwks_uri).await?;
{
@@ -190,11 +192,11 @@ impl ClientMetadataCache {
if !jwks_uri.starts_with("https://")
&& (!jwks_uri.starts_with("http://")
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
{
return Err(OAuthError::InvalidClient(
"jwks_uri must use https (except for localhost)".to_string(),
));
}
{
return Err(OAuthError::InvalidClient(
"jwks_uri must use https (except for localhost)".to_string(),
));
}
let response = self
.http_client
.get(jwks_uri)
@@ -302,26 +304,27 @@ impl ClientMetadataCache {
return Ok(());
}
if Self::is_loopback_client(&metadata.client_id)
&& let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
let req_host = req_url.host_str().unwrap_or("");
let is_loopback_redirect = req_url.scheme() == "http"
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
if is_loopback_redirect {
for registered in &metadata.redirect_uris {
if let Ok(reg_url) = reqwest::Url::parse(registered) {
let reg_host = reg_url.host_str().unwrap_or("");
let hosts_match = (req_host == "localhost" && reg_host == "localhost")
|| (req_host == "127.0.0.1" && reg_host == "127.0.0.1")
|| (req_host == "[::1]" && reg_host == "[::1]")
|| (req_host == "localhost" && reg_host == "127.0.0.1")
|| (req_host == "127.0.0.1" && reg_host == "localhost");
if hosts_match && req_url.path() == reg_url.path() {
return Ok(());
}
&& let Ok(req_url) = reqwest::Url::parse(redirect_uri)
{
let req_host = req_url.host_str().unwrap_or("");
let is_loopback_redirect = req_url.scheme() == "http"
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
if is_loopback_redirect {
for registered in &metadata.redirect_uris {
if let Ok(reg_url) = reqwest::Url::parse(registered) {
let reg_host = reg_url.host_str().unwrap_or("");
let hosts_match = (req_host == "localhost" && reg_host == "localhost")
|| (req_host == "127.0.0.1" && reg_host == "127.0.0.1")
|| (req_host == "[::1]" && reg_host == "[::1]")
|| (req_host == "localhost" && reg_host == "127.0.0.1")
|| (req_host == "127.0.0.1" && reg_host == "localhost");
if hosts_match && req_url.path() == reg_url.path() {
return Ok(());
}
}
}
}
}
Err(OAuthError::InvalidRequest(
"redirect_uri not registered for client".to_string(),
))
@@ -501,11 +504,12 @@ async fn verify_private_key_jwt_async(
));
}
if let Some(iat) = iat
&& iat > now + 60 {
return Err(OAuthError::InvalidClient(
"client_assertion iat is in the future".to_string(),
));
}
&& iat > now + 60
{
return Err(OAuthError::InvalidClient(
"client_assertion iat is in the future".to_string(),
));
}
let jwks = cache.get_jwks(metadata).await?;
let keys = jwks
.get("keys")
+8 -2
View File
@@ -3,6 +3,7 @@ mod device;
mod dpop;
mod helpers;
mod request;
mod scope_preference;
mod token;
mod two_factor;
@@ -15,12 +16,17 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
pub use request::{
consume_authorization_request_by_code, create_authorization_request,
delete_authorization_request, delete_expired_authorization_requests, get_authorization_request,
update_authorization_request,
mark_request_authenticated, set_authorization_did, update_authorization_request,
update_request_scope,
};
pub use scope_preference::{
ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent,
upsert_scope_preferences,
};
pub use token::{
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
get_token_by_refresh_token, list_tokens_for_user, rotate_token,
get_token_by_refresh_token, list_tokens_for_user, revoke_tokens_for_client, rotate_token,
};
pub use two_factor::{
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+61
View File
@@ -67,6 +67,27 @@ pub async fn get_authorization_request(
}
}
pub async fn set_authorization_did(
pool: &PgPool,
request_id: &str,
did: &str,
device_id: Option<&str>,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET did = $2, device_id = $3
WHERE id = $1
"#,
request_id,
did,
device_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_authorization_request(
pool: &PgPool,
request_id: &str,
@@ -151,3 +172,43 @@ pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result<u64,
.await?;
Ok(result.rows_affected())
}
pub async fn mark_request_authenticated(
pool: &PgPool,
request_id: &str,
did: &str,
device_id: Option<&str>,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET did = $2, device_id = $3
WHERE id = $1
"#,
request_id,
did,
device_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn update_request_scope(
pool: &PgPool,
request_id: &str,
scope: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_authorization_request
SET parameters = jsonb_set(parameters, '{scope}', to_jsonb($2::text))
WHERE id = $1
"#,
request_id,
scope
)
.execute(pool)
.await?;
Ok(())
}
+103
View File
@@ -0,0 +1,103 @@
use super::super::OAuthError;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopePreference {
pub scope: String,
pub granted: bool,
}
pub async fn get_scope_preferences(
pool: &PgPool,
did: &str,
client_id: &str,
) -> Result<Vec<ScopePreference>, OAuthError> {
let rows = sqlx::query!(
r#"
SELECT scope, granted FROM oauth_scope_preference
WHERE did = $1 AND client_id = $2
"#,
did,
client_id
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| ScopePreference {
scope: r.scope,
granted: r.granted,
})
.collect())
}
pub async fn upsert_scope_preferences(
pool: &PgPool,
did: &str,
client_id: &str,
prefs: &[ScopePreference],
) -> Result<(), OAuthError> {
for pref in prefs {
sqlx::query!(
r#"
INSERT INTO oauth_scope_preference (did, client_id, scope, granted, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (did, client_id, scope) DO UPDATE SET granted = $4, updated_at = NOW()
"#,
did,
client_id,
pref.scope,
pref.granted
)
.execute(pool)
.await?;
}
Ok(())
}
pub async fn should_show_consent(
pool: &PgPool,
did: &str,
client_id: &str,
requested_scopes: &[String],
) -> Result<bool, OAuthError> {
if requested_scopes.is_empty() {
return Ok(false);
}
let stored_prefs = get_scope_preferences(pool, did, client_id).await?;
if stored_prefs.is_empty() {
return Ok(true);
}
let stored_scopes: std::collections::HashSet<&str> =
stored_prefs.iter().map(|p| p.scope.as_str()).collect();
for scope in requested_scopes {
if !stored_scopes.contains(scope.as_str()) {
return Ok(true);
}
}
Ok(false)
}
pub async fn delete_scope_preferences(
pool: &PgPool,
did: &str,
client_id: &str,
) -> Result<(), OAuthError> {
sqlx::query!(
r#"
DELETE FROM oauth_scope_preference
WHERE did = $1 AND client_id = $2
"#,
did,
client_id
)
.execute(pool)
.await?;
Ok(())
}
+15
View File
@@ -268,3 +268,18 @@ pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<()
}
Ok(())
}
pub async fn revoke_tokens_for_client(
pool: &PgPool,
did: &str,
client_id: &str,
) -> Result<u64, OAuthError> {
let result = sqlx::query!(
"DELETE FROM oauth_token WHERE did = $1 AND client_id = $2",
did,
client_id
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -79,6 +79,17 @@ pub async fn oauth_authorization_server(
"atproto".to_string(),
"transition:generic".to_string(),
"transition:chat.bsky".to_string(),
"repo:*".to_string(),
"repo:*?action=create".to_string(),
"repo:*?action=read".to_string(),
"repo:*?action=update".to_string(),
"repo:*?action=delete".to_string(),
"blob:*/*".to_string(),
"rpc:*".to_string(),
"account:*".to_string(),
"account:*?action=read".to_string(),
"account:*?action=write".to_string(),
"identity:*".to_string(),
]),
response_types_supported: vec!["code".to_string()],
response_modes_supported: Some(vec!["query".to_string(), "fragment".to_string()]),
+91 -11
View File
@@ -1,14 +1,16 @@
use crate::oauth::{
AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId,
client::ClientMetadataCache, db,
client::ClientMetadataCache,
db,
scopes::{ParsedScope, parse_scope},
};
use crate::state::{AppState, RateLimitKind};
use axum::{Form, Json, extract::State, http::HeaderMap};
use axum::body::Bytes;
use axum::{Json, extract::State, http::HeaderMap};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
const PAR_EXPIRY_SECONDS: i64 = 600;
const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"];
#[derive(Debug, Deserialize)]
pub struct ParRequest {
@@ -24,6 +26,8 @@ pub struct ParRequest {
#[serde(default)]
pub code_challenge_method: Option<String>,
#[serde(default)]
pub response_mode: Option<String>,
#[serde(default)]
pub login_hint: Option<String>,
#[serde(default)]
pub dpop_jkt: Option<String>,
@@ -44,8 +48,24 @@ pub struct ParResponse {
pub async fn pushed_authorization_request(
State(state): State<AppState>,
headers: HeaderMap,
Form(request): Form<ParRequest>,
body: Bytes,
) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let request: ParRequest = if content_type.starts_with("application/json") {
serde_json::from_slice(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
} else if content_type.starts_with("application/x-www-form-urlencoded") {
serde_urlencoded::from_bytes(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?
} else {
return Err(OAuthError::InvalidRequest(
"Content-Type must be application/json or application/x-www-form-urlencoded"
.to_string(),
));
};
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::OAuthPar, &client_ip)
@@ -77,6 +97,16 @@ pub async fn pushed_authorization_request(
let validated_scope = validate_scope(&request.scope, &client_metadata)?;
let request_id = RequestId::generate();
let expires_at = Utc::now() + Duration::seconds(PAR_EXPIRY_SECONDS);
let response_mode = match request.response_mode.as_deref() {
Some("fragment") => Some("fragment".to_string()),
Some("query") | None => None,
Some(mode) => {
return Err(OAuthError::InvalidRequest(format!(
"Unsupported response_mode: {}",
mode
)));
}
};
let parameters = AuthorizationRequestParameters {
response_type: request.response_type,
client_id: request.client_id.clone(),
@@ -85,6 +115,7 @@ pub async fn pushed_authorization_request(
state: request.state,
code_challenge: code_challenge.clone(),
code_challenge_method: code_challenge_method.to_string(),
response_mode,
login_hint: request.login_hint,
dpop_jkt: request.dpop_jkt,
extra: None,
@@ -149,19 +180,45 @@ fn validate_scope(
if requested_scopes.is_empty() {
return Ok(Some("atproto".to_string()));
}
let mut has_transition = false;
let mut has_granular = false;
for scope in &requested_scopes {
if !SUPPORTED_SCOPES.contains(scope) {
return Err(OAuthError::InvalidScope(format!(
"Unsupported scope: {}. Supported scopes: {}",
scope,
SUPPORTED_SCOPES.join(", ")
)));
let parsed = parse_scope(scope);
match &parsed {
ParsedScope::Unknown(_) => {
return Err(OAuthError::InvalidScope(format!(
"Unsupported scope: {}",
scope
)));
}
ParsedScope::TransitionGeneric
| ParsedScope::TransitionChat
| ParsedScope::TransitionEmail => {
has_transition = true;
}
ParsedScope::Repo(_)
| ParsedScope::Blob(_)
| ParsedScope::Rpc(_)
| ParsedScope::Account(_)
| ParsedScope::Identity(_)
| ParsedScope::Include(_) => {
has_granular = true;
}
ParsedScope::Atproto => {}
}
}
if has_transition && has_granular {
return Err(OAuthError::InvalidScope(
"Cannot mix transition scopes with granular scopes. Use either transition:* scopes OR granular scopes (repo:*, blob:*, rpc:*, account:*, include:*), not both.".to_string()
));
}
if let Some(client_scope) = &client_metadata.scope {
let client_scopes: Vec<&str> = client_scope.split_whitespace().collect();
for scope in &requested_scopes {
if !client_scopes.contains(scope) {
if !client_scopes.iter().any(|cs| scope_matches(cs, scope)) {
return Err(OAuthError::InvalidScope(format!(
"Scope '{}' not registered for this client",
scope
@@ -171,3 +228,26 @@ fn validate_scope(
}
Ok(Some(requested_scopes.join(" ")))
}
fn scope_matches(client_scope: &str, requested_scope: &str) -> bool {
if client_scope == requested_scope {
return true;
}
fn get_resource_type(scope: &str) -> &str {
let base = scope.split('?').next().unwrap_or(scope);
base.split(':').next().unwrap_or(base)
}
let client_type = get_resource_type(client_scope);
let requested_type = get_resource_type(requested_scope);
if client_type == requested_type {
let client_base = client_scope.split('?').next().unwrap_or(client_scope);
if client_base.contains('*') {
return true;
}
}
false
}
+37 -20
View File
@@ -36,9 +36,10 @@ pub async fn handle_authorization_code_grant(
));
}
if let Some(request_client_id) = &request.client_id
&& request_client_id != &auth_request.client_id {
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
}
&& request_client_id != &auth_request.client_id
{
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
}
let did = auth_request
.did
.ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
@@ -65,11 +66,12 @@ pub async fn handle_authorization_code_grant(
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
if let Some(redirect_uri) = &request.redirect_uri
&& redirect_uri != &auth_request.parameters.redirect_uri {
return Err(OAuthError::InvalidGrant(
"redirect_uri mismatch".to_string(),
));
}
&& redirect_uri != &auth_request.parameters.redirect_uri
{
return Err(OAuthError::InvalidGrant(
"redirect_uri mismatch".to_string(),
));
}
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
@@ -83,11 +85,12 @@ pub async fn handle_authorization_code_grant(
));
}
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt
&& &result.jkt != expected_jkt {
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
&& &result.jkt != expected_jkt
{
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
Some(result.jkt)
} else if auth_request.parameters.dpop_jkt.is_some() {
return Err(OAuthError::InvalidRequest(
@@ -96,10 +99,18 @@ pub async fn handle_authorization_code_grant(
} else {
None
};
if let Err(e) = db::revoke_tokens_for_client(&state.db, &did, &auth_request.client_id).await {
tracing::warn!("Failed to revoke previous tokens for client: {:?}", e);
}
let token_id = TokenId::generate();
let refresh_token = RefreshToken::generate();
let now = Utc::now();
let access_token = create_access_token(&token_id.0, &did, dpop_jkt.as_deref())?;
let access_token = create_access_token(
&token_id.0,
&did,
dpop_jkt.as_deref(),
auth_request.parameters.scope.as_deref(),
)?;
let token_data = TokenData {
did: did.clone(),
token_id: token_id.0.clone(),
@@ -179,11 +190,12 @@ pub async fn handle_refresh_token_grant(
));
}
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt
&& &result.jkt != expected_jkt {
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
&& &result.jkt != expected_jkt
{
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
Some(result.jkt)
} else if token_data.parameters.dpop_jkt.is_some() {
return Err(OAuthError::InvalidRequest(
@@ -203,7 +215,12 @@ pub async fn handle_refresh_token_grant(
new_expires_at,
)
.await?;
let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?;
let access_token = create_access_token(
&new_token_id.0,
&token_data.did,
dpop_jkt.as_deref(),
token_data.scope.as_deref(),
)?;
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
+3 -1
View File
@@ -36,12 +36,14 @@ pub fn create_access_token(
token_id: &str,
sub: &str,
dpop_jkt: Option<&str>,
scope: Option<&str>,
) -> Result<String, OAuthError> {
use serde_json::json;
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let issuer = format!("https://{}", pds_hostname);
let now = Utc::now().timestamp();
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
let actual_scope = scope.unwrap_or("atproto");
let mut payload = json!({
"iss": issuer,
"sub": sub,
@@ -49,7 +51,7 @@ pub fn create_access_token(
"iat": now,
"exp": exp,
"jti": token_id,
"scope": "atproto"
"scope": actual_scope
});
if let Some(jkt) = dpop_jkt {
payload["cnf"] = json!({ "jkt": jkt });
+27 -8
View File
@@ -5,7 +5,8 @@ mod types;
use crate::oauth::OAuthError;
use crate::state::{AppState, RateLimitKind};
use axum::{Form, Json, extract::State, http::HeaderMap};
use axum::body::Bytes;
use axum::{Json, extract::State, http::HeaderMap};
pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant};
pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify_pkce};
@@ -17,21 +18,39 @@ pub use types::{TokenRequest, TokenResponse};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
"unknown".to_string()
}
pub async fn token_endpoint(
State(state): State<AppState>,
headers: HeaderMap,
Form(request): Form<TokenRequest>,
body: Bytes,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let request: TokenRequest = if content_type.starts_with("application/json") {
serde_json::from_slice(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
} else if content_type.starts_with("application/x-www-form-urlencoded") {
serde_urlencoded::from_bytes(&body)
.map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?
} else {
return Err(OAuthError::InvalidRequest(
"Content-Type must be application/json or application/x-www-form-urlencoded"
.to_string(),
));
};
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::OAuthToken, &client_ip)
+2 -2
View File
@@ -4,12 +4,12 @@ pub mod dpop;
pub mod endpoints;
pub mod error;
pub mod jwks;
pub mod templates;
pub mod scopes;
pub mod types;
pub mod verify;
pub use error::OAuthError;
pub use templates::{DeviceAccount, mask_email};
pub use scopes::{AccountAction, AccountAttr, RepoAction, ScopeError, ScopePermissions};
pub use types::*;
pub use verify::{
OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
+134
View File
@@ -0,0 +1,134 @@
use std::collections::HashMap;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeCategory {
Core,
Transition,
Repo,
Blob,
Rpc,
Account,
}
impl ScopeCategory {
pub fn display_name(&self) -> &'static str {
match self {
ScopeCategory::Core => "Core Access",
ScopeCategory::Transition => "Transition",
ScopeCategory::Repo => "Repository",
ScopeCategory::Blob => "Media",
ScopeCategory::Rpc => "API Access",
ScopeCategory::Account => "Account",
}
}
}
#[derive(Debug, Clone)]
pub struct ScopeDefinition {
pub scope: &'static str,
pub category: ScopeCategory,
pub required: bool,
pub description: &'static str,
pub display_name: &'static str,
}
pub static SCOPE_DEFINITIONS: LazyLock<HashMap<&'static str, ScopeDefinition>> =
LazyLock::new(|| {
let definitions = vec![
ScopeDefinition {
scope: "atproto",
category: ScopeCategory::Core,
required: true,
description: "Use AT Protocol OAuth (required for all sessions)",
display_name: "AT Protocol",
},
ScopeDefinition {
scope: "transition:generic",
category: ScopeCategory::Transition,
required: false,
description: "Generic transition scope for compatibility",
display_name: "Transition Access",
},
ScopeDefinition {
scope: "transition:chat.bsky",
category: ScopeCategory::Transition,
required: false,
description: "Access to Bluesky chat features",
display_name: "Chat Access",
},
ScopeDefinition {
scope: "transition:email",
category: ScopeCategory::Account,
required: false,
description: "Read your account email address",
display_name: "Email Access",
},
ScopeDefinition {
scope: "repo:*?action=create",
category: ScopeCategory::Repo,
required: false,
description: "Create new records in your repository",
display_name: "Create Records",
},
ScopeDefinition {
scope: "repo:*?action=update",
category: ScopeCategory::Repo,
required: false,
description: "Update existing records in your repository",
display_name: "Update Records",
},
ScopeDefinition {
scope: "repo:*?action=delete",
category: ScopeCategory::Repo,
required: false,
description: "Delete records from your repository",
display_name: "Delete Records",
},
ScopeDefinition {
scope: "blob:*/*",
category: ScopeCategory::Blob,
required: false,
description: "Upload images, videos, and other media files",
display_name: "Upload Media",
},
];
definitions.into_iter().map(|d| (d.scope, d)).collect()
});
#[allow(dead_code)]
pub fn get_scope_definition(scope: &str) -> Option<&'static ScopeDefinition> {
SCOPE_DEFINITIONS.get(scope)
}
#[allow(dead_code)]
pub fn is_valid_scope(scope: &str) -> bool {
if SCOPE_DEFINITIONS.contains_key(scope) {
return true;
}
if scope.starts_with("ref:") {
return true;
}
false
}
#[allow(dead_code)]
pub fn get_required_scopes() -> Vec<&'static str> {
SCOPE_DEFINITIONS
.values()
.filter(|d| d.required)
.map(|d| d.scope)
.collect()
}
#[allow(dead_code)]
pub fn format_scope_for_display(scope: &str) -> String {
if let Some(def) = get_scope_definition(scope) {
def.description.to_string()
} else if scope.starts_with("ref:") {
"Referenced scope".to_string()
} else {
format!("Access to {}", scope)
}
}
+39
View File
@@ -0,0 +1,39 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(Debug, Clone)]
pub enum ScopeError {
InsufficientScope { required: String, message: String },
InvalidScope(String),
}
impl std::fmt::Display for ScopeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScopeError::InsufficientScope { message, .. } => write!(f, "{}", message),
ScopeError::InvalidScope(msg) => write!(f, "Invalid scope: {}", msg),
}
}
}
impl std::error::Error for ScopeError {}
impl IntoResponse for ScopeError {
fn into_response(self) -> Response {
let (status, error_code, message) = match &self {
ScopeError::InsufficientScope { message, .. } => {
(StatusCode::FORBIDDEN, "InsufficientScope", message.clone())
}
ScopeError::InvalidScope(msg) => (StatusCode::BAD_REQUEST, "InvalidScope", msg.clone()),
};
(
status,
axum::Json(json!({
"error": error_code,
"message": message
})),
)
.into_response()
}
}
+12
View File
@@ -0,0 +1,12 @@
mod definitions;
mod error;
mod parser;
mod permissions;
pub use definitions::{SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition};
pub use error::ScopeError;
pub use parser::{
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, IncludeScope,
ParsedScope, RepoAction, RepoScope, RpcScope, parse_scope, parse_scope_string,
};
pub use permissions::ScopePermissions;
+483
View File
@@ -0,0 +1,483 @@
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedScope {
Atproto,
TransitionGeneric,
TransitionChat,
TransitionEmail,
Repo(RepoScope),
Blob(BlobScope),
Rpc(RpcScope),
Account(AccountScope),
Identity(IdentityScope),
Include(IncludeScope),
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeScope {
pub nsid: String,
pub aud: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoScope {
pub collection: Option<String>,
pub actions: HashSet<RepoAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RepoAction {
Create,
Update,
Delete,
}
impl RepoAction {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"create" => Some(Self::Create),
"update" => Some(Self::Update),
"delete" => Some(Self::Delete),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobScope {
pub accept: HashSet<String>,
}
impl BlobScope {
pub fn matches_mime(&self, mime: &str) -> bool {
if self.accept.is_empty() || self.accept.contains("*/*") {
return true;
}
for pattern in &self.accept {
if pattern == mime {
return true;
}
if let Some(prefix) = pattern.strip_suffix("/*")
&& mime.starts_with(prefix)
&& mime.chars().nth(prefix.len()) == Some('/')
{
return true;
}
}
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RpcScope {
pub lxm: Option<String>,
pub aud: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountScope {
pub attr: AccountAttr,
pub action: AccountAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountAttr {
Email,
Handle,
Repo,
Status,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityScope {
pub attr: IdentityAttr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IdentityAttr {
Handle,
Wildcard,
}
impl AccountAttr {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"email" => Some(Self::Email),
"handle" => Some(Self::Handle),
"repo" => Some(Self::Repo),
"status" => Some(Self::Status),
_ => None,
}
}
}
impl IdentityAttr {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"handle" => Some(Self::Handle),
"*" => Some(Self::Wildcard),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountAction {
Read,
Manage,
}
impl AccountAction {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"read" => Some(Self::Read),
"manage" => Some(Self::Manage),
_ => None,
}
}
}
fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
let mut params: HashMap<String, Vec<String>> = HashMap::new();
for part in query.split('&') {
if let Some((key, value)) = part.split_once('=') {
params
.entry(key.to_string())
.or_default()
.push(value.to_string());
}
}
params
}
pub fn parse_scope(scope: &str) -> ParsedScope {
match scope {
"atproto" => return ParsedScope::Atproto,
"transition:generic" => return ParsedScope::TransitionGeneric,
"transition:chat.bsky" => return ParsedScope::TransitionChat,
"transition:email" => return ParsedScope::TransitionEmail,
_ => {}
}
let (base, query) = scope.split_once('?').unwrap_or((scope, ""));
let params = parse_query_params(query);
if let Some(rest) = base.strip_prefix("repo:") {
let collection = if rest == "*" || rest.is_empty() {
None
} else {
Some(rest.to_string())
};
let mut actions = HashSet::new();
if let Some(action_values) = params.get("action") {
for action_str in action_values {
if let Some(action) = RepoAction::parse_str(action_str) {
actions.insert(action);
}
}
}
if actions.is_empty() {
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
}
return ParsedScope::Repo(RepoScope {
collection,
actions,
});
}
if base == "repo" {
let mut actions = HashSet::new();
if let Some(action_values) = params.get("action") {
for action_str in action_values {
if let Some(action) = RepoAction::parse_str(action_str) {
actions.insert(action);
}
}
}
if actions.is_empty() {
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
}
return ParsedScope::Repo(RepoScope {
collection: None,
actions,
});
}
if base.starts_with("blob") {
let positional = base.strip_prefix("blob:").unwrap_or("");
let mut accept = HashSet::new();
if !positional.is_empty() {
accept.insert(positional.to_string());
}
if let Some(accept_values) = params.get("accept") {
for v in accept_values {
accept.insert(v.to_string());
}
}
return ParsedScope::Blob(BlobScope { accept });
}
if base.starts_with("rpc") {
let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string());
let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned()));
let aud = params.get("aud").and_then(|v| v.first().cloned());
let is_lxm_wildcard = lxm.as_deref() == Some("*") || lxm.is_none();
let is_aud_wildcard = aud.as_deref() == Some("*");
if is_lxm_wildcard && is_aud_wildcard {
return ParsedScope::Unknown(scope.to_string());
}
return ParsedScope::Rpc(RpcScope { lxm, aud });
}
if let Some(attr_str) = base.strip_prefix("account:")
&& let Some(attr) = AccountAttr::parse_str(attr_str)
{
let action = params
.get("action")
.and_then(|v| v.first())
.and_then(|s| AccountAction::parse_str(s))
.unwrap_or(AccountAction::Read);
return ParsedScope::Account(AccountScope { attr, action });
}
if let Some(attr_str) = base.strip_prefix("identity:")
&& let Some(attr) = IdentityAttr::parse_str(attr_str)
{
return ParsedScope::Identity(IdentityScope { attr });
}
if let Some(nsid) = base.strip_prefix("include:") {
let aud = params.get("aud").and_then(|v| v.first().cloned());
return ParsedScope::Include(IncludeScope {
nsid: nsid.to_string(),
aud,
});
}
ParsedScope::Unknown(scope.to_string())
}
pub fn parse_scope_string(scope_str: &str) -> Vec<ParsedScope> {
scope_str.split_whitespace().map(parse_scope).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_atproto() {
assert_eq!(parse_scope("atproto"), ParsedScope::Atproto);
}
#[test]
fn test_parse_transition_scopes() {
assert_eq!(
parse_scope("transition:generic"),
ParsedScope::TransitionGeneric
);
assert_eq!(
parse_scope("transition:chat.bsky"),
ParsedScope::TransitionChat
);
assert_eq!(
parse_scope("transition:email"),
ParsedScope::TransitionEmail
);
}
#[test]
fn test_parse_repo_wildcard() {
let scope = parse_scope("repo:*?action=create");
match scope {
ParsedScope::Repo(r) => {
assert!(r.collection.is_none());
assert!(r.actions.contains(&RepoAction::Create));
assert!(!r.actions.contains(&RepoAction::Update));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_repo_collection() {
let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete");
match scope {
ParsedScope::Repo(r) => {
assert_eq!(r.collection, Some("app.bsky.feed.post".to_string()));
assert!(r.actions.contains(&RepoAction::Create));
assert!(r.actions.contains(&RepoAction::Delete));
assert!(!r.actions.contains(&RepoAction::Update));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_repo_no_actions_means_all() {
let scope = parse_scope("repo:app.bsky.feed.post");
match scope {
ParsedScope::Repo(r) => {
assert!(r.actions.contains(&RepoAction::Create));
assert!(r.actions.contains(&RepoAction::Update));
assert!(r.actions.contains(&RepoAction::Delete));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_blob_wildcard() {
let scope = parse_scope("blob:*/*");
match scope {
ParsedScope::Blob(b) => {
assert!(b.accept.contains("*/*"));
assert!(b.matches_mime("image/png"));
assert!(b.matches_mime("video/mp4"));
}
_ => panic!("Expected Blob scope"),
}
}
#[test]
fn test_parse_blob_specific() {
let scope = parse_scope("blob?accept=image/*&accept=video/*");
match scope {
ParsedScope::Blob(b) => {
assert!(b.matches_mime("image/png"));
assert!(b.matches_mime("image/jpeg"));
assert!(b.matches_mime("video/mp4"));
assert!(!b.matches_mime("text/plain"));
}
_ => panic!("Expected Blob scope"),
}
}
#[test]
fn test_parse_rpc() {
let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app");
match scope {
ParsedScope::Rpc(r) => {
assert_eq!(r.lxm, Some("app.bsky.feed.getTimeline".to_string()));
assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string()));
}
_ => panic!("Expected Rpc scope"),
}
}
#[test]
fn test_parse_account() {
let scope = parse_scope("account:email?action=read");
match scope {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Email);
assert_eq!(a.action, AccountAction::Read);
}
_ => panic!("Expected Account scope"),
}
let scope2 = parse_scope("account:repo?action=manage");
match scope2 {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Repo);
assert_eq!(a.action, AccountAction::Manage);
}
_ => panic!("Expected Account scope"),
}
}
#[test]
fn test_parse_scope_string() {
let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*");
assert_eq!(scopes.len(), 3);
assert_eq!(scopes[0], ParsedScope::Atproto);
match &scopes[1] {
ParsedScope::Repo(_) => {}
_ => panic!("Expected Repo"),
}
match &scopes[2] {
ParsedScope::Blob(_) => {}
_ => panic!("Expected Blob"),
}
}
#[test]
fn test_parse_include() {
let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app");
match scope {
ParsedScope::Include(i) => {
assert_eq!(i.nsid, "app.bsky.authFullApp");
assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string()));
}
_ => panic!("Expected Include scope"),
}
let scope2 = parse_scope("include:com.example.authBasicFeatures");
match scope2 {
ParsedScope::Include(i) => {
assert_eq!(i.nsid, "com.example.authBasicFeatures");
assert_eq!(i.aud, None);
}
_ => panic!("Expected Include scope"),
}
}
#[test]
fn test_parse_identity() {
let scope = parse_scope("identity:handle");
match scope {
ParsedScope::Identity(i) => {
assert_eq!(i.attr, IdentityAttr::Handle);
}
_ => panic!("Expected Identity scope"),
}
let scope2 = parse_scope("identity:*");
match scope2 {
ParsedScope::Identity(i) => {
assert_eq!(i.attr, IdentityAttr::Wildcard);
}
_ => panic!("Expected Identity scope"),
}
}
#[test]
fn test_parse_account_status() {
let scope = parse_scope("account:status?action=read");
match scope {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Status);
assert_eq!(a.action, AccountAction::Read);
}
_ => panic!("Expected Account scope"),
}
}
#[test]
fn test_rpc_wildcard_aud_forbidden() {
let scope = parse_scope("rpc:*?aud=*");
assert!(matches!(scope, ParsedScope::Unknown(_)));
let scope2 = parse_scope("rpc?aud=*");
assert!(matches!(scope2, ParsedScope::Unknown(_)));
let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*");
assert!(matches!(scope3, ParsedScope::Rpc(_)));
let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app");
assert!(matches!(scope4, ParsedScope::Rpc(_)));
}
}
+488
View File
@@ -0,0 +1,488 @@
use super::error::ScopeError;
use super::parser::{
AccountAction, AccountAttr, BlobScope, IdentityAttr, IdentityScope, ParsedScope, RepoAction,
RepoScope, RpcScope, parse_scope_string,
};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct ScopePermissions {
scopes: HashSet<String>,
parsed: Vec<ParsedScope>,
has_atproto: bool,
has_transition_generic: bool,
has_transition_chat: bool,
has_transition_email: bool,
}
impl ScopePermissions {
pub fn from_scope_string(scope: Option<&str>) -> Self {
let scope_str = scope.unwrap_or("atproto");
let scopes: HashSet<String> = scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let parsed = parse_scope_string(scope_str);
let has_atproto = parsed.iter().any(|p| matches!(p, ParsedScope::Atproto));
let has_transition_generic = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionGeneric));
let has_transition_chat = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionChat));
let has_transition_email = parsed
.iter()
.any(|p| matches!(p, ParsedScope::TransitionEmail));
Self {
scopes,
parsed,
has_atproto,
has_transition_generic,
has_transition_chat,
has_transition_email,
}
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains(scope)
}
pub fn scopes(&self) -> &HashSet<String> {
&self.scopes
}
pub fn has_full_access(&self) -> bool {
self.has_atproto
}
fn find_repo_scopes(&self) -> impl Iterator<Item = &RepoScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Repo(r) = p {
Some(r)
} else {
None
}
})
}
fn find_blob_scopes(&self) -> impl Iterator<Item = &BlobScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Blob(b) = p {
Some(b)
} else {
None
}
})
}
fn find_rpc_scopes(&self) -> impl Iterator<Item = &RpcScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Rpc(r) = p {
Some(r)
} else {
None
}
})
}
fn find_account_scopes(&self) -> impl Iterator<Item = &super::parser::AccountScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Account(a) = p {
Some(a)
} else {
None
}
})
}
fn find_identity_scopes(&self) -> impl Iterator<Item = &IdentityScope> {
self.parsed.iter().filter_map(|p| {
if let ParsedScope::Identity(i) = p {
Some(i)
} else {
None
}
})
}
pub fn assert_repo(&self, action: RepoAction, collection: &str) -> Result<(), ScopeError> {
if self.has_atproto || self.has_transition_generic {
return Ok(());
}
for repo_scope in self.find_repo_scopes() {
if !repo_scope.actions.contains(&action) {
continue;
}
match &repo_scope.collection {
None => return Ok(()),
Some(coll) if coll == collection => return Ok(()),
Some(coll) if coll.ends_with(".*") => {
let prefix = coll.strip_suffix(".*").unwrap();
if collection.starts_with(prefix)
&& collection.chars().nth(prefix.len()) == Some('.')
{
return Ok(());
}
}
_ => {}
}
}
Err(ScopeError::InsufficientScope {
required: format!("repo:{}?action={}", collection, action_str(action)),
message: format!(
"Insufficient scope to {} records in {}",
action_str(action),
collection
),
})
}
pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> {
if self.has_atproto || self.has_transition_generic {
return Ok(());
}
for blob_scope in self.find_blob_scopes() {
if blob_scope.matches_mime(mime) {
return Ok(());
}
}
Err(ScopeError::InsufficientScope {
required: format!("blob:{}", mime),
message: format!("Insufficient scope to upload blob with mime type {}", mime),
})
}
pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> {
if self.has_atproto || self.has_transition_generic {
return Ok(());
}
if lxm.starts_with("chat.bsky.") && self.has_transition_chat {
return Ok(());
}
for rpc_scope in self.find_rpc_scopes() {
let lxm_matches = match &rpc_scope.lxm {
None => true,
Some(scope_lxm) if scope_lxm == lxm => true,
Some(scope_lxm) if scope_lxm.ends_with(".*") => {
let prefix = scope_lxm.strip_suffix(".*").unwrap();
lxm.starts_with(prefix) && lxm.chars().nth(prefix.len()) == Some('.')
}
_ => false,
};
let aud_matches = match &rpc_scope.aud {
None => true,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => scope_aud == aud,
};
if lxm_matches && aud_matches {
return Ok(());
}
}
Err(ScopeError::InsufficientScope {
required: format!("rpc:{}?aud={}", lxm, aud),
message: format!("Insufficient scope to call {} on {}", lxm, aud),
})
}
pub fn assert_account(
&self,
attr: AccountAttr,
action: AccountAction,
) -> Result<(), ScopeError> {
if self.has_atproto || self.has_transition_generic {
return Ok(());
}
if attr == AccountAttr::Email && action == AccountAction::Read && self.has_transition_email
{
return Ok(());
}
for account_scope in self.find_account_scopes() {
if account_scope.attr == attr && account_scope.action == action {
return Ok(());
}
if account_scope.attr == attr && account_scope.action == AccountAction::Manage {
return Ok(());
}
}
Err(ScopeError::InsufficientScope {
required: format!(
"account:{}?action={}",
attr_str(attr),
action_str_account(action)
),
message: format!(
"Insufficient scope to {} account {}",
action_str_account(action),
attr_str(attr)
),
})
}
pub fn allows_email_read(&self) -> bool {
self.has_atproto
|| self.has_transition_generic
|| self.has_transition_email
|| self
.find_account_scopes()
.any(|a| a.attr == AccountAttr::Email)
}
pub fn allows_repo(&self, action: RepoAction, collection: &str) -> bool {
self.assert_repo(action, collection).is_ok()
}
pub fn allows_blob(&self, mime: &str) -> bool {
self.assert_blob(mime).is_ok()
}
pub fn allows_rpc(&self, aud: &str, lxm: &str) -> bool {
self.assert_rpc(aud, lxm).is_ok()
}
pub fn allows_account(&self, attr: AccountAttr, action: AccountAction) -> bool {
self.assert_account(attr, action).is_ok()
}
pub fn assert_identity(&self, attr: IdentityAttr) -> Result<(), ScopeError> {
if self.has_atproto || self.has_transition_generic {
return Ok(());
}
for identity_scope in self.find_identity_scopes() {
if identity_scope.attr == IdentityAttr::Wildcard {
return Ok(());
}
if identity_scope.attr == attr {
return Ok(());
}
}
Err(ScopeError::InsufficientScope {
required: format!("identity:{}", identity_attr_str(attr)),
message: format!(
"Insufficient scope to modify identity {}",
identity_attr_str(attr)
),
})
}
pub fn allows_identity(&self, attr: IdentityAttr) -> bool {
self.assert_identity(attr).is_ok()
}
}
fn action_str(action: RepoAction) -> &'static str {
match action {
RepoAction::Create => "create",
RepoAction::Update => "update",
RepoAction::Delete => "delete",
}
}
fn attr_str(attr: AccountAttr) -> &'static str {
match attr {
AccountAttr::Email => "email",
AccountAttr::Handle => "handle",
AccountAttr::Repo => "repo",
AccountAttr::Status => "status",
}
}
fn identity_attr_str(attr: IdentityAttr) -> &'static str {
match attr {
IdentityAttr::Handle => "handle",
IdentityAttr::Wildcard => "*",
}
}
fn action_str_account(action: AccountAction) -> &'static str {
match action {
AccountAction::Read => "read",
AccountAction::Manage => "manage",
}
}
impl Default for ScopePermissions {
fn default() -> Self {
Self::from_scope_string(Some("atproto"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atproto_scope_allows_everything() {
let perms = ScopePermissions::from_scope_string(Some("atproto"));
assert!(perms.has_full_access());
assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Manage));
}
#[test]
fn test_transition_generic_allows_everything() {
let perms = ScopePermissions::from_scope_string(Some("transition:generic"));
assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
assert!(perms.allows_blob("image/png"));
}
#[test]
fn test_transition_chat_only_allows_chat() {
let perms = ScopePermissions::from_scope_string(Some("transition:chat.bsky"));
assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "chat.bsky.convo.getMessages"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
}
#[test]
fn test_empty_scope_defaults_to_atproto() {
let perms = ScopePermissions::from_scope_string(None);
assert!(perms.has_full_access());
}
#[test]
fn test_multiple_scopes() {
let perms = ScopePermissions::from_scope_string(Some("atproto transition:chat.bsky"));
assert!(perms.has_scope("atproto"));
assert!(perms.has_scope("transition:chat.bsky"));
assert!(!perms.has_scope("transition:generic"));
}
#[test]
fn test_transition_email_allows_email_read() {
let perms = ScopePermissions::from_scope_string(Some("transition:email"));
assert!(perms.allows_email_read());
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
}
#[test]
fn test_granular_repo_wildcard() {
let perms =
ScopePermissions::from_scope_string(Some("atproto repo:*?action=create blob:*/*"));
assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
assert!(perms.allows_blob("image/png"));
}
#[test]
fn test_granular_repo_collection_specific() {
let perms = ScopePermissions::from_scope_string(Some(
"repo:app.bsky.feed.post?action=create&action=delete",
));
assert!(perms.allows_repo(RepoAction::Create, "app.bsky.feed.post"));
assert!(perms.allows_repo(RepoAction::Delete, "app.bsky.feed.post"));
assert!(!perms.allows_repo(RepoAction::Update, "app.bsky.feed.post"));
assert!(!perms.allows_repo(RepoAction::Create, "app.bsky.feed.like"));
}
#[test]
fn test_granular_blob_specific_mime() {
let perms = ScopePermissions::from_scope_string(Some("blob?accept=image/*&accept=video/*"));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_blob("image/jpeg"));
assert!(perms.allows_blob("video/mp4"));
assert!(!perms.allows_blob("text/plain"));
assert!(!perms.allows_blob("application/json"));
}
#[test]
fn test_granular_rpc() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
assert!(!perms.allows_rpc("did:web:other.service", "app.bsky.feed.getTimeline"));
}
#[test]
fn test_granular_rpc_wildcard_aud() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getTimeline?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
assert!(perms.allows_rpc("did:web:any.service", "app.bsky.feed.getTimeline"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
}
#[test]
fn test_granular_account() {
let perms = ScopePermissions::from_scope_string(Some("account:email?action=read"));
assert!(perms.allows_account(AccountAttr::Email, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Email, AccountAction::Manage));
assert!(!perms.allows_account(AccountAttr::Handle, AccountAction::Read));
let perms2 = ScopePermissions::from_scope_string(Some("account:repo?action=manage"));
assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Manage));
assert!(perms2.allows_account(AccountAttr::Repo, AccountAction::Read));
}
#[test]
fn test_granular_scopes_without_atproto() {
let perms = ScopePermissions::from_scope_string(Some("repo:*?action=create"));
assert!(!perms.has_full_access());
assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
assert!(!perms.allows_repo(RepoAction::Update, "any.collection"));
assert!(!perms.allows_repo(RepoAction::Delete, "any.collection"));
}
#[test]
fn test_pdsls_style_scopes() {
let perms = ScopePermissions::from_scope_string(Some(
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
));
assert!(perms.allows_repo(RepoAction::Create, "any.collection"));
assert!(perms.allows_repo(RepoAction::Update, "any.collection"));
assert!(perms.allows_repo(RepoAction::Delete, "any.collection"));
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_blob("video/mp4"));
}
#[test]
fn test_identity_scope_handle() {
let perms = ScopePermissions::from_scope_string(Some("identity:handle"));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(!perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_identity_scope_wildcard() {
let perms = ScopePermissions::from_scope_string(Some("identity:*"));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_identity_scope_with_atproto() {
let perms = ScopePermissions::from_scope_string(Some("atproto"));
assert!(perms.allows_identity(IdentityAttr::Handle));
assert!(perms.allows_identity(IdentityAttr::Wildcard));
}
#[test]
fn test_account_status_scope() {
let perms = ScopePermissions::from_scope_string(Some("account:status?action=read"));
assert!(perms.allows_account(AccountAttr::Status, AccountAction::Read));
assert!(!perms.allows_account(AccountAttr::Status, AccountAction::Manage));
}
}
-595
View File
@@ -1,595 +0,0 @@
use chrono::{DateTime, Utc};
fn format_scope_for_display(scope: Option<&str>) -> String {
let scope = scope.unwrap_or("");
if scope.is_empty() || scope.contains("atproto") || scope.contains("transition:generic") {
return "access your account".to_string();
}
let parts: Vec<&str> = scope.split_whitespace().collect();
let friendly: Vec<&str> = parts
.iter()
.filter_map(|s| {
match *s {
"atproto" | "transition:generic" | "transition:chat.bsky" => None,
"read" => Some("read your data"),
"write" => Some("write data"),
other => Some(other),
}
})
.collect();
if friendly.is_empty() {
"access your account".to_string()
} else {
friendly.join(", ")
}
}
fn base_styles() -> &'static str {
r#"
:root {
--bg-primary: #fafafa;
--bg-secondary: #f9f9f9;
--bg-card: #ffffff;
--bg-input: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--border-color: #dddddd;
--border-color-light: #cccccc;
--accent: #0066cc;
--accent-hover: #0052a3;
--success-bg: #dfd;
--success-border: #8c8;
--success-text: #060;
--error-bg: #fee;
--error-border: #fcc;
--error-text: #c00;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #242424;
--bg-card: #2a2a2a;
--bg-input: #333333;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--border-color: #404040;
--border-color-light: #505050;
--accent: #4da6ff;
--accent-hover: #7abbff;
--success-bg: #1a3d1a;
--success-border: #2d5a2d;
--success-text: #7bc67b;
--error-bg: #3d1a1a;
--error-border: #5a2d2d;
--error-text: #ff7b7b;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
}
.container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
}
h1 {
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.subtitle {
color: var(--text-secondary);
margin: 0 0 2rem 0;
}
.subtitle strong {
color: var(--text-primary);
}
.client-info {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.client-info .client-name {
font-weight: 500;
color: var(--text-primary);
display: block;
margin-bottom: 0.25rem;
}
.client-info .scope {
color: var(--text-secondary);
font-size: 0.875rem;
}
.error-banner {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color-light);
border-radius: 4px;
font-size: 1rem;
color: var(--text-primary);
background: var(--bg-input);
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--accent);
}
input[type="text"]::placeholder,
input[type="email"]::placeholder,
input[type="password"]::placeholder {
color: var(--text-muted);
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.checkbox-group input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
}
.checkbox-group label {
margin-bottom: 0;
font-weight: normal;
color: var(--text-secondary);
cursor: pointer;
}
.buttons {
display: flex;
gap: 0.75rem;
}
.btn {
flex: 1;
padding: 0.75rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
border: none;
text-align: center;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
}
.btn-secondary:hover {
background: var(--accent);
color: white;
}
.footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.accounts {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.account-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: left;
}
.account-item:hover {
border-color: var(--accent);
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
}
.account-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.account-info .handle {
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-info .did {
font-size: 0.75rem;
color: var(--text-muted);
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron {
color: var(--text-muted);
font-size: 1.25rem;
flex-shrink: 0;
margin-left: 0.5rem;
}
.divider {
height: 1px;
background: var(--border-color);
margin: 1rem 0;
}
.new-account-link {
display: block;
text-align: center;
color: var(--accent);
text-decoration: none;
font-size: 0.875rem;
}
.new-account-link:hover {
text-decoration: underline;
}
.help-text {
text-align: center;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-code {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 0.5rem 1rem;
border-radius: 4px;
font-family: monospace;
display: inline-block;
margin-bottom: 1rem;
}
.success-icon {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin: 0 auto 1rem;
}
.text-center {
text-align: center;
}
.code-input {
letter-spacing: 0.5em;
text-align: center;
font-size: 1.5rem;
font-family: monospace;
}
"#
}
pub fn login_page(
client_id: &str,
client_name: Option<&str>,
scope: Option<&str>,
request_uri: &str,
error_message: Option<&str>,
login_hint: Option<&str>,
) -> String {
let client_display = client_name.unwrap_or(client_id);
let scope_display = format_scope_for_display(scope);
let error_html = error_message
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
.unwrap_or_default();
let login_hint_value = login_hint.unwrap_or("");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Sign in</title>
<style>{styles}</style>
</head>
<body>
<div class="container">
<h1>Sign In</h1>
<p class="subtitle">Sign in to continue to <strong>{client_display}</strong></p>
<div class="client-info">
<span class="client-name">{client_display}</span>
<span class="scope">wants to {scope_display}</span>
</div>
{error_html}
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="request_uri" value="{request_uri}">
<div class="form-group">
<label for="username">Handle</label>
<input type="text" id="username" name="username" value="{login_hint_value}"
required autocomplete="username" autofocus
placeholder="your.handle">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required
autocomplete="current-password" placeholder="Enter your password">
</div>
<div class="checkbox-group">
<input type="checkbox" id="remember_device" name="remember_device" value="true">
<label for="remember_device">Remember this device</label>
</div>
<div class="buttons">
<button type="submit" class="btn btn-primary">Sign In</button>
<button type="submit" formaction="/oauth/authorize/deny" formnovalidate class="btn btn-secondary">Cancel</button>
</div>
</form>
<p class="help-text">
By signing in, you agree to share your account information with this application.
</p>
</div>
</body>
</html>"#,
styles = base_styles(),
client_display = html_escape(client_display),
scope_display = html_escape(&scope_display),
request_uri = html_escape(request_uri),
error_html = error_html,
login_hint_value = html_escape(login_hint_value),
)
}
pub struct DeviceAccount {
pub did: String,
pub handle: String,
pub email: Option<String>,
pub last_used_at: DateTime<Utc>,
}
pub fn account_selector_page(
client_id: &str,
client_name: Option<&str>,
request_uri: &str,
accounts: &[DeviceAccount],
) -> String {
let client_display = client_name.unwrap_or(client_id);
let accounts_html: String = accounts
.iter()
.map(|account| {
format!(
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
<input type="hidden" name="request_uri" value="{request_uri}">
<input type="hidden" name="did" value="{did}">
<button type="submit" class="account-item">
<div class="account-info">
<span class="handle">@{handle}</span>
<span class="did">{did}</span>
</div>
<span class="chevron"></span>
</button>
</form>"#,
request_uri = html_escape(request_uri),
did = html_escape(&account.did),
handle = html_escape(&account.handle),
)
})
.collect();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Choose an account</title>
<style>{styles}</style>
</head>
<body>
<div class="container">
<h1>Sign In</h1>
<p class="subtitle">Choose an account to continue to <strong>{client_display}</strong></p>
<div class="accounts">
{accounts_html}
</div>
<div class="divider"></div>
<a href="/oauth/authorize?request_uri={request_uri_encoded}&new_account=true" class="new-account-link">
Sign in to another account
</a>
</div>
</body>
</html>"#,
styles = base_styles(),
client_display = html_escape(client_display),
accounts_html = accounts_html,
request_uri_encoded = urlencoding::encode(request_uri),
)
}
pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String {
let error_html = error_message
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
.unwrap_or_default();
let (title, subtitle) = match channel {
"email" => (
"Check Your Email",
"We sent a verification code to your email",
),
"Discord" => (
"Check Discord",
"We sent a verification code to your Discord",
),
"Telegram" => (
"Check Telegram",
"We sent a verification code to your Telegram",
),
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
_ => ("Check Your Messages", "We sent you a verification code"),
};
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Verify your identity</title>
<style>{styles}</style>
</head>
<body>
<div class="container">
<h1>{title}</h1>
<p class="subtitle">{subtitle}</p>
{error_html}
<form method="POST" action="/oauth/authorize/2fa">
<input type="hidden" name="request_uri" value="{request_uri}">
<div class="form-group">
<label for="code">Verification Code</label>
<input type="text" id="code" name="code" class="code-input"
placeholder="000000"
pattern="[0-9]{{6}}" maxlength="6"
inputmode="numeric" autocomplete="one-time-code"
autofocus required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%">Verify</button>
</form>
<p class="help-text">
Code expires in 10 minutes.
</p>
</div>
</body>
</html>"#,
styles = base_styles(),
title = title,
subtitle = subtitle,
request_uri = html_escape(request_uri),
error_html = error_html,
)
}
pub fn error_page(error: &str, error_description: Option<&str>) -> String {
let description =
error_description.unwrap_or("An error occurred during the authorization process.");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Authorization Error</title>
<style>{styles}</style>
</head>
<body>
<div class="container text-center">
<h1>Authorization Failed</h1>
<div class="error-code">{error}</div>
<p class="subtitle" style="margin-bottom:0">{description}</p>
<div style="margin-top:1.5rem">
<button onclick="window.close()" class="btn btn-secondary" style="width:100%">Close this window</button>
</div>
</div>
</body>
</html>"#,
styles = base_styles(),
error = html_escape(error),
description = html_escape(description),
)
}
pub fn success_page(client_name: Option<&str>) -> String {
let client_display = client_name.unwrap_or("The application");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Authorization Successful</title>
<style>{styles}</style>
</head>
<body>
<div class="container text-center">
<div class="success-icon"></div>
<h1 style="color:var(--success-text)">Authorization Successful</h1>
<p class="subtitle">{client_display} has been granted access to your account.</p>
<p class="help-text">You can close this window and return to the application.</p>
</div>
</body>
</html>"#,
styles = base_styles(),
client_display = html_escape(client_display),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
pub fn mask_email(email: &str) -> String {
if let Some(at_pos) = email.find('@') {
let local = &email[..at_pos];
let domain = &email[at_pos..];
if local.len() <= 2 {
format!("{}***{}", local.chars().next().unwrap_or('*'), domain)
} else {
let first = local.chars().next().unwrap_or('*');
let last = local.chars().last().unwrap_or('*');
format!("{}***{}{}", first, last, domain)
}
} else {
"***".to_string()
}
}
+1
View File
@@ -91,6 +91,7 @@ pub struct AuthorizationRequestParameters {
pub state: Option<String>,
pub code_challenge: String,
pub code_challenge_method: String,
pub response_mode: Option<String>,
pub login_hint: Option<String>,
pub dpop_jkt: Option<String>,
#[serde(flatten)]
+13 -6
View File
@@ -14,6 +14,7 @@ use subtle::ConstantTimeEq;
use super::OAuthError;
use super::db;
use super::dpop::DPoPVerifier;
use super::scopes::ScopePermissions;
use crate::config::AuthConfig;
use crate::state::AppState;
@@ -175,6 +176,7 @@ pub struct OAuthUser {
pub client_id: Option<String>,
pub scope: Option<String>,
pub is_oauth: bool,
pub permissions: ScopePermissions,
}
pub struct OAuthAuthError {
@@ -244,18 +246,23 @@ impl FromRequestParts<AppState> for OAuthUser {
client_id: None,
scope: None,
is_oauth: false,
permissions: ScopePermissions::default(),
});
}
let http_method = parts.method.as_str();
let http_uri = parts.uri.to_string();
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
{
Ok(result) => Ok(OAuthUser {
did: result.did,
client_id: Some(result.client_id),
scope: result.scope,
is_oauth: true,
}),
Ok(result) => {
let permissions = ScopePermissions::from_scope_string(result.scope.as_deref());
Ok(OAuthUser {
did: result.did,
client_id: Some(result.client_id),
scope: result.scope,
is_oauth: true,
permissions,
})
}
Err(OAuthError::UseDpopNonce(nonce)) => Err(OAuthAuthError {
status: StatusCode::UNAUTHORIZED,
error: "use_dpop_nonce".to_string(),
+6 -5
View File
@@ -408,11 +408,12 @@ pub fn validate_plc_operation_for_submission(
PlcError::InvalidResponse("verificationMethods must be an object".to_string())
})?;
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
&& atproto_key != ctx.expected_signing_key {
return Err(PlcError::InvalidResponse(
"Incorrect signing key".to_string(),
));
}
&& atproto_key != ctx.expected_signing_key
{
return Err(PlcError::InvalidResponse(
"Incorrect signing key".to_string(),
));
}
let also_known_as = obj
.get("alsoKnownAs")
.and_then(|v| v.as_array())
+8 -6
View File
@@ -122,14 +122,16 @@ impl RateLimiters {
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
&& let Some(first_ip) = value.split(',').next()
{
return first_ip.trim().to_string();
}
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
&& let Ok(value) = real_ip.to_str()
{
return value.trim().to_string();
}
addr.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
+45 -42
View File
@@ -77,19 +77,20 @@ pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec<BlobRef> {
Ipld::Map(obj) => {
if let Some(Ipld::String(type_str)) = obj.get("$type")
&& type_str == "blob"
&& let Some(Ipld::Link(link_cid)) = obj.get("ref") {
let mime = obj.get("mimeType").and_then(|v| {
if let Ipld::String(s) = v {
Some(s.clone())
} else {
None
}
});
return vec![BlobRef {
cid: link_cid.to_string(),
mime_type: mime,
}];
&& let Some(Ipld::Link(link_cid)) = obj.get("ref")
{
let mime = obj.get("mimeType").and_then(|v| {
if let Ipld::String(s) = v {
Some(s.clone())
} else {
None
}
});
return vec![BlobRef {
cid: link_cid.to_string(),
mime_type: mime,
}];
}
obj.values()
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
.collect()
@@ -110,17 +111,18 @@ pub fn find_blob_refs(value: &JsonValue, depth: usize) -> Vec<BlobRef> {
JsonValue::Object(obj) => {
if let Some(JsonValue::String(type_str)) = obj.get("$type")
&& type_str == "blob"
&& let Some(JsonValue::Object(ref_obj)) = obj.get("ref")
&& let Some(JsonValue::String(link)) = ref_obj.get("$link") {
let mime = obj
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from);
return vec![BlobRef {
cid: link.clone(),
mime_type: mime,
}];
}
&& let Some(JsonValue::Object(ref_obj)) = obj.get("ref")
&& let Some(JsonValue::String(link)) = ref_obj.get("$link")
{
let mime = obj
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from);
return vec![BlobRef {
cid: link.clone(),
mime_type: mime,
}];
}
obj.values()
.flat_map(|v| find_blob_refs(v, depth + 1))
.collect()
@@ -195,22 +197,22 @@ pub fn walk_mst(
});
if let (Some(key), Some(record_cid)) = (key, record_cid)
&& let Some(record_block) = blocks.get(&record_cid)
&& let Ok(record_value) =
serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
{
let blob_refs = find_blob_refs_ipld(&record_value, 0);
let parts: Vec<&str> = key.split('/').collect();
if parts.len() >= 2 {
let collection = parts[..parts.len() - 1].join("/");
let rkey = parts[parts.len() - 1].to_string();
records.push(ImportedRecord {
collection,
rkey,
cid: record_cid,
blob_refs,
});
}
}
&& let Ok(record_value) =
serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
{
let blob_refs = find_blob_refs_ipld(&record_value, 0);
let parts: Vec<&str> = key.split('/').collect();
if parts.len() >= 2 {
let collection = parts[..parts.len() - 1].join("/");
let rkey = parts[parts.len() - 1].to_string();
records.push(ImportedRecord {
collection,
rkey,
cid: record_cid,
blob_refs,
});
}
}
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
stack.push(*tree_cid);
}
@@ -300,9 +302,10 @@ pub async fn apply_import(
.await
.map_err(|e| {
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("55P03") {
return ImportError::ConcurrentModification;
}
&& db_err.code().as_deref() == Some("55P03")
{
return ImportError::ConcurrentModification;
}
ImportError::Database(e)
})?;
if repo.is_none() {
+28 -21
View File
@@ -140,9 +140,10 @@ pub async fn format_event_for_sending(
.try_into()
.map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?;
if let Some(ref pdc) = prev_data_cid_str
&& let Ok(cid) = Cid::from_str(pdc) {
frame.prev_data = Some(cid);
}
&& let Ok(cid) = Cid::from_str(pdc)
{
frame.prev_data = Some(cid);
}
let commit_cid = frame.commit;
let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok());
let mut all_cids: Vec<Cid> = block_cids_str
@@ -155,9 +156,10 @@ pub async fn format_event_for_sending(
}
if let Some(ref pc) = prev_cid
&& let Ok(Some(prev_bytes)) = state.block_store.get(pc).await
&& let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) {
frame.since = Some(rev);
}
&& let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes)
{
frame.since = Some(rev);
}
let car_bytes = if !all_cids.is_empty() {
let fetched = state.block_store.get_many(&all_cids).await?;
let mut blocks = std::collections::BTreeMap::new();
@@ -196,13 +198,15 @@ pub async fn prefetch_blocks_for_events(
let mut all_cids: Vec<Cid> = Vec::new();
for event in events {
if let Some(ref commit_cid_str) = event.commit_cid
&& let Ok(cid) = Cid::from_str(commit_cid_str) {
all_cids.push(cid);
}
&& let Ok(cid) = Cid::from_str(commit_cid_str)
{
all_cids.push(cid);
}
if let Some(ref prev_cid_str) = event.prev_cid
&& let Ok(cid) = Cid::from_str(prev_cid_str) {
all_cids.push(cid);
}
&& let Ok(cid) = Cid::from_str(prev_cid_str)
{
all_cids.push(cid);
}
if let Some(ref block_cids_str) = event.blocks_cids {
for s in block_cids_str {
if let Ok(cid) = Cid::from_str(s) {
@@ -279,9 +283,10 @@ pub async fn format_event_with_prefetched_blocks(
.try_into()
.map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?;
if let Some(ref pdc) = prev_data_cid_str
&& let Ok(cid) = Cid::from_str(pdc) {
frame.prev_data = Some(cid);
}
&& let Ok(cid) = Cid::from_str(pdc)
{
frame.prev_data = Some(cid);
}
let commit_cid = frame.commit;
let prev_cid = prev_cid_str.as_ref().and_then(|s| Cid::from_str(s).ok());
let mut all_cids: Vec<Cid> = block_cids_str
@@ -293,14 +298,16 @@ pub async fn format_event_with_prefetched_blocks(
all_cids.push(commit_cid);
}
if let Some(commit_bytes) = prefetched.get(&commit_cid)
&& let Some(rev) = extract_rev_from_commit_bytes(commit_bytes) {
frame.rev = rev;
}
&& let Some(rev) = extract_rev_from_commit_bytes(commit_bytes)
{
frame.rev = rev;
}
if let Some(ref pc) = prev_cid
&& let Some(prev_bytes) = prefetched.get(pc)
&& let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) {
frame.since = Some(rev);
}
&& let Some(rev) = extract_rev_from_commit_bytes(prev_bytes)
{
frame.since = Some(rev);
}
let car_bytes = if !all_cids.is_empty() {
let mut blocks = BTreeMap::new();
let mut commit_bytes_for_car: Option<Bytes> = None;
+7 -6
View File
@@ -268,12 +268,13 @@ impl CarVerifier {
stack.push(*tree_cid);
}
if let Some(Ipld::Link(value_cid)) = entry_obj.get("v")
&& !blocks.contains_key(value_cid) {
warn!(
"Record block {} referenced in MST not in CAR (may be expected for partial export)",
value_cid
);
}
&& !blocks.contains_key(value_cid)
{
warn!(
"Record block {} referenced in MST not in CAR (may be expected for partial export)",
value_cid
);
}
}
}
}
+49 -42
View File
@@ -111,12 +111,13 @@ impl RecordValidator {
}
}
if let Some(langs) = obj.get("langs").and_then(|v| v.as_array())
&& langs.len() > 3 {
return Err(ValidationError::InvalidField {
path: "langs".to_string(),
message: "Maximum 3 languages allowed".to_string(),
});
}
&& langs.len() > 3
{
return Err(ValidationError::InvalidField {
path: "langs".to_string(),
message: "Maximum 3 languages allowed".to_string(),
});
}
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
if tags.len() > 8 {
return Err(ValidationError::InvalidField {
@@ -126,12 +127,13 @@ impl RecordValidator {
}
for (i, tag) in tags.iter().enumerate() {
if let Some(tag_str) = tag.as_str()
&& tag_str.len() > 640 {
return Err(ValidationError::InvalidField {
path: format!("tags/{}", i),
message: "Tag exceeds maximum length of 640 bytes".to_string(),
});
}
&& tag_str.len() > 640
{
return Err(ValidationError::InvalidField {
path: format!("tags/{}", i),
message: "Tag exceeds maximum length of 640 bytes".to_string(),
});
}
}
}
Ok(())
@@ -198,12 +200,13 @@ impl RecordValidator {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
&& !subject.starts_with("did:") {
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
&& !subject.starts_with("did:")
{
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
Ok(())
}
@@ -215,12 +218,13 @@ impl RecordValidator {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(subject) = obj.get("subject").and_then(|v| v.as_str())
&& !subject.starts_with("did:") {
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
&& !subject.starts_with("did:")
{
return Err(ValidationError::InvalidField {
path: "subject".to_string(),
message: "Subject must be a DID".to_string(),
});
}
Ok(())
}
@@ -235,12 +239,13 @@ impl RecordValidator {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(name) = obj.get("name").and_then(|v| v.as_str())
&& (name.is_empty() || name.len() > 64) {
return Err(ValidationError::InvalidField {
path: "name".to_string(),
message: "Name must be 1-64 characters".to_string(),
});
}
&& (name.is_empty() || name.len() > 64)
{
return Err(ValidationError::InvalidField {
path: "name".to_string(),
message: "Name must be 1-64 characters".to_string(),
});
}
Ok(())
}
@@ -274,12 +279,13 @@ impl RecordValidator {
return Err(ValidationError::MissingField("createdAt".to_string()));
}
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str())
&& (display_name.is_empty() || display_name.len() > 240) {
return Err(ValidationError::InvalidField {
path: "displayName".to_string(),
message: "displayName must be 1-240 characters".to_string(),
});
}
&& (display_name.is_empty() || display_name.len() > 240)
{
return Err(ValidationError::InvalidField {
path: "displayName".to_string(),
message: "displayName must be 1-240 characters".to_string(),
});
}
Ok(())
}
@@ -328,12 +334,13 @@ impl RecordValidator {
return Err(ValidationError::MissingField(format!("{}/cid", path)));
}
if let Some(uri) = obj.get("uri").and_then(|v| v.as_str())
&& !uri.starts_with("at://") {
return Err(ValidationError::InvalidField {
path: format!("{}/uri", path),
message: "URI must be an at:// URI".to_string(),
});
}
&& !uri.starts_with("at://")
{
return Err(ValidationError::InvalidField {
path: format!("{}/uri", path),
message: "URI must be an at:// URI".to_string(),
});
}
Ok(())
}
}
+56 -14
View File
@@ -1,8 +1,8 @@
mod common;
use common::{base_url, client, create_account_and_login, get_db_connection_string};
use tranquil_pds::comms::{NewComms, CommsType, enqueue_comms};
use serde_json::{Value, json};
use sqlx::PgPool;
use tranquil_pds::comms::{CommsType, NewComms, enqueue_comms};
async fn get_pool() -> PgPool {
let conn_str = get_db_connection_string().await;
@@ -33,11 +33,16 @@ async fn test_get_notification_history() {
format!("Subject {}", i),
format!("Body {}", i),
);
enqueue_comms(&pool, comms).await.expect("Failed to enqueue");
enqueue_comms(&pool, comms)
.await
.expect("Failed to enqueue");
}
let resp = client
.get(format!("{}/xrpc/com.tranquil.account.getNotificationHistory", base))
.get(format!(
"{}/xrpc/com.tranquil.account.getNotificationHistory",
base
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
@@ -63,7 +68,10 @@ async fn test_verify_channel_discord() {
"discordId": "123456789"
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base))
.post(format!(
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&prefs)
.send()
@@ -71,7 +79,12 @@ async fn test_verify_channel_discord() {
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("discord")));
assert!(
body["verificationRequired"]
.as_array()
.unwrap()
.contains(&json!("discord"))
);
let pool = get_pool().await;
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
@@ -92,7 +105,10 @@ async fn test_verify_channel_discord() {
"code": code
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base))
.post(format!(
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&input)
.send()
@@ -101,7 +117,10 @@ async fn test_verify_channel_discord() {
assert_eq!(resp.status(), 200);
let resp = client
.get(format!("{}/xrpc/com.tranquil.account.getNotificationPrefs", base))
.get(format!(
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
base
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
@@ -121,7 +140,10 @@ async fn test_verify_channel_invalid_code() {
"telegramUsername": "testuser"
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base))
.post(format!(
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&prefs)
.send()
@@ -134,7 +156,10 @@ async fn test_verify_channel_invalid_code() {
"code": "000000"
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base))
.post(format!(
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&input)
.send()
@@ -154,7 +179,10 @@ async fn test_verify_channel_not_set() {
"code": "123456"
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base))
.post(format!(
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&input)
.send()
@@ -175,7 +203,10 @@ async fn test_update_email_via_notification_prefs() {
"email": unique_email
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.updateNotificationPrefs", base))
.post(format!(
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&prefs)
.send()
@@ -183,7 +214,12 @@ async fn test_update_email_via_notification_prefs() {
.unwrap();
assert_eq!(resp.status(), 200);
let body: Value = resp.json().await.unwrap();
assert!(body["verificationRequired"].as_array().unwrap().contains(&json!("email")));
assert!(
body["verificationRequired"]
.as_array()
.unwrap()
.contains(&json!("email"))
);
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_one(&pool)
@@ -203,7 +239,10 @@ async fn test_update_email_via_notification_prefs() {
"code": code
});
let resp = client
.post(format!("{}/xrpc/com.tranquil.account.confirmChannelVerification", base))
.post(format!(
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
base
))
.header("Authorization", format!("Bearer {}", token))
.json(&input)
.send()
@@ -212,7 +251,10 @@ async fn test_update_email_via_notification_prefs() {
assert_eq!(resp.status(), 200);
let resp = client
.get(format!("{}/xrpc/com.tranquil.account.getNotificationPrefs", base))
.get(format!(
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
base
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
+36 -9
View File
@@ -21,10 +21,18 @@ async fn test_search_accounts_as_admin() {
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().expect("accounts should be array");
let accounts = body["accounts"]
.as_array()
.expect("accounts should be array");
assert!(!accounts.is_empty(), "Should return some accounts");
let found = accounts.iter().any(|a| a["did"].as_str() == Some(&user_did));
assert!(found, "Should find the created user in results (DID: {})", user_did);
let found = accounts
.iter()
.any(|a| a["did"].as_str() == Some(&user_did));
assert!(
found,
"Should find the created user in results (DID: {})",
user_did
);
}
#[tokio::test]
@@ -61,7 +69,11 @@ async fn test_search_accounts_with_handle_filter() {
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let accounts = body["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1, "Should find exactly one account with this handle");
assert_eq!(
accounts.len(),
1,
"Should find exactly one account with this handle"
);
assert_eq!(accounts[0]["handle"].as_str(), Some(unique_handle.as_str()));
}
@@ -100,11 +112,23 @@ async fn test_search_accounts_pagination() {
assert_eq!(res2.status(), StatusCode::OK);
let body2: Value = res2.json().await.unwrap();
let accounts2 = body2["accounts"].as_array().unwrap();
assert!(!accounts2.is_empty(), "Should return more accounts after cursor");
let first_page_dids: Vec<&str> = accounts.iter().map(|a| a["did"].as_str().unwrap()).collect();
let second_page_dids: Vec<&str> = accounts2.iter().map(|a| a["did"].as_str().unwrap()).collect();
assert!(
!accounts2.is_empty(),
"Should return more accounts after cursor"
);
let first_page_dids: Vec<&str> = accounts
.iter()
.map(|a| a["did"].as_str().unwrap())
.collect();
let second_page_dids: Vec<&str> = accounts2
.iter()
.map(|a| a["did"].as_str().unwrap())
.collect();
for did in &second_page_dids {
assert!(!first_page_dids.contains(did), "Second page should not repeat first page DIDs");
assert!(
!first_page_dids.contains(did),
"Second page should not repeat first page DIDs"
);
}
}
@@ -160,5 +184,8 @@ async fn test_search_accounts_returns_expected_fields() {
let account = &accounts[0];
assert!(account["did"].as_str().is_some(), "Should have did");
assert!(account["handle"].as_str().is_some(), "Should have handle");
assert!(account["indexedAt"].as_str().is_some(), "Should have indexedAt");
assert!(
account["indexedAt"].as_str().is_some(),
"Should have indexedAt"
);
}
+1 -1
View File
@@ -38,4 +38,4 @@ async fn test_get_server_stats_no_auth() {
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
}
+10 -2
View File
@@ -57,7 +57,11 @@ async fn test_change_password_success() {
.send()
.await
.expect("Failed to try old password");
assert_eq!(login_old.status(), StatusCode::UNAUTHORIZED, "Old password should not work");
assert_eq!(
login_old.status(),
StatusCode::UNAUTHORIZED,
"Old password should not work"
);
let login_new = client
.post(format!(
"{}/xrpc/com.atproto.server.createSession",
@@ -70,7 +74,11 @@ async fn test_change_password_success() {
.send()
.await
.expect("Failed to try new password");
assert_eq!(login_new.status(), StatusCode::OK, "New password should work");
assert_eq!(
login_new.status(),
StatusCode::OK,
"New password should work"
);
}
#[tokio::test]
+2 -3
View File
@@ -1,7 +1,6 @@
use aws_config::BehaviorVersion;
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::config::Credentials;
use tranquil_pds::state::AppState;
use chrono::Utc;
use reqwest::{Client, StatusCode, header};
use serde_json::{Value, json};
@@ -12,6 +11,7 @@ use std::sync::OnceLock;
#[allow(unused_imports)]
use std::time::Duration;
use tokio::net::TcpListener;
use tranquil_pds::state::AppState;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -232,8 +232,7 @@ async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_en
.await;
}
async fn setup_mock_appview(_mock_server: &MockServer) {
}
async fn setup_mock_appview(_mock_server: &MockServer) {}
async fn spawn_app(database_url: String) -> String {
use tranquil_pds::rate_limit::RateLimiters;
+8 -14
View File
@@ -84,13 +84,10 @@ async fn test_email_update_flow_success() {
.await
.expect("Failed to confirm email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!(
"SELECT email FROM users WHERE handle = $1",
handle
)
.fetch_one(&pool)
.await
.expect("User not found");
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
@@ -320,13 +317,10 @@ async fn test_update_email_with_valid_token() {
.await
.expect("Failed to update email");
assert_eq!(res.status(), StatusCode::OK);
let user = sqlx::query!(
"SELECT email FROM users WHERE handle = $1",
handle
)
.fetch_one(&pool)
.await
.expect("User not found");
let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle)
.fetch_one(&pool)
.await
.expect("User not found");
assert_eq!(user.email, Some(new_email));
let verification = sqlx::query!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'",
+98 -21
View File
@@ -1,35 +1,39 @@
use image::{DynamicImage, ImageFormat};
use std::io::Cursor;
use tranquil_pds::image::{
DEFAULT_MAX_FILE_SIZE, ImageError, ImageProcessor, OutputFormat, THUMB_SIZE_FEED,
THUMB_SIZE_FULL,
};
use image::{DynamicImage, ImageFormat};
use std::io::Cursor;
fn create_test_png(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
.unwrap();
buf
}
fn create_test_jpeg(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg).unwrap();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg)
.unwrap();
buf
}
fn create_test_gif(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif).unwrap();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif)
.unwrap();
buf
}
fn create_test_webp(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP).unwrap();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
.unwrap();
buf
}
@@ -62,18 +66,36 @@ fn test_thumbnail_generation() {
let small = create_test_png(100, 100);
let result = processor.process(&small, "image/png").unwrap();
assert!(result.thumbnail_feed.is_none(), "Small image should not get feed thumbnail");
assert!(result.thumbnail_full.is_none(), "Small image should not get full thumbnail");
assert!(
result.thumbnail_feed.is_none(),
"Small image should not get feed thumbnail"
);
assert!(
result.thumbnail_full.is_none(),
"Small image should not get full thumbnail"
);
let medium = create_test_png(500, 500);
let result = processor.process(&medium, "image/png").unwrap();
assert!(result.thumbnail_feed.is_some(), "Medium image should have feed thumbnail");
assert!(result.thumbnail_full.is_none(), "Medium image should NOT have full thumbnail");
assert!(
result.thumbnail_feed.is_some(),
"Medium image should have feed thumbnail"
);
assert!(
result.thumbnail_full.is_none(),
"Medium image should NOT have full thumbnail"
);
let large = create_test_png(2000, 2000);
let result = processor.process(&large, "image/png").unwrap();
assert!(result.thumbnail_feed.is_some(), "Large image should have feed thumbnail");
assert!(result.thumbnail_full.is_some(), "Large image should have full thumbnail");
assert!(
result.thumbnail_feed.is_some(),
"Large image should have feed thumbnail"
);
assert!(
result.thumbnail_full.is_some(),
"Large image should have full thumbnail"
);
let thumb = result.thumbnail_feed.unwrap();
assert!(thumb.width <= THUMB_SIZE_FEED && thumb.height <= THUMB_SIZE_FEED);
let full = result.thumbnail_full.unwrap();
@@ -81,13 +103,37 @@ fn test_thumbnail_generation() {
let at_feed = create_test_png(THUMB_SIZE_FEED, THUMB_SIZE_FEED);
let above_feed = create_test_png(THUMB_SIZE_FEED + 1, THUMB_SIZE_FEED + 1);
assert!(processor.process(&at_feed, "image/png").unwrap().thumbnail_feed.is_none());
assert!(processor.process(&above_feed, "image/png").unwrap().thumbnail_feed.is_some());
assert!(
processor
.process(&at_feed, "image/png")
.unwrap()
.thumbnail_feed
.is_none()
);
assert!(
processor
.process(&above_feed, "image/png")
.unwrap()
.thumbnail_feed
.is_some()
);
let at_full = create_test_png(THUMB_SIZE_FULL, THUMB_SIZE_FULL);
let above_full = create_test_png(THUMB_SIZE_FULL + 1, THUMB_SIZE_FULL + 1);
assert!(processor.process(&at_full, "image/png").unwrap().thumbnail_full.is_none());
assert!(processor.process(&above_full, "image/png").unwrap().thumbnail_full.is_some());
assert!(
processor
.process(&at_full, "image/png")
.unwrap()
.thumbnail_full
.is_none()
);
assert!(
processor
.process(&above_full, "image/png")
.unwrap()
.thumbnail_full
.is_some()
);
let disabled = ImageProcessor::new().with_thumbnails(false);
let result = disabled.process(&large, "image/png").unwrap();
@@ -100,13 +146,34 @@ fn test_output_format_conversion() {
let jpeg = create_test_jpeg(300, 300);
let webp_proc = ImageProcessor::new().with_output_format(OutputFormat::WebP);
assert_eq!(webp_proc.process(&png, "image/png").unwrap().original.mime_type, "image/webp");
assert_eq!(
webp_proc
.process(&png, "image/png")
.unwrap()
.original
.mime_type,
"image/webp"
);
let jpeg_proc = ImageProcessor::new().with_output_format(OutputFormat::Jpeg);
assert_eq!(jpeg_proc.process(&png, "image/png").unwrap().original.mime_type, "image/jpeg");
assert_eq!(
jpeg_proc
.process(&png, "image/png")
.unwrap()
.original
.mime_type,
"image/jpeg"
);
let png_proc = ImageProcessor::new().with_output_format(OutputFormat::Png);
assert_eq!(png_proc.process(&jpeg, "image/jpeg").unwrap().original.mime_type, "image/png");
assert_eq!(
png_proc
.process(&jpeg, "image/jpeg")
.unwrap()
.original
.mime_type,
"image/png"
);
}
#[test]
@@ -116,12 +183,22 @@ fn test_size_and_dimension_limits() {
let max_dim = ImageProcessor::new().with_max_dimension(1000);
let large = create_test_png(2000, 2000);
let result = max_dim.process(&large, "image/png");
assert!(matches!(result, Err(ImageError::TooLarge { width: 2000, height: 2000, max_dimension: 1000 })));
assert!(matches!(
result,
Err(ImageError::TooLarge {
width: 2000,
height: 2000,
max_dimension: 1000
})
));
let max_file = ImageProcessor::new().with_max_file_size(100);
let data = create_test_png(500, 500);
let result = max_file.process(&data, "image/png");
assert!(matches!(result, Err(ImageError::FileTooLarge { max_size: 100, .. })));
assert!(matches!(
result,
Err(ImageError::FileTooLarge { max_size: 100, .. })
));
}
#[test]
+318 -84
View File
@@ -1,12 +1,6 @@
#![allow(unused_imports)]
mod common;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use tranquil_pds::auth::{
self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH,
TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token,
create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token,
verify_access_token, verify_refresh_token, verify_token,
};
use chrono::{Duration, Utc};
use common::{base_url, client, create_account_and_login, get_db_connection_string};
use k256::SecretKey;
@@ -15,6 +9,12 @@ use rand::rngs::OsRng;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tranquil_pds::auth::{
self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH,
TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token,
create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token,
verify_access_token, verify_refresh_token, verify_token,
};
fn generate_user_key() -> Vec<u8> {
let secret_key = SecretKey::random(&mut OsRng);
@@ -48,27 +48,51 @@ fn test_signature_attacks() {
let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature);
let result = verify_access_token(&forged_token, &key_bytes);
assert!(result.is_err(), "Forged signature must be rejected");
assert!(result.err().unwrap().to_string().to_lowercase().contains("signature"));
assert!(
result
.err()
.unwrap()
.to_string()
.to_lowercase()
.contains("signature")
);
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
payload["sub"] = json!("did:plc:attacker");
let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
assert!(verify_access_token(&modified_token, &key_bytes).is_err(), "Modified payload must be rejected");
assert!(
verify_access_token(&modified_token, &key_bytes).is_err(),
"Modified payload must be rejected"
);
let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap();
let truncated_sig = URL_SAFE_NO_PAD.encode(&sig_bytes[..32]);
let truncated_token = format!("{}.{}.{}", parts[0], parts[1], truncated_sig);
assert!(verify_access_token(&truncated_token, &key_bytes).is_err(), "Truncated signature must be rejected");
assert!(
verify_access_token(&truncated_token, &key_bytes).is_err(),
"Truncated signature must be rejected"
);
let mut extended_sig = sig_bytes.clone();
extended_sig.extend_from_slice(&[0u8; 32]);
let extended_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&extended_sig));
assert!(verify_access_token(&extended_token, &key_bytes).is_err(), "Extended signature must be rejected");
let extended_token = format!(
"{}.{}.{}",
parts[0],
parts[1],
URL_SAFE_NO_PAD.encode(&extended_sig)
);
assert!(
verify_access_token(&extended_token, &key_bytes).is_err(),
"Extended signature must be rejected"
);
let key_bytes_user2 = generate_user_key();
assert!(verify_access_token(&token, &key_bytes_user2).is_err(), "Token signed with different key must be rejected");
assert!(
verify_access_token(&token, &key_bytes_user2).is_err(),
"Token signed with different key must be rejected"
);
}
#[test]
@@ -83,7 +107,10 @@ fn test_algorithm_substitution_attacks() {
"jti": "attack-token", "scope": SCOPE_ACCESS
});
let none_token = create_unsigned_jwt(&none_header, &claims);
assert!(verify_access_token(&none_token, &key_bytes).is_err(), "Algorithm 'none' must be rejected");
assert!(
verify_access_token(&none_token, &key_bytes).is_err(),
"Algorithm 'none' must be rejected"
);
let hs256_header = json!({ "alg": "HS256", "typ": TOKEN_TYPE_ACCESS });
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&hs256_header).unwrap());
@@ -95,14 +122,21 @@ fn test_algorithm_substitution_attacks() {
mac.update(message.as_bytes());
let hmac_sig = mac.finalize().into_bytes();
let hs256_token = format!("{}.{}", message, URL_SAFE_NO_PAD.encode(&hmac_sig));
assert!(verify_access_token(&hs256_token, &key_bytes).is_err(), "HS256 substitution must be rejected");
assert!(
verify_access_token(&hs256_token, &key_bytes).is_err(),
"HS256 substitution must be rejected"
);
for (alg, sig_len) in [("RS256", 256), ("ES256", 64)] {
let header = json!({ "alg": alg, "typ": TOKEN_TYPE_ACCESS });
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let fake_sig = URL_SAFE_NO_PAD.encode(&vec![1u8; sig_len]);
let token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig);
assert!(verify_access_token(&token, &key_bytes).is_err(), "{} substitution must be rejected", alg);
assert!(
verify_access_token(&token, &key_bytes).is_err(),
"{} substitution must be rejected",
alg
);
}
}
@@ -114,15 +148,31 @@ fn test_token_type_confusion() {
let refresh_token = create_refresh_token(did, &key_bytes).expect("create refresh token");
let result = verify_access_token(&refresh_token, &key_bytes);
assert!(result.is_err(), "Refresh token as access must be rejected");
assert!(result.err().unwrap().to_string().contains("Invalid token type"));
assert!(
result
.err()
.unwrap()
.to_string()
.contains("Invalid token type")
);
let access_token = create_access_token(did, &key_bytes).expect("create access token");
let result = verify_refresh_token(&access_token, &key_bytes);
assert!(result.is_err(), "Access token as refresh must be rejected");
assert!(result.err().unwrap().to_string().contains("Invalid token type"));
assert!(
result
.err()
.unwrap()
.to_string()
.contains("Invalid token type")
);
let service_token = create_service_token(did, "did:web:target", "com.example.method", &key_bytes).unwrap();
assert!(verify_access_token(&service_token, &key_bytes).is_err(), "Service token as access must be rejected");
let service_token =
create_service_token(did, "did:web:target", "com.example.method", &key_bytes).unwrap();
assert!(
verify_access_token(&service_token, &key_bytes).is_err(),
"Service token as access must be rejected"
);
}
#[test]
@@ -136,22 +186,44 @@ fn test_scope_validation() {
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": "admin.all"
});
let result = verify_access_token(&create_custom_jwt(&header, &invalid_scope, &key_bytes), &key_bytes);
assert!(result.is_err() && result.err().unwrap().to_string().contains("Invalid token scope"));
let result = verify_access_token(
&create_custom_jwt(&header, &invalid_scope, &key_bytes),
&key_bytes,
);
assert!(
result.is_err()
&& result
.err()
.unwrap()
.to_string()
.contains("Invalid token scope")
);
let empty_scope = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": ""
});
assert!(verify_access_token(&create_custom_jwt(&header, &empty_scope, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &empty_scope, &key_bytes),
&key_bytes
)
.is_err()
);
let missing_scope = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test"
});
assert!(verify_access_token(&create_custom_jwt(&header, &missing_scope, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &missing_scope, &key_bytes),
&key_bytes
)
.is_err()
);
for scope in [SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED] {
let claims = json!({
@@ -159,7 +231,10 @@ fn test_scope_validation() {
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": scope
});
assert!(verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok());
assert!(
verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes)
.is_ok()
);
}
let refresh_scope = json!({
@@ -167,7 +242,13 @@ fn test_scope_validation() {
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": SCOPE_REFRESH
});
assert!(verify_access_token(&create_custom_jwt(&header, &refresh_scope, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &refresh_scope, &key_bytes),
&key_bytes
)
.is_err()
);
}
#[test]
@@ -181,52 +262,97 @@ fn test_expiration_and_timing() {
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": now - 7200, "exp": now - 3600, "jti": "test", "scope": SCOPE_ACCESS
});
let result = verify_access_token(&create_custom_jwt(&header, &expired, &key_bytes), &key_bytes);
let result = verify_access_token(
&create_custom_jwt(&header, &expired, &key_bytes),
&key_bytes,
);
assert!(result.is_err() && result.err().unwrap().to_string().contains("expired"));
let future_iat = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": now + 60, "exp": now + 7200, "jti": "test", "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &future_iat, &key_bytes), &key_bytes).is_ok());
assert!(
verify_access_token(
&create_custom_jwt(&header, &future_iat, &key_bytes),
&key_bytes
)
.is_ok()
);
let just_expired = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": now - 10, "exp": now - 1, "jti": "test", "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &just_expired, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &just_expired, &key_bytes),
&key_bytes
)
.is_err()
);
let far_future = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": now, "exp": i64::MAX, "jti": "test", "scope": SCOPE_ACCESS
});
let _ = verify_access_token(&create_custom_jwt(&header, &far_future, &key_bytes), &key_bytes);
let _ = verify_access_token(
&create_custom_jwt(&header, &far_future, &key_bytes),
&key_bytes,
);
let negative_iat = json!({
"iss": did, "sub": did, "aud": "did:web:test.pds",
"iat": -1000000000i64, "exp": now + 3600, "jti": "test", "scope": SCOPE_ACCESS
});
let _ = verify_access_token(&create_custom_jwt(&header, &negative_iat, &key_bytes), &key_bytes);
let _ = verify_access_token(
&create_custom_jwt(&header, &negative_iat, &key_bytes),
&key_bytes,
);
}
#[test]
fn test_malformed_tokens() {
let key_bytes = generate_user_key();
for token in ["", "not-a-token", "one.two", "one.two.three.four", "....",
"eyJhbGciOiJFUzI1NksifQ", "eyJhbGciOiJFUzI1NksifQ.", "eyJhbGciOiJFUzI1NksifQ..",
".eyJzdWIiOiJ0ZXN0In0.", "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig"] {
assert!(verify_access_token(token, &key_bytes).is_err(), "Malformed token must be rejected");
for token in [
"",
"not-a-token",
"one.two",
"one.two.three.four",
"....",
"eyJhbGciOiJFUzI1NksifQ",
"eyJhbGciOiJFUzI1NksifQ.",
"eyJhbGciOiJFUzI1NksifQ..",
".eyJzdWIiOiJ0ZXN0In0.",
"!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig",
] {
assert!(
verify_access_token(token, &key_bytes).is_err(),
"Malformed token must be rejected"
);
}
let invalid_header = URL_SAFE_NO_PAD.encode("{not valid json}");
let claims_b64 = URL_SAFE_NO_PAD.encode(r#"{"sub":"test"}"#);
let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]);
assert!(verify_access_token(&format!("{}.{}.{}", invalid_header, claims_b64, fake_sig), &key_bytes).is_err());
assert!(
verify_access_token(
&format!("{}.{}.{}", invalid_header, claims_b64, fake_sig),
&key_bytes
)
.is_err()
);
let header_b64 = URL_SAFE_NO_PAD.encode(r#"{"alg":"ES256K","typ":"at+jwt"}"#);
let invalid_claims = URL_SAFE_NO_PAD.encode("{not valid json}");
assert!(verify_access_token(&format!("{}.{}.{}", header_b64, invalid_claims, fake_sig), &key_bytes).is_err());
assert!(
verify_access_token(
&format!("{}.{}.{}", header_b64, invalid_claims, fake_sig),
&key_bytes
)
.is_err()
);
}
#[test]
@@ -239,32 +365,59 @@ fn test_claim_validation() {
"iss": did, "sub": did, "aud": "did:web:test",
"iat": Utc::now().timestamp(), "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &missing_exp, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &missing_exp, &key_bytes),
&key_bytes
)
.is_err()
);
let missing_iat = json!({
"iss": did, "sub": did, "aud": "did:web:test",
"exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &missing_iat, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &missing_iat, &key_bytes),
&key_bytes
)
.is_err()
);
let missing_sub = json!({
"iss": did, "aud": "did:web:test",
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &missing_sub, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &missing_sub, &key_bytes),
&key_bytes
)
.is_err()
);
let wrong_types = json!({
"iss": 12345, "sub": ["did:plc:test"], "aud": {"url": "did:web:test"},
"iat": "not a number", "exp": "also not a number", "jti": null, "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &wrong_types, &key_bytes), &key_bytes).is_err());
assert!(
verify_access_token(
&create_custom_jwt(&header, &wrong_types, &key_bytes),
&key_bytes
)
.is_err()
);
let unicode_injection = json!({
"iss": "did:plc:test\u{0000}attacker", "sub": "did:plc:test\u{202E}rekatta",
"aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": SCOPE_ACCESS
});
if let Ok(data) = verify_access_token(&create_custom_jwt(&header, &unicode_injection, &key_bytes), &key_bytes) {
if let Ok(data) = verify_access_token(
&create_custom_jwt(&header, &unicode_injection, &key_bytes),
&key_bytes,
) {
assert!(!data.claims.sub.contains('\0'));
}
}
@@ -308,14 +461,26 @@ fn test_header_injection_and_constant_time() {
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600,
"jti": "test", "scope": SCOPE_ACCESS
});
assert!(verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok());
assert!(
verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok()
);
let valid_token = create_access_token(did, &key_bytes).expect("create token");
let parts: Vec<&str> = valid_token.split('.').collect();
let mut almost_valid = URL_SAFE_NO_PAD.decode(parts[2]).unwrap();
almost_valid[0] ^= 1;
let almost_valid_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&almost_valid));
let completely_invalid_token = format!("{}.{}.{}", parts[0], parts[1], URL_SAFE_NO_PAD.encode(&[0xFFu8; 64]));
let almost_valid_token = format!(
"{}.{}.{}",
parts[0],
parts[1],
URL_SAFE_NO_PAD.encode(&almost_valid)
);
let completely_invalid_token = format!(
"{}.{}.{}",
parts[0],
parts[1],
URL_SAFE_NO_PAD.encode(&[0xFFu8; 64])
);
let _ = verify_access_token(&almost_valid_token, &key_bytes);
let _ = verify_access_token(&completely_invalid_token, &key_bytes);
}
@@ -327,10 +492,17 @@ async fn test_server_rejects_invalid_tokens() {
let key_bytes = generate_user_key();
let forged_token = create_access_token("did:plc:fake-user", &key_bytes).unwrap();
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", forged_token))
.send().await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged token must be rejected");
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"Forged token must be rejected"
);
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let parts: Vec<&str> = access_jwt.split('.').collect();
@@ -338,19 +510,35 @@ async fn test_server_rejects_invalid_tokens() {
let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
payload["exp"] = json!(Utc::now().timestamp() - 3600);
let expired_token = format!("{}.{}.{}", parts[0], URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), parts[2]);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let expired_token = format!(
"{}.{}.{}",
parts[0],
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
parts[2]
);
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", expired_token))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let mut tampered_payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
tampered_payload["sub"] = json!("did:plc:attacker");
tampered_payload["iss"] = json!("did:plc:attacker");
let tampered_token = format!("{}.{}.{}", parts[0], URL_SAFE_NO_PAD.encode(serde_json::to_string(&tampered_payload).unwrap()), parts[2]);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let tampered_token = format!(
"{}.{}.{}",
parts[0],
URL_SAFE_NO_PAD.encode(serde_json::to_string(&tampered_payload).unwrap()),
parts[2]
);
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", tampered_token))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
@@ -360,29 +548,44 @@ async fn test_authorization_header_formats() {
let http_client = client();
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Basic {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", &access_jwt)
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", "Bearer ")
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
@@ -392,19 +595,28 @@ async fn test_session_lifecycle_security() {
let http_client = client();
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let logout = http_client.post(format!("{}/xrpc/com.atproto.server.deleteSession", url))
let logout = http_client
.post(format!("{}/xrpc/com.atproto.server.deleteSession", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(logout.status(), StatusCode::OK);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
@@ -414,20 +626,27 @@ async fn test_deactivated_account_behavior() {
let http_client = client();
let (access_jwt, _did) = create_account_and_login(&http_client).await;
let deact = http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
let deact = http_client
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.json(&json!({}))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(deact.status(), StatusCode::OK);
let res = http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
assert_eq!(body["active"], false);
let post_res = http_client.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
let post_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.header("Authorization", format!("Bearer {}", access_jwt))
.json(&json!({
"repo": _did,
@@ -438,7 +657,9 @@ async fn test_deactivated_account_behavior() {
"createdAt": "2024-01-01T00:00:00Z"
}
}))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(post_res.status(), StatusCode::UNAUTHORIZED);
let post_body: Value = post_res.json().await.unwrap();
assert_eq!(post_body["error"], "AccountDeactivated");
@@ -452,9 +673,12 @@ async fn test_refresh_token_replay_protection() {
let handle = format!("rt-replay-jwt-{}", ts);
let email = format!("rt-replay-jwt-{}@example.com", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": "test-password-123" }))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap();
@@ -462,26 +686,36 @@ async fn test_refresh_token_replay_protection() {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&get_db_connection_string().await)
.await.unwrap();
.await
.unwrap();
let code: String = sqlx::query_scalar!(
"SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'",
did
).fetch_one(&pool).await.unwrap();
let confirm = http_client.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
let confirm = http_client
.post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))
.json(&json!({ "did": did, "verificationCode": code }))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(confirm.status(), StatusCode::OK);
let confirmed: Value = confirm.json().await.unwrap();
let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string();
let first = http_client.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
let first = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", refresh_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let replay = http_client.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
let replay = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", refresh_jwt))
.send().await.unwrap();
.send()
.await
.unwrap();
assert_eq!(replay.status(), StatusCode::UNAUTHORIZED);
}
+413 -101
View File
@@ -26,24 +26,45 @@ async fn test_record_crud_lifecycle() {
}
});
let create_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&create_payload)
.send()
.await
.expect("Failed to send create request");
assert_eq!(create_res.status(), StatusCode::OK, "Failed to create record");
let create_body: Value = create_res.json().await.expect("create response was not JSON");
assert_eq!(
create_res.status(),
StatusCode::OK,
"Failed to create record"
);
let create_body: Value = create_res
.json()
.await
.expect("create response was not JSON");
let uri = create_body["uri"].as_str().unwrap();
let initial_cid = create_body["cid"].as_str().unwrap().to_string();
let params = [("repo", did.as_str()), ("collection", collection), ("rkey", &rkey)];
let params = [
("repo", did.as_str()),
("collection", collection),
("rkey", &rkey),
];
let get_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send get request");
assert_eq!(get_res.status(), StatusCode::OK, "Failed to get record after create");
assert_eq!(
get_res.status(),
StatusCode::OK,
"Failed to get record after create"
);
let get_body: Value = get_res.json().await.expect("get response was not JSON");
assert_eq!(get_body["uri"], uri);
assert_eq!(get_body["value"]["text"], original_text);
@@ -56,23 +77,42 @@ async fn test_record_crud_lifecycle() {
"swapRecord": initial_cid
});
let update_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&update_payload)
.send()
.await
.expect("Failed to send update request");
assert_eq!(update_res.status(), StatusCode::OK, "Failed to update record");
let update_body: Value = update_res.json().await.expect("update response was not JSON");
assert_eq!(
update_res.status(),
StatusCode::OK,
"Failed to update record"
);
let update_body: Value = update_res
.json()
.await
.expect("update response was not JSON");
let updated_cid = update_body["cid"].as_str().unwrap().to_string();
let get_updated_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send get-after-update request");
let get_updated_body: Value = get_updated_res.json().await.expect("get-updated response was not JSON");
assert_eq!(get_updated_body["value"]["text"], updated_text, "Text was not updated");
let get_updated_body: Value = get_updated_res
.json()
.await
.expect("get-updated response was not JSON");
assert_eq!(
get_updated_body["value"]["text"], updated_text,
"Text was not updated"
);
let stale_update_payload = json!({
"repo": did,
"collection": collection,
@@ -81,13 +121,20 @@ async fn test_record_crud_lifecycle() {
"swapRecord": initial_cid
});
let stale_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&stale_update_payload)
.send()
.await
.expect("Failed to send stale update");
assert_eq!(stale_res.status(), StatusCode::CONFLICT, "Stale update should cause 409");
assert_eq!(
stale_res.status(),
StatusCode::CONFLICT,
"Stale update should cause 409"
);
let good_update_payload = json!({
"repo": did,
"collection": collection,
@@ -96,29 +143,50 @@ async fn test_record_crud_lifecycle() {
"swapRecord": updated_cid
});
let good_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&good_update_payload)
.send()
.await
.expect("Failed to send good update");
assert_eq!(good_res.status(), StatusCode::OK, "Good update should succeed");
assert_eq!(
good_res.status(),
StatusCode::OK,
"Good update should succeed"
);
let delete_payload = json!({ "repo": did, "collection": collection, "rkey": rkey });
let delete_res = client
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&delete_payload)
.send()
.await
.expect("Failed to send delete request");
assert_eq!(delete_res.status(), StatusCode::OK, "Failed to delete record");
assert_eq!(
delete_res.status(),
StatusCode::OK,
"Failed to delete record"
);
let get_deleted_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&params)
.send()
.await
.expect("Failed to send get-after-delete request");
assert_eq!(get_deleted_res.status(), StatusCode::NOT_FOUND, "Record should be deleted");
assert_eq!(
get_deleted_res.status(),
StatusCode::NOT_FOUND,
"Record should be deleted"
);
}
#[tokio::test]
@@ -127,7 +195,10 @@ async fn test_profile_with_blob_lifecycle() {
let (did, jwt) = setup_new_user("profile-blob").await;
let blob_data = b"This is test blob data for a profile avatar";
let upload_res = client
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.uploadBlob",
base_url().await
))
.header(header::CONTENT_TYPE, "text/plain")
.bearer_auth(&jwt)
.body(blob_data.to_vec())
@@ -149,18 +220,32 @@ async fn test_profile_with_blob_lifecycle() {
}
});
let create_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&profile_payload)
.send()
.await
.expect("Failed to create profile");
assert_eq!(create_res.status(), StatusCode::OK, "Failed to create profile");
assert_eq!(
create_res.status(),
StatusCode::OK,
"Failed to create profile"
);
let create_body: Value = create_res.json().await.unwrap();
let initial_cid = create_body["cid"].as_str().unwrap().to_string();
let get_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get profile");
@@ -176,16 +261,30 @@ async fn test_profile_with_blob_lifecycle() {
"swapRecord": initial_cid
});
let update_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&update_payload)
.send()
.await
.expect("Failed to update profile");
assert_eq!(update_res.status(), StatusCode::OK, "Failed to update profile");
assert_eq!(
update_res.status(),
StatusCode::OK,
"Failed to update profile"
);
let get_updated_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get updated profile");
@@ -198,7 +297,8 @@ async fn test_reply_thread_lifecycle() {
let client = client();
let (alice_did, alice_jwt) = setup_new_user("alice-thread").await;
let (bob_did, bob_jwt) = setup_new_user("bob-thread").await;
let (root_uri, root_cid) = create_post(&client, &alice_did, &alice_jwt, "This is the root post").await;
let (root_uri, root_cid) =
create_post(&client, &alice_did, &alice_jwt, "This is the root post").await;
tokio::time::sleep(Duration::from_millis(100)).await;
let reply_collection = "app.bsky.feed.post";
let reply_rkey = format!("e2e_reply_{}", Utc::now().timestamp_millis());
@@ -217,7 +317,10 @@ async fn test_reply_thread_lifecycle() {
}
});
let reply_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&bob_jwt)
.json(&reply_payload)
.send()
@@ -228,8 +331,15 @@ async fn test_reply_thread_lifecycle() {
let reply_uri = reply_body["uri"].as_str().unwrap();
let reply_cid = reply_body["cid"].as_str().unwrap();
let get_reply_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", bob_did.as_str()), ("collection", reply_collection), ("rkey", reply_rkey.as_str())])
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", bob_did.as_str()),
("collection", reply_collection),
("rkey", reply_rkey.as_str()),
])
.send()
.await
.expect("Failed to get reply");
@@ -253,13 +363,20 @@ async fn test_reply_thread_lifecycle() {
}
});
let nested_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&alice_jwt)
.json(&nested_payload)
.send()
.await
.expect("Failed to create nested reply");
assert_eq!(nested_res.status(), StatusCode::OK, "Failed to create nested reply");
assert_eq!(
nested_res.status(),
StatusCode::OK,
"Failed to create nested reply"
);
}
#[tokio::test]
@@ -276,31 +393,57 @@ async fn test_authorization_protects_repos() {
"record": { "$type": "app.bsky.feed.post", "text": "Bob trying to post as Alice", "createdAt": Utc::now().to_rfc3339() }
});
let write_res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&bob_jwt)
.json(&post_payload)
.send()
.await
.expect("Failed to send request");
assert!(write_res.status() == StatusCode::FORBIDDEN || write_res.status() == StatusCode::UNAUTHORIZED,
"Expected 403/401 for writing to another user's repo, got {}", write_res.status());
let delete_payload = json!({ "repo": alice_did, "collection": "app.bsky.feed.post", "rkey": post_rkey });
assert!(
write_res.status() == StatusCode::FORBIDDEN
|| write_res.status() == StatusCode::UNAUTHORIZED,
"Expected 403/401 for writing to another user's repo, got {}",
write_res.status()
);
let delete_payload =
json!({ "repo": alice_did, "collection": "app.bsky.feed.post", "rkey": post_rkey });
let delete_res = client
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.deleteRecord",
base_url().await
))
.bearer_auth(&bob_jwt)
.json(&delete_payload)
.send()
.await
.expect("Failed to send request");
assert!(delete_res.status() == StatusCode::FORBIDDEN || delete_res.status() == StatusCode::UNAUTHORIZED,
"Expected 403/401 for deleting another user's record, got {}", delete_res.status());
assert!(
delete_res.status() == StatusCode::FORBIDDEN
|| delete_res.status() == StatusCode::UNAUTHORIZED,
"Expected 403/401 for deleting another user's record, got {}",
delete_res.status()
);
let get_res = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", alice_did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", post_rkey)])
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", alice_did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", post_rkey),
])
.send()
.await
.expect("Failed to verify record exists");
assert_eq!(get_res.status(), StatusCode::OK, "Record should still exist");
assert_eq!(
get_res.status(),
StatusCode::OK,
"Record should still exist"
);
}
#[tokio::test]
@@ -317,7 +460,10 @@ async fn test_apply_writes_batch() {
]
});
let apply_res = client
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&writes_payload)
.send()
@@ -325,21 +471,48 @@ async fn test_apply_writes_batch() {
.expect("Failed to apply writes");
assert_eq!(apply_res.status(), StatusCode::OK);
let get_post1 = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")])
.send().await.expect("Failed to get post 1");
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-post-1"),
])
.send()
.await
.expect("Failed to get post 1");
assert_eq!(get_post1.status(), StatusCode::OK);
let post1_body: Value = get_post1.json().await.unwrap();
assert_eq!(post1_body["value"]["text"], "First batch post");
let get_post2 = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-2")])
.send().await.expect("Failed to get post 2");
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-post-2"),
])
.send()
.await
.expect("Failed to get post 2");
assert_eq!(get_post2.status(), StatusCode::OK);
let get_profile = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.send().await.expect("Failed to get profile");
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get profile");
let profile_body: Value = get_profile.json().await.unwrap();
assert_eq!(profile_body["value"]["displayName"], "Batch User");
let update_writes = json!({
@@ -350,7 +523,10 @@ async fn test_apply_writes_batch() {
]
});
let update_res = client
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&update_writes)
.send()
@@ -358,25 +534,59 @@ async fn test_apply_writes_batch() {
.expect("Failed to apply update writes");
assert_eq!(update_res.status(), StatusCode::OK);
let get_updated_profile = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.actor.profile"), ("rkey", "self")])
.send().await.expect("Failed to get updated profile");
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get updated profile");
let updated_profile: Value = get_updated_profile.json().await.unwrap();
assert_eq!(updated_profile["value"]["displayName"], "Updated Batch User");
assert_eq!(
updated_profile["value"]["displayName"],
"Updated Batch User"
);
let get_deleted_post = client
.get(format!("{}/xrpc/com.atproto.repo.getRecord", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("rkey", "batch-post-1")])
.send().await.expect("Failed to check deleted post");
assert_eq!(get_deleted_post.status(), StatusCode::NOT_FOUND, "Batch-deleted post should be gone");
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-post-1"),
])
.send()
.await
.expect("Failed to check deleted post");
assert_eq!(
get_deleted_post.status(),
StatusCode::NOT_FOUND,
"Batch-deleted post should be gone"
);
}
async fn create_post_with_rkey(client: &reqwest::Client, did: &str, jwt: &str, rkey: &str, text: &str) -> (String, String) {
async fn create_post_with_rkey(
client: &reqwest::Client,
did: &str,
jwt: &str,
rkey: &str,
text: &str,
) -> (String, String) {
let payload = json!({
"repo": did, "collection": "app.bsky.feed.post", "rkey": rkey,
"record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": Utc::now().to_rfc3339() }
});
let res = client
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base_url().await))
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(jwt)
.json(&payload)
.send()
@@ -384,7 +594,10 @@ async fn create_post_with_rkey(client: &reqwest::Client, did: &str, jwt: &str, r
.expect("Failed to create record");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
(body["uri"].as_str().unwrap().to_string(), body["cid"].as_str().unwrap().to_string())
(
body["uri"].as_str().unwrap().to_string(),
body["cid"].as_str().unwrap().to_string(),
)
}
#[tokio::test]
@@ -392,19 +605,38 @@ async fn test_list_records_comprehensive() {
let client = client();
let (did, jwt) = setup_new_user("list-records-test").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
create_post_with_rkey(
&client,
&did,
&jwt,
&format!("post{:02}", i),
&format!("Post {}", i),
)
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
}
let res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post")])
.send().await.expect("Failed to list records");
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 5);
let rkeys: Vec<&str> = records.iter().map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
assert_eq!(rkeys, vec!["post04", "post03", "post02", "post01", "post00"], "Default order should be DESC");
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(
rkeys,
vec!["post04", "post03", "post02", "post01", "post00"],
"Default order should be DESC"
);
for record in records {
assert!(record["uri"].is_string());
assert!(record["cid"].is_string());
@@ -412,52 +644,132 @@ async fn test_list_records_comprehensive() {
assert!(record["value"].is_object());
}
let rev_res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("reverse", "true")])
.send().await.expect("Failed to list records reverse");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records reverse");
let rev_body: Value = rev_res.json().await.unwrap();
let rev_rkeys: Vec<&str> = rev_body["records"].as_array().unwrap().iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
assert_eq!(rev_rkeys, vec!["post00", "post01", "post02", "post03", "post04"], "reverse=true should give ASC");
let rev_rkeys: Vec<&str> = rev_body["records"]
.as_array()
.unwrap()
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(
rev_rkeys,
vec!["post00", "post01", "post02", "post03", "post04"],
"reverse=true should give ASC"
);
let page1 = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2")])
.send().await.expect("Failed to list page 1");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
])
.send()
.await
.expect("Failed to list page 1");
let page1_body: Value = page1.json().await.unwrap();
let page1_records = page1_body["records"].as_array().unwrap();
assert_eq!(page1_records.len(), 2);
let cursor = page1_body["cursor"].as_str().expect("Should have cursor");
let page2 = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "2"), ("cursor", cursor)])
.send().await.expect("Failed to list page 2");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list page 2");
let page2_body: Value = page2.json().await.unwrap();
let page2_records = page2_body["records"].as_array().unwrap();
assert_eq!(page2_records.len(), 2);
let all_uris: Vec<&str> = page1_records.iter().chain(page2_records.iter())
.map(|r| r["uri"].as_str().unwrap()).collect();
let all_uris: Vec<&str> = page1_records
.iter()
.chain(page2_records.iter())
.map(|r| r["uri"].as_str().unwrap())
.collect();
let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
assert_eq!(
all_uris.len(),
unique_uris.len(),
"Cursor pagination should not repeat records"
);
let range_res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"),
("rkeyStart", "post01"), ("rkeyEnd", "post03"), ("reverse", "true")])
.send().await.expect("Failed to list range");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "post01"),
("rkeyEnd", "post03"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list range");
let range_body: Value = range_res.json().await.unwrap();
let range_rkeys: Vec<&str> = range_body["records"].as_array().unwrap().iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap()).collect();
let range_rkeys: Vec<&str> = range_body["records"]
.as_array()
.unwrap()
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &range_rkeys {
assert!(*rkey >= "post01" && *rkey <= "post03", "Range should be inclusive");
assert!(
*rkey >= "post01" && *rkey <= "post03",
"Range should be inclusive"
);
}
let limit_res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", did.as_str()), ("collection", "app.bsky.feed.post"), ("limit", "1000")])
.send().await.expect("Failed with high limit");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "1000"),
])
.send()
.await
.expect("Failed with high limit");
let limit_body: Value = limit_res.json().await.unwrap();
assert!(limit_body["records"].as_array().unwrap().len() <= 100, "Limit should be clamped to max 100");
assert!(
limit_body["records"].as_array().unwrap().len() <= 100,
"Limit should be clamped to max 100"
);
let not_found_res = client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", base_url().await))
.query(&[("repo", "did:plc:nonexistent12345"), ("collection", "app.bsky.feed.post")])
.send().await.expect("Failed with nonexistent repo");
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", "did:plc:nonexistent12345"),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed with nonexistent repo");
assert_eq!(not_found_res.status(), StatusCode::NOT_FOUND);
}
+1 -1
View File
@@ -4,7 +4,7 @@ use chrono::Utc;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::{json, Value};
use serde_json::{Value, json};
#[tokio::test]
async fn test_like_lifecycle() {

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