mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
OAuth scopes full impl.
This commit is contained in:
+17
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+3
-6
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-34
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-22
@@ -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
@@ -6207,6 +6207,7 @@ dependencies = [
|
||||
"serde_bytes",
|
||||
"serde_ipld_dagcbor",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(¤t_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
|
||||
|
||||
@@ -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(¤t_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
@@ -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,
|
||||
|
||||
@@ -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) = ¤t_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
@@ -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(¤t_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(¤t_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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
¶ms.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, ¶ms.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,
|
||||
¶ms.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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
+757
-273
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,4 @@ async fn test_get_server_stats_no_auth() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(¶ms)
|
||||
.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(¶ms)
|
||||
.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(¶ms)
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user