Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

App password scopes

+608 -204
+4 -3
.sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json .sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", 3 + "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 9 9 "Text", 10 10 "Text", 11 11 "Timestamptz", 12 - "Bool" 12 + "Bool", 13 + "Text" 13 14 ] 14 15 }, 15 16 "nullable": [] 16 17 }, 17 - "hash": "15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1" 18 + "hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40" 18 19 }
+18
.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Int4", 9 + "Text", 10 + "Text", 11 + "Timestamptz", 12 + "Text" 13 + ] 14 + }, 15 + "nullable": [] 16 + }, 17 + "hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f" 18 + }
+4 -3
.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json .sqlx/query-815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 3 + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 11 11 "Timestamptz", 12 12 "Timestamptz", 13 13 "Bool", 14 - "Bool" 14 + "Bool", 15 + "Text" 15 16 ] 16 17 }, 17 18 "nullable": [] 18 19 }, 19 - "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" 20 + "hash": "815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24" 20 21 }
+28
.sqlx/query-3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_hash", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "scopes", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9" 28 + }
+23
.sqlx/query-44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "access_expires_at", 9 + "type_info": "Timestamptz" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459" 23 + }
+46
.sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "scope", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "key_bytes", 24 + "type_info": "Bytea" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "encryption_version", 29 + "type_info": "Int4" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + true, 41 + false, 42 + true 43 + ] 44 + }, 45 + "hash": "6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883" 46 + }
-17
.sqlx/query-b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()\n WHERE id = $1\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Int4", 9 - "Text", 10 - "Text", 11 - "Timestamptz" 12 - ] 13 - }, 14 - "nullable": [] 15 - }, 16 - "hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284" 17 - }
+9 -3
.sqlx/query-cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01.json .sqlx/query-f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 3 + "query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 17 17 "ordinal": 2, 18 18 "name": "privileged", 19 19 "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "scopes", 24 + "type_info": "Text" 20 25 } 21 26 ], 22 27 "parameters": { ··· 27 32 "nullable": [ 28 33 false, 29 34 false, 30 - false 35 + false, 36 + true 31 37 ] 32 38 }, 33 - "hash": "cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01" 39 + "hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb" 34 40 }
-23
.sqlx/query-d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "one", 9 - "type_info": "Int4" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text", 15 - "Text" 16 - ] 17 - }, 18 - "nullable": [ 19 - null 20 - ] 21 - }, 22 - "hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7" 23 - }
-22
.sqlx/query-dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_hash", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Uuid" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37" 22 - }
-40
.sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Int4" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "key_bytes", 19 - "type_info": "Bytea" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "encryption_version", 24 - "type_info": "Int4" 25 - } 26 - ], 27 - "parameters": { 28 - "Left": [ 29 - "Text" 30 - ] 31 - }, 32 - "nullable": [ 33 - false, 34 - false, 35 - false, 36 - true 37 - ] 38 - }, 39 - "hash": "e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8" 40 - }
+101
.sqlx/query-fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "token_id", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "created_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "updated_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "expires_at", 34 + "type_info": "Timestamptz" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "client_id", 39 + "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "client_auth", 44 + "type_info": "Jsonb" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "device_id", 49 + "type_info": "Text" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "parameters", 54 + "type_info": "Jsonb" 55 + }, 56 + { 57 + "ordinal": 10, 58 + "name": "details", 59 + "type_info": "Jsonb" 60 + }, 61 + { 62 + "ordinal": 11, 63 + "name": "code", 64 + "type_info": "Text" 65 + }, 66 + { 67 + "ordinal": 12, 68 + "name": "current_refresh_token", 69 + "type_info": "Text" 70 + }, 71 + { 72 + "ordinal": 13, 73 + "name": "scope", 74 + "type_info": "Text" 75 + } 76 + ], 77 + "parameters": { 78 + "Left": [ 79 + "Text", 80 + "Timestamptz" 81 + ] 82 + }, 83 + "nullable": [ 84 + false, 85 + false, 86 + false, 87 + false, 88 + false, 89 + false, 90 + false, 91 + false, 92 + true, 93 + false, 94 + true, 95 + true, 96 + true, 97 + true 98 + ] 99 + }, 100 + "hash": "fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913" 101 + }
+3 -2
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20 ··· 66 66 67 67 ## License 68 68 69 - TBD 69 + AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details. 70 +
+2 -7
TODO.md
··· 2 2 3 3 ## Active development 4 4 5 - ### Frontend 6 - So like... make the thing unique, make it cool. 7 - 8 - - [ ] Frontpage that explains what this thing is 9 - - [ ] Unique "brand" style both unauthed and authed 10 - - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 11 - 12 5 ### Delegated accounts 13 6 Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 14 7 ··· 90 83 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. 91 84 92 85 Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods. 86 + 87 + App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
+6 -2
frontend/src/lib/api.ts
··· 93 93 export interface AppPassword { 94 94 name: string; 95 95 createdAt: string; 96 + scopes?: string; 96 97 } 97 98 98 99 export interface InviteCode { ··· 226 227 async createAppPassword( 227 228 token: string, 228 229 name: string, 229 - ): Promise<{ name: string; password: string; createdAt: string }> { 230 + scopes?: string, 231 + ): Promise< 232 + { name: string; password: string; createdAt: string; scopes?: string } 233 + > { 230 234 return xrpc("com.atproto.server.createAppPassword", { 231 235 method: "POST", 232 236 token, 233 - body: { name }, 237 + body: { name, scopes }, 234 238 }); 235 239 }, 236 240
+6 -1
frontend/src/locales/en.json
··· 266 266 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 267 267 "saveWarningTitle": "Important: Save this app password!", 268 268 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", 269 - "acknowledgeLabel": "I have saved my app password in a secure location" 269 + "acknowledgeLabel": "I have saved my app password in a secure location", 270 + "permissions": "Permissions", 271 + "scopeFull": "Full Access", 272 + "scopeReadOnly": "Read Only", 273 + "scopePostOnly": "Post Only", 274 + "scopeCustom": "Custom" 270 275 }, 271 276 "sessions": { 272 277 "title": "Active Sessions",
+6 -1
frontend/src/locales/fi.json
··· 266 266 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 267 267 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 268 268 "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", 269 - "acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan" 269 + "acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan", 270 + "permissions": "Käyttöoikeudet", 271 + "scopeFull": "Täydet oikeudet", 272 + "scopeReadOnly": "Vain luku", 273 + "scopePostOnly": "Vain julkaisut", 274 + "scopeCustom": "Mukautettu" 270 275 }, 271 276 "sessions": { 272 277 "title": "Aktiiviset istunnot",
+6 -1
frontend/src/locales/ja.json
··· 266 266 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 267 267 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 268 268 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", 269 - "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました" 269 + "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました", 270 + "permissions": "権限", 271 + "scopeFull": "フルアクセス", 272 + "scopeReadOnly": "読み取り専用", 273 + "scopePostOnly": "投稿のみ", 274 + "scopeCustom": "カスタム" 270 275 }, 271 276 "sessions": { 272 277 "title": "アクティブセッション",
+6 -1
frontend/src/locales/ko.json
··· 266 266 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 267 267 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 268 268 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", 269 - "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다" 269 + "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다", 270 + "permissions": "권한", 271 + "scopeFull": "전체 권한", 272 + "scopeReadOnly": "읽기 전용", 273 + "scopePostOnly": "게시만 가능", 274 + "scopeCustom": "사용자 지정" 270 275 }, 271 276 "sessions": { 272 277 "title": "활성 세션",
+6 -1
frontend/src/locales/sv.json
··· 266 266 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 267 267 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 268 268 "saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.", 269 - "acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats" 269 + "acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats", 270 + "permissions": "Behörigheter", 271 + "scopeFull": "Full åtkomst", 272 + "scopeReadOnly": "Endast läsning", 273 + "scopePostOnly": "Endast publicering", 274 + "scopeCustom": "Anpassad" 270 275 }, 271 276 "sessions": { 272 277 "title": "Aktiva sessioner",
+6 -1
frontend/src/locales/zh.json
··· 266 266 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 267 267 "saveWarningTitle": "重要:请保存此应用专用密码!", 268 268 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", 269 - "acknowledgeLabel": "我已将应用专用密码保存在安全的地方" 269 + "acknowledgeLabel": "我已将应用专用密码保存在安全的地方", 270 + "permissions": "权限", 271 + "scopeFull": "完全访问", 272 + "scopeReadOnly": "只读", 273 + "scopePostOnly": "仅发帖", 274 + "scopeCustom": "自定义" 270 275 }, 271 276 "sessions": { 272 277 "title": "登录会话",
+109 -4
frontend/src/routes/AppPasswords.svelte
··· 9 9 let loading = $state(true) 10 10 let error = $state<string | null>(null) 11 11 let newPasswordName = $state('') 12 + let selectedScope = $state<string | null>(null) 12 13 let creating = $state(false) 13 14 let createdPassword = $state<{ name: string; password: string } | null>(null) 14 15 let passwordCopied = $state(false) 15 16 let passwordAcknowledged = $state(false) 16 17 let revoking = $state<string | null>(null) 18 + 19 + const SCOPE_PRESETS = [ 20 + { id: 'full', label: 'appPasswords.scopeFull', scopes: null }, 21 + { id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: 'rpc:app.bsky.*?aud=* rpc:chat.bsky.*?aud=* account:status?action=read' }, 22 + { id: 'post', label: 'appPasswords.scopePostOnly', scopes: 'repo:app.bsky.feed.post?action=create blob:*/*' }, 23 + ] 24 + 25 + function getScopeLabel(scopes: string | null | undefined): string { 26 + if (!scopes) return $_('appPasswords.scopeFull') 27 + const preset = SCOPE_PRESETS.find(p => p.scopes === scopes) 28 + if (preset) return $_(preset.label) 29 + return $_('appPasswords.scopeCustom') 30 + } 17 31 $effect(() => { 18 32 if (!auth.loading && !auth.session) { 19 33 navigate('/login') ··· 43 57 creating = true 44 58 error = null 45 59 try { 46 - const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim()) 60 + const scopeValue = selectedScope === null ? undefined : selectedScope 61 + const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined) 47 62 createdPassword = { name: result.name, password: result.password } 48 63 newPasswordName = '' 64 + selectedScope = null 49 65 await loadPasswords() 50 66 } catch (e) { 51 67 error = e instanceof ApiError ? e.message : 'Failed to create app password' ··· 122 138 disabled={creating} 123 139 required 124 140 /> 141 + <div class="scope-selector" role="group" aria-label={$_('appPasswords.permissions')}> 142 + <span class="scope-label">{$_('appPasswords.permissions')}:</span> 143 + <div class="scope-buttons"> 144 + {#each SCOPE_PRESETS as preset} 145 + <button 146 + type="button" 147 + class="scope-btn" 148 + class:selected={selectedScope === preset.scopes} 149 + onclick={() => selectedScope = preset.scopes} 150 + disabled={creating} 151 + > 152 + {$_(preset.label)} 153 + </button> 154 + {/each} 155 + </div> 156 + </div> 125 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 126 158 {creating ? $_('appPasswords.creating') : $_('common.create')} 127 159 </button> ··· 139 171 <li> 140 172 <div class="password-info"> 141 173 <span class="name">{pw.name}</span> 142 - <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span> 174 + <span class="meta"> 175 + <span class="scope-badge" class:full={!pw.scopes}>{getScopeLabel(pw.scopes)}</span> 176 + <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span> 177 + </span> 143 178 </div> 144 179 <button 145 180 class="revoke" ··· 279 314 280 315 .create-section form { 281 316 display: flex; 317 + flex-direction: column; 318 + gap: var(--space-4); 319 + } 320 + 321 + .create-section form > input { 322 + flex: 1; 323 + } 324 + 325 + .create-section form > button { 326 + align-self: flex-start; 327 + } 328 + 329 + .scope-selector { 330 + display: flex; 331 + flex-direction: column; 282 332 gap: var(--space-2); 283 333 } 284 334 285 - .create-section input { 286 - flex: 1; 335 + .scope-label { 336 + font-size: var(--text-sm); 337 + color: var(--text-secondary); 338 + } 339 + 340 + .scope-buttons { 341 + display: flex; 342 + flex-wrap: wrap; 343 + gap: var(--space-2); 344 + } 345 + 346 + .scope-btn { 347 + padding: var(--space-2) var(--space-4); 348 + background: var(--bg-secondary); 349 + border: 1px solid var(--border-color); 350 + border-radius: var(--radius-md); 351 + color: var(--text-primary); 352 + cursor: pointer; 353 + font-size: var(--text-sm); 354 + transition: all 0.15s ease; 355 + } 356 + 357 + .scope-btn:hover:not(:disabled) { 358 + background: var(--bg-hover); 359 + border-color: var(--accent); 360 + } 361 + 362 + .scope-btn.selected { 363 + background: var(--accent); 364 + border-color: var(--accent); 365 + color: var(--text-inverse); 366 + } 367 + 368 + .scope-btn:disabled { 369 + opacity: 0.6; 370 + cursor: not-allowed; 287 371 } 288 372 289 373 .password-list { ··· 311 395 312 396 .name { 313 397 font-weight: var(--font-medium); 398 + } 399 + 400 + .meta { 401 + display: flex; 402 + align-items: center; 403 + gap: var(--space-3); 404 + } 405 + 406 + .scope-badge { 407 + font-size: var(--text-xs); 408 + padding: var(--space-1) var(--space-2); 409 + background: var(--bg-secondary); 410 + border: 1px solid var(--border-color); 411 + border-radius: var(--radius-sm); 412 + color: var(--text-secondary); 413 + } 414 + 415 + .scope-badge.full { 416 + background: var(--success-bg); 417 + border-color: var(--success-border); 418 + color: var(--success-text); 314 419 } 315 420 316 421 .date {
+5
frontend/src/routes/Home.svelte
··· 173 173 <h3>You decide what apps can do</h3> 174 174 <p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p> 175 175 </div> 176 + 177 + <div class="feature"> 178 + <h3>App passwords with guardrails</h3> 179 + <p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p> 180 + </div> 176 181 </div> 177 182 178 183 <h2>Everything in one place</h2>
+1
migrations/20251234_app_password_scopes.sql
··· 1 + ALTER TABLE app_passwords ADD COLUMN scopes TEXT;
+1
migrations/20251235_session_token_scope.sql
··· 1 + ALTER TABLE session_tokens ADD COLUMN scope TEXT;
+2
migrations/20251236_oauth_refresh_grace_period.sql
··· 1 + ALTER TABLE oauth_token ADD COLUMN previous_refresh_token TEXT; 2 + ALTER TABLE oauth_token ADD COLUMN rotated_at TIMESTAMPTZ;
+10
src/api/proxy.rs
··· 94 94 } 95 95 Err(e) => { 96 96 warn!("Token validation failed: {:?}", e); 97 + if matches!(e, crate::auth::TokenValidationError::TokenExpired) { 98 + return ( 99 + StatusCode::BAD_REQUEST, 100 + Json(json!({ 101 + "error": "ExpiredToken", 102 + "message": "Token has expired" 103 + })), 104 + ) 105 + .into_response(); 106 + } 97 107 } 98 108 } 99 109 }
+11 -2
src/api/repo/record/batch.rs
··· 19 19 use serde_json::json; 20 20 use std::str::FromStr; 21 21 use std::sync::Arc; 22 - use tracing::error; 22 + use tracing::{error, info}; 23 23 24 24 const MAX_BATCH_WRITES: usize = 200; 25 25 ··· 79 79 headers: axum::http::HeaderMap, 80 80 Json(input): Json<ApplyWritesInput>, 81 81 ) -> Response { 82 + info!( 83 + "apply_writes called: repo={}, writes={}", 84 + input.repo, 85 + input.writes.len() 86 + ); 82 87 let token = match crate::auth::extract_bearer_token_from_header( 83 88 headers.get("Authorization").and_then(|h| h.to_str().ok()), 84 89 ) { ··· 147 152 .into_response(); 148 153 } 149 154 150 - if is_oauth { 155 + let has_custom_scope = scope 156 + .as_ref() 157 + .map(|s| s != "com.atproto.access") 158 + .unwrap_or(false); 159 + if is_oauth || has_custom_scope { 151 160 use std::collections::HashSet; 152 161 let create_collections: HashSet<&str> = input 153 162 .writes
+4 -4
src/api/repo/record/utils.rs
··· 16 16 prev: Option<Cid>, 17 17 signing_key: &SigningKey, 18 18 ) -> Result<(Vec<u8>, Bytes), String> { 19 - let did = jacquard::types::string::Did::new(did) 20 - .map_err(|e| format!("Invalid DID: {:?}", e))?; 21 - let rev = jacquard::types::string::Tid::from_str(rev) 22 - .map_err(|e| format!("Invalid TID: {:?}", e))?; 19 + let did = 20 + jacquard::types::string::Did::new(did).map_err(|e| format!("Invalid DID: {:?}", e))?; 21 + let rev = 22 + jacquard::types::string::Tid::from_str(rev).map_err(|e| format!("Invalid TID: {:?}", e))?; 23 23 let unsigned = Commit::new_unsigned(did, data, rev, prev); 24 24 let signed = unsigned 25 25 .sign(signing_key)
+12 -3
src/api/server/app_password.rs
··· 18 18 pub name: String, 19 19 pub created_at: String, 20 20 pub privileged: bool, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub scopes: Option<String>, 21 23 } 22 24 23 25 #[derive(Serialize)] ··· 34 36 Err(e) => return ApiError::from(e).into_response(), 35 37 }; 36 38 match sqlx::query!( 37 - "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 39 + "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 38 40 user_id 39 41 ) 40 42 .fetch_all(&state.db) ··· 47 49 name: row.name.clone(), 48 50 created_at: row.created_at.to_rfc3339(), 49 51 privileged: row.privileged, 52 + scopes: row.scopes.clone(), 50 53 }) 51 54 .collect(); 52 55 Json(ListAppPasswordsOutput { passwords }).into_response() ··· 62 65 pub struct CreateAppPasswordInput { 63 66 pub name: String, 64 67 pub privileged: Option<bool>, 68 + pub scopes: Option<String>, 65 69 } 66 70 67 71 #[derive(Serialize)] ··· 71 75 pub password: String, 72 76 pub created_at: String, 73 77 pub privileged: bool, 78 + #[serde(skip_serializing_if = "Option::is_none")] 79 + pub scopes: Option<String>, 74 80 } 75 81 76 82 pub async fn create_app_password( ··· 131 137 } 132 138 }; 133 139 let privileged = input.privileged.unwrap_or(false); 140 + let scopes = input.scopes.clone(); 134 141 let created_at = chrono::Utc::now(); 135 142 match sqlx::query!( 136 - "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", 143 + "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 137 144 user_id, 138 145 name, 139 146 password_hash, 140 147 created_at, 141 - privileged 148 + privileged, 149 + scopes 142 150 ) 143 151 .execute(&state.db) 144 152 .await ··· 148 156 password, 149 157 created_at: created_at.to_rfc3339(), 150 158 privileged, 159 + scopes, 151 160 }) 152 161 .into_response(), 153 162 Err(e) => {
+33 -19
src/api/server/session.rs
··· 125 125 return ApiError::InternalError.into_response(); 126 126 } 127 127 }; 128 - let password_valid = if row 128 + let (password_valid, app_password_scopes) = if row 129 129 .password_hash 130 130 .as_ref() 131 131 .map(|h| verify(&input.password, h).unwrap_or(false)) 132 132 .unwrap_or(false) 133 133 { 134 - true 134 + (true, None) 135 135 } else { 136 136 let app_passwords = sqlx::query!( 137 - "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 137 + "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 138 138 row.id 139 139 ) 140 140 .fetch_all(&state.db) 141 141 .await 142 142 .unwrap_or_default(); 143 - app_passwords 143 + let matched = app_passwords 144 144 .iter() 145 - .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)) 145 + .find(|app| verify(&input.password, &app.password_hash).unwrap_or(false)); 146 + match matched { 147 + Some(app) => (true, app.scopes.clone()), 148 + None => (false, None), 149 + } 146 150 }; 147 151 if !password_valid { 148 152 warn!("Password verification failed for login attempt"); ··· 177 181 ) 178 182 .into_response(); 179 183 } 180 - let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 184 + let access_meta = match crate::auth::create_access_token_with_scope_metadata( 185 + &row.did, 186 + &key_bytes, 187 + app_password_scopes.as_deref(), 188 + ) { 181 189 Ok(m) => m, 182 190 Err(e) => { 183 191 error!("Failed to create access token: {:?}", e); ··· 192 200 } 193 201 }; 194 202 if let Err(e) = sqlx::query!( 195 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 203 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 196 204 row.did, 197 205 access_meta.jti, 198 206 refresh_meta.jti, 199 207 access_meta.expires_at, 200 208 refresh_meta.expires_at, 201 209 is_legacy_login, 202 - false 210 + false, 211 + app_password_scopes 203 212 ) 204 213 .execute(&state.db) 205 214 .await ··· 388 397 .into_response(); 389 398 } 390 399 let session_row = match sqlx::query!( 391 - r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version 400 + r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version 392 401 FROM session_tokens st 393 402 JOIN users u ON st.did = u.did 394 403 JOIN user_keys k ON u.id = k.user_id ··· 420 429 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 421 430 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); 422 431 } 423 - let new_access_meta = 424 - match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) { 425 - Ok(m) => m, 426 - Err(e) => { 427 - error!("Failed to create access token: {:?}", e); 428 - return ApiError::InternalError.into_response(); 429 - } 430 - }; 432 + let new_access_meta = match crate::auth::create_access_token_with_scope_metadata( 433 + &session_row.did, 434 + &key_bytes, 435 + session_row.scope.as_deref(), 436 + ) { 437 + Ok(m) => m, 438 + Err(e) => { 439 + error!("Failed to create access token: {:?}", e); 440 + return ApiError::InternalError.into_response(); 441 + } 442 + }; 431 443 let new_refresh_meta = 432 444 match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { 433 445 Ok(m) => m, ··· 653 665 return ApiError::InternalError.into_response(); 654 666 } 655 667 }; 668 + let no_scope: Option<String> = None; 656 669 if let Err(e) = sqlx::query!( 657 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", 670 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 658 671 row.did, 659 672 access_meta.jti, 660 673 refresh_meta.jti, 661 674 access_meta.expires_at, 662 675 refresh_meta.expires_at, 663 676 false, 664 - false 677 + false, 678 + no_scope 665 679 ) 666 680 .execute(&state.db) 667 681 .await
+28 -15
src/auth/mod.rs
··· 24 24 pub use token::{ 25 25 SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, 26 26 TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token, 27 - create_access_token_with_metadata, create_refresh_token, create_refresh_token_with_metadata, 28 - create_service_token, 27 + create_access_token_with_metadata, create_access_token_with_scope_metadata, 28 + create_refresh_token, create_refresh_token_with_metadata, create_service_token, 29 29 }; 30 30 pub use verify::{ 31 31 TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token, ··· 66 66 67 67 impl AuthenticatedUser { 68 68 pub fn permissions(&self) -> ScopePermissions { 69 + if let Some(ref scope) = self.scope 70 + && scope != SCOPE_ACCESS 71 + { 72 + return ScopePermissions::from_scope_string(Some(scope)); 73 + } 69 74 if !self.is_oauth { 70 75 return ScopePermissions::from_scope_string(Some("atproto")); 71 76 } ··· 212 217 } 213 218 214 219 if !session_valid { 215 - let session_exists = sqlx::query_scalar!( 216 - "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()", 220 + let session_row = sqlx::query!( 221 + "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2", 217 222 did, 218 223 jti 219 224 ) ··· 222 227 .ok() 223 228 .flatten(); 224 229 225 - session_valid = session_exists.is_some(); 226 - 227 - if session_valid && let Some(c) = cache { 228 - let _ = c 229 - .set( 230 - &session_cache_key, 231 - "1", 232 - Duration::from_secs(SESSION_CACHE_TTL_SECS), 233 - ) 234 - .await; 230 + match session_row { 231 + Some(row) => { 232 + if row.access_expires_at > chrono::Utc::now() { 233 + session_valid = true; 234 + if let Some(c) = cache { 235 + let _ = c 236 + .set( 237 + &session_cache_key, 238 + "1", 239 + Duration::from_secs(SESSION_CACHE_TTL_SECS), 240 + ) 241 + .await; 242 + } 243 + } else { 244 + return Err(TokenValidationError::TokenExpired); 245 + } 246 + } 247 + None => {} 235 248 } 236 249 } 237 250 ··· 241 254 key_bytes: Some(decrypted_key), 242 255 is_oauth: false, 243 256 is_admin, 244 - scope: None, 257 + scope: token_data.claims.scope.clone(), 245 258 }); 246 259 } 247 260 }
+14 -5
src/auth/scope_check.rs
··· 8 8 AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, 9 9 }; 10 10 11 + use super::token::SCOPE_ACCESS; 12 + 13 + fn has_custom_scope(scope: Option<&str>) -> bool { 14 + match scope { 15 + None => false, 16 + Some(s) => s != SCOPE_ACCESS, 17 + } 18 + } 19 + 11 20 pub fn check_repo_scope( 12 21 is_oauth: bool, 13 22 scope: Option<&str>, 14 23 action: RepoAction, 15 24 collection: &str, 16 25 ) -> Result<(), Response> { 17 - if !is_oauth { 26 + if !is_oauth && !has_custom_scope(scope) { 18 27 return Ok(()); 19 28 } 20 29 ··· 32 41 } 33 42 34 43 pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { 35 - if !is_oauth { 44 + if !is_oauth && !has_custom_scope(scope) { 36 45 return Ok(()); 37 46 } 38 47 ··· 55 64 aud: &str, 56 65 lxm: &str, 57 66 ) -> Result<(), Response> { 58 - if !is_oauth { 67 + if !is_oauth && !has_custom_scope(scope) { 59 68 return Ok(()); 60 69 } 61 70 ··· 78 87 attr: AccountAttr, 79 88 action: AccountAction, 80 89 ) -> Result<(), Response> { 81 - if !is_oauth { 90 + if !is_oauth && !has_custom_scope(scope) { 82 91 return Ok(()); 83 92 } 84 93 ··· 100 109 scope: Option<&str>, 101 110 attr: IdentityAttr, 102 111 ) -> Result<(), Response> { 103 - if !is_oauth { 112 + if !is_oauth && !has_custom_scope(scope) { 104 113 return Ok(()); 105 114 } 106 115
+10 -1
src/auth/token.rs
··· 33 33 } 34 34 35 35 pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> { 36 + create_access_token_with_scope_metadata(did, key_bytes, None) 37 + } 38 + 39 + pub fn create_access_token_with_scope_metadata( 40 + did: &str, 41 + key_bytes: &[u8], 42 + scopes: Option<&str>, 43 + ) -> Result<TokenWithMetadata> { 44 + let scope = scopes.unwrap_or(SCOPE_ACCESS); 36 45 create_signed_token_with_metadata( 37 46 did, 38 - SCOPE_ACCESS, 47 + scope, 39 48 TOKEN_TYPE_ACCESS, 40 49 key_bytes, 41 50 Duration::minutes(15),
+5 -7
src/auth/verify.rs
··· 256 256 token: &str, 257 257 key_bytes: &[u8], 258 258 ) -> Result<TokenData<Claims>, TokenVerifyError> { 259 - verify_token_typed_internal( 260 - token, 261 - key_bytes, 262 - Some(TOKEN_TYPE_ACCESS), 263 - Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]), 264 - ) 259 + verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None) 265 260 } 266 261 267 262 fn verify_token_typed_internal( ··· 307 302 let verifying_key = VerifyingKey::from(&signing_key); 308 303 309 304 let message = format!("{}.{}", header_b64, claims_b64); 310 - if verifying_key.verify(message.as_bytes(), &signature).is_err() { 305 + if verifying_key 306 + .verify(message.as_bytes(), &signature) 307 + .is_err() 308 + { 311 309 return Err(TokenVerifyError::Invalid); 312 310 } 313 311
+2 -1
src/oauth/db/mod.rs
··· 26 26 pub use token::{ 27 27 check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, 28 28 delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, 29 - get_token_by_refresh_token, list_tokens_for_user, revoke_tokens_for_client, rotate_token, 29 + get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user, 30 + revoke_tokens_for_client, rotate_token, 30 31 }; 31 32 pub use two_factor::{ 32 33 TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+47 -3
src/oauth/db/token.rs
··· 122 122 ) 123 123 .fetch_one(&mut *tx) 124 124 .await?; 125 - if let Some(old_rt) = old_refresh { 125 + if let Some(ref old_rt) = old_refresh { 126 126 sqlx::query!( 127 127 r#" 128 128 INSERT INTO oauth_used_refresh_token (refresh_token, token_id) ··· 137 137 sqlx::query!( 138 138 r#" 139 139 UPDATE oauth_token 140 - SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() 140 + SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(), 141 + previous_refresh_token = $5, rotated_at = NOW() 141 142 WHERE id = $1 142 143 "#, 143 144 old_db_id, 144 145 new_token_id, 145 146 new_refresh_token, 146 - new_expires_at 147 + new_expires_at, 148 + old_refresh 147 149 ) 148 150 .execute(&mut *tx) 149 151 .await?; ··· 164 166 .fetch_optional(pool) 165 167 .await?; 166 168 Ok(row) 169 + } 170 + 171 + const REFRESH_GRACE_PERIOD_SECS: i64 = 60; 172 + 173 + pub async fn get_token_by_previous_refresh_token( 174 + pool: &PgPool, 175 + refresh_token: &str, 176 + ) -> Result<Option<(i32, TokenData)>, OAuthError> { 177 + let grace_cutoff = Utc::now() - chrono::Duration::seconds(REFRESH_GRACE_PERIOD_SECS); 178 + let row = sqlx::query!( 179 + r#" 180 + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 181 + device_id, parameters, details, code, current_refresh_token, scope 182 + FROM oauth_token 183 + WHERE previous_refresh_token = $1 AND rotated_at > $2 184 + "#, 185 + refresh_token, 186 + grace_cutoff 187 + ) 188 + .fetch_optional(pool) 189 + .await?; 190 + match row { 191 + Some(r) => Ok(Some(( 192 + r.id, 193 + TokenData { 194 + did: r.did, 195 + token_id: r.token_id, 196 + created_at: r.created_at, 197 + updated_at: r.updated_at, 198 + expires_at: r.expires_at, 199 + client_id: r.client_id, 200 + client_auth: from_json(r.client_auth)?, 201 + device_id: r.device_id, 202 + parameters: from_json(r.parameters)?, 203 + details: r.details, 204 + code: r.code, 205 + current_refresh_token: r.current_refresh_token, 206 + scope: r.scope, 207 + }, 208 + ))), 209 + None => Ok(None), 210 + } 167 211 } 168 212 169 213 pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
+30
src/oauth/endpoints/token/grants.rs
··· 175 175 "Refresh token grant requested" 176 176 ); 177 177 if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { 178 + if let Some((_db_id, token_data)) = 179 + db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await? 180 + { 181 + tracing::info!( 182 + refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], 183 + "Refresh token reuse within grace period, returning existing tokens" 184 + ); 185 + let dpop_jkt = token_data.parameters.dpop_jkt.as_deref(); 186 + let access_token = create_access_token( 187 + &token_data.token_id, 188 + &token_data.did, 189 + dpop_jkt, 190 + token_data.scope.as_deref(), 191 + )?; 192 + let mut response_headers = HeaderMap::new(); 193 + let config = AuthConfig::get(); 194 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 195 + response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); 196 + return Ok(( 197 + response_headers, 198 + Json(TokenResponse { 199 + access_token, 200 + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), 201 + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, 202 + refresh_token: token_data.current_refresh_token, 203 + scope: token_data.scope, 204 + sub: Some(token_data.did), 205 + }), 206 + )); 207 + } 178 208 tracing::warn!( 179 209 refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], 180 210 "Refresh token reuse detected, revoking token family"
+2 -6
tests/common/mod.rs
··· 309 309 let verification_code = lines 310 310 .iter() 311 311 .enumerate() 312 - .find(|(_, line)| { 313 - line.contains("verification code is:") || line.contains("code is:") 314 - }) 312 + .find(|(_, line)| line.contains("verification code is:") || line.contains("code is:")) 315 313 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 316 314 .or_else(|| { 317 315 body_text 318 316 .split_whitespace() 319 - .find(|word| { 320 - word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 321 - }) 317 + .find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3) 322 318 .map(|s| s.to_string()) 323 319 }) 324 320 .unwrap_or_else(|| body_text.clone());
+2 -6
tests/jwt_security.rs
··· 696 696 let code = lines 697 697 .iter() 698 698 .enumerate() 699 - .find(|(_, line)| { 700 - line.contains("verification code is:") || line.contains("code is:") 701 - }) 699 + .find(|(_, line)| line.contains("verification code is:") || line.contains("code is:")) 702 700 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 703 701 .or_else(|| { 704 702 body_text 705 703 .split_whitespace() 706 - .find(|word| { 707 - word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 708 - }) 704 + .find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3) 709 705 .map(|s| s.to_string()) 710 706 }) 711 707 .unwrap_or_else(|| body_text.clone());