diff --git a/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json b/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json
new file mode 100644
index 0000000..39d788a
--- /dev/null
+++ b/.sqlx/query-0dfe6b602497942ce871d9b54f4d34ae9e846f3bb9f8693ecd6d90463e83d114.json
@@ -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"
+}
diff --git a/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json b/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json
new file mode 100644
index 0000000..2b7ef22
--- /dev/null
+++ b/.sqlx/query-10429e16b7a6bb2d97728526d921027c873c8c2d31e695a14241220c1339937f.json
@@ -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"
+}
diff --git a/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json b/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json
new file mode 100644
index 0000000..d763261
--- /dev/null
+++ b/.sqlx/query-1407d741caf7e074347e6cfdff07b3f72f02571976d875d5c75542c69f0fcdfe.json
@@ -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"
+}
diff --git a/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json b/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json
new file mode 100644
index 0000000..bb65a3d
--- /dev/null
+++ b/.sqlx/query-15144f5e5d9853126a59f36b2cbd1f8eea4fe719c6cba9406a9843bea2f8dc9e.json
@@ -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"
+}
diff --git a/.sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json b/.sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json
similarity index 64%
rename from .sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json
rename to .sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json
index 626b011..775550e 100644
--- a/.sqlx/query-c47715c259bb7b56b576d9719f8facb87a9e9b6b530ca6f81ce308a4c584c002.json
+++ b/.sqlx/query-2b6987e2a4139bfbd262682a309ebabde5e48a5cabe08a5a2135e8856efd844d.json
@@ -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"
}
diff --git a/.sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json b/.sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json
similarity index 57%
rename from .sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json
rename to .sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json
index bcc3dc1..40c18af 100644
--- a/.sqlx/query-d7d7e002dcdc663811303411c1200ef4509aef9416a177dc6888a8e2648b173f.json
+++ b/.sqlx/query-53b0ea60a759f8bb37d01461fd0769dcc683e796287e41d5180340296286fcbe.json
@@ -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"
}
diff --git a/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json b/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json
new file mode 100644
index 0000000..9771f16
--- /dev/null
+++ b/.sqlx/query-833816de8586d7a886a14698a734c0dad7952676303749d140294c46b9536b91.json
@@ -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"
+}
diff --git a/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json b/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json
new file mode 100644
index 0000000..0b2acfc
--- /dev/null
+++ b/.sqlx/query-859a028033a1c7f66fd16843a357aa9f67b3fec5dac616edef36fbeb143d76f0.json
@@ -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"
+}
diff --git a/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json b/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json
deleted file mode 100644
index 259fb57..0000000
--- a/.sqlx/query-94966f20b7b0adb02e8c83a693a4dcc7f54b72983ba8ebd66fd805851db5c06c.json
+++ /dev/null
@@ -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"
-}
diff --git a/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json b/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json
new file mode 100644
index 0000000..38d12db
--- /dev/null
+++ b/.sqlx/query-a4e657ed91c9ecfcf419deeae5f42ede88cddc842bdf37f2ef082b252ab1642c.json
@@ -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"
+}
diff --git a/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json b/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json
new file mode 100644
index 0000000..413d359
--- /dev/null
+++ b/.sqlx/query-bcee8331c85a558fa1e9177759f23cc69b40bf8d2fc1cb0d1d4cf2499a753e5b.json
@@ -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"
+}
diff --git a/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json b/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json
new file mode 100644
index 0000000..4c2852f
--- /dev/null
+++ b/.sqlx/query-ca6196defa93057f20220f433e79e4d2cdd5d6cda0add6e5d56471cd319f92cd.json
@@ -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"
+}
diff --git a/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json b/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json
deleted file mode 100644
index 152d885..0000000
--- a/.sqlx/query-ed34111a7f41b419a23d16ddd23cbc6aff9ab373946ff243512c52f857b7980d.json
+++ /dev/null
@@ -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"
-}
diff --git a/Cargo.lock b/Cargo.lock
index 3f80afb..9690e7e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6207,6 +6207,7 @@ dependencies = [
"serde_bytes",
"serde_ipld_dagcbor",
"serde_json",
+ "serde_urlencoded",
"sha2",
"sqlx",
"subtle",
diff --git a/Cargo.toml b/Cargo.toml
index 37b7a7e..c58edad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"] }
diff --git a/TODO.md b/TODO.md
index 9cfb3dc..0900b66 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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.
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index e207a58..b6640c2 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -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
}
diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts
index 1d113b0..ce6c05f 100644
--- a/frontend/src/lib/router.svelte.ts
+++ b/frontend/src/lib/router.svelte.ts
@@ -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) {
diff --git a/frontend/src/routes/OAuth2FA.svelte b/frontend/src/routes/OAuth2FA.svelte
new file mode 100644
index 0000000..1c44f61
--- /dev/null
+++ b/frontend/src/routes/OAuth2FA.svelte
@@ -0,0 +1,213 @@
+
+
+
+
Two-Factor Authentication
+
+ A verification code has been sent to your {channel}.
+ Enter the code below to continue.
+
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
diff --git a/frontend/src/routes/OAuthAccounts.svelte b/frontend/src/routes/OAuthAccounts.svelte
new file mode 100644
index 0000000..3341087
--- /dev/null
+++ b/frontend/src/routes/OAuthAccounts.svelte
@@ -0,0 +1,264 @@
+
+
+
+ {#if loading}
+
+ {:else if error}
+
+
Error
+
{error}
+
+
+ {:else}
+
Choose an Account
+
Select an account to continue
+
+
+ {#each accounts as account}
+
+ {/each}
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte
new file mode 100644
index 0000000..3cf35db
--- /dev/null
+++ b/frontend/src/routes/OAuthConsent.svelte
@@ -0,0 +1,451 @@
+
+
+
+ {#if loading}
+
+ {:else if error}
+
+
Authorization Error
+
{error}
+
+
+ {:else if consentData}
+
+ {#if consentData.logo_uri}
+

+ {/if}
+
{consentData.client_name || 'Application'}
+
wants to access your account
+ {#if consentData.client_uri}
+
+ {consentData.client_uri}
+
+ {/if}
+
+
+
+ Signing in as:
+ {consentData.did}
+
+
+
+
Permissions Requested
+ {#each Object.entries(scopeGroups) as [category, scopes]}
+
+
{category}
+ {#each scopes as scope}
+
+ {/each}
+
+ {/each}
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/OAuthError.svelte b/frontend/src/routes/OAuthError.svelte
new file mode 100644
index 0000000..c5c5ce8
--- /dev/null
+++ b/frontend/src/routes/OAuthError.svelte
@@ -0,0 +1,81 @@
+
+
+
+
Authorization Error
+
+
+
{error}
+ {#if errorDescription}
+
{errorDescription}
+ {/if}
+
+
+
+
+
+
diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte
new file mode 100644
index 0000000..9f4ffbb
--- /dev/null
+++ b/frontend/src/routes/OAuthLogin.svelte
@@ -0,0 +1,269 @@
+
+
+
+
Sign In
+
Sign in to continue to the application
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
diff --git a/migrations/20251221_oauth_scope_preferences.sql b/migrations/20251221_oauth_scope_preferences.sql
new file mode 100644
index 0000000..01477a1
--- /dev/null
+++ b/migrations/20251221_oauth_scope_preferences.sql
@@ -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);
diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs
index 6509620..77b5ef5 100644
--- a/src/api/actor/preferences.rs
+++ b/src/api/actor/preferences.rs
@@ -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,
diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs
index 48db175..ff2e83d 100644
--- a/src/api/admin/account/info.rs
+++ b/src/api/admin/account/info.rs
@@ -93,9 +93,8 @@ fn parse_repeated_param(query: Option<&str>, key: &str) -> Vec {
.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 {
diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs
index 05612ff..295a2c4 100644
--- a/src/api/admin/account/search.rs
+++ b/src/api/admin/account/search.rs
@@ -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, chrono::DateTime, bool, Option>)>(
+ let result = sqlx::query_as::<
+ _,
+ (
+ String,
+ String,
+ Option,
+ chrono::DateTime,
+ bool,
+ Option>,
+ ),
+ >(
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 = 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())
diff --git a/src/api/admin/server_stats.rs b/src/api/admin/server_stats.rs
index c9da10b..12d7b72 100644
--- a/src/api/admin/server_stats.rs
+++ b/src/api/admin/server_stats.rs
@@ -16,10 +16,7 @@ pub struct ServerStatsResponse {
pub blob_storage_bytes: i64,
}
-pub async fn get_server_stats(
- State(state): State,
- _auth: BearerAuthAdmin,
-) -> Response {
+pub async fn get_server_stats(State(state): State, _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,
diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs
index b994c43..a270b6d 100644
--- a/src/api/identity/account.rs
+++ b/src/api/identity/account.rs
@@ -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>)> =
- 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
+ );
}
}
diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs
index cad0e84..1920d3a 100644
--- a/src/api/identity/did.rs
+++ b/src/api/identity/did.rs
@@ -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(())
diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs
index e8cb559..4d6e37a 100644
--- a/src/api/identity/plc/request.rs
+++ b/src/api/identity/plc/request.rs
@@ -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
diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs
index 445b6bf..333d04d 100644
--- a/src/api/identity/plc/sign.rs
+++ b/src/api/identity/plc/sign.rs
@@ -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,
diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs
index 9e27dc0..112ab5c 100644
--- a/src/api/identity/plc/submit.rs
+++ b/src/api/identity/plc/submit.rs
@@ -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());
diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs
index 989302c..8c7fca0 100644
--- a/src/api/notification_prefs.rs
+++ b/src/api/notification_prefs.rs
@@ -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()
}
diff --git a/src/api/proxy.rs b/src/api/proxy.rs
index 31725d6..243ccba 100644
--- a/src/api/proxy.rs
+++ b/src/api/proxy.rs
@@ -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,
diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs
index ab45c1b..34dac58 100644
--- a/src/api/repo/blob.rs
+++ b/src/api/repo/blob.rs
@@ -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) {
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);
}
diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs
index 94acb68..656853e 100644
--- a/src/api/repo/import.rs
+++ b/src/api/repo/import.rs
@@ -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);
}
diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs
index 43a2295..3810062 100644
--- a/src/api/repo/record/batch.rs
+++ b/src/api/repo/record/batch.rs
@@ -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
diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs
index 63cf961..7a82f3b 100644
--- a/src/api/repo/record/delete.rs
+++ b/src/api/repo/record/delete.rs
@@ -34,19 +34,34 @@ pub async fn delete_record(
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
Json(input): Json,
) -> 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();
diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs
index f79080c..b5ed253 100644
--- a/src/api/repo/record/read.rs
+++ b/src/api/repo/record/read.rs
@@ -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::(&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::(&block)
+ {
+ records.push(json!({
+ "uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
+ "cid": cid_str,
+ "value": value
+ }));
+ }
}
Json(ListRecordsOutput {
cursor: last_rkey,
diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs
index bf6089c..6e20177 100644
--- a/src/api/repo/record/utils.rs
+++ b/src/api/repo/record/utils.rs
@@ -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::>();
- 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 {
+ 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 = vec![];
+ let blocks_cids: Vec = 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)
+}
diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs
index fa2b1f6..d83f1b7 100644
--- a/src/api/repo/record/write.rs
+++ b/src/api/repo/record/write.rs
@@ -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 {
+pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result {
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,
+}
+
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 {
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,
) -> 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,
) -> 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));
diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs
index ea4f30f..39b92bd 100644
--- a/src/api/server/account_status.rs
+++ b/src/api/server/account_status.rs
@@ -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()
diff --git a/src/api/server/email.rs b/src/api/server/email.rs
index 1cd9e4a..6c1d582 100644
--- a/src/api/server/email.rs
+++ b/src/api/server/email.rs
@@ -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();
}
diff --git a/src/api/server/password.rs b/src/api/server/password.rs
index 6f623b8..611aa2e 100644
--- a/src/api/server/password.rs
+++ b/src/api/server/password.rs
@@ -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) => {
diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs
index 7cfda43..c9a4837 100644
--- a/src/api/server/service_auth.rs
+++ b/src/api/server/service_auth.rs
@@ -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 {
diff --git a/src/api/server/session.rs b/src/api/server/session.rs
index 0ca4305..2a1c851 100644
--- a/src/api/server/session.rs
+++ b/src/api/server/session.rs
@@ -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,
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::DateTime)>(
+ let result = sqlx::query_as::<
+ _,
+ (
+ i32,
+ String,
+ chrono::DateTime,
+ chrono::DateTime,
+ ),
+ >(
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()
diff --git a/src/api/temp.rs b/src/api/temp.rs
index b089efc..279e182 100644
--- a/src/api/temp.rs
+++ b/src/api/temp.rs
@@ -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, 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, 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,
+ headers: HeaderMap,
+ Json(input): Json,
+) -> 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 = 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()
+}
diff --git a/src/api/verification.rs b/src/api/verification.rs
index 7ed9552..f414e57 100644
--- a/src/api/verification.rs
+++ b/src/api/verification.rs
@@ -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"})),
diff --git a/src/appview/mod.rs b/src/appview/mod.rs
index 93fb4f8..fb69600 100644
--- a/src/appview/mod.rs
+++ b/src/appview/mod.rs
@@ -83,13 +83,13 @@ impl DidResolver {
pub async fn resolve_did(&self, did: &str) -> Option {
{
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:") {
diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs
index 52a2b2e..43bdaff 100644
--- a/src/auth/extractor.rs
+++ b/src/auth/extractor.rs
@@ -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 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 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 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))
}
}
diff --git a/src/auth/mod.rs b/src/auth/mod.rs
index b9a1f82..359b842 100644
--- a/src/auth/mod.rs
+++ b/src/auth/mod.rs
@@ -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>,
pub is_oauth: bool,
pub is_admin: bool,
+ pub scope: Option,
+}
+
+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),
diff --git a/src/auth/scope_check.rs b/src/auth/scope_check.rs
new file mode 100644
index 0000000..275a99d
--- /dev/null
+++ b/src/auth/scope_check.rs
@@ -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()
+ })
+}
diff --git a/src/auth/service.rs b/src/auth/service.rs
index a912695..1b52020 100644
--- a/src/auth/service.rs
+++ b/src/auth/service.rs
@@ -278,11 +278,13 @@ impl Default for ServiceTokenVerifier {
fn parse_did_key_multibase(multibase: &str) -> Result {
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 {
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 {
diff --git a/src/auth/verify.rs b/src/auth/verify.rs
index 670ef99..a38f810 100644
--- a/src/auth/verify.rs
+++ b/src/auth/verify.rs
@@ -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)
diff --git a/src/comms/mod.rs b/src/comms/mod.rs
index c14c4be..cf39102 100644
--- a/src/comms/mod.rs
+++ b/src/comms/mod.rs
@@ -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,
};
diff --git a/src/comms/sender.rs b/src/comms/sender.rs
index 02286a9..236b3db 100644
--- a/src/comms/sender.rs
+++ b/src/comms/sender.rs
@@ -87,7 +87,8 @@ impl EmailSender {
pub fn from_env() -> Option {
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))
}
diff --git a/src/comms/service.rs b/src/comms/service.rs
index 3a75585..099171b 100644
--- a/src/comms/service.rs
+++ b/src/comms/service.rs
@@ -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,
diff --git a/src/config.rs b/src/config.rs
index 7bf915f..b1998f1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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");
}
diff --git a/src/crawlers.rs b/src/crawlers.rs
index cc58b8c..9e664f9 100644
--- a/src/crawlers.rs
+++ b/src/crawlers.rs
@@ -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();
diff --git a/src/handle/mod.rs b/src/handle/mod.rs
index 31cb09a..a1c90a0 100644
--- a/src/handle/mod.rs
+++ b/src/handle/mod.rs
@@ -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;
diff --git a/src/lib.rs b/src/lib.rs
index eafad2f..2a33529 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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),
diff --git a/src/main.rs b/src/main.rs
index bf46381..b895c4c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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 {
diff --git a/src/metrics.rs b/src/metrics.rs
index b09787e..f7c0181 100644
--- a/src/metrics.rs
+++ b/src/metrics.rs
@@ -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, 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) {
diff --git a/src/oauth/client.rs b/src/oauth/client.rs
index ceea205..df4771b 100644
--- a/src/oauth/client.rs
+++ b/src/oauth/client.rs
@@ -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")
diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs
index 9e3bf47..14bdc31 100644
--- a/src/oauth/db/mod.rs
+++ b/src/oauth/db/mod.rs
@@ -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,
diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs
index d1d01cf..aec70e4 100644
--- a/src/oauth/db/request.rs
+++ b/src/oauth/db/request.rs
@@ -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,
+) -> 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(())
+}
diff --git a/src/oauth/db/scope_preference.rs b/src/oauth/db/scope_preference.rs
new file mode 100644
index 0000000..b5459f6
--- /dev/null
+++ b/src/oauth/db/scope_preference.rs
@@ -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, 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 {
+ 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(())
+}
diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs
index ba8d67a..f855b25 100644
--- a/src/oauth/db/token.rs
+++ b/src/oauth/db/token.rs
@@ -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 {
+ 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())
+}
diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs
index 60d0698..f21c860 100644
--- a/src/oauth/endpoints/authorize.rs
+++ b/src/oauth/endpoints/authorize.rs
@@ -1,16 +1,16 @@
use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code};
use crate::oauth::{
- Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, templates,
+ Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db,
};
use crate::state::{AppState, RateLimitKind};
use axum::{
- Form, Json,
+ Json,
extract::{Query, State},
http::{
HeaderMap, StatusCode,
header::{LOCATION, SET_COOKIE},
},
- response::{Html, IntoResponse, Redirect, Response},
+ response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -23,6 +23,14 @@ fn redirect_see_other(uri: &str) -> Response {
(StatusCode::SEE_OTHER, [(LOCATION, uri.to_string())]).into_response()
}
+fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
+ redirect_see_other(&format!(
+ "/#/oauth/error?error={}&error_description={}",
+ url_encode(error),
+ url_encode(description)
+ ))
+}
+
fn extract_device_cookie(headers: &HeaderMap) -> Option {
headers
.get("cookie")
@@ -41,13 +49,15 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option {
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();
+ }
"0.0.0.0".to_string()
}
@@ -115,21 +125,17 @@ pub async fn authorize_get(
None => {
if wants_json(&headers) {
return (
- axum::http::StatusCode::BAD_REQUEST,
+ StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Missing request_uri parameter. Use PAR to initiate authorization."
})),
).into_response();
}
- return (
- axum::http::StatusCode::BAD_REQUEST,
- Html(templates::error_page(
- "invalid_request",
- Some("Missing request_uri parameter. Use PAR to initiate authorization."),
- )),
- )
- .into_response();
+ return redirect_to_frontend_error(
+ "invalid_request",
+ "Missing request_uri parameter. Use PAR to initiate authorization.",
+ );
}
};
let request_data = match db::get_authorization_request(&state.db, &request_uri).await {
@@ -137,28 +143,22 @@ pub async fn authorize_get(
Ok(None) => {
if wants_json(&headers) {
return (
- axum::http::StatusCode::BAD_REQUEST,
+ StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri. Please start a new authorization request."
})),
).into_response();
}
- return (
- axum::http::StatusCode::BAD_REQUEST,
- Html(templates::error_page(
- "invalid_request",
- Some(
- "Invalid or expired request_uri. Please start a new authorization request.",
- ),
- )),
- )
- .into_response();
+ return redirect_to_frontend_error(
+ "invalid_request",
+ "Invalid or expired request_uri. Please start a new authorization request.",
+ );
}
Err(e) => {
if wants_json(&headers) {
return (
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
@@ -166,35 +166,24 @@ pub async fn authorize_get(
)
.into_response();
}
- return (
- axum::http::StatusCode::INTERNAL_SERVER_ERROR,
- Html(templates::error_page(
- "server_error",
- Some(&format!("Database error: {:?}", e)),
- )),
- )
- .into_response();
+ return redirect_to_frontend_error("server_error", "A database error occurred.");
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &request_uri).await;
if wants_json(&headers) {
return (
- axum::http::StatusCode::BAD_REQUEST,
+ StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired. Please start a new request."
})),
).into_response();
}
- return (
- axum::http::StatusCode::BAD_REQUEST,
- Html(templates::error_page(
- "invalid_request",
- Some("Authorization request has expired. Please start a new request."),
- )),
- )
- .into_response();
+ return redirect_to_frontend_error(
+ "invalid_request",
+ "Authorization request has expired. Please start a new request.",
+ );
}
let client_cache = ClientMetadataCache::new(3600);
let client_name = client_cache
@@ -216,34 +205,18 @@ pub async fn authorize_get(
let force_new_account = query.new_account.unwrap_or(false);
if !force_new_account
&& let Some(device_id) = extract_device_cookie(&headers)
- && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
- && !accounts.is_empty() {
- let device_accounts: Vec = accounts
- .into_iter()
- .map(|row| DeviceAccount {
- did: row.did,
- handle: row.handle,
- email: row.email,
- last_used_at: row.last_used_at,
- })
- .collect();
- return Html(templates::account_selector_page(
- &request_data.parameters.client_id,
- client_name.as_deref(),
- &request_uri,
- &device_accounts,
- ))
- .into_response();
- }
- Html(templates::login_page(
- &request_data.parameters.client_id,
- client_name.as_deref(),
- request_data.parameters.scope.as_deref(),
- &request_uri,
- None,
- request_data.parameters.login_hint.as_deref(),
+ && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
+ && !accounts.is_empty()
+ {
+ return redirect_see_other(&format!(
+ "/#/oauth/accounts?request_uri={}",
+ url_encode(&request_uri)
+ ));
+ }
+ redirect_see_other(&format!(
+ "/#/oauth/login?request_uri={}",
+ url_encode(&request_uri)
))
- .into_response()
}
pub async fn authorize_get_json(
@@ -272,10 +245,93 @@ pub async fn authorize_get_json(
}))
}
+#[derive(Debug, Serialize)]
+pub struct AccountInfo {
+ pub did: String,
+ pub handle: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub email: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AccountsResponse {
+ pub accounts: Vec,
+ pub request_uri: String,
+}
+
+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()
+ }
+}
+
+pub async fn authorize_accounts(
+ State(state): State,
+ headers: HeaderMap,
+ Query(query): Query,
+) -> Response {
+ let request_uri = match query.request_uri {
+ Some(uri) => uri,
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Missing request_uri parameter"
+ })),
+ )
+ .into_response();
+ }
+ };
+ let device_id = match extract_device_cookie(&headers) {
+ Some(id) => id,
+ None => {
+ return Json(AccountsResponse {
+ accounts: vec![],
+ request_uri,
+ })
+ .into_response();
+ }
+ };
+ let accounts = match db::get_device_accounts(&state.db, &device_id).await {
+ Ok(accts) => accts,
+ Err(_) => {
+ return Json(AccountsResponse {
+ accounts: vec![],
+ request_uri,
+ })
+ .into_response();
+ }
+ };
+ let account_infos: Vec = accounts
+ .into_iter()
+ .map(|row| AccountInfo {
+ did: row.did,
+ handle: row.handle,
+ email: row.email.map(|e| mask_email(&e)),
+ })
+ .collect();
+ Json(AccountsResponse {
+ accounts: account_infos,
+ request_uri,
+ })
+ .into_response()
+}
+
pub async fn authorize_post(
State(state): State,
headers: HeaderMap,
- Form(form): Form,
+ Json(form): Json,
) -> Response {
let json_response = wants_json(&headers);
let client_ip = extract_client_ip(&headers);
@@ -294,14 +350,10 @@ pub async fn authorize_post(
)
.into_response();
}
- return (
- axum::http::StatusCode::TOO_MANY_REQUESTS,
- Html(templates::error_page(
- "RateLimitExceeded",
- Some("Too many login attempts. Please try again later."),
- )),
- )
- .into_response();
+ return redirect_to_frontend_error(
+ "RateLimitExceeded",
+ "Too many login attempts. Please try again later.",
+ );
}
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
@@ -316,11 +368,10 @@ pub async fn authorize_post(
)
.into_response();
}
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"invalid_request",
- Some("Invalid or expired request_uri. Please start a new authorization request."),
- ))
- .into_response();
+ "Invalid or expired request_uri. Please start a new authorization request.",
+ );
}
Err(e) => {
if json_response {
@@ -333,11 +384,7 @@ pub async fn authorize_post(
)
.into_response();
}
- return Html(templates::error_page(
- "server_error",
- Some(&format!("Database error: {:?}", e)),
- ))
- .into_response();
+ return redirect_to_frontend_error("server_error", &format!("Database error: {:?}", e));
}
};
if request_data.expires_at < Utc::now() {
@@ -352,18 +399,11 @@ pub async fn authorize_post(
)
.into_response();
}
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"invalid_request",
- Some("Authorization request has expired. Please start a new request."),
- ))
- .into_response();
+ "Authorization request has expired. Please start a new request.",
+ );
}
- let client_cache = ClientMetadataCache::new(3600);
- let client_name = client_cache
- .get(&request_data.parameters.client_id)
- .await
- .ok()
- .and_then(|m| m.client_name);
let show_login_error = |error_msg: &str, json: bool| -> Response {
if json {
return (
@@ -375,15 +415,11 @@ pub async fn authorize_post(
)
.into_response();
}
- Html(templates::login_page(
- &request_data.parameters.client_id,
- client_name.as_deref(),
- request_data.parameters.scope.as_deref(),
- &form.request_uri,
- Some(error_msg),
- Some(&form.username),
+ redirect_see_other(&format!(
+ "/#/oauth/login?request_uri={}&error={}",
+ url_encode(&form.request_uri),
+ url_encode(error_msg)
))
- .into_response()
};
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_username = form.username.trim();
@@ -419,7 +455,10 @@ pub async fn authorize_post(
{
Ok(Some(u)) => u,
Ok(None) => {
- let _ = bcrypt::verify(&form.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK");
+ let _ = bcrypt::verify(
+ &form.password,
+ "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
+ );
return show_login_error("Invalid handle/email or password.", json_response);
}
Err(_) => return show_login_error("An error occurred. Please try again.", json_response),
@@ -435,7 +474,10 @@ pub async fn authorize_post(
|| user.telegram_verified
|| user.signal_verified;
if !is_verified {
- return show_login_error("Please verify your account before logging in.", json_response);
+ return show_login_error(
+ "Please verify your account before logging in.",
+ json_response,
+ );
}
let password_valid = match bcrypt::verify(&form.password, &user.password_hash) {
Ok(valid) => valid,
@@ -460,19 +502,24 @@ pub async fn authorize_post(
);
}
let channel_name = channel_display_name(user.preferred_comms_channel);
- let redirect_url = format!(
- "/oauth/authorize/2fa?request_uri={}&channel={}",
+ if json_response {
+ return Json(serde_json::json!({
+ "needs_2fa": true,
+ "channel": channel_name
+ }))
+ .into_response();
+ }
+ return redirect_see_other(&format!(
+ "/#/oauth/2fa?request_uri={}&channel={}",
url_encode(&form.request_uri),
url_encode(channel_name)
- );
- return Redirect::temporary(&redirect_url).into_response();
+ ));
}
Err(_) => {
return show_login_error("An error occurred. Please try again.", json_response);
}
}
}
- let code = Code::generate();
let mut device_id: Option = extract_device_cookie(&headers);
let mut new_cookie: Option = None;
if form.remember_device {
@@ -497,6 +544,60 @@ pub async fn authorize_post(
};
let _ = db::upsert_account_device(&state.db, &user.did, &final_device_id).await;
}
+ if db::set_authorization_did(
+ &state.db,
+ &form.request_uri,
+ &user.did,
+ device_id.as_deref(),
+ )
+ .await
+ .is_err()
+ {
+ return show_login_error("An error occurred. Please try again.", json_response);
+ }
+ let requested_scope_str = request_data
+ .parameters
+ .scope
+ .as_deref()
+ .unwrap_or("atproto");
+ let requested_scopes: Vec = requested_scope_str
+ .split_whitespace()
+ .map(|s| s.to_string())
+ .collect();
+ let needs_consent = db::should_show_consent(
+ &state.db,
+ &user.did,
+ &request_data.parameters.client_id,
+ &requested_scopes,
+ )
+ .await
+ .unwrap_or(true);
+ if needs_consent {
+ let consent_url = format!(
+ "/#/oauth/consent?request_uri={}",
+ url_encode(&form.request_uri)
+ );
+ if json_response {
+ if let Some(cookie) = new_cookie {
+ return (
+ StatusCode::OK,
+ [(SET_COOKIE, cookie)],
+ Json(serde_json::json!({"redirect_uri": consent_url})),
+ )
+ .into_response();
+ }
+ return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
+ }
+ if let Some(cookie) = new_cookie {
+ return (
+ StatusCode::SEE_OTHER,
+ [(SET_COOKIE, cookie), (LOCATION, consent_url)],
+ )
+ .into_response();
+ }
+ return redirect_see_other(&consent_url);
+ }
+ let code = Code::generate();
if db::update_authorization_request(
&state.db,
&form.request_uri,
@@ -513,8 +614,20 @@ pub async fn authorize_post(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
+ request_data.parameters.response_mode.as_deref(),
);
- if let Some(cookie) = new_cookie {
+ if json_response {
+ if let Some(cookie) = new_cookie {
+ (
+ StatusCode::OK,
+ [(SET_COOKIE, cookie)],
+ Json(serde_json::json!({"redirect_uri": redirect_url})),
+ )
+ .into_response()
+ } else {
+ Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
+ }
+ } else if let Some(cookie) = new_cookie {
(
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
@@ -528,59 +641,69 @@ pub async fn authorize_post(
pub async fn authorize_select(
State(state): State,
headers: HeaderMap,
- Form(form): Form,
+ Json(form): Json,
) -> Response {
+ let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
+ (
+ status,
+ Json(serde_json::json!({
+ "error": error,
+ "error_description": description
+ })),
+ )
+ .into_response()
+ };
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("Invalid or expired request_uri. Please start a new authorization request."),
- ))
- .into_response();
+ "Invalid or expired request_uri. Please start a new authorization request.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("Authorization request has expired. Please start a new request."),
- ))
- .into_response();
+ "Authorization request has expired. Please start a new request.",
+ );
}
let device_id = match extract_device_cookie(&headers) {
Some(id) => id,
None => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("No device session found. Please sign in."),
- ))
- .into_response();
+ "No device session found. Please sign in.",
+ );
}
};
let account_valid = match db::verify_account_on_device(&state.db, &device_id, &form.did).await {
Ok(valid) => valid,
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
};
if !account_valid {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::FORBIDDEN,
"access_denied",
- Some("This account is not available on this device. Please sign in."),
- ))
- .into_response();
+ "This account is not available on this device. Please sign in.",
+ );
}
let user = match sqlx::query!(
r#"
@@ -597,16 +720,18 @@ pub async fn authorize_select(
{
Ok(Some(u)) => u,
Ok(None) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::FORBIDDEN,
"access_denied",
- Some("Account not found. Please sign in."),
- )).into_response();
+ "Account not found. Please sign in.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- )).into_response();
+ "An error occurred. Please try again.",
+ );
}
};
let is_verified = user.email_verified
@@ -614,11 +739,11 @@ pub async fn authorize_select(
|| user.telegram_verified
|| user.signal_verified;
if !is_verified {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::FORBIDDEN,
"access_denied",
- Some("Please verify your account before logging in."),
- ))
- .into_response();
+ "Please verify your account before logging in.",
+ );
}
if user.two_factor_enabled {
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
@@ -636,19 +761,18 @@ pub async fn authorize_select(
);
}
let channel_name = channel_display_name(user.preferred_comms_channel);
- let redirect_url = format!(
- "/oauth/authorize/2fa?request_uri={}&channel={}",
- url_encode(&form.request_uri),
- url_encode(channel_name)
- );
- return Redirect::temporary(&redirect_url).into_response();
+ return Json(serde_json::json!({
+ "needs_2fa": true,
+ "channel": channel_name
+ }))
+ .into_response();
}
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
}
}
@@ -664,23 +788,39 @@ pub async fn authorize_select(
.await
.is_err()
{
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
+ request_data.parameters.response_mode.as_deref(),
);
- redirect_see_other(&redirect_url)
+ Json(serde_json::json!({
+ "redirect_uri": redirect_url
+ }))
+ .into_response()
}
-fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -> String {
+fn build_success_redirect(
+ redirect_uri: &str,
+ code: &str,
+ state: Option<&str>,
+ response_mode: Option<&str>,
+) -> String {
let mut redirect_url = redirect_uri.to_string();
- let separator = if redirect_url.contains('?') { '&' } else { '?' };
+ let use_fragment = response_mode == Some("fragment");
+ let separator = if use_fragment {
+ '#'
+ } else if redirect_url.contains('?') {
+ '&'
+ } else {
+ '?'
+ };
redirect_url.push(separator);
redirect_url.push_str(&format!("code={}", url_encode(code)));
if let Some(req_state) = state {
@@ -702,12 +842,32 @@ pub struct AuthorizeDenyResponse {
pub async fn authorize_deny(
State(state): State,
- Form(form): Form,
-) -> Result {
- let request_data = db::get_authorization_request(&state.db, &form.request_uri)
- .await?
- .ok_or_else(|| OAuthError::InvalidRequest("Invalid request_uri".to_string()))?;
- db::delete_authorization_request(&state.db, &form.request_uri).await?;
+ Json(form): Json,
+) -> Response {
+ let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
+ Ok(Some(data)) => data,
+ Ok(None) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Invalid request_uri"
+ })),
+ )
+ .into_response();
+ }
+ Err(_) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": "server_error",
+ "error_description": "An error occurred"
+ })),
+ )
+ .into_response();
+ }
+ };
+ let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
let redirect_uri = &request_data.parameters.redirect_uri;
let mut redirect_url = redirect_uri.to_string();
let separator = if redirect_url.contains('?') { '&' } else { '?' };
@@ -717,7 +877,10 @@ pub async fn authorize_deny(
if let Some(state) = &request_data.parameters.state {
redirect_url.push_str(&format!("&state={}", url_encode(state)));
}
- Ok(redirect_see_other(&redirect_url))
+ Json(serde_json::json!({
+ "redirect_uri": redirect_url
+ }))
+ .into_response()
}
#[derive(Debug, Deserialize)]
@@ -746,106 +909,452 @@ pub async fn authorize_2fa_get(
let challenge = match db::get_2fa_challenge(&state.db, &query.request_uri).await {
Ok(Some(c)) => c,
Ok(None) => {
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"invalid_request",
- Some("No 2FA challenge found. Please start over."),
- ))
- .into_response();
+ "No 2FA challenge found. Please start over.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
};
if challenge.expires_at < Utc::now() {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"invalid_request",
- Some("2FA code has expired. Please start over."),
- ))
- .into_response();
+ "2FA code has expired. Please start over.",
+ );
}
let _request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
Ok(Some(d)) => d,
Ok(None) => {
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"invalid_request",
- Some("Authorization request not found. Please start over."),
- ))
- .into_response();
+ "Authorization request not found. Please start over.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return redirect_to_frontend_error(
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
};
let channel = query.channel.as_deref().unwrap_or("email");
- Html(templates::two_factor_page(
- &query.request_uri,
- channel,
- None,
+ redirect_see_other(&format!(
+ "/#/oauth/2fa?request_uri={}&channel={}",
+ url_encode(&query.request_uri),
+ url_encode(channel)
))
+}
+
+#[derive(Debug, Serialize)]
+pub struct ScopeInfo {
+ pub scope: String,
+ pub category: String,
+ pub required: bool,
+ pub description: String,
+ pub display_name: String,
+ pub granted: Option,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ConsentResponse {
+ pub request_uri: String,
+ pub client_id: String,
+ pub client_name: Option,
+ pub client_uri: Option,
+ pub logo_uri: Option,
+ pub scopes: Vec,
+ pub show_consent: bool,
+ pub did: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ConsentQuery {
+ pub request_uri: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ConsentSubmit {
+ pub request_uri: String,
+ pub approved_scopes: Vec,
+ pub remember: bool,
+}
+
+pub async fn consent_get(
+ State(state): State,
+ Query(query): Query,
+) -> Response {
+ let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
+ Ok(Some(data)) => data,
+ Ok(None) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Invalid or expired request_uri"
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": "server_error",
+ "error_description": format!("Database error: {:?}", e)
+ })),
+ )
+ .into_response();
+ }
+ };
+ if request_data.expires_at < Utc::now() {
+ let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Authorization request has expired"
+ })),
+ )
+ .into_response();
+ }
+ let did = match &request_data.did {
+ Some(d) => d.clone(),
+ None => {
+ return (
+ StatusCode::FORBIDDEN,
+ Json(serde_json::json!({
+ "error": "access_denied",
+ "error_description": "Not authenticated"
+ })),
+ )
+ .into_response();
+ }
+ };
+ let client_cache = ClientMetadataCache::new(3600);
+ let client_metadata = client_cache
+ .get(&request_data.parameters.client_id)
+ .await
+ .ok();
+ let requested_scope_str = request_data
+ .parameters
+ .scope
+ .as_deref()
+ .unwrap_or("atproto");
+ let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
+ let preferences =
+ db::get_scope_preferences(&state.db, &did, &request_data.parameters.client_id)
+ .await
+ .unwrap_or_default();
+ let pref_map: std::collections::HashMap<_, _> = preferences
+ .iter()
+ .map(|p| (p.scope.as_str(), p.granted))
+ .collect();
+ let requested_scope_strings: Vec =
+ requested_scopes.iter().map(|s| s.to_string()).collect();
+ let show_consent = db::should_show_consent(
+ &state.db,
+ &did,
+ &request_data.parameters.client_id,
+ &requested_scope_strings,
+ )
+ .await
+ .unwrap_or(true);
+ let mut scopes = Vec::new();
+ for scope in &requested_scopes {
+ let (category, required, description, display_name) =
+ if let Some(def) = crate::oauth::scopes::SCOPE_DEFINITIONS.get(*scope) {
+ (
+ def.category.display_name().to_string(),
+ def.required,
+ def.description.to_string(),
+ def.display_name.to_string(),
+ )
+ } else if scope.starts_with("ref:") {
+ (
+ "Reference".to_string(),
+ false,
+ "Referenced scope".to_string(),
+ scope.to_string(),
+ )
+ } else {
+ (
+ "Other".to_string(),
+ false,
+ format!("Access to {}", scope),
+ scope.to_string(),
+ )
+ };
+ let granted = pref_map.get(*scope).copied();
+ scopes.push(ScopeInfo {
+ scope: scope.to_string(),
+ category,
+ required,
+ description,
+ display_name,
+ granted,
+ });
+ }
+ Json(ConsentResponse {
+ request_uri: query.request_uri.clone(),
+ client_id: request_data.parameters.client_id.clone(),
+ client_name: client_metadata.as_ref().and_then(|m| m.client_name.clone()),
+ client_uri: client_metadata.as_ref().and_then(|m| m.client_uri.clone()),
+ logo_uri: client_metadata.as_ref().and_then(|m| m.logo_uri.clone()),
+ scopes,
+ show_consent,
+ did,
+ })
+ .into_response()
+}
+
+pub async fn consent_post(
+ State(state): State,
+ Json(form): Json,
+) -> Response {
+ let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
+ Ok(Some(data)) => data,
+ Ok(None) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Invalid or expired request_uri"
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": "server_error",
+ "error_description": format!("Database error: {:?}", e)
+ })),
+ )
+ .into_response();
+ }
+ };
+ if request_data.expires_at < Utc::now() {
+ let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Authorization request has expired"
+ })),
+ )
+ .into_response();
+ }
+ let did = match &request_data.did {
+ Some(d) => d.clone(),
+ None => {
+ return (
+ StatusCode::FORBIDDEN,
+ Json(serde_json::json!({
+ "error": "access_denied",
+ "error_description": "Not authenticated"
+ })),
+ )
+ .into_response();
+ }
+ };
+ let requested_scope_str = request_data
+ .parameters
+ .scope
+ .as_deref()
+ .unwrap_or("atproto");
+ let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect();
+ let has_granular_scopes = requested_scopes.iter().any(|s| {
+ s.starts_with("repo:")
+ || s.starts_with("blob:")
+ || s.starts_with("rpc:")
+ || s.starts_with("account:")
+ || s.starts_with("identity:")
+ });
+ let user_denied_some_granular = has_granular_scopes
+ && requested_scopes
+ .iter()
+ .filter(|s| {
+ s.starts_with("repo:")
+ || s.starts_with("blob:")
+ || s.starts_with("rpc:")
+ || s.starts_with("account:")
+ || s.starts_with("identity:")
+ })
+ .any(|s| !form.approved_scopes.contains(&s.to_string()));
+ let atproto_was_requested = requested_scopes.contains(&"atproto");
+ if atproto_was_requested
+ && !has_granular_scopes
+ && !form.approved_scopes.contains(&"atproto".to_string())
+ {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "The atproto scope was requested and must be approved"
+ })),
+ )
+ .into_response();
+ }
+ let final_approved: Vec = if user_denied_some_granular {
+ form.approved_scopes
+ .iter()
+ .filter(|s| *s != "atproto")
+ .cloned()
+ .collect()
+ } else {
+ form.approved_scopes.clone()
+ };
+ if final_approved.is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "At least one scope must be approved"
+ })),
+ )
+ .into_response();
+ }
+ let approved_scope_str = final_approved.join(" ");
+ let has_valid_scope = final_approved.iter().all(|s| {
+ s == "atproto"
+ || s == "transition:generic"
+ || s == "transition:chat.bsky"
+ || s == "transition:email"
+ || s.starts_with("repo:")
+ || s.starts_with("blob:")
+ || s.starts_with("rpc:")
+ || s.starts_with("account:")
+ || s.starts_with("include:")
+ });
+ if !has_valid_scope {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "error_description": "Invalid scope format"
+ })),
+ )
+ .into_response();
+ }
+ if form.remember {
+ let preferences: Vec = requested_scopes
+ .iter()
+ .map(|s| db::ScopePreference {
+ scope: s.to_string(),
+ granted: form.approved_scopes.contains(&s.to_string()),
+ })
+ .collect();
+ let _ = db::upsert_scope_preferences(
+ &state.db,
+ &did,
+ &request_data.parameters.client_id,
+ &preferences,
+ )
+ .await;
+ }
+ if let Err(e) =
+ db::update_request_scope(&state.db, &form.request_uri, &approved_scope_str).await
+ {
+ tracing::warn!("Failed to update request scope: {:?}", e);
+ }
+ let code = Code::generate();
+ if db::update_authorization_request(
+ &state.db,
+ &form.request_uri,
+ &did,
+ request_data.device_id.as_deref(),
+ &code.0,
+ )
+ .await
+ .is_err()
+ {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({
+ "error": "server_error",
+ "error_description": "Failed to complete authorization"
+ })),
+ )
+ .into_response();
+ }
+ let redirect_url = build_success_redirect(
+ &request_data.parameters.redirect_uri,
+ &code.0,
+ request_data.parameters.state.as_deref(),
+ request_data.parameters.response_mode.as_deref(),
+ );
+ Json(serde_json::json!({
+ "redirect_uri": redirect_url
+ }))
.into_response()
}
pub async fn authorize_2fa_post(
State(state): State,
headers: HeaderMap,
- Form(form): Form,
+ Json(form): Json,
) -> Response {
+ let json_error = |status: StatusCode, error: &str, description: &str| -> Response {
+ (
+ status,
+ Json(serde_json::json!({
+ "error": error,
+ "error_description": description
+ })),
+ )
+ .into_response()
+ };
let client_ip = extract_client_ip(&headers);
if !state
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded");
- return (
- axum::http::StatusCode::TOO_MANY_REQUESTS,
- Html(templates::error_page(
- "RateLimitExceeded",
- Some("Too many attempts. Please try again later."),
- )),
- )
- .into_response();
+ return json_error(
+ StatusCode::TOO_MANY_REQUESTS,
+ "RateLimitExceeded",
+ "Too many attempts. Please try again later.",
+ );
}
let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await {
Ok(Some(c)) => c,
Ok(None) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("No 2FA challenge found. Please start over."),
- ))
- .into_response();
+ "No 2FA challenge found. Please start over.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
};
if challenge.expires_at < Utc::now() {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("2FA code has expired. Please start over."),
- ))
- .into_response();
+ "2FA code has expired. Please start over.",
+ );
}
if challenge.attempts >= MAX_2FA_ATTEMPTS {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::FORBIDDEN,
"access_denied",
- Some("Too many failed attempts. Please start over."),
- ))
- .into_response();
+ "Too many failed attempts. Please start over.",
+ );
}
let code_valid: bool = form
.code
@@ -855,57 +1364,28 @@ pub async fn authorize_2fa_post(
.into();
if !code_valid {
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
- let channel = match sqlx::query_scalar!(
- r#"SELECT preferred_comms_channel as "channel: CommsChannel" FROM users WHERE did = $1"#,
- challenge.did
- )
- .fetch_optional(&state.db)
- .await
- {
- Ok(Some(ch)) => channel_display_name(ch).to_string(),
- Ok(None) | Err(_) => "email".to_string(),
- };
- let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await
- {
- Ok(Some(d)) => d,
- Ok(None) => {
- return Html(templates::error_page(
- "invalid_request",
- Some("Authorization request not found. Please start over."),
- ))
- .into_response();
- }
- Err(_) => {
- return Html(templates::error_page(
- "server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
- }
- };
- return Html(templates::two_factor_page(
- &form.request_uri,
- &channel,
- Some("Invalid verification code. Please try again."),
- ))
- .into_response();
+ return json_error(
+ StatusCode::FORBIDDEN,
+ "invalid_code",
+ "Invalid verification code. Please try again.",
+ );
}
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(d)) => d,
Ok(None) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::BAD_REQUEST,
"invalid_request",
- Some("Authorization request not found."),
- ))
- .into_response();
+ "Authorization request not found.",
+ );
}
Err(_) => {
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred."),
- ))
- .into_response();
+ "An error occurred.",
+ );
}
};
let code = Code::generate();
@@ -920,16 +1400,20 @@ pub async fn authorize_2fa_post(
.await
.is_err()
{
- return Html(templates::error_page(
+ return json_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
- Some("An error occurred. Please try again."),
- ))
- .into_response();
+ "An error occurred. Please try again.",
+ );
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
+ request_data.parameters.response_mode.as_deref(),
);
- redirect_see_other(&redirect_url)
+ Json(serde_json::json!({
+ "redirect_uri": redirect_url
+ }))
+ .into_response()
}
diff --git a/src/oauth/endpoints/metadata.rs b/src/oauth/endpoints/metadata.rs
index 569791d..aecd98e 100644
--- a/src/oauth/endpoints/metadata.rs
+++ b/src/oauth/endpoints/metadata.rs
@@ -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()]),
diff --git a/src/oauth/endpoints/par.rs b/src/oauth/endpoints/par.rs
index b27464e..8df5353 100644
--- a/src/oauth/endpoints/par.rs
+++ b/src/oauth/endpoints/par.rs
@@ -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,
#[serde(default)]
+ pub response_mode: Option,
+ #[serde(default)]
pub login_hint: Option,
#[serde(default)]
pub dpop_jkt: Option,
@@ -44,8 +48,24 @@ pub struct ParResponse {
pub async fn pushed_authorization_request(
State(state): State,
headers: HeaderMap,
- Form(request): Form,
+ body: Bytes,
) -> Result<(axum::http::StatusCode, Json), 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
+}
diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs
index f6f2ff4..78bbc98 100644
--- a/src/oauth/endpoints/token/grants.rs
+++ b/src/oauth/endpoints/token/grants.rs
@@ -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());
diff --git a/src/oauth/endpoints/token/helpers.rs b/src/oauth/endpoints/token/helpers.rs
index 214daf1..643cf1c 100644
--- a/src/oauth/endpoints/token/helpers.rs
+++ b/src/oauth/endpoints/token/helpers.rs
@@ -36,12 +36,14 @@ pub fn create_access_token(
token_id: &str,
sub: &str,
dpop_jkt: Option<&str>,
+ scope: Option<&str>,
) -> Result {
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 });
diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs
index 306219f..51182f1 100644
--- a/src/oauth/endpoints/token/mod.rs
+++ b/src/oauth/endpoints/token/mod.rs
@@ -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,
headers: HeaderMap,
- Form(request): Form,
+ body: Bytes,
) -> Result<(HeaderMap, Json), 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)
diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs
index 4236131..8b20c0a 100644
--- a/src/oauth/mod.rs
+++ b/src/oauth/mod.rs
@@ -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,
diff --git a/src/oauth/scopes/definitions.rs b/src/oauth/scopes/definitions.rs
new file mode 100644
index 0000000..02104ce
--- /dev/null
+++ b/src/oauth/scopes/definitions.rs
@@ -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> =
+ 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)
+ }
+}
diff --git a/src/oauth/scopes/error.rs b/src/oauth/scopes/error.rs
new file mode 100644
index 0000000..ba1d41c
--- /dev/null
+++ b/src/oauth/scopes/error.rs
@@ -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()
+ }
+}
diff --git a/src/oauth/scopes/mod.rs b/src/oauth/scopes/mod.rs
new file mode 100644
index 0000000..67f9e64
--- /dev/null
+++ b/src/oauth/scopes/mod.rs
@@ -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;
diff --git a/src/oauth/scopes/parser.rs b/src/oauth/scopes/parser.rs
new file mode 100644
index 0000000..ba71e8f
--- /dev/null
+++ b/src/oauth/scopes/parser.rs
@@ -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,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RepoScope {
+ pub collection: Option,
+ pub actions: HashSet,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum RepoAction {
+ Create,
+ Update,
+ Delete,
+}
+
+impl RepoAction {
+ pub fn parse_str(s: &str) -> Option {
+ 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,
+}
+
+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,
+ pub aud: Option,
+}
+
+#[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 {
+ 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 {
+ 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 {
+ match s {
+ "read" => Some(Self::Read),
+ "manage" => Some(Self::Manage),
+ _ => None,
+ }
+ }
+}
+
+fn parse_query_params(query: &str) -> HashMap> {
+ let mut params: HashMap> = 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 {
+ 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(_)));
+ }
+}
diff --git a/src/oauth/scopes/permissions.rs b/src/oauth/scopes/permissions.rs
new file mode 100644
index 0000000..dac66fb
--- /dev/null
+++ b/src/oauth/scopes/permissions.rs
@@ -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,
+ parsed: Vec,
+ 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 = 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 {
+ &self.scopes
+ }
+
+ pub fn has_full_access(&self) -> bool {
+ self.has_atproto
+ }
+
+ fn find_repo_scopes(&self) -> impl Iterator- {
+ self.parsed.iter().filter_map(|p| {
+ if let ParsedScope::Repo(r) = p {
+ Some(r)
+ } else {
+ None
+ }
+ })
+ }
+
+ fn find_blob_scopes(&self) -> impl Iterator
- {
+ self.parsed.iter().filter_map(|p| {
+ if let ParsedScope::Blob(b) = p {
+ Some(b)
+ } else {
+ None
+ }
+ })
+ }
+
+ fn find_rpc_scopes(&self) -> impl Iterator
- {
+ self.parsed.iter().filter_map(|p| {
+ if let ParsedScope::Rpc(r) = p {
+ Some(r)
+ } else {
+ None
+ }
+ })
+ }
+
+ fn find_account_scopes(&self) -> impl Iterator
- {
+ self.parsed.iter().filter_map(|p| {
+ if let ParsedScope::Account(a) = p {
+ Some(a)
+ } else {
+ None
+ }
+ })
+ }
+
+ fn find_identity_scopes(&self) -> impl Iterator
- {
+ 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));
+ }
+}
diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs
deleted file mode 100644
index d3ecb61..0000000
--- a/src/oauth/templates.rs
+++ /dev/null
@@ -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#"
{}
"#, html_escape(msg)))
- .unwrap_or_default();
- let login_hint_value = login_hint.unwrap_or("");
- format!(
- r#"
-
-
-
-
-
- Sign in
-
-
-
-
-
Sign In
-
Sign in to continue to {client_display}
-
- {client_display}
- wants to {scope_display}
-
- {error_html}
-
-
- By signing in, you agree to share your account information with this application.
-
-
-
-"#,
- 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,
- pub last_used_at: DateTime,
-}
-
-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#""#,
- request_uri = html_escape(request_uri),
- did = html_escape(&account.did),
- handle = html_escape(&account.handle),
- )
- })
- .collect();
- format!(
- r#"
-
-
-
-
-
- Choose an account
-
-
-
-
-
-"#,
- 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#"{}
"#, 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#"
-
-
-
-
-
- Verify your identity
-
-
-
-
-
{title}
-
{subtitle}
- {error_html}
-
-
- Code expires in 10 minutes.
-
-
-
-"#,
- 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#"
-
-
-
-
-
- Authorization Error
-
-
-
-
-
Authorization Failed
-
{error}
-
{description}
-
-
-
-
-
-"#,
- 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#"
-
-
-
-
-
- Authorization Successful
-
-
-
-
-
✓
-
Authorization Successful
-
{client_display} has been granted access to your account.
-
You can close this window and return to the application.
-
-
-"#,
- 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()
- }
-}
diff --git a/src/oauth/types.rs b/src/oauth/types.rs
index 608f283..6fb279a 100644
--- a/src/oauth/types.rs
+++ b/src/oauth/types.rs
@@ -91,6 +91,7 @@ pub struct AuthorizationRequestParameters {
pub state: Option,
pub code_challenge: String,
pub code_challenge_method: String,
+ pub response_mode: Option,
pub login_hint: Option,
pub dpop_jkt: Option,
#[serde(flatten)]
diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs
index ca7333c..010b8e5 100644
--- a/src/oauth/verify.rs
+++ b/src/oauth/verify.rs
@@ -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,
pub scope: Option,
pub is_oauth: bool,
+ pub permissions: ScopePermissions,
}
pub struct OAuthAuthError {
@@ -244,18 +246,23 @@ impl FromRequestParts 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(),
diff --git a/src/plc/mod.rs b/src/plc/mod.rs
index 9e8e863..556ffd4 100644
--- a/src/plc/mod.rs
+++ b/src/plc/mod.rs
@@ -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())
diff --git a/src/rate_limit.rs b/src/rate_limit.rs
index c55d6fe..ee33978 100644
--- a/src/rate_limit.rs
+++ b/src/rate_limit.rs
@@ -122,14 +122,16 @@ impl RateLimiters {
pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> 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())
diff --git a/src/sync/import.rs b/src/sync/import.rs
index f26f86e..fa13fac 100644
--- a/src/sync/import.rs
+++ b/src/sync/import.rs
@@ -77,19 +77,20 @@ pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec {
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 {
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::(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::(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() {
diff --git a/src/sync/util.rs b/src/sync/util.rs
index a93c78f..34cb7de 100644
--- a/src/sync/util.rs
+++ b/src/sync/util.rs
@@ -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 = 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 = 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 = 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 = None;
diff --git a/src/sync/verify.rs b/src/sync/verify.rs
index c90908c..6832ead 100644
--- a/src/sync/verify.rs
+++ b/src/sync/verify.rs
@@ -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
+ );
+ }
}
}
}
diff --git a/src/validation/mod.rs b/src/validation/mod.rs
index 000d32b..c51f80c 100644
--- a/src/validation/mod.rs
+++ b/src/validation/mod.rs
@@ -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(())
}
}
diff --git a/tests/account_notifications.rs b/tests/account_notifications.rs
index 758d4ff..87ba309 100644
--- a/tests/account_notifications.rs
+++ b/tests/account_notifications.rs
@@ -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
diff --git a/tests/admin_search.rs b/tests/admin_search.rs
index 92da44e..a0b30c1 100644
--- a/tests/admin_search.rs
+++ b/tests/admin_search.rs
@@ -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"
+ );
}
diff --git a/tests/admin_stats.rs b/tests/admin_stats.rs
index 1e58658..e219a2e 100644
--- a/tests/admin_stats.rs
+++ b/tests/admin_stats.rs
@@ -38,4 +38,4 @@ async fn test_get_server_stats_no_auth() {
.await
.unwrap();
assert_eq!(resp.status(), 401);
-}
\ No newline at end of file
+}
diff --git a/tests/change_password.rs b/tests/change_password.rs
index 77d2957..c19bd1a 100644
--- a/tests/change_password.rs
+++ b/tests/change_password.rs
@@ -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]
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 90c1dbb..8d4ad4d 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -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;
diff --git a/tests/email_update.rs b/tests/email_update.rs
index dd4ddfb..99c6c42 100644
--- a/tests/email_update.rs
+++ b/tests/email_update.rs
@@ -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'",
diff --git a/tests/image_processing.rs b/tests/image_processing.rs
index e876085..ed0f97f 100644
--- a/tests/image_processing.rs
+++ b/tests/image_processing.rs
@@ -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 {
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 {
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 {
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 {
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]
diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs
index be52076..8d290f4 100644
--- a/tests/jwt_security.rs
+++ b/tests/jwt_security.rs
@@ -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 {
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);
}
diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs
index 46cdde5..3b8f2aa 100644
--- a/tests/lifecycle_record.rs
+++ b/tests/lifecycle_record.rs
@@ -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);
}
diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs
index 43bc05a..25a1799 100644
--- a/tests/lifecycle_social.rs
+++ b/tests/lifecycle_social.rs
@@ -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() {
diff --git a/tests/notifications.rs b/tests/notifications.rs
index bcb445b..187118b 100644
--- a/tests/notifications.rs
+++ b/tests/notifications.rs
@@ -1,8 +1,8 @@
mod common;
+use sqlx::PgPool;
use tranquil_pds::comms::{
CommsChannel, CommsStatus, CommsType, NewComms, enqueue_comms, enqueue_welcome,
};
-use sqlx::PgPool;
async fn get_pool() -> PgPool {
let conn_str = common::get_db_connection_string().await;
@@ -109,9 +109,7 @@ async fn test_comms_queue_status_index() {
"Test".to_string(),
"Body".to_string(),
);
- enqueue_comms(&pool, item)
- .await
- .expect("Failed to enqueue");
+ enqueue_comms(&pool, item).await.expect("Failed to enqueue");
}
let final_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
diff --git a/tests/oauth.rs b/tests/oauth.rs
index b544530..7562ff3 100644
--- a/tests/oauth.rs
+++ b/tests/oauth.rs
@@ -11,7 +11,10 @@ use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn no_redirect_client() -> reqwest::Client {
- reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap()
+ reqwest::Client::builder()
+ .redirect(redirect::Policy::none())
+ .build()
+ .unwrap()
}
fn generate_pkce() -> (String, String) {
@@ -47,25 +50,65 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
async fn test_oauth_metadata_endpoints() {
let url = base_url().await;
let client = client();
- let pr_res = client.get(format!("{}/.well-known/oauth-protected-resource", url)).send().await.unwrap();
+ let pr_res = client
+ .get(format!("{}/.well-known/oauth-protected-resource", url))
+ .send()
+ .await
+ .unwrap();
assert_eq!(pr_res.status(), StatusCode::OK);
let pr_body: Value = pr_res.json().await.unwrap();
assert!(pr_body["resource"].is_string());
assert!(pr_body["authorization_servers"].is_array());
- assert!(pr_body["bearer_methods_supported"].as_array().unwrap().contains(&json!("header")));
- let as_res = client.get(format!("{}/.well-known/oauth-authorization-server", url)).send().await.unwrap();
+ assert!(
+ pr_body["bearer_methods_supported"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("header"))
+ );
+ let as_res = client
+ .get(format!("{}/.well-known/oauth-authorization-server", url))
+ .send()
+ .await
+ .unwrap();
assert_eq!(as_res.status(), StatusCode::OK);
let as_body: Value = as_res.json().await.unwrap();
assert!(as_body["issuer"].is_string());
assert!(as_body["authorization_endpoint"].is_string());
assert!(as_body["token_endpoint"].is_string());
assert!(as_body["jwks_uri"].is_string());
- assert!(as_body["response_types_supported"].as_array().unwrap().contains(&json!("code")));
- assert!(as_body["grant_types_supported"].as_array().unwrap().contains(&json!("authorization_code")));
- assert!(as_body["code_challenge_methods_supported"].as_array().unwrap().contains(&json!("S256")));
- assert_eq!(as_body["require_pushed_authorization_requests"], json!(true));
- assert!(as_body["dpop_signing_alg_values_supported"].as_array().unwrap().contains(&json!("ES256")));
- let jwks_res = client.get(format!("{}/oauth/jwks", url)).send().await.unwrap();
+ assert!(
+ as_body["response_types_supported"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("code"))
+ );
+ assert!(
+ as_body["grant_types_supported"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("authorization_code"))
+ );
+ assert!(
+ as_body["code_challenge_methods_supported"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("S256"))
+ );
+ assert_eq!(
+ as_body["require_pushed_authorization_requests"],
+ json!(true)
+ );
+ assert!(
+ as_body["dpop_signing_alg_values_supported"]
+ .as_array()
+ .unwrap()
+ .contains(&json!("ES256"))
+ );
+ let jwks_res = client
+ .get(format!("{}/oauth/jwks", url))
+ .send()
+ .await
+ .unwrap();
assert_eq!(jwks_res.status(), StatusCode::OK);
let jwks_body: Value = jwks_res.json().await.unwrap();
assert!(jwks_body["keys"].is_array());
@@ -81,9 +124,18 @@ async fn test_par_and_authorize() {
let (_, code_challenge) = generate_pkce();
let par_res = client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("scope", "atproto"), ("state", "test-state")])
- .send().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", "atproto"),
+ ("state", "test-state"),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed");
let par_body: Value = par_res.json().await.unwrap();
assert!(par_body["request_uri"].is_string());
@@ -94,7 +146,9 @@ async fn test_par_and_authorize() {
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.query(&[("request_uri", request_uri)])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(auth_res.status(), StatusCode::OK);
let auth_body: Value = auth_res.json().await.unwrap();
assert_eq!(auth_body["client_id"], client_id);
@@ -103,11 +157,34 @@ async fn test_par_and_authorize() {
let invalid_res = client
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
- .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")])
- .send().await.unwrap();
+ .query(&[(
+ "request_uri",
+ "urn:ietf:params:oauth:request_uri:nonexistent",
+ )])
+ .send()
+ .await
+ .unwrap();
assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST);
- let missing_res = client.get(format!("{}/oauth/authorize", url)).send().await.unwrap();
- assert_eq!(missing_res.status(), StatusCode::BAD_REQUEST);
+ let missing_client = no_redirect_client();
+ let missing_res = missing_client
+ .get(format!("{}/oauth/authorize", url))
+ .send()
+ .await
+ .unwrap();
+ assert!(
+ missing_res.status().is_redirection(),
+ "Should redirect to error page"
+ );
+ let error_location = missing_res
+ .headers()
+ .get("location")
+ .unwrap()
+ .to_str()
+ .unwrap();
+ assert!(
+ error_location.contains("oauth/error"),
+ "Should redirect to error page"
+ );
}
#[tokio::test]
@@ -121,7 +198,9 @@ async fn test_full_oauth_flow() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
@@ -133,27 +212,84 @@ async fn test_full_oauth_flow() {
let state = format!("state-{}", ts);
let par_res = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("scope", "atproto"), ("state", &state)])
- .send().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", "atproto"),
+ ("state", &state),
+ ])
+ .send()
+ .await
+ .unwrap();
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
.send().await.unwrap();
- assert!(auth_res.status().is_redirection(), "Expected redirect, got {}", auth_res.status());
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
- assert!(location.starts_with(redirect_uri), "Redirect to wrong URI");
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Expected OK with JSON response"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri in response")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_status = consent_res.status();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ assert_eq!(
+ consent_status,
+ StatusCode::OK,
+ "Consent should succeed. Got: {:?}",
+ consent_body
+ );
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
+ assert!(
+ location.starts_with(redirect_uri),
+ "Redirect to wrong URI: {}",
+ location
+ );
assert!(location.contains("code="), "No code in redirect");
- assert!(location.contains(&format!("state={}", state)), "Wrong state");
- let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
+ assert!(
+ location.contains(&format!("state={}", state)),
+ "Wrong state"
+ );
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
let token_body: Value = token_res.json().await.unwrap();
assert!(token_body["access_token"].is_string());
@@ -165,30 +301,48 @@ async fn test_full_oauth_flow() {
let refresh_token = token_body["refresh_token"].as_str().unwrap();
let refresh_res = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &client_id)])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", refresh_token),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(refresh_res.status(), StatusCode::OK);
let refresh_body: Value = refresh_res.json().await.unwrap();
assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token);
- assert_ne!(refresh_body["refresh_token"].as_str().unwrap(), refresh_token);
+ assert_ne!(
+ refresh_body["refresh_token"].as_str().unwrap(),
+ refresh_token
+ );
let introspect_res = http_client
.post(format!("{}/oauth/introspect", url))
.form(&[("token", refresh_body["access_token"].as_str().unwrap())])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(introspect_res.status(), StatusCode::OK);
let introspect_body: Value = introspect_res.json().await.unwrap();
assert_eq!(introspect_body["active"], true);
let revoke_res = http_client
.post(format!("{}/oauth/revoke", url))
.form(&[("token", refresh_body["refresh_token"].as_str().unwrap())])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(revoke_res.status(), StatusCode::OK);
let introspect_after = http_client
.post(format!("{}/oauth/introspect", url))
.form(&[("token", refresh_body["access_token"].as_str().unwrap())])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
let after_body: Value = introspect_after.json().await.unwrap();
- assert_eq!(after_body["active"], false, "Revoked token should be inactive");
+ assert_eq!(
+ after_body["active"], false,
+ "Revoked token should be inactive"
+ );
}
#[tokio::test]
@@ -198,45 +352,72 @@ async fn test_oauth_error_cases() {
let ts = Utc::now().timestamp_millis();
let handle = format!("wrong-creds-{}", ts);
let email = format!("wrong-creds-{}@example.com", ts);
- http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": "correct-password" }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
let redirect_uri = "https://example.com/callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
.header("Accept", "application/json")
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", "wrong-password"), ("remember_device", "false")])
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false}))
.send().await.unwrap();
assert_eq!(auth_res.status(), StatusCode::FORBIDDEN);
let error_body: Value = auth_res.json().await.unwrap();
assert_eq!(error_body["error"], "access_denied");
let unsupported = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "client_credentials"), ("client_id", "https://example.com")])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "client_credentials"),
+ ("client_id", "https://example.com"),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST);
let body: Value = unsupported.json().await.unwrap();
assert_eq!(body["error"], "unsupported_grant_type");
let invalid_refresh = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "refresh_token"), ("refresh_token", "invalid-token"), ("client_id", "https://example.com")])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", "invalid-token"),
+ ("client_id", "https://example.com"),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST);
let body: Value = invalid_refresh.json().await.unwrap();
assert_eq!(body["error"], "invalid_grant");
let invalid_introspect = http_client
.post(format!("{}/oauth/introspect", url))
.form(&[("token", "invalid.token.here")])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(invalid_introspect.status(), StatusCode::OK);
let body: Value = invalid_introspect.json().await.unwrap();
assert_eq!(body["active"], false);
@@ -244,7 +425,9 @@ async fn test_oauth_error_cases() {
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")])
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST);
}
@@ -259,55 +442,117 @@ async fn test_oauth_2fa_flow() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
verify_new_account(&http_client, user_did).await;
let db_url = get_db_connection_string().await;
- let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
+ let pool = sqlx::postgres::PgPoolOptions::new()
+ .max_connections(1)
+ .connect(&db_url)
+ .await
+ .unwrap();
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
- .bind(user_did).execute(&pool).await.unwrap();
+ .bind(user_did)
+ .execute(&pool)
+ .await
+ .unwrap();
let redirect_uri = "https://example.com/2fa-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
.send().await.unwrap();
- assert!(auth_res.status().is_redirection(), "Should redirect to 2FA page");
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
- assert!(location.contains("/oauth/authorize/2fa"), "Should redirect to 2FA page, got: {}", location);
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Should return OK with needs_2fa"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ assert!(
+ auth_body["needs_2fa"].as_bool().unwrap_or(false),
+ "Should need 2FA, got: {:?}",
+ auth_body
+ );
let twofa_invalid = http_client
.post(format!("{}/oauth/authorize/2fa", url))
- .form(&[("request_uri", request_uri), ("code", "000000")])
- .send().await.unwrap();
- assert_eq!(twofa_invalid.status(), StatusCode::OK);
- let body = twofa_invalid.text().await.unwrap();
- assert!(body.contains("Invalid verification code") || body.contains("invalid"));
- let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
- .bind(request_uri).fetch_one(&pool).await.unwrap();
- let twofa_res = auth_client
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "code": "000000"}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN);
+ let body: Value = twofa_invalid.json().await.unwrap();
+ assert!(
+ body["error_description"]
+ .as_str()
+ .unwrap_or("")
+ .contains("Invalid")
+ || body["error"].as_str().unwrap_or("") == "invalid_code"
+ );
+ let twofa_code: String =
+ sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
+ .bind(request_uri)
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ let twofa_res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
- .form(&[("request_uri", request_uri), ("code", &twofa_code)])
- .send().await.unwrap();
- assert!(twofa_res.status().is_redirection(), "Valid 2FA code should redirect");
- let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap();
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "code": &twofa_code}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ twofa_res.status(),
+ StatusCode::OK,
+ "Valid 2FA code should succeed"
+ );
+ let twofa_body: Value = twofa_res.json().await.unwrap();
+ let final_location = twofa_body["redirect_uri"].as_str().unwrap();
assert!(final_location.starts_with(redirect_uri) && final_location.contains("code="));
- let auth_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap();
+ let auth_code = final_location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", auth_code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", auth_code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(token_res.status(), StatusCode::OK);
let token_body: Value = token_res.json().await.unwrap();
assert_eq!(token_body["sub"], user_did);
@@ -324,45 +569,90 @@ async fn test_oauth_2fa_lockout() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
verify_new_account(&http_client, user_did).await;
let db_url = get_db_connection_string().await;
- let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
+ let pool = sqlx::postgres::PgPoolOptions::new()
+ .max_connections(1)
+ .connect(&db_url)
+ .await
+ .unwrap();
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
- .bind(user_did).execute(&pool).await.unwrap();
+ .bind(user_did)
+ .execute(&pool)
+ .await
+ .unwrap();
let redirect_uri = "https://example.com/2fa-lockout-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
.send().await.unwrap();
- assert!(auth_res.status().is_redirection());
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Should return OK with needs_2fa"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ assert!(
+ auth_body["needs_2fa"].as_bool().unwrap_or(false),
+ "Should need 2FA"
+ );
for i in 0..5 {
let res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
- .form(&[("request_uri", request_uri), ("code", "999999")])
- .send().await.unwrap();
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "code": "999999"}))
+ .send()
+ .await
+ .unwrap();
if i < 4 {
- assert_eq!(res.status(), StatusCode::OK);
+ assert_eq!(
+ res.status(),
+ StatusCode::FORBIDDEN,
+ "Attempt {} should return 403",
+ i
+ );
}
}
let lockout_res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
- .form(&[("request_uri", request_uri), ("code", "999999")])
- .send().await.unwrap();
- let body = lockout_res.text().await.unwrap();
- assert!(body.contains("Too many failed attempts") || body.contains("No 2FA challenge found"));
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "code": "999999"}))
+ .send()
+ .await
+ .unwrap();
+ let body: Value = lockout_res.json().await.unwrap();
+ let desc = body["error_description"].as_str().unwrap_or("");
+ assert!(
+ desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request",
+ "Expected lockout error, got: {:?}",
+ body
+ );
}
#[tokio::test]
@@ -376,7 +666,9 @@ async fn test_account_selector_with_2fa() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap().to_string();
verify_new_account(&http_client, &user_did).await;
@@ -386,63 +678,169 @@ async fn test_account_selector_with_2fa() {
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "true")])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true}))
.send().await.unwrap();
- assert!(auth_res.status().is_redirection());
- let device_cookie = auth_res.headers().get("set-cookie")
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Expected OK with JSON response"
+ );
+ let device_cookie = auth_res
+ .headers()
+ .get("set-cookie")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or("").to_string())
.expect("Should have device cookie");
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true}))
+ .send().await.unwrap();
+ assert_eq!(
+ consent_res.status(),
+ StatusCode::OK,
+ "Consent should succeed"
+ );
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
assert!(location.contains("code="));
- let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
let _ = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
- .send().await.unwrap().json::().await.unwrap();
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json::()
+ .await
+ .unwrap();
let db_url = get_db_connection_string().await;
- let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect(&db_url).await.unwrap();
+ let pool = sqlx::postgres::PgPoolOptions::new()
+ .max_connections(1)
+ .connect(&db_url)
+ .await
+ .unwrap();
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
- .bind(&user_did).execute(&pool).await.unwrap();
+ .bind(&user_did)
+ .execute(&pool)
+ .await
+ .unwrap();
let (code_verifier2, code_challenge2) = generate_pkce();
let par_body2: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge2),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri2 = par_body2["request_uri"].as_str().unwrap();
- let select_res = auth_client
+ let select_res = http_client
.post(format!("{}/oauth/authorize/select", url))
.header("cookie", &device_cookie)
- .form(&[("request_uri", request_uri2), ("did", &user_did)])
- .send().await.unwrap();
- assert!(select_res.status().is_redirection());
- let select_location = select_res.headers().get("location").unwrap().to_str().unwrap();
- assert!(select_location.contains("/oauth/authorize/2fa"), "Should redirect to 2FA page");
- let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
- .bind(request_uri2).fetch_one(&pool).await.unwrap();
- let twofa_res = auth_client
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri2, "did": &user_did}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ select_res.status(),
+ StatusCode::OK,
+ "Select should return OK with JSON"
+ );
+ let select_body: Value = select_res.json().await.unwrap();
+ assert!(
+ select_body["needs_2fa"].as_bool().unwrap_or(false),
+ "Should need 2FA"
+ );
+ let twofa_code: String =
+ sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
+ .bind(request_uri2)
+ .fetch_one(&pool)
+ .await
+ .unwrap();
+ let twofa_res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
.header("cookie", &device_cookie)
- .form(&[("request_uri", request_uri2), ("code", &twofa_code)])
- .send().await.unwrap();
- assert!(twofa_res.status().is_redirection());
- let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap();
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri2, "code": &twofa_code}))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ twofa_res.status(),
+ StatusCode::OK,
+ "Valid 2FA should succeed"
+ );
+ let twofa_body: Value = twofa_res.json().await.unwrap();
+ let final_location = twofa_body["redirect_uri"].as_str().unwrap();
assert!(final_location.starts_with(redirect_uri) && final_location.contains("code="));
- let final_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap();
+ let final_code = final_location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", final_code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier2), ("client_id", &client_id)])
- .send().await.unwrap();
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", final_code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier2),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(token_res.status(), StatusCode::OK);
let final_token: Value = token_res.json().await.unwrap();
assert_eq!(final_token["sub"], user_did);
@@ -459,7 +857,9 @@ async fn test_oauth_state_encoding() {
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({ "handle": handle, "email": email, "password": password }))
- .send().await.unwrap();
+ .send()
+ .await
+ .unwrap();
let account: Value = create_res.json().await.unwrap();
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
let redirect_uri = "https://example.com/state-special-callback";
@@ -469,18 +869,378 @@ async fn test_oauth_state_encoding() {
let special_state = "state=with&special=chars&plus+more";
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("state", special_state)])
- .send().await.unwrap().json().await.unwrap();
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("state", special_state),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", password), ("remember_device", "false")])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
.send().await.unwrap();
- assert!(auth_res.status().is_redirection());
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Expected OK with JSON response"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ assert_eq!(
+ consent_res.status(),
+ StatusCode::OK,
+ "Consent should succeed"
+ );
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
assert!(location.contains("state="));
let encoded_state = urlencoding::encode(special_state);
- assert!(location.contains(&format!("state={}", encoded_state)), "State should be URL-encoded. Got: {}", location);
+ assert!(
+ location.contains(&format!("state={}", encoded_state)),
+ "State should be URL-encoded. Got: {}",
+ location
+ );
+}
+
+async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) {
+ let url = base_url().await;
+ let http_client = client();
+ let ts = Utc::now().timestamp_millis();
+ let handle = format!("scope-test-{}", ts);
+ let email = format!("scope-test-{}@example.com", ts);
+ let password = "scope-test-password";
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ .json(&json!({ "handle": handle, "email": email, "password": password }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(create_res.status(), StatusCode::OK);
+ let account: Value = create_res.json().await.unwrap();
+ let user_did = account["did"].as_str().unwrap().to_string();
+ verify_new_account(&http_client, &user_did).await;
+ let redirect_uri = "https://example.com/scope-callback";
+ let mock_client = setup_mock_client_metadata(redirect_uri).await;
+ let client_id = mock_client.uri();
+ let (code_verifier, code_challenge) = generate_pkce();
+ let par_res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", scope),
+ ("state", "test"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ par_res.status(),
+ StatusCode::CREATED,
+ "PAR should succeed for scope: {}",
+ scope
+ );
+ let par_body: Value = par_res.json().await.unwrap();
+ let request_uri = par_body["request_uri"].as_str().unwrap();
+ let auth_res = http_client
+ .post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
+ .send().await.unwrap();
+ assert_eq!(auth_res.status(), StatusCode::OK);
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let approved_scopes: Vec<&str> = scope.split_whitespace().collect();
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false}))
+ .send().await.unwrap();
+ let consent_status = consent_res.status();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ assert_eq!(
+ consent_status,
+ StatusCode::OK,
+ "Consent should succeed. Scope: {}, Body: {:?}",
+ scope,
+ consent_body
+ );
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+ let token_res = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
+ let token_body: Value = token_res.json().await.unwrap();
+ let access_token = token_body["access_token"].as_str().unwrap().to_string();
+ (access_token, user_did, handle)
+}
+
+#[tokio::test]
+async fn test_granular_scope_repo_create_only() {
+ let url = base_url().await;
+ let http_client = client();
+ let (token, did, _) =
+ get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await;
+ let now = chrono::Utc::now().to_rfc3339();
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({
+ "repo": &did,
+ "collection": "app.bsky.feed.post",
+ "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now }
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ create_res.status(),
+ StatusCode::OK,
+ "Should allow creating posts with repo:app.bsky.feed.post?action=create"
+ );
+ let body: Value = create_res.json().await.unwrap();
+ let uri = body["uri"].as_str().expect("Should have uri");
+ let rkey = uri.split('/').last().unwrap();
+ let delete_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ delete_res.status(),
+ StatusCode::FORBIDDEN,
+ "Should NOT allow deleting with create-only scope"
+ );
+ let like_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({
+ "repo": &did,
+ "collection": "app.bsky.feed.like",
+ "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now }
+ }))
+ .send().await.unwrap();
+ assert_eq!(
+ like_res.status(),
+ StatusCode::FORBIDDEN,
+ "Should NOT allow creating likes (wrong collection)"
+ );
+}
+
+#[tokio::test]
+async fn test_granular_scope_wildcard_collection() {
+ let url = base_url().await;
+ let http_client = client();
+ let (token, did, _) = get_oauth_token_with_scope(
+ "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*",
+ )
+ .await;
+ let now = chrono::Utc::now().to_rfc3339();
+ let post_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({
+ "repo": &did,
+ "collection": "app.bsky.feed.post",
+ "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now }
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ post_res.status(),
+ StatusCode::OK,
+ "Should allow app.bsky.feed.post with app.bsky.* scope"
+ );
+ let body: Value = post_res.json().await.unwrap();
+ let uri = body["uri"].as_str().unwrap();
+ let rkey = uri.split('/').last().unwrap();
+ let delete_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ delete_res.status(),
+ StatusCode::OK,
+ "Should allow delete with action=delete"
+ );
+ let other_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&token)
+ .json(&json!({
+ "repo": &did,
+ "collection": "com.example.record",
+ "record": { "$type": "com.example.record", "data": "test", "createdAt": now }
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ other_res.status(),
+ StatusCode::FORBIDDEN,
+ "Should NOT allow com.example.* with app.bsky.* scope"
+ );
+}
+
+#[tokio::test]
+async fn test_granular_scope_email_read() {
+ let url = base_url().await;
+ let http_client = client();
+ let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await;
+ let session_res = http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(session_res.status(), StatusCode::OK);
+ let body: Value = session_res.json().await.unwrap();
+ assert_eq!(body["did"], did);
+ assert!(
+ body["email"].is_string(),
+ "Email should be visible with account:email?action=read. Got: {:?}",
+ body
+ );
+}
+
+#[tokio::test]
+async fn test_granular_scope_no_email_access() {
+ let url = base_url().await;
+ let http_client = client();
+ let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await;
+ let session_res = http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(session_res.status(), StatusCode::OK);
+ let body: Value = session_res.json().await.unwrap();
+ assert_eq!(body["did"], did);
+ assert!(
+ body["email"].is_null() || body.get("email").is_none(),
+ "Email should be hidden without account:email scope. Got: {:?}",
+ body["email"]
+ );
+}
+
+#[tokio::test]
+async fn test_granular_scope_rpc_specific_method() {
+ let url = base_url().await;
+ let http_client = client();
+ let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await;
+ let allowed_res = http_client
+ .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
+ .bearer_auth(&token)
+ .query(&[
+ ("aud", "did:web:api.bsky.app"),
+ ("lxm", "app.bsky.feed.getTimeline"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ allowed_res.status(),
+ StatusCode::OK,
+ "Should allow getServiceAuth for app.bsky.feed.getTimeline"
+ );
+ let body: Value = allowed_res.json().await.unwrap();
+ assert!(body["token"].is_string(), "Should return service token");
+ let blocked_res = http_client
+ .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
+ .bearer_auth(&token)
+ .query(&[
+ ("aud", "did:web:api.bsky.app"),
+ ("lxm", "app.bsky.feed.getAuthorFeed"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ blocked_res.status(),
+ StatusCode::FORBIDDEN,
+ "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed"
+ );
+ let blocked_body: Value = blocked_res.json().await.unwrap();
+ assert!(
+ blocked_body["error"]
+ .as_str()
+ .unwrap_or("")
+ .contains("Scope")
+ || blocked_body["message"]
+ .as_str()
+ .unwrap_or("")
+ .contains("scope"),
+ "Should mention scope restriction: {:?}",
+ blocked_body
+ );
+ let no_lxm_res = http_client
+ .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
+ .bearer_auth(&token)
+ .query(&[("aud", "did:web:api.bsky.app")])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ no_lxm_res.status(),
+ StatusCode::BAD_REQUEST,
+ "Should require lxm parameter for granular scopes"
+ );
}
diff --git a/tests/oauth_client_metadata.rs b/tests/oauth_client_metadata.rs
index a59a5af..4839ac1 100644
--- a/tests/oauth_client_metadata.rs
+++ b/tests/oauth_client_metadata.rs
@@ -7,49 +7,85 @@ use serde_json::Value;
async fn test_frontend_client_metadata_returns_valid_json() {
let client = client();
let res = client
- .get(format!(
- "{}/oauth/client-metadata.json",
- base_url().await
- ))
+ .get(format!("{}/oauth/client-metadata.json", base_url().await))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Should return valid JSON");
- assert!(body["client_id"].as_str().is_some(), "Should have client_id");
- assert!(body["client_name"].as_str().is_some(), "Should have client_name");
- assert!(body["redirect_uris"].as_array().is_some(), "Should have redirect_uris");
- assert!(body["grant_types"].as_array().is_some(), "Should have grant_types");
- assert!(body["response_types"].as_array().is_some(), "Should have response_types");
+ assert!(
+ body["client_id"].as_str().is_some(),
+ "Should have client_id"
+ );
+ assert!(
+ body["client_name"].as_str().is_some(),
+ "Should have client_name"
+ );
+ assert!(
+ body["redirect_uris"].as_array().is_some(),
+ "Should have redirect_uris"
+ );
+ assert!(
+ body["grant_types"].as_array().is_some(),
+ "Should have grant_types"
+ );
+ assert!(
+ body["response_types"].as_array().is_some(),
+ "Should have response_types"
+ );
assert!(body["scope"].as_str().is_some(), "Should have scope");
- assert!(body["token_endpoint_auth_method"].as_str().is_some(), "Should have token_endpoint_auth_method");
+ assert!(
+ body["token_endpoint_auth_method"].as_str().is_some(),
+ "Should have token_endpoint_auth_method"
+ );
}
#[tokio::test]
async fn test_frontend_client_metadata_correct_values() {
let client = client();
let res = client
- .get(format!(
- "{}/oauth/client-metadata.json",
- base_url().await
- ))
+ .get(format!("{}/oauth/client-metadata.json", base_url().await))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let client_id = body["client_id"].as_str().unwrap();
- assert!(client_id.ends_with("/oauth/client-metadata.json"), "client_id should end with /oauth/client-metadata.json");
+ assert!(
+ client_id.ends_with("/oauth/client-metadata.json"),
+ "client_id should end with /oauth/client-metadata.json"
+ );
let grant_types = body["grant_types"].as_array().unwrap();
let grant_strs: Vec<&str> = grant_types.iter().filter_map(|v| v.as_str()).collect();
- assert!(grant_strs.contains(&"authorization_code"), "Should support authorization_code grant");
- assert!(grant_strs.contains(&"refresh_token"), "Should support refresh_token grant");
+ assert!(
+ grant_strs.contains(&"authorization_code"),
+ "Should support authorization_code grant"
+ );
+ assert!(
+ grant_strs.contains(&"refresh_token"),
+ "Should support refresh_token grant"
+ );
let response_types = body["response_types"].as_array().unwrap();
let response_strs: Vec<&str> = response_types.iter().filter_map(|v| v.as_str()).collect();
- assert!(response_strs.contains(&"code"), "Should support code response type");
- assert_eq!(body["token_endpoint_auth_method"].as_str(), Some("none"), "Should be public client (none auth)");
- assert_eq!(body["application_type"].as_str(), Some("web"), "Should be web application");
- assert_eq!(body["dpop_bound_access_tokens"].as_bool(), Some(false), "Should not require DPoP");
+ assert!(
+ response_strs.contains(&"code"),
+ "Should support code response type"
+ );
+ assert_eq!(
+ body["token_endpoint_auth_method"].as_str(),
+ Some("none"),
+ "Should be public client (none auth)"
+ );
+ assert_eq!(
+ body["application_type"].as_str(),
+ Some("web"),
+ "Should be web application"
+ );
+ assert_eq!(
+ body["dpop_bound_access_tokens"].as_bool(),
+ Some(false),
+ "Should not require DPoP"
+ );
let scope = body["scope"].as_str().unwrap();
assert!(scope.contains("atproto"), "Scope should include atproto");
}
@@ -58,10 +94,7 @@ async fn test_frontend_client_metadata_correct_values() {
async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
let client = client();
let res = client
- .get(format!(
- "{}/oauth/client-metadata.json",
- base_url().await
- ))
+ .get(format!("{}/oauth/client-metadata.json", base_url().await))
.send()
.await
.expect("Failed to send request");
@@ -69,7 +102,13 @@ async fn test_frontend_client_metadata_redirect_uri_matches_client_uri() {
let body: Value = res.json().await.unwrap();
let client_uri = body["client_uri"].as_str().unwrap();
let redirect_uris = body["redirect_uris"].as_array().unwrap();
- assert!(!redirect_uris.is_empty(), "Should have at least one redirect URI");
+ assert!(
+ !redirect_uris.is_empty(),
+ "Should have at least one redirect URI"
+ );
let redirect_uri = redirect_uris[0].as_str().unwrap();
- assert!(redirect_uri.starts_with(client_uri), "Redirect URI should be on same origin as client_uri");
+ assert!(
+ redirect_uri.starts_with(client_uri),
+ "Redirect URI should be on same origin as client_uri"
+ );
}
diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs
index aa8e95a..651f58b 100644
--- a/tests/oauth_lifecycle.rs
+++ b/tests/oauth_lifecycle.rs
@@ -5,7 +5,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client};
use helpers::verify_new_account;
-use reqwest::{StatusCode, redirect};
+use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path};
@@ -21,13 +21,6 @@ fn generate_pkce() -> (String, String) {
(code_verifier, code_challenge)
}
-fn no_redirect_client() -> reqwest::Client {
- reqwest::Client::builder()
- .redirect(redirect::Policy::none())
- .build()
- .unwrap()
-}
-
async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
let mock_server = MockServer::start().await;
let client_id = mock_server.uri();
@@ -102,24 +95,46 @@ async fn create_user_and_oauth_session(
);
let par_body: Value = par_res.json().await.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client
+ let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[
- ("request_uri", request_uri),
- ("username", &handle),
- ("password", &password),
- ("remember_device", "false"),
- ])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri,
+ "username": &handle,
+ "password": &password,
+ "remember_device": false
+ }))
.send()
.await
.expect("Authorize failed");
- let location = auth_res
- .headers()
- .get("location")
- .unwrap()
- .to_str()
- .unwrap();
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Authorize should return OK"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.expect("Consent request failed");
+ assert_eq!(
+ consent_res.status(),
+ StatusCode::OK,
+ "Consent should succeed"
+ );
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
let code = location
.split("code=")
.nth(1)
@@ -596,24 +611,31 @@ async fn test_oauth_multiple_clients_same_user() {
.unwrap();
let par_body1: Value = par_res1.json().await.unwrap();
let request_uri1 = par_body1["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res1 = auth_client
+ let auth_res1 = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[
- ("request_uri", request_uri1),
- ("username", &handle),
- ("password", password),
- ("remember_device", "false"),
- ])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri1,
+ "username": &handle,
+ "password": password,
+ "remember_device": false
+ }))
.send()
.await
.unwrap();
- let location1 = auth_res1
- .headers()
- .get("location")
- .unwrap()
- .to_str()
- .unwrap();
+ assert_eq!(auth_res1.status(), StatusCode::OK);
+ let auth_body1: Value = auth_res1.json().await.unwrap();
+ let mut location1 = auth_body1["redirect_uri"].as_str().unwrap().to_string();
+ if location1.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
let code1 = location1
.split("code=")
.nth(1)
@@ -650,23 +672,31 @@ async fn test_oauth_multiple_clients_same_user() {
.unwrap();
let par_body2: Value = par_res2.json().await.unwrap();
let request_uri2 = par_body2["request_uri"].as_str().unwrap();
- let auth_res2 = auth_client
+ let auth_res2 = http_client
.post(format!("{}/oauth/authorize", url))
- .form(&[
- ("request_uri", request_uri2),
- ("username", &handle),
- ("password", password),
- ("remember_device", "false"),
- ])
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri2,
+ "username": &handle,
+ "password": password,
+ "remember_device": false
+ }))
.send()
.await
.unwrap();
- let location2 = auth_res2
- .headers()
- .get("location")
- .unwrap()
- .to_str()
- .unwrap();
+ assert_eq!(auth_res2.status(), StatusCode::OK);
+ let auth_body2: Value = auth_res2.json().await.unwrap();
+ let mut location2 = auth_body2["redirect_uri"].as_str().unwrap().to_string();
+ if location2.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
let code2 = location2
.split("code=")
.nth(1)
diff --git a/tests/oauth_scopes.rs b/tests/oauth_scopes.rs
new file mode 100644
index 0000000..b344fb4
--- /dev/null
+++ b/tests/oauth_scopes.rs
@@ -0,0 +1,753 @@
+mod common;
+mod helpers;
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use chrono::Utc;
+use common::{base_url, client};
+use helpers::verify_new_account;
+use reqwest::StatusCode;
+use serde_json::{Value, json};
+use sha2::{Digest, Sha256};
+use wiremock::matchers::{method, path};
+use wiremock::{Mock, MockServer, ResponseTemplate};
+
+fn generate_pkce() -> (String, String) {
+ let verifier_bytes: [u8; 32] = rand::random();
+ let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
+ let mut hasher = Sha256::new();
+ hasher.update(code_verifier.as_bytes());
+ let hash = hasher.finalize();
+ let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
+ (code_verifier, code_challenge)
+}
+
+async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
+ let mock_server = MockServer::start().await;
+ let client_id = mock_server.uri();
+ let metadata = json!({
+ "client_id": client_id,
+ "client_name": "Test OAuth Scope Client",
+ "redirect_uris": [redirect_uri],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ "dpop_bound_access_tokens": false
+ });
+ Mock::given(method("GET"))
+ .and(path("/"))
+ .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
+ .mount(&mock_server)
+ .await;
+ mock_server
+}
+
+struct OAuthSession {
+ access_token: String,
+ #[allow(dead_code)]
+ refresh_token: String,
+ did: String,
+ #[allow(dead_code)]
+ client_id: String,
+ scope: String,
+}
+
+async fn create_user_and_oauth_session_with_scope(
+ handle_prefix: &str,
+ redirect_uri: &str,
+ scope: &str,
+) -> (OAuthSession, MockServer) {
+ let url = base_url().await;
+ let http_client = client();
+ let ts = Utc::now().timestamp_millis();
+ let handle = format!("{}-{}", handle_prefix, ts);
+ let email = format!("{}-{}@example.com", handle_prefix, ts);
+ let password = format!("{}-password", handle_prefix);
+
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ .json(&json!({
+ "handle": handle,
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .expect("Account creation failed");
+ assert_eq!(create_res.status(), StatusCode::OK);
+ let account: Value = create_res.json().await.unwrap();
+ let user_did = account["did"].as_str().unwrap().to_string();
+
+ let _ = verify_new_account(&http_client, &user_did).await;
+
+ let mock_client = setup_mock_client_metadata(redirect_uri).await;
+ let client_id = mock_client.uri();
+ let (code_verifier, code_challenge) = generate_pkce();
+
+ let par_res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", scope),
+ ])
+ .send()
+ .await
+ .expect("PAR failed");
+ assert!(
+ par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
+ "PAR should succeed, got {}",
+ par_res.status()
+ );
+ let par_body: Value = par_res.json().await.unwrap();
+ let request_uri = par_body["request_uri"].as_str().unwrap();
+
+ let auth_res = http_client
+ .post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri,
+ "username": &handle,
+ "password": &password,
+ "remember_device": false
+ }))
+ .send()
+ .await
+ .expect("Authorize failed");
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::OK,
+ "Authorize should return OK"
+ );
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri")
+ .to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.expect("Consent request failed");
+ assert_eq!(
+ consent_res.status(),
+ StatusCode::OK,
+ "Consent should succeed"
+ );
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"]
+ .as_str()
+ .expect("Expected redirect_uri from consent")
+ .to_string();
+ }
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+
+ let token_res = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .expect("Token request failed");
+ assert_eq!(token_res.status(), StatusCode::OK);
+ let token_body: Value = token_res.json().await.unwrap();
+
+ let session = OAuthSession {
+ access_token: token_body["access_token"].as_str().unwrap().to_string(),
+ refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
+ did: user_did,
+ client_id,
+ scope: scope.to_string(),
+ };
+ (session, mock_client)
+}
+
+#[tokio::test]
+async fn test_atproto_scope_allows_full_access() {
+ let url = base_url().await;
+ let http_client = client();
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-full",
+ "https://example.com/callback",
+ "atproto",
+ )
+ .await;
+
+ let collection = "app.bsky.feed.post";
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "repo": session.did,
+ "collection": collection,
+ "record": {
+ "$type": collection,
+ "text": "Full access post",
+ "createdAt": Utc::now().to_rfc3339()
+ }
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ create_res.status(),
+ StatusCode::OK,
+ "atproto scope should allow creating records"
+ );
+ let create_body: Value = create_res.json().await.unwrap();
+ let rkey = create_body["uri"]
+ .as_str()
+ .unwrap()
+ .split('/')
+ .last()
+ .unwrap();
+
+ let put_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "repo": session.did,
+ "collection": collection,
+ "rkey": rkey,
+ "record": {
+ "$type": collection,
+ "text": "Updated post",
+ "createdAt": Utc::now().to_rfc3339()
+ }
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ put_res.status(),
+ StatusCode::OK,
+ "atproto scope should allow updating records"
+ );
+
+ let delete_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "repo": session.did,
+ "collection": collection,
+ "rkey": rkey
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ delete_res.status(),
+ StatusCode::OK,
+ "atproto scope should allow deleting records"
+ );
+}
+
+#[tokio::test]
+async fn test_atproto_scope_allows_blob_upload() {
+ let url = base_url().await;
+ let http_client = client();
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-blob",
+ "https://example.com/callback",
+ "atproto",
+ )
+ .await;
+
+ let blob_data = b"Test blob data for scope test";
+ let upload_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
+ .bearer_auth(&session.access_token)
+ .header("Content-Type", "text/plain")
+ .body(blob_data.to_vec())
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ upload_res.status(),
+ StatusCode::OK,
+ "atproto scope should allow blob upload"
+ );
+ let upload_body: Value = upload_res.json().await.unwrap();
+ assert!(upload_body["blob"]["ref"]["$link"].is_string());
+}
+
+#[tokio::test]
+async fn test_atproto_scope_allows_batch_writes() {
+ let url = base_url().await;
+ let http_client = client();
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-batch",
+ "https://example.com/callback",
+ "atproto",
+ )
+ .await;
+
+ let collection = "app.bsky.feed.post";
+ let now = Utc::now().to_rfc3339();
+ let apply_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "repo": session.did,
+ "writes": [
+ {
+ "$type": "com.atproto.repo.applyWrites#create",
+ "collection": collection,
+ "rkey": "batch-scope-1",
+ "value": {
+ "$type": collection,
+ "text": "Batch post 1",
+ "createdAt": now
+ }
+ },
+ {
+ "$type": "com.atproto.repo.applyWrites#create",
+ "collection": collection,
+ "rkey": "batch-scope-2",
+ "value": {
+ "$type": collection,
+ "text": "Batch post 2",
+ "createdAt": now
+ }
+ }
+ ]
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ apply_res.status(),
+ StatusCode::OK,
+ "atproto scope should allow batch writes"
+ );
+}
+
+#[tokio::test]
+async fn test_transition_generic_scope_allows_access() {
+ let url = base_url().await;
+ let http_client = client();
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-transition",
+ "https://example.com/callback",
+ "atproto transition:generic",
+ )
+ .await;
+
+ let collection = "app.bsky.feed.post";
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "repo": session.did,
+ "collection": collection,
+ "record": {
+ "$type": collection,
+ "text": "Post with transition scope",
+ "createdAt": Utc::now().to_rfc3339()
+ }
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ create_res.status(),
+ StatusCode::OK,
+ "transition:generic scope combined with atproto should work"
+ );
+}
+
+#[tokio::test]
+async fn test_consent_endpoint_returns_scope_info() {
+ let url = base_url().await;
+ let http_client = client();
+
+ let ts = Utc::now().timestamp_millis();
+ let handle = format!("consent-test-{}", ts);
+ let email = format!("consent-{}@example.com", ts);
+ let password = "consent-password";
+ let redirect_uri = "https://consent-test.example.com/callback";
+
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ .json(&json!({
+ "handle": handle,
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(create_res.status(), StatusCode::OK);
+ let account: Value = create_res.json().await.unwrap();
+ let user_did = account["did"].as_str().unwrap();
+ let _ = verify_new_account(&http_client, user_did).await;
+
+ let mock_client = setup_mock_client_metadata(redirect_uri).await;
+ let client_id = mock_client.uri();
+ let (_, code_challenge) = generate_pkce();
+
+ let par_res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", "atproto transition:generic"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ let par_body: Value = par_res.json().await.unwrap();
+ let request_uri = par_body["request_uri"].as_str().unwrap();
+
+ let auth_res = http_client
+ .post(format!("{}/oauth/authorize", url))
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri,
+ "username": &handle,
+ "password": password,
+ "remember_device": false
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
+
+ let consent_res = http_client
+ .get(format!("{}/oauth/authorize/consent", url))
+ .query(&[("request_uri", request_uri)])
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(consent_res.status(), StatusCode::OK);
+ let consent_body: Value = consent_res.json().await.unwrap();
+
+ assert_eq!(consent_body["client_id"], client_id);
+ assert_eq!(consent_body["did"], user_did);
+ assert!(consent_body["scopes"].is_array());
+
+ let scopes = consent_body["scopes"].as_array().unwrap();
+ assert!(!scopes.is_empty(), "Should have scopes in response");
+
+ let atproto_scope = scopes.iter().find(|s| s["scope"] == "atproto");
+ assert!(atproto_scope.is_some(), "Should include atproto scope");
+ let atproto = atproto_scope.unwrap();
+ assert_eq!(atproto["required"], true, "atproto should be required");
+ assert!(atproto["description"].is_string());
+ assert!(atproto["display_name"].is_string());
+
+ let transition_scope = scopes.iter().find(|s| s["scope"] == "transition:generic");
+ assert!(
+ transition_scope.is_some(),
+ "Should include transition:generic scope"
+ );
+ let transition = transition_scope.unwrap();
+ assert_eq!(
+ transition["required"], false,
+ "transition:generic should be optional"
+ );
+}
+
+#[tokio::test]
+async fn test_consent_post_generates_code() {
+ let url = base_url().await;
+ let http_client = client();
+
+ let ts = Utc::now().timestamp_millis();
+ let handle = format!("consent-post-{}", ts);
+ let email = format!("consent-post-{}@example.com", ts);
+ let password = "consent-post-password";
+ let redirect_uri = "https://consent-post.example.com/callback";
+
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ .json(&json!({
+ "handle": handle,
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(create_res.status(), StatusCode::OK);
+ let account: Value = create_res.json().await.unwrap();
+ let user_did = account["did"].as_str().unwrap();
+ let _ = verify_new_account(&http_client, user_did).await;
+
+ let mock_client = setup_mock_client_metadata(redirect_uri).await;
+ let client_id = mock_client.uri();
+ let (code_verifier, code_challenge) = generate_pkce();
+
+ let par_res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", "atproto"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ let par_body: Value = par_res.json().await.unwrap();
+ let request_uri = par_body["request_uri"].as_str().unwrap();
+
+ let auth_res = http_client
+ .post(format!("{}/oauth/authorize", url))
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri,
+ "username": &handle,
+ "password": password,
+ "remember_device": false
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
+
+ let consent_post_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .json(&json!({
+ "request_uri": request_uri,
+ "approved_scopes": ["atproto"],
+ "remember": false
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(consent_post_res.status(), StatusCode::OK);
+ let consent_body: Value = consent_post_res.json().await.unwrap();
+ assert!(
+ consent_body["redirect_uri"].is_string(),
+ "Should return redirect URI"
+ );
+
+ let redirect_uri_response = consent_body["redirect_uri"].as_str().unwrap();
+ assert!(
+ redirect_uri_response.contains("code="),
+ "Redirect URI should contain authorization code"
+ );
+
+ let code = redirect_uri_response
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+
+ let token_res = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ token_res.status(),
+ StatusCode::OK,
+ "Token exchange should succeed"
+ );
+ let token_body: Value = token_res.json().await.unwrap();
+ assert!(token_body["access_token"].is_string());
+}
+
+#[tokio::test]
+async fn test_consent_post_requires_atproto_scope() {
+ let url = base_url().await;
+ let http_client = client();
+
+ let ts = Utc::now().timestamp_millis();
+ let handle = format!("consent-req-{}", ts);
+ let email = format!("consent-req-{}@example.com", ts);
+ let password = "consent-req-password";
+ let redirect_uri = "https://consent-req.example.com/callback";
+
+ let create_res = http_client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
+ .json(&json!({
+ "handle": handle,
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(create_res.status(), StatusCode::OK);
+ let account: Value = create_res.json().await.unwrap();
+ let user_did = account["did"].as_str().unwrap();
+ let _ = verify_new_account(&http_client, user_did).await;
+
+ let mock_client = setup_mock_client_metadata(redirect_uri).await;
+ let client_id = mock_client.uri();
+ let (_, code_challenge) = generate_pkce();
+
+ let par_res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ("scope", "atproto transition:generic"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ let par_body: Value = par_res.json().await.unwrap();
+ let request_uri = par_body["request_uri"].as_str().unwrap();
+
+ let auth_res = http_client
+ .post(format!("{}/oauth/authorize", url))
+ .header("Accept", "application/json")
+ .json(&json!({
+ "request_uri": request_uri,
+ "username": &handle,
+ "password": password,
+ "remember_device": false
+ }))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
+
+ let consent_post_res = http_client
+ .post(format!("{}/oauth/authorize/consent", url))
+ .json(&json!({
+ "request_uri": request_uri,
+ "approved_scopes": ["transition:generic"],
+ "remember": false
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ consent_post_res.status(),
+ StatusCode::BAD_REQUEST,
+ "Should reject consent without atproto scope"
+ );
+ let error_body: Value = consent_post_res.json().await.unwrap();
+ assert!(
+ error_body["error_description"]
+ .as_str()
+ .unwrap()
+ .contains("atproto")
+ );
+}
+
+#[tokio::test]
+async fn test_token_contains_requested_scope() {
+ let scope = "atproto transition:generic";
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-token",
+ "https://example.com/callback",
+ scope,
+ )
+ .await;
+
+ assert_eq!(
+ session.scope, scope,
+ "Session should have the requested scope"
+ );
+
+ let parts: Vec<&str> = session.access_token.split('.').collect();
+ assert_eq!(parts.len(), 3, "Token should be a valid JWT");
+
+ let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
+ let payload: Value = serde_json::from_slice(&payload_json).unwrap();
+
+ assert!(
+ payload["scope"].is_string(),
+ "Token payload should contain scope"
+ );
+ let token_scope = payload["scope"].as_str().unwrap();
+ assert!(
+ token_scope.contains("atproto"),
+ "Token scope should contain atproto"
+ );
+}
+
+#[tokio::test]
+async fn test_dereference_scope_endpoint() {
+ let url = base_url().await;
+ let http_client = client();
+ let (session, _mock) = create_user_and_oauth_session_with_scope(
+ "scope-deref",
+ "https://example.com/callback",
+ "atproto",
+ )
+ .await;
+
+ let deref_res = http_client
+ .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url))
+ .bearer_auth(&session.access_token)
+ .json(&json!({
+ "scope": "atproto transition:generic"
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(deref_res.status(), StatusCode::OK);
+ let deref_body: Value = deref_res.json().await.unwrap();
+ assert!(deref_body["scope"].is_string());
+ let resolved_scope = deref_body["scope"].as_str().unwrap();
+ assert!(resolved_scope.contains("atproto"));
+ assert!(resolved_scope.contains("transition:generic"));
+}
+
+#[tokio::test]
+async fn test_dereference_scope_requires_auth() {
+ let url = base_url().await;
+ let http_client = client();
+
+ let deref_res = http_client
+ .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url))
+ .json(&json!({
+ "scope": "atproto"
+ }))
+ .send()
+ .await
+ .unwrap();
+
+ assert_eq!(
+ deref_res.status(),
+ StatusCode::UNAUTHORIZED,
+ "Should require authentication"
+ );
+}
diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs
index d40c0af..c9f2499 100644
--- a/tests/oauth_security.rs
+++ b/tests/oauth_security.rs
@@ -2,20 +2,16 @@
mod common;
mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
-use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint};
use chrono::Utc;
use common::{base_url, client};
use helpers::verify_new_account;
-use reqwest::{StatusCode, redirect};
+use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
+use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
-fn no_redirect_client() -> reqwest::Client {
- reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap()
-}
-
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
@@ -36,9 +32,11 @@ async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": false
});
- Mock::given(method("GET")).and(path("/"))
+ Mock::given(method("GET"))
+ .and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
- .mount(&mock_server).await;
+ .mount(&mock_server)
+ .await;
mock_server
}
@@ -55,23 +53,64 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String,
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
- let par_body: Value = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ let par_body: Value = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", "security-test-password"), ("remember_device", "false")])
+ let auth_res = http_client.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": "security-test-password", "remember_device": false}))
.send().await.unwrap();
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
- let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
- let token_body: Value = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
- .send().await.unwrap().json().await.unwrap();
- (token_body["access_token"].as_str().unwrap().to_string(),
- token_body["refresh_token"].as_str().unwrap().to_string(), client_id)
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+ let token_body: Value = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
+ (
+ token_body["access_token"].as_str().unwrap().to_string(),
+ token_body["refresh_token"].as_str().unwrap().to_string(),
+ client_id,
+ )
}
#[tokio::test]
@@ -83,33 +122,90 @@ async fn test_token_tampering_attacks() {
assert_eq!(parts.len(), 3);
let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig);
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&forged_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected");
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&forged_token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "Forged signature should be rejected"
+ );
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_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&modified_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected");
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&modified_token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "Modified payload should be rejected"
+ );
let none_header = json!({ "alg": "none", "typ": "at+jwt" });
let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds",
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" });
- let none_token = format!("{}.{}.", URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()),
- URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()));
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&none_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "alg=none should be rejected");
+ let none_token = format!(
+ "{}.{}.",
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()),
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap())
+ );
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&none_token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "alg=none should be rejected"
+ );
let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" });
- let rs256_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()),
- URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 64]));
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&rs256_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Algorithm substitution should be rejected");
+ let rs256_token = format!(
+ "{}.{}.{}",
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()),
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()),
+ URL_SAFE_NO_PAD.encode(&[1u8; 64])
+ );
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&rs256_token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "Algorithm substitution should be rejected"
+ );
let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds",
"iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" });
- let expired_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()),
- URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32]));
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&expired_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected");
+ let expired_token = format!(
+ "{}.{}.{}",
+ URL_SAFE_NO_PAD
+ .encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()),
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()),
+ URL_SAFE_NO_PAD.encode(&[1u8; 32])
+ );
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&expired_token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "Expired token should be rejected"
+ );
}
#[tokio::test]
@@ -119,17 +215,46 @@ async fn test_pkce_security() {
let redirect_uri = "https://example.com/pkce-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
- let res = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", "plain-text-challenge"), ("code_challenge_method", "plain")])
- .send().await.unwrap();
- assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected");
+ let res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", "plain-text-challenge"),
+ ("code_challenge_method", "plain"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ res.status(),
+ StatusCode::BAD_REQUEST,
+ "PKCE plain method should be rejected"
+ );
let body: Value = res.json().await.unwrap();
- assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("s256"));
- let res = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri)])
- .send().await.unwrap();
- assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
+ assert!(
+ body["error_description"]
+ .as_str()
+ .unwrap()
+ .to_lowercase()
+ .contains("s256")
+ );
+ let res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ res.status(),
+ StatusCode::BAD_REQUEST,
+ "Missing PKCE challenge should be rejected"
+ );
let ts = Utc::now().timestamp_millis();
let handle = format!("pkce-attack-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
@@ -139,22 +264,62 @@ async fn test_pkce_security() {
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
let (_, code_challenge) = generate_pkce();
let (attacker_verifier, _) = generate_pkce();
- let par_body: Value = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ let par_body: Value = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", "pkce-password"), ("remember_device", "false")])
+ let auth_res = http_client.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": "pkce-password", "remember_device": false}))
.send().await.unwrap();
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
- let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
- let token_res = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
- ("code_verifier", &attacker_verifier), ("client_id", &client_id)])
- .send().await.unwrap();
- assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected");
+ assert_eq!(auth_res.status(), StatusCode::OK);
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+ let token_res = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &attacker_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ token_res.status(),
+ StatusCode::BAD_REQUEST,
+ "Wrong PKCE verifier should be rejected"
+ );
}
#[tokio::test]
@@ -172,44 +337,134 @@ async fn test_replay_attacks() {
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
- let par_body: Value = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ let par_body: Value = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
- let auth_client = no_redirect_client();
- let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", request_uri), ("username", &handle), ("password", "replay-password"), ("remember_device", "false")])
- .send().await.unwrap();
- let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
- let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap().to_string();
- let first = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
+ let auth_res = http_client.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri, "username": &handle, "password": "replay-password", "remember_device": false}))
.send().await.unwrap();
+ assert_eq!(auth_res.status(), StatusCode::OK);
+ let auth_body: Value = auth_res.json().await.unwrap();
+ let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
+ if location.contains("/oauth/consent") {
+ let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ location = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
+ let code = location
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap()
+ .to_string();
+ let first = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", &code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
assert_eq!(first.status(), StatusCode::OK, "First use should succeed");
let first_body: Value = first.json().await.unwrap();
- let replay = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri),
- ("code_verifier", &code_verifier), ("client_id", &client_id)])
- .send().await.unwrap();
- assert_eq!(replay.status(), StatusCode::BAD_REQUEST, "Auth code replay should fail");
+ let replay = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", &code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", &code_verifier),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ replay.status(),
+ StatusCode::BAD_REQUEST,
+ "Auth code replay should fail"
+ );
let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string();
- let first_refresh: Value = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)])
- .send().await.unwrap().json().await.unwrap();
- assert!(first_refresh["access_token"].is_string(), "First refresh should succeed");
+ let first_refresh: Value = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", &stolen_rt),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
+ assert!(
+ first_refresh["access_token"].is_string(),
+ "First refresh should succeed"
+ );
let new_rt = first_refresh["refresh_token"].as_str().unwrap();
- let rt_replay = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)])
- .send().await.unwrap();
- assert_eq!(rt_replay.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail");
+ let rt_replay = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", &stolen_rt),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ rt_replay.status(),
+ StatusCode::BAD_REQUEST,
+ "Refresh token replay should fail"
+ );
let body: Value = rt_replay.json().await.unwrap();
- assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"));
- let family_revoked = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "refresh_token"), ("refresh_token", new_rt), ("client_id", &client_id)])
- .send().await.unwrap();
- assert_eq!(family_revoked.status(), StatusCode::BAD_REQUEST, "Token family should be revoked");
+ assert!(
+ body["error_description"]
+ .as_str()
+ .unwrap()
+ .to_lowercase()
+ .contains("reuse")
+ );
+ let family_revoked = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", new_rt),
+ ("client_id", &client_id),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ family_revoked.status(),
+ StatusCode::BAD_REQUEST,
+ "Token family should be revoked"
+ );
}
#[tokio::test]
@@ -220,11 +475,23 @@ async fn test_oauth_security_boundaries() {
let mock_client = setup_mock_client_metadata(registered_redirect).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
- let res = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", "https://attacker.com/steal"),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap();
- assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected");
+ let res = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", "https://attacker.com/steal"),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ res.status(),
+ StatusCode::BAD_REQUEST,
+ "Unregistered redirect_uri should be rejected"
+ );
let ts = Utc::now().timestamp_millis();
let handle = format!("deact-{}", ts);
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
@@ -232,17 +499,38 @@ async fn test_oauth_security_boundaries() {
.send().await.unwrap();
let account: Value = create_res.json().await.unwrap();
let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
- http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
- .bearer_auth(&access_jwt).json(&json!({})).send().await.unwrap();
- let deact_par: Value = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", registered_redirect),
- ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
+ http_client
+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
+ .bearer_auth(&access_jwt)
+ .json(&json!({}))
+ .send()
+ .await
+ .unwrap();
+ let deact_par: Value = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id),
+ ("redirect_uri", registered_redirect),
+ ("code_challenge", &code_challenge),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
.header("Accept", "application/json")
- .form(&[("request_uri", deact_par["request_uri"].as_str().unwrap()), ("username", &handle), ("password", "deact-password"), ("remember_device", "false")])
+ .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false}))
.send().await.unwrap();
- assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked");
+ assert_eq!(
+ auth_res.status(),
+ StatusCode::FORBIDDEN,
+ "Deactivated account should be blocked"
+ );
let redirect_uri_a = "https://app-a.com/callback";
let mock_a = setup_mock_client_metadata(redirect_uri_a).await;
let client_id_a = mock_a.uri();
@@ -256,58 +544,173 @@ async fn test_oauth_security_boundaries() {
let account2: Value = create_res2.json().await.unwrap();
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
let (code_verifier2, code_challenge2) = generate_pkce();
- let par_a: Value = http_client.post(format!("{}/oauth/par", url))
- .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a),
- ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")])
- .send().await.unwrap().json().await.unwrap();
- let auth_client = no_redirect_client();
- let auth_a = auth_client.post(format!("{}/oauth/authorize", url))
- .form(&[("request_uri", par_a["request_uri"].as_str().unwrap()), ("username", &handle2), ("password", "cross-password"), ("remember_device", "false")])
+ let par_a: Value = http_client
+ .post(format!("{}/oauth/par", url))
+ .form(&[
+ ("response_type", "code"),
+ ("client_id", &client_id_a),
+ ("redirect_uri", redirect_uri_a),
+ ("code_challenge", &code_challenge2),
+ ("code_challenge_method", "S256"),
+ ])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
+ let request_uri_a = par_a["request_uri"].as_str().unwrap();
+ let auth_a = http_client.post(format!("{}/oauth/authorize", url))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false}))
.send().await.unwrap();
- let loc_a = auth_a.headers().get("location").unwrap().to_str().unwrap();
- let code_a = loc_a.split("code=").nth(1).unwrap().split('&').next().unwrap();
- let cross_client = http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", "authorization_code"), ("code", code_a), ("redirect_uri", redirect_uri_a),
- ("code_verifier", &code_verifier2), ("client_id", &client_id_b)])
- .send().await.unwrap();
- assert_eq!(cross_client.status(), StatusCode::BAD_REQUEST, "Cross-client code exchange must be rejected");
+ assert_eq!(auth_a.status(), StatusCode::OK);
+ let auth_body_a: Value = auth_a.json().await.unwrap();
+ let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string();
+ if loc_a.contains("/oauth/consent") {
+ let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
+ .header("Content-Type", "application/json")
+ .json(&json!({"request_uri": request_uri_a, "approved_scopes": ["atproto"], "remember": false}))
+ .send().await.unwrap();
+ let consent_body: Value = consent_res.json().await.unwrap();
+ loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string();
+ }
+ let code_a = loc_a
+ .split("code=")
+ .nth(1)
+ .unwrap()
+ .split('&')
+ .next()
+ .unwrap();
+ let cross_client = http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[
+ ("grant_type", "authorization_code"),
+ ("code", code_a),
+ ("redirect_uri", redirect_uri_a),
+ ("code_verifier", &code_verifier2),
+ ("client_id", &client_id_b),
+ ])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(
+ cross_client.status(),
+ StatusCode::BAD_REQUEST,
+ "Cross-client code exchange must be rejected"
+ );
}
#[tokio::test]
async fn test_malformed_tokens_and_headers() {
let url = base_url().await;
let http_client = client();
- let malformed = vec!["", "not-a-token", "one.two", "one.two.three.four", "....", "eyJhbGciOiJIUzI1NiJ9",
- "eyJhbGciOiJIUzI1NiJ9.", "eyJhbGciOiJIUzI1NiJ9..", ".eyJzdWIiOiJ0ZXN0In0.", "!!invalid!!.eyJ9.sig"];
+ let malformed = vec![
+ "",
+ "not-a-token",
+ "one.two",
+ "one.two.three.four",
+ "....",
+ "eyJhbGciOiJIUzI1NiJ9",
+ "eyJhbGciOiJIUzI1NiJ9.",
+ "eyJhbGciOiJIUzI1NiJ9..",
+ ".eyJzdWIiOiJ0ZXN0In0.",
+ "!!invalid!!.eyJ9.sig",
+ ];
for token in &malformed {
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED
+ );
}
let wrong_types = vec!["JWT", "jwt", "at+JWT", ""];
for typ in wrong_types {
let header = json!({ "alg": "HS256", "typ": typ });
let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" });
- let token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
- URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32]));
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .bearer_auth(&token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "typ='{}' should be rejected", typ);
+ let token = format!(
+ "{}.{}.{}",
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
+ URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
+ URL_SAFE_NO_PAD.encode(&[1u8; 32])
+ );
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED,
+ "typ='{}' should be rejected",
+ typ
+ );
}
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
- let invalid_formats = vec![format!("Basic {}", access_token), format!("Digest {}", access_token),
- access_token.clone(), format!("Bearer{}", access_token)];
+ let invalid_formats = vec![
+ format!("Basic {}", access_token),
+ format!("Digest {}", access_token),
+ access_token.clone(),
+ format!("Bearer{}", access_token),
+ ];
for auth in &invalid_formats {
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .header("Authorization", auth).send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .header("Authorization", auth)
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED
+ );
}
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
- assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
- .header("Authorization", "").send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
- let grants = vec!["client_credentials", "password", "implicit", "", "AUTHORIZATION_CODE"];
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED
+ );
+ assert_eq!(
+ http_client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", url))
+ .header("Authorization", "")
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::UNAUTHORIZED
+ );
+ let grants = vec![
+ "client_credentials",
+ "password",
+ "implicit",
+ "",
+ "AUTHORIZATION_CODE",
+ ];
for grant in grants {
- assert_eq!(http_client.post(format!("{}/oauth/token", url))
- .form(&[("grant_type", grant), ("client_id", "https://example.com")])
- .send().await.unwrap().status(), StatusCode::BAD_REQUEST, "Grant '{}' should be rejected", grant);
+ assert_eq!(
+ http_client
+ .post(format!("{}/oauth/token", url))
+ .form(&[("grant_type", grant), ("client_id", "https://example.com")])
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::BAD_REQUEST,
+ "Grant '{}' should be rejected",
+ grant
+ );
}
}
@@ -316,14 +719,38 @@ async fn test_token_revocation() {
let url = base_url().await;
let http_client = client();
let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
- assert_eq!(http_client.post(format!("{}/oauth/revoke", url))
- .form(&[("token", &refresh_token)]).send().await.unwrap().status(), StatusCode::OK);
- let introspect: Value = http_client.post(format!("{}/oauth/introspect", url))
- .form(&[("token", &access_token)]).send().await.unwrap().json().await.unwrap();
- assert_eq!(introspect["active"], false, "Revoked token should be inactive");
+ assert_eq!(
+ http_client
+ .post(format!("{}/oauth/revoke", url))
+ .form(&[("token", &refresh_token)])
+ .send()
+ .await
+ .unwrap()
+ .status(),
+ StatusCode::OK
+ );
+ let introspect: Value = http_client
+ .post(format!("{}/oauth/introspect", url))
+ .form(&[("token", &access_token)])
+ .send()
+ .await
+ .unwrap()
+ .json()
+ .await
+ .unwrap();
+ assert_eq!(
+ introspect["active"], false,
+ "Revoked token should be inactive"
+ );
}
-fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option<&str>, iat_offset: i64) -> String {
+fn create_dpop_proof(
+ method: &str,
+ uri: &str,
+ _nonce: Option<&str>,
+ ath: Option<&str>,
+ iat_offset: i64,
+) -> String {
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
use p256::elliptic_curve::sec1::ToEncodedPoint;
let signing_key = SigningKey::random(&mut rand::thread_rng());
@@ -333,12 +760,18 @@ fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option<
let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
"htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset });
- if let Some(a) = ath { payload["ath"] = json!(a); }
+ if let Some(a) = ath {
+ payload["ath"] = json!(a);
+ }
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
- format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()))
+ format!(
+ "{}.{}",
+ signing_input,
+ URL_SAFE_NO_PAD.encode(signature.to_bytes())
+ )
}
#[test]
@@ -350,11 +783,20 @@ fn test_dpop_nonce_security() {
let nonce = v1.generate_nonce();
assert!(!nonce.is_empty());
assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass");
- assert!(v2.validate_nonce(&nonce).is_err(), "Nonce from different secret should fail");
+ assert!(
+ v2.validate_nonce(&nonce).is_err(),
+ "Nonce from different secret should fail"
+ );
let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
let mut tampered = nonce_bytes.clone();
- if !tampered.is_empty() { tampered[0] ^= 0xFF; }
- assert!(v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)).is_err(), "Tampered nonce should fail");
+ if !tampered.is_empty() {
+ tampered[0] ^= 0xFF;
+ }
+ assert!(
+ v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered))
+ .is_err(),
+ "Tampered nonce should fail"
+ );
assert!(v1.validate_nonce("invalid").is_err());
assert!(v1.validate_nonce("").is_err());
assert!(v1.validate_nonce("!!!not-base64!!!").is_err());
@@ -364,20 +806,79 @@ fn test_dpop_nonce_security() {
fn test_dpop_proof_validation() {
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
- assert!(verifier.verify_proof("not.enough", "POST", "https://example.com", None).is_err());
- assert!(verifier.verify_proof("invalid", "POST", "https://example.com", None).is_err());
+ assert!(
+ verifier
+ .verify_proof("not.enough", "POST", "https://example.com", None)
+ .is_err()
+ );
+ assert!(
+ verifier
+ .verify_proof("invalid", "POST", "https://example.com", None)
+ .is_err()
+ );
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
- assert!(verifier.verify_proof(&proof, "GET", "https://example.com/token", None).is_err(), "Method mismatch");
- assert!(verifier.verify_proof(&proof, "POST", "https://other.com/token", None).is_err(), "URI mismatch");
- assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None).is_ok(), "Query params should be ignored");
+ assert!(
+ verifier
+ .verify_proof(&proof, "GET", "https://example.com/token", None)
+ .is_err(),
+ "Method mismatch"
+ );
+ assert!(
+ verifier
+ .verify_proof(&proof, "POST", "https://other.com/token", None)
+ .is_err(),
+ "URI mismatch"
+ );
+ assert!(
+ verifier
+ .verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None)
+ .is_ok(),
+ "Query params should be ignored"
+ );
let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
- assert!(verifier.verify_proof(&old_proof, "POST", "https://example.com/token", None).is_err(), "iat too old");
+ assert!(
+ verifier
+ .verify_proof(&old_proof, "POST", "https://example.com/token", None)
+ .is_err(),
+ "iat too old"
+ );
let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
- assert!(verifier.verify_proof(&future_proof, "POST", "https://example.com/token", None).is_err(), "iat in future");
- let ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, Some("wrong"), 0);
- assert!(verifier.verify_proof(&ath_proof, "GET", "https://example.com/resource", Some("correct")).is_err(), "ath mismatch");
+ assert!(
+ verifier
+ .verify_proof(&future_proof, "POST", "https://example.com/token", None)
+ .is_err(),
+ "iat in future"
+ );
+ let ath_proof = create_dpop_proof(
+ "GET",
+ "https://example.com/resource",
+ None,
+ Some("wrong"),
+ 0,
+ );
+ assert!(
+ verifier
+ .verify_proof(
+ &ath_proof,
+ "GET",
+ "https://example.com/resource",
+ Some("correct")
+ )
+ .is_err(),
+ "ath mismatch"
+ );
let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
- assert!(verifier.verify_proof(&no_ath_proof, "GET", "https://example.com/resource", Some("expected")).is_err(), "Missing ath");
+ assert!(
+ verifier
+ .verify_proof(
+ &no_ath_proof,
+ "GET",
+ "https://example.com/resource",
+ Some("expected")
+ )
+ .is_err(),
+ "Missing ath"
+ );
}
#[test]
@@ -398,8 +899,17 @@ fn test_dpop_proof_signature_attacks() {
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
- let mismatched = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
- assert!(verifier.verify_proof(&mismatched, "POST", "https://example.com/token", None).is_err(), "Mismatched key should fail");
+ let mismatched = format!(
+ "{}.{}",
+ signing_input,
+ URL_SAFE_NO_PAD.encode(signature.to_bytes())
+ );
+ assert!(
+ verifier
+ .verify_proof(&mismatched, "POST", "https://example.com/token", None)
+ .is_err(),
+ "Mismatched key should fail"
+ );
let point = signing_key.verifying_key().to_encoded_point(false);
let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256",
"x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } });
@@ -409,26 +919,80 @@ fn test_dpop_proof_signature_attacks() {
let mut sig_bytes = good_sig.to_bytes().to_vec();
sig_bytes[0] ^= 0xFF;
let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes));
- assert!(verifier.verify_proof(&tampered, "POST", "https://example.com/token", None).is_err(), "Tampered sig should fail");
+ assert!(
+ verifier
+ .verify_proof(&tampered, "POST", "https://example.com/token", None)
+ .is_err(),
+ "Tampered sig should fail"
+ );
}
#[test]
fn test_jwk_thumbprint() {
- let jwk = DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()),
+ let jwk = DPoPJwk {
+ kty: "EC".to_string(),
+ crv: Some("P-256".to_string()),
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
- y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()) };
+ y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
+ };
let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
assert!(!tp1.is_empty());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("secp256k1".to_string()),
- x: Some("x".to_string()), y: Some("y".to_string()) }).is_ok());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "OKP".to_string(), crv: Some("Ed25519".to_string()),
- x: Some("x".to_string()), y: None }).is_ok());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: None, x: Some("x".to_string()), y: Some("y".to_string()) }).is_err());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: None, y: Some("y".to_string()) }).is_err());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: Some("x".to_string()), y: None }).is_err());
- assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "RSA".to_string(), crv: None, x: None, y: None }).is_err());
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "EC".to_string(),
+ crv: Some("secp256k1".to_string()),
+ x: Some("x".to_string()),
+ y: Some("y".to_string())
+ })
+ .is_ok()
+ );
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "OKP".to_string(),
+ crv: Some("Ed25519".to_string()),
+ x: Some("x".to_string()),
+ y: None
+ })
+ .is_ok()
+ );
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "EC".to_string(),
+ crv: None,
+ x: Some("x".to_string()),
+ y: Some("y".to_string())
+ })
+ .is_err()
+ );
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "EC".to_string(),
+ crv: Some("P-256".to_string()),
+ x: None,
+ y: Some("y".to_string())
+ })
+ .is_err()
+ );
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "EC".to_string(),
+ crv: Some("P-256".to_string()),
+ x: Some("x".to_string()),
+ y: None
+ })
+ .is_err()
+ );
+ assert!(
+ compute_jwk_thumbprint(&DPoPJwk {
+ kty: "RSA".to_string(),
+ crv: None,
+ x: None,
+ y: None
+ })
+ .is_err()
+ );
}
#[test]
@@ -437,7 +1001,15 @@ fn test_dpop_clock_skew() {
use p256::elliptic_curve::sec1::ToEncodedPoint;
let secret = b"test-dpop-secret-32-bytes-long!!";
let verifier = DPoPVerifier::new(secret);
- let test_cases = vec![(-600, true), (-301, true), (-299, false), (0, false), (299, false), (301, true), (600, true)];
+ let test_cases = vec![
+ (-600, true),
+ (-301, true),
+ (-299, false),
+ (0, false),
+ (299, false),
+ (301, true),
+ (600, true),
+ ];
for (offset, should_fail) in test_cases {
let signing_key = SigningKey::random(&mut rand::thread_rng());
let point = signing_key.verifying_key().to_encoded_point(false);
@@ -450,10 +1022,17 @@ fn test_dpop_clock_skew() {
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
- let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
+ let proof = format!(
+ "{}.{}",
+ signing_input,
+ URL_SAFE_NO_PAD.encode(signature.to_bytes())
+ );
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
- if should_fail { assert!(result.is_err(), "offset {} should fail", offset); }
- else { assert!(result.is_ok(), "offset {} should pass", offset); }
+ if should_fail {
+ assert!(result.is_err(), "offset {} should fail", offset);
+ } else {
+ assert!(result.is_ok(), "offset {} should pass", offset);
+ }
}
}
@@ -474,6 +1053,15 @@ fn test_dpop_http_method_case() {
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: Signature = signing_key.sign(signing_input.as_bytes());
- let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
- assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token", None).is_ok(), "HTTP method should be case-insensitive");
+ let proof = format!(
+ "{}.{}",
+ signing_input,
+ URL_SAFE_NO_PAD.encode(signature.to_bytes())
+ );
+ assert!(
+ verifier
+ .verify_proof(&proof, "POST", "https://example.com/token", None)
+ .is_ok(),
+ "HTTP method should be case-insensitive"
+ );
}
diff --git a/tests/plc_operations.rs b/tests/plc_operations.rs
index 2b5abbd..fbdb49c 100644
--- a/tests/plc_operations.rs
+++ b/tests/plc_operations.rs
@@ -7,18 +7,45 @@ use sqlx::PgPool;
#[tokio::test]
async fn test_plc_operation_auth() {
let client = client();
- let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await))
- .send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
+ base_url().await
+ ))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
- let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await))
- .json(&json!({})).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.signPlcOperation",
+ base_url().await
+ ))
+ .json(&json!({}))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
- let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await))
- .json(&json!({ "operation": {} })).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.submitPlcOperation",
+ base_url().await
+ ))
+ .json(&json!({ "operation": {} }))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let (token, _) = create_account_and_login(&client).await;
- let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await))
- .bearer_auth(&token).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
@@ -26,13 +53,29 @@ async fn test_plc_operation_auth() {
async fn test_sign_plc_operation_validation() {
let client = client();
let (token, _) = create_account_and_login(&client).await;
- let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await))
- .bearer_auth(&token).json(&json!({})).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.signPlcOperation",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .json(&json!({}))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"], "InvalidRequest");
- let res = client.post(format!("{}/xrpc/com.atproto.identity.signPlcOperation", base_url().await))
- .bearer_auth(&token).json(&json!({ "token": "invalid-token-12345" })).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.signPlcOperation",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .json(&json!({ "token": "invalid-token-12345" }))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert!(body["error"] == "InvalidToken" || body["error"] == "ExpiredToken");
@@ -42,17 +85,34 @@ async fn test_sign_plc_operation_validation() {
async fn test_submit_plc_operation_validation() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
- let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port()));
- let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await))
- .bearer_auth(&token).json(&json!({ "operation": { "type": "invalid_type" } })).send().await.unwrap();
+ let hostname =
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| format!("127.0.0.1:{}", app_port()));
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.submitPlcOperation",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .json(&json!({ "operation": { "type": "invalid_type" } }))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"], "InvalidRequest");
- let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await))
- .bearer_auth(&token).json(&json!({
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.submitPlcOperation",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .json(&json!({
"operation": { "type": "plc_operation", "rotationKeys": [], "verificationMethods": {},
"alsoKnownAs": [], "services": {}, "prev": null }
- })).send().await.unwrap();
+ }))
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let handle = did.split(':').last().unwrap_or("user");
let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await))
@@ -75,7 +135,13 @@ async fn test_submit_plc_operation_validation() {
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"], "InvalidRequest");
- assert!(body["message"].as_str().unwrap_or("").contains("signing key") || body["message"].as_str().unwrap_or("").contains("rotation"));
+ assert!(
+ body["message"]
+ .as_str()
+ .unwrap_or("")
+ .contains("signing key")
+ || body["message"].as_str().unwrap_or("").contains("rotation")
+ );
let res = client.post(format!("{}/xrpc/com.atproto.identity.submitPlcOperation", base_url().await))
.bearer_auth(&token).json(&json!({
"operation": { "type": "plc_operation", "rotationKeys": ["did:key:z123"],
@@ -100,8 +166,15 @@ async fn test_submit_plc_operation_validation() {
async fn test_plc_token_lifecycle() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
- let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await))
- .bearer_auth(&token).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = PgPool::connect(&db_url).await.unwrap();
@@ -113,12 +186,25 @@ async fn test_plc_token_lifecycle() {
let row = row.unwrap();
assert_eq!(row.token.len(), 11, "Token should be in format xxxxx-xxxxx");
assert!(row.token.contains('-'), "Token should contain hyphen");
- assert!(row.expires_at > chrono::Utc::now(), "Token should not be expired");
+ assert!(
+ row.expires_at > chrono::Utc::now(),
+ "Token should not be expired"
+ );
let diff = row.expires_at - chrono::Utc::now();
- assert!(diff.num_minutes() >= 9 && diff.num_minutes() <= 11, "Token should expire in ~10 minutes");
+ assert!(
+ diff.num_minutes() >= 9 && diff.num_minutes() <= 11,
+ "Token should expire in ~10 minutes"
+ );
let token1 = row.token.clone();
- let res = client.post(format!("{}/xrpc/com.atproto.identity.requestPlcOperationSignature", base_url().await))
- .bearer_auth(&token).send().await.unwrap();
+ let res = client
+ .post(format!(
+ "{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
+ base_url().await
+ ))
+ .bearer_auth(&token)
+ .send()
+ .await
+ .unwrap();
assert_eq!(res.status(), StatusCode::OK);
let token2 = sqlx::query_scalar!(
"SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", did
diff --git a/tests/plc_validation.rs b/tests/plc_validation.rs
index 07af247..c829d66 100644
--- a/tests/plc_validation.rs
+++ b/tests/plc_validation.rs
@@ -1,11 +1,11 @@
+use k256::ecdsa::SigningKey;
+use serde_json::json;
+use std::collections::HashMap;
use tranquil_pds::plc::{
PlcError, PlcOperation, PlcService, PlcValidationContext, cid_for_cbor, sign_operation,
signing_key_to_did_key, validate_plc_operation, validate_plc_operation_for_submission,
verify_operation_signature,
};
-use k256::ecdsa::SigningKey;
-use serde_json::json;
-use std::collections::HashMap;
fn create_valid_operation() -> serde_json::Value {
let key = SigningKey::random(&mut rand::thread_rng());
@@ -32,27 +32,44 @@ fn test_plc_operation_basic_validation() {
assert!(validate_plc_operation(&op).is_ok());
let missing_type = json!({ "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "sig": "test" });
- assert!(matches!(validate_plc_operation(&missing_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing type")));
+ assert!(
+ matches!(validate_plc_operation(&missing_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing type"))
+ );
let invalid_type = json!({ "type": "invalid_type", "sig": "test" });
- assert!(matches!(validate_plc_operation(&invalid_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Invalid type")));
+ assert!(
+ matches!(validate_plc_operation(&invalid_type), Err(PlcError::InvalidResponse(msg)) if msg.contains("Invalid type"))
+ );
let missing_sig = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {} });
- assert!(matches!(validate_plc_operation(&missing_sig), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing sig")));
+ assert!(
+ matches!(validate_plc_operation(&missing_sig), Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing sig"))
+ );
let missing_rotation = json!({ "type": "plc_operation", "verificationMethods": {}, "alsoKnownAs": [], "services": {}, "sig": "test" });
- assert!(matches!(validate_plc_operation(&missing_rotation), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotationKeys")));
+ assert!(
+ matches!(validate_plc_operation(&missing_rotation), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotationKeys"))
+ );
let missing_verification = json!({ "type": "plc_operation", "rotationKeys": [], "alsoKnownAs": [], "services": {}, "sig": "test" });
- assert!(matches!(validate_plc_operation(&missing_verification), Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods")));
+ assert!(
+ matches!(validate_plc_operation(&missing_verification), Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods"))
+ );
let missing_aka = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "services": {}, "sig": "test" });
- assert!(matches!(validate_plc_operation(&missing_aka), Err(PlcError::InvalidResponse(msg)) if msg.contains("alsoKnownAs")));
+ assert!(
+ matches!(validate_plc_operation(&missing_aka), Err(PlcError::InvalidResponse(msg)) if msg.contains("alsoKnownAs"))
+ );
let missing_services = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "sig": "test" });
- assert!(matches!(validate_plc_operation(&missing_services), Err(PlcError::InvalidResponse(msg)) if msg.contains("services")));
+ assert!(
+ matches!(validate_plc_operation(&missing_services), Err(PlcError::InvalidResponse(msg)) if msg.contains("services"))
+ );
- assert!(matches!(validate_plc_operation(&json!("not an object")), Err(PlcError::InvalidResponse(_))));
+ assert!(matches!(
+ validate_plc_operation(&json!("not an object")),
+ Err(PlcError::InvalidResponse(_))
+ ));
}
#[test]
@@ -61,14 +78,20 @@ fn test_plc_submission_validation() {
let did_key = signing_key_to_did_key(&key);
let server_key = "did:key:zServer123";
- let base_op = |rotation_key: &str, signing_key: &str, handle: &str, service_type: &str, endpoint: &str| json!({
- "type": "plc_operation",
- "rotationKeys": [rotation_key],
- "verificationMethods": {"atproto": signing_key},
- "alsoKnownAs": [format!("at://{}", handle)],
- "services": { "atproto_pds": { "type": service_type, "endpoint": endpoint } },
- "sig": "test"
- });
+ let base_op = |rotation_key: &str,
+ signing_key: &str,
+ handle: &str,
+ service_type: &str,
+ endpoint: &str| {
+ json!({
+ "type": "plc_operation",
+ "rotationKeys": [rotation_key],
+ "verificationMethods": {"atproto": signing_key},
+ "alsoKnownAs": [format!("at://{}", handle)],
+ "services": { "atproto_pds": { "type": service_type, "endpoint": endpoint } },
+ "sig": "test"
+ })
+ };
let ctx = PlcValidationContext {
server_rotation_key: server_key.to_string(),
@@ -77,8 +100,16 @@ fn test_plc_submission_validation() {
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
- let op = base_op(&did_key, &did_key, "test.handle", "AtprotoPersonalDataServer", "https://pds.example.com");
- assert!(matches!(validate_plc_operation_for_submission(&op, &ctx), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotation key")));
+ let op = base_op(
+ &did_key,
+ &did_key,
+ "test.handle",
+ "AtprotoPersonalDataServer",
+ "https://pds.example.com",
+ );
+ assert!(
+ matches!(validate_plc_operation_for_submission(&op, &ctx), Err(PlcError::InvalidResponse(msg)) if msg.contains("rotation key"))
+ );
let ctx_with_user_key = PlcValidationContext {
server_rotation_key: did_key.clone(),
@@ -87,17 +118,49 @@ fn test_plc_submission_validation() {
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
- let wrong_signing = base_op(&did_key, "did:key:zWrongKey", "test.handle", "AtprotoPersonalDataServer", "https://pds.example.com");
- assert!(matches!(validate_plc_operation_for_submission(&wrong_signing, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("signing key")));
+ let wrong_signing = base_op(
+ &did_key,
+ "did:key:zWrongKey",
+ "test.handle",
+ "AtprotoPersonalDataServer",
+ "https://pds.example.com",
+ );
+ assert!(
+ matches!(validate_plc_operation_for_submission(&wrong_signing, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("signing key"))
+ );
- let wrong_handle = base_op(&did_key, &did_key, "wrong.handle", "AtprotoPersonalDataServer", "https://pds.example.com");
- assert!(matches!(validate_plc_operation_for_submission(&wrong_handle, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("handle")));
+ let wrong_handle = base_op(
+ &did_key,
+ &did_key,
+ "wrong.handle",
+ "AtprotoPersonalDataServer",
+ "https://pds.example.com",
+ );
+ assert!(
+ matches!(validate_plc_operation_for_submission(&wrong_handle, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("handle"))
+ );
- let wrong_service_type = base_op(&did_key, &did_key, "test.handle", "WrongServiceType", "https://pds.example.com");
- assert!(matches!(validate_plc_operation_for_submission(&wrong_service_type, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("type")));
+ let wrong_service_type = base_op(
+ &did_key,
+ &did_key,
+ "test.handle",
+ "WrongServiceType",
+ "https://pds.example.com",
+ );
+ assert!(
+ matches!(validate_plc_operation_for_submission(&wrong_service_type, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("type"))
+ );
- let wrong_endpoint = base_op(&did_key, &did_key, "test.handle", "AtprotoPersonalDataServer", "https://wrong.endpoint.com");
- assert!(matches!(validate_plc_operation_for_submission(&wrong_endpoint, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("endpoint")));
+ let wrong_endpoint = base_op(
+ &did_key,
+ &did_key,
+ "test.handle",
+ "AtprotoPersonalDataServer",
+ "https://wrong.endpoint.com",
+ );
+ assert!(
+ matches!(validate_plc_operation_for_submission(&wrong_endpoint, &ctx_with_user_key), Err(PlcError::InvalidResponse(msg)) if msg.contains("endpoint"))
+ );
}
#[test]
@@ -121,13 +184,18 @@ fn test_signature_verification() {
assert!(result.is_ok() && !result.unwrap());
let missing_sig = json!({ "type": "plc_operation", "rotationKeys": [], "verificationMethods": {}, "alsoKnownAs": [], "services": {} });
- assert!(matches!(verify_operation_signature(&missing_sig, &[]), Err(PlcError::InvalidResponse(msg)) if msg.contains("sig")));
+ assert!(
+ matches!(verify_operation_signature(&missing_sig, &[]), Err(PlcError::InvalidResponse(msg)) if msg.contains("sig"))
+ );
let invalid_base64 = json!({
"type": "plc_operation", "rotationKeys": [], "verificationMethods": {},
"alsoKnownAs": [], "services": {}, "sig": "not-valid-base64!!!"
});
- assert!(matches!(verify_operation_signature(&invalid_base64, &[]), Err(PlcError::InvalidResponse(_))));
+ assert!(matches!(
+ verify_operation_signature(&invalid_base64, &[]),
+ Err(PlcError::InvalidResponse(_))
+ ));
}
#[test]
@@ -136,7 +204,10 @@ fn test_cid_and_key_utilities() {
let cid1 = cid_for_cbor(&value).unwrap();
let cid2 = cid_for_cbor(&value).unwrap();
assert_eq!(cid1, cid2, "CID should be deterministic");
- assert!(cid1.starts_with("bafyrei"), "CID should be dag-cbor + sha256");
+ assert!(
+ cid1.starts_with("bafyrei"),
+ "CID should be dag-cbor + sha256"
+ );
let value2 = json!({ "alpha": 999 });
let cid3 = cid_for_cbor(&value2).unwrap();
@@ -145,15 +216,24 @@ fn test_cid_and_key_utilities() {
let key = SigningKey::random(&mut rand::thread_rng());
let did = signing_key_to_did_key(&key);
assert!(did.starts_with("did:key:z") && did.len() > 50);
- assert_eq!(did, signing_key_to_did_key(&key), "Same key should produce same did");
+ assert_eq!(
+ did,
+ signing_key_to_did_key(&key),
+ "Same key should produce same did"
+ );
let key2 = SigningKey::random(&mut rand::thread_rng());
- assert_ne!(did, signing_key_to_did_key(&key2), "Different keys should produce different dids");
+ assert_ne!(
+ did,
+ signing_key_to_did_key(&key2),
+ "Different keys should produce different dids"
+ );
}
#[test]
fn test_tombstone_operations() {
- let tombstone = json!({ "type": "plc_tombstone", "prev": "bafyreig6xxxxxyyyyyzzzzzz", "sig": "test" });
+ let tombstone =
+ json!({ "type": "plc_tombstone", "prev": "bafyreig6xxxxxyyyyyzzzzzz", "sig": "test" });
assert!(validate_plc_operation(&tombstone).is_ok());
let key = SigningKey::random(&mut rand::thread_rng());
@@ -175,13 +255,19 @@ fn test_sign_operation_and_struct() {
"alsoKnownAs": [], "services": {}, "prev": null, "sig": "old_signature"
});
let signed = sign_operation(&op, &key).unwrap();
- assert_ne!(signed.get("sig").and_then(|v| v.as_str()).unwrap(), "old_signature");
+ assert_ne!(
+ signed.get("sig").and_then(|v| v.as_str()).unwrap(),
+ "old_signature"
+ );
let mut services = HashMap::new();
- services.insert("atproto_pds".to_string(), PlcService {
- service_type: "AtprotoPersonalDataServer".to_string(),
- endpoint: "https://pds.example.com".to_string(),
- });
+ services.insert(
+ "atproto_pds".to_string(),
+ PlcService {
+ service_type: "AtprotoPersonalDataServer".to_string(),
+ endpoint: "https://pds.example.com".to_string(),
+ },
+ );
let mut verification_methods = HashMap::new();
verification_methods.insert("atproto".to_string(), "did:key:zTest123".to_string());
let op = PlcOperation {
diff --git a/tests/record_validation.rs b/tests/record_validation.rs
index 99cbdd5..692b759 100644
--- a/tests/record_validation.rs
+++ b/tests/record_validation.rs
@@ -1,8 +1,8 @@
+use serde_json::json;
use tranquil_pds::validation::{
RecordValidator, ValidationError, ValidationStatus, validate_collection_nsid,
validate_record_key,
};
-use serde_json::json;
fn now() -> String {
chrono::Utc::now().to_rfc3339()
@@ -17,33 +17,49 @@ fn test_post_record_validation() {
"text": "Hello world!",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_post, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_post, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let missing_text = json!({
"$type": "app.bsky.feed.post",
"createdAt": now()
});
- assert!(matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text"));
+ assert!(
+ matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text")
+ );
let missing_created_at = json!({
"$type": "app.bsky.feed.post",
"text": "Hello"
});
- assert!(matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt"));
+ assert!(
+ matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt")
+ );
let text_too_long = json!({
"$type": "app.bsky.feed.post",
"text": "a".repeat(3001),
"createdAt": now()
});
- assert!(matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text"));
+ assert!(
+ matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text")
+ );
let text_at_limit = json!({
"$type": "app.bsky.feed.post",
"text": "a".repeat(3000),
"createdAt": now()
});
- assert_eq!(validator.validate(&text_at_limit, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&text_at_limit, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let too_many_langs = json!({
"$type": "app.bsky.feed.post",
@@ -51,7 +67,9 @@ fn test_post_record_validation() {
"createdAt": now(),
"langs": ["en", "fr", "de", "es"]
});
- assert!(matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs"));
+ assert!(
+ matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs")
+ );
let three_langs_ok = json!({
"$type": "app.bsky.feed.post",
@@ -59,7 +77,12 @@ fn test_post_record_validation() {
"createdAt": now(),
"langs": ["en", "fr", "de"]
});
- assert_eq!(validator.validate(&three_langs_ok, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&three_langs_ok, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let too_many_tags = json!({
"$type": "app.bsky.feed.post",
@@ -67,7 +90,9 @@ fn test_post_record_validation() {
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"]
});
- assert!(matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags"));
+ assert!(
+ matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags")
+ );
let eight_tags_ok = json!({
"$type": "app.bsky.feed.post",
@@ -75,7 +100,12 @@ fn test_post_record_validation() {
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"]
});
- assert_eq!(validator.validate(&eight_tags_ok, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&eight_tags_ok, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let tag_too_long = json!({
"$type": "app.bsky.feed.post",
@@ -83,7 +113,9 @@ fn test_post_record_validation() {
"createdAt": now(),
"tags": ["t".repeat(641)]
});
- assert!(matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")));
+ assert!(
+ matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))
+ );
}
#[test]
@@ -95,24 +127,38 @@ fn test_profile_record_validation() {
"displayName": "Test User",
"description": "A test user profile"
});
- assert_eq!(validator.validate(&valid, "app.bsky.actor.profile").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid, "app.bsky.actor.profile")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let empty_ok = json!({
"$type": "app.bsky.actor.profile"
});
- assert_eq!(validator.validate(&empty_ok, "app.bsky.actor.profile").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&empty_ok, "app.bsky.actor.profile")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let displayname_too_long = json!({
"$type": "app.bsky.actor.profile",
"displayName": "n".repeat(641)
});
- assert!(matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName"));
+ assert!(
+ matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
+ );
let description_too_long = json!({
"$type": "app.bsky.actor.profile",
"description": "d".repeat(2561)
});
- assert!(matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description"));
+ assert!(
+ matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description")
+ );
}
#[test]
@@ -127,13 +173,20 @@ fn test_like_and_repost_validation() {
},
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_like, "app.bsky.feed.like").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_like, "app.bsky.feed.like")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let missing_subject = json!({
"$type": "app.bsky.feed.like",
"createdAt": now()
});
- assert!(matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject"));
+ assert!(
+ matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject")
+ );
let missing_subject_uri = json!({
"$type": "app.bsky.feed.like",
@@ -142,7 +195,9 @@ fn test_like_and_repost_validation() {
},
"createdAt": now()
});
- assert!(matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri")));
+ assert!(
+ matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri"))
+ );
let invalid_subject_uri = json!({
"$type": "app.bsky.feed.like",
@@ -152,7 +207,9 @@ fn test_like_and_repost_validation() {
},
"createdAt": now()
});
- assert!(matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")));
+ assert!(
+ matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))
+ );
let valid_repost = json!({
"$type": "app.bsky.feed.repost",
@@ -162,13 +219,20 @@ fn test_like_and_repost_validation() {
},
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_repost, "app.bsky.feed.repost").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_repost, "app.bsky.feed.repost")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let repost_missing_subject = json!({
"$type": "app.bsky.feed.repost",
"createdAt": now()
});
- assert!(matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject"));
+ assert!(
+ matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject")
+ );
}
#[test]
@@ -180,34 +244,50 @@ fn test_follow_and_block_validation() {
"subject": "did:plc:test12345",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_follow, "app.bsky.graph.follow").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_follow, "app.bsky.graph.follow")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let missing_follow_subject = json!({
"$type": "app.bsky.graph.follow",
"createdAt": now()
});
- assert!(matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject"));
+ assert!(
+ matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject")
+ );
let invalid_follow_subject = json!({
"$type": "app.bsky.graph.follow",
"subject": "not-a-did",
"createdAt": now()
});
- assert!(matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject"));
+ assert!(
+ matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
+ );
let valid_block = json!({
"$type": "app.bsky.graph.block",
"subject": "did:plc:blocked123",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_block, "app.bsky.graph.block").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_block, "app.bsky.graph.block")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let invalid_block_subject = json!({
"$type": "app.bsky.graph.block",
"subject": "not-a-did",
"createdAt": now()
});
- assert!(matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject"));
+ assert!(
+ matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
+ );
}
#[test]
@@ -220,7 +300,12 @@ fn test_list_and_graph_records_validation() {
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_list, "app.bsky.graph.list").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_list, "app.bsky.graph.list")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let list_name_too_long = json!({
"$type": "app.bsky.graph.list",
@@ -228,7 +313,9 @@ fn test_list_and_graph_records_validation() {
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
- assert!(matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name"));
+ assert!(
+ matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
+ );
let list_empty_name = json!({
"$type": "app.bsky.graph.list",
@@ -236,7 +323,9 @@ fn test_list_and_graph_records_validation() {
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
- assert!(matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name"));
+ assert!(
+ matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
+ );
let valid_list_item = json!({
"$type": "app.bsky.graph.listitem",
@@ -244,7 +333,12 @@ fn test_list_and_graph_records_validation() {
"list": "at://did:plc:owner/app.bsky.graph.list/mylist",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_list_item, "app.bsky.graph.listitem").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_list_item, "app.bsky.graph.listitem")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
}
#[test]
@@ -257,7 +351,12 @@ fn test_misc_record_types_validation() {
"displayName": "My Feed",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_generator, "app.bsky.feed.generator").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_generator, "app.bsky.feed.generator")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let generator_displayname_too_long = json!({
"$type": "app.bsky.feed.generator",
@@ -265,14 +364,21 @@ fn test_misc_record_types_validation() {
"displayName": "f".repeat(241),
"createdAt": now()
});
- assert!(matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName"));
+ assert!(
+ matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
+ );
let valid_threadgate = json!({
"$type": "app.bsky.feed.threadgate",
"post": "at://did:plc:test/app.bsky.feed.post/123",
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_threadgate, "app.bsky.feed.threadgate").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_threadgate, "app.bsky.feed.threadgate")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let valid_labeler = json!({
"$type": "app.bsky.labeler.service",
@@ -281,7 +387,12 @@ fn test_misc_record_types_validation() {
},
"createdAt": now()
});
- assert_eq!(validator.validate(&valid_labeler, "app.bsky.labeler.service").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_labeler, "app.bsky.labeler.service")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
}
#[test]
@@ -293,8 +404,16 @@ fn test_type_and_format_validation() {
"$type": "com.custom.record",
"data": "test"
});
- assert_eq!(validator.validate(&custom_record, "com.custom.record").unwrap(), ValidationStatus::Unknown);
- assert!(matches!(strict_validator.validate(&custom_record, "com.custom.record"), Err(ValidationError::UnknownType(_))));
+ assert_eq!(
+ validator
+ .validate(&custom_record, "com.custom.record")
+ .unwrap(),
+ ValidationStatus::Unknown
+ );
+ assert!(matches!(
+ strict_validator.validate(&custom_record, "com.custom.record"),
+ Err(ValidationError::UnknownType(_))
+ ));
let type_mismatch = json!({
"$type": "app.bsky.feed.like",
@@ -309,31 +428,50 @@ fn test_type_and_format_validation() {
let missing_type = json!({
"text": "Hello"
});
- assert!(matches!(validator.validate(&missing_type, "app.bsky.feed.post"), Err(ValidationError::MissingType)));
+ assert!(matches!(
+ validator.validate(&missing_type, "app.bsky.feed.post"),
+ Err(ValidationError::MissingType)
+ ));
let not_object = json!("just a string");
- assert!(matches!(validator.validate(¬_object, "app.bsky.feed.post"), Err(ValidationError::InvalidRecord(_))));
+ assert!(matches!(
+ validator.validate(¬_object, "app.bsky.feed.post"),
+ Err(ValidationError::InvalidRecord(_))
+ ));
let valid_datetime = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00.000Z"
});
- assert_eq!(validator.validate(&valid_datetime, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&valid_datetime, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let datetime_with_offset = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00+05:30"
});
- assert_eq!(validator.validate(&datetime_with_offset, "app.bsky.feed.post").unwrap(), ValidationStatus::Valid);
+ assert_eq!(
+ validator
+ .validate(&datetime_with_offset, "app.bsky.feed.post")
+ .unwrap(),
+ ValidationStatus::Valid
+ );
let invalid_datetime = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024/01/15"
});
- assert!(matches!(validator.validate(&invalid_datetime, "app.bsky.feed.post"), Err(ValidationError::InvalidDatetime { .. })));
+ assert!(matches!(
+ validator.validate(&invalid_datetime, "app.bsky.feed.post"),
+ Err(ValidationError::InvalidDatetime { .. })
+ ));
}
#[test]
@@ -345,7 +483,10 @@ fn test_record_key_validation() {
assert!(validate_record_key("valid~key").is_ok());
assert!(validate_record_key("self").is_ok());
- assert!(matches!(validate_record_key(""), Err(ValidationError::InvalidRecord(_))));
+ assert!(matches!(
+ validate_record_key(""),
+ Err(ValidationError::InvalidRecord(_))
+ ));
assert!(validate_record_key(".").is_err());
assert!(validate_record_key("..").is_err());
@@ -355,7 +496,10 @@ fn test_record_key_validation() {
assert!(validate_record_key("invalid@key").is_err());
assert!(validate_record_key("invalid#key").is_err());
- assert!(matches!(validate_record_key(&"k".repeat(513)), Err(ValidationError::InvalidRecord(_))));
+ assert!(matches!(
+ validate_record_key(&"k".repeat(513)),
+ Err(ValidationError::InvalidRecord(_))
+ ));
assert!(validate_record_key(&"k".repeat(512)).is_ok());
}
@@ -366,7 +510,10 @@ fn test_collection_nsid_validation() {
assert!(validate_collection_nsid("a.b.c").is_ok());
assert!(validate_collection_nsid("my-app.domain.record-type").is_ok());
- assert!(matches!(validate_collection_nsid(""), Err(ValidationError::InvalidRecord(_))));
+ assert!(matches!(
+ validate_collection_nsid(""),
+ Err(ValidationError::InvalidRecord(_))
+ ));
assert!(validate_collection_nsid("a").is_err());
assert!(validate_collection_nsid("a.b").is_err());
diff --git a/tests/security_fixes.rs b/tests/security_fixes.rs
index 3bcad3d..a3ff09c 100644
--- a/tests/security_fixes.rs
+++ b/tests/security_fixes.rs
@@ -1,7 +1,6 @@
mod common;
-use tranquil_pds::image::{ImageError, ImageProcessor};
use tranquil_pds::comms::{SendError, is_valid_phone_number, sanitize_header_value};
-use tranquil_pds::oauth::templates::{error_page, login_page, success_page};
+use tranquil_pds::image::{ImageError, ImageProcessor};
#[test]
fn test_header_injection_sanitization() {
@@ -24,7 +23,11 @@ fn test_header_injection_sanitization() {
let header_injection = "Normal Subject\r\nBcc: attacker@evil.com\r\nX-Injected: value";
let sanitized = sanitize_header_value(header_injection);
assert_eq!(sanitized.split("\r\n").count(), 1);
- assert!(sanitized.contains("Normal Subject") && sanitized.contains("Bcc:") && sanitized.contains("X-Injected:"));
+ assert!(
+ sanitized.contains("Normal Subject")
+ && sanitized.contains("Bcc:")
+ && sanitized.contains("X-Injected:")
+ );
let with_null = "client\0id";
assert!(sanitize_header_value(with_null).contains("client"));
@@ -59,10 +62,21 @@ fn test_phone_number_validation() {
assert!(!is_valid_phone_number("+1(234)567890"));
assert!(!is_valid_phone_number("+1.234.567.890"));
- for malicious in ["+123; rm -rf /", "+123 && cat /etc/passwd", "+123`id`",
- "+123$(whoami)", "+123|cat /etc/shadow", "+123\n--help",
- "+123\r\n--version", "+123--help"] {
- assert!(!is_valid_phone_number(malicious), "Command injection '{}' should be rejected", malicious);
+ for malicious in [
+ "+123; rm -rf /",
+ "+123 && cat /etc/passwd",
+ "+123`id`",
+ "+123$(whoami)",
+ "+123|cat /etc/shadow",
+ "+123\n--help",
+ "+123\r\n--version",
+ "+123--help",
+ ] {
+ assert!(
+ !is_valid_phone_number(malicious),
+ "Command injection '{}' should be rejected",
+ malicious
+ );
}
}
@@ -87,71 +101,6 @@ fn test_image_file_size_limits() {
assert!(processor.process(&data, "image/jpeg").is_err());
}
-#[test]
-fn test_oauth_template_xss_protection() {
- let html = login_page("", None, None, "test-uri", None, None);
- assert!(!html.contains(""), "test-uri", None, None);
- assert!(!html.contains(""), None);
- assert!(!html.contains("", Some("
"));
- assert!(!html.contains(""));
- assert!(!html.contains("