this repo has no description

App password scopes

lewis ae6d13cc 01ed0643

+4 -3
.sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json .sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json
··· 1 { 2 "db_name": "PostgreSQL", 3 - "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 9 "Text", 10 "Text", 11 "Timestamptz", 12 - "Bool" 13 ] 14 }, 15 "nullable": [] 16 }, 17 - "hash": "15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1" 18 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 9 "Text", 10 "Text", 11 "Timestamptz", 12 + "Bool", 13 + "Text" 14 ] 15 }, 16 "nullable": [] 17 }, 18 + "hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40" 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 { 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)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 11 "Timestamptz", 12 "Timestamptz", 13 "Bool", 14 - "Bool" 15 ] 16 }, 17 "nullable": [] 18 }, 19 - "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" 20 }
··· 1 { 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, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 11 "Timestamptz", 12 "Timestamptz", 13 "Bool", 14 + "Bool", 15 + "Text" 16 ] 17 }, 18 "nullable": [] 19 }, 20 + "hash": "815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24" 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 { 2 "db_name": "PostgreSQL", 3 - "query": "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 4 "describe": { 5 "columns": [ 6 { ··· 17 "ordinal": 2, 18 "name": "privileged", 19 "type_info": "Bool" 20 } 21 ], 22 "parameters": { ··· 27 "nullable": [ 28 false, 29 false, 30 - false 31 ] 32 }, 33 - "hash": "cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01" 34 }
··· 1 { 2 "db_name": "PostgreSQL", 3 + "query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 4 "describe": { 5 "columns": [ 6 { ··· 17 "ordinal": 2, 18 "name": "privileged", 19 "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "scopes", 24 + "type_info": "Text" 25 } 26 ], 27 "parameters": { ··· 32 "nullable": [ 33 false, 34 false, 35 + false, 36 + true 37 ] 38 }, 39 + "hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb" 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 15 This software isn't an afterthought by a company with limited resources. 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. 18 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 ··· 66 67 ## License 68 69 - TBD
··· 14 15 This software isn't an afterthought by a company with limited resources. 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, 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 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 ··· 66 67 ## License 68 69 + AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details. 70 +
+2 -7
TODO.md
··· 2 3 ## Active development 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 ### Delegated accounts 13 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 ··· 90 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 92 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.
··· 2 3 ## Active development 4 5 ### Delegated accounts 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. 7 ··· 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. 84 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 export interface AppPassword { 94 name: string; 95 createdAt: string; 96 } 97 98 export interface InviteCode { ··· 226 async createAppPassword( 227 token: string, 228 name: string, 229 - ): Promise<{ name: string; password: string; createdAt: string }> { 230 return xrpc("com.atproto.server.createAppPassword", { 231 method: "POST", 232 token, 233 - body: { name }, 234 }); 235 }, 236
··· 93 export interface AppPassword { 94 name: string; 95 createdAt: string; 96 + scopes?: string; 97 } 98 99 export interface InviteCode { ··· 227 async createAppPassword( 228 token: string, 229 name: string, 230 + scopes?: string, 231 + ): Promise< 232 + { name: string; password: string; createdAt: string; scopes?: string } 233 + > { 234 return xrpc("com.atproto.server.createAppPassword", { 235 method: "POST", 236 token, 237 + body: { name, scopes }, 238 }); 239 }, 240
+6 -1
frontend/src/locales/en.json
··· 266 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 267 "saveWarningTitle": "Important: Save this app password!", 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" 270 }, 271 "sessions": { 272 "title": "Active Sessions",
··· 266 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 267 "saveWarningTitle": "Important: Save this app password!", 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", 270 + "permissions": "Permissions", 271 + "scopeFull": "Full Access", 272 + "scopeReadOnly": "Read Only", 273 + "scopePostOnly": "Post Only", 274 + "scopeCustom": "Custom" 275 }, 276 "sessions": { 277 "title": "Active Sessions",
+6 -1
frontend/src/locales/fi.json
··· 266 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 267 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 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" 270 }, 271 "sessions": { 272 "title": "Aktiiviset istunnot",
··· 266 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 267 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 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", 270 + "permissions": "Käyttöoikeudet", 271 + "scopeFull": "Täydet oikeudet", 272 + "scopeReadOnly": "Vain luku", 273 + "scopePostOnly": "Vain julkaisut", 274 + "scopeCustom": "Mukautettu" 275 }, 276 "sessions": { 277 "title": "Aktiiviset istunnot",
+6 -1
frontend/src/locales/ja.json
··· 266 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 267 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 268 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", 269 - "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました" 270 }, 271 "sessions": { 272 "title": "アクティブセッション",
··· 266 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 267 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 268 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", 269 + "acknowledgeLabel": "アプリパスワードを安全な場所に保存しました", 270 + "permissions": "権限", 271 + "scopeFull": "フルアクセス", 272 + "scopeReadOnly": "読み取り専用", 273 + "scopePostOnly": "投稿のみ", 274 + "scopeCustom": "カスタム" 275 }, 276 "sessions": { 277 "title": "アクティブセッション",
+6 -1
frontend/src/locales/ko.json
··· 266 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 267 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 268 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", 269 - "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다" 270 }, 271 "sessions": { 272 "title": "활성 세션",
··· 266 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 267 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 268 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", 269 + "acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다", 270 + "permissions": "권한", 271 + "scopeFull": "전체 권한", 272 + "scopeReadOnly": "읽기 전용", 273 + "scopePostOnly": "게시만 가능", 274 + "scopeCustom": "사용자 지정" 275 }, 276 "sessions": { 277 "title": "활성 세션",
+6 -1
frontend/src/locales/sv.json
··· 266 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 267 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 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" 270 }, 271 "sessions": { 272 "title": "Aktiva sessioner",
··· 266 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 267 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 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", 270 + "permissions": "Behörigheter", 271 + "scopeFull": "Full åtkomst", 272 + "scopeReadOnly": "Endast läsning", 273 + "scopePostOnly": "Endast publicering", 274 + "scopeCustom": "Anpassad" 275 }, 276 "sessions": { 277 "title": "Aktiva sessioner",
+6 -1
frontend/src/locales/zh.json
··· 266 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 267 "saveWarningTitle": "重要:请保存此应用专用密码!", 268 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", 269 - "acknowledgeLabel": "我已将应用专用密码保存在安全的地方" 270 }, 271 "sessions": { 272 "title": "登录会话",
··· 266 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 267 "saveWarningTitle": "重要:请保存此应用专用密码!", 268 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", 269 + "acknowledgeLabel": "我已将应用专用密码保存在安全的地方", 270 + "permissions": "权限", 271 + "scopeFull": "完全访问", 272 + "scopeReadOnly": "只读", 273 + "scopePostOnly": "仅发帖", 274 + "scopeCustom": "自定义" 275 }, 276 "sessions": { 277 "title": "登录会话",
+109 -4
frontend/src/routes/AppPasswords.svelte
··· 9 let loading = $state(true) 10 let error = $state<string | null>(null) 11 let newPasswordName = $state('') 12 let creating = $state(false) 13 let createdPassword = $state<{ name: string; password: string } | null>(null) 14 let passwordCopied = $state(false) 15 let passwordAcknowledged = $state(false) 16 let revoking = $state<string | null>(null) 17 $effect(() => { 18 if (!auth.loading && !auth.session) { 19 navigate('/login') ··· 43 creating = true 44 error = null 45 try { 46 - const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim()) 47 createdPassword = { name: result.name, password: result.password } 48 newPasswordName = '' 49 await loadPasswords() 50 } catch (e) { 51 error = e instanceof ApiError ? e.message : 'Failed to create app password' ··· 122 disabled={creating} 123 required 124 /> 125 <button type="submit" disabled={creating || !newPasswordName.trim()}> 126 {creating ? $_('appPasswords.creating') : $_('common.create')} 127 </button> ··· 139 <li> 140 <div class="password-info"> 141 <span class="name">{pw.name}</span> 142 - <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span> 143 </div> 144 <button 145 class="revoke" ··· 279 280 .create-section form { 281 display: flex; 282 gap: var(--space-2); 283 } 284 285 - .create-section input { 286 - flex: 1; 287 } 288 289 .password-list { ··· 311 312 .name { 313 font-weight: var(--font-medium); 314 } 315 316 .date {
··· 9 let loading = $state(true) 10 let error = $state<string | null>(null) 11 let newPasswordName = $state('') 12 + let selectedScope = $state<string | null>(null) 13 let creating = $state(false) 14 let createdPassword = $state<{ name: string; password: string } | null>(null) 15 let passwordCopied = $state(false) 16 let passwordAcknowledged = $state(false) 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 + } 31 $effect(() => { 32 if (!auth.loading && !auth.session) { 33 navigate('/login') ··· 57 creating = true 58 error = null 59 try { 60 + const scopeValue = selectedScope === null ? undefined : selectedScope 61 + const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined) 62 createdPassword = { name: result.name, password: result.password } 63 newPasswordName = '' 64 + selectedScope = null 65 await loadPasswords() 66 } catch (e) { 67 error = e instanceof ApiError ? e.message : 'Failed to create app password' ··· 138 disabled={creating} 139 required 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> 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 {creating ? $_('appPasswords.creating') : $_('common.create')} 159 </button> ··· 171 <li> 172 <div class="password-info"> 173 <span class="name">{pw.name}</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> 178 </div> 179 <button 180 class="revoke" ··· 314 315 .create-section form { 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; 332 gap: var(--space-2); 333 } 334 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; 371 } 372 373 .password-list { ··· 395 396 .name { 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); 419 } 420 421 .date {
+5
frontend/src/routes/Home.svelte
··· 173 <h3>You decide what apps can do</h3> 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 </div> 176 </div> 177 178 <h2>Everything in one place</h2>
··· 173 <h3>You decide what apps can do</h3> 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 </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> 181 </div> 182 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 } 95 Err(e) => { 96 warn!("Token validation failed: {:?}", e); 97 } 98 } 99 }
··· 94 } 95 Err(e) => { 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 + } 107 } 108 } 109 }
+11 -2
src/api/repo/record/batch.rs
··· 19 use serde_json::json; 20 use std::str::FromStr; 21 use std::sync::Arc; 22 - use tracing::error; 23 24 const MAX_BATCH_WRITES: usize = 200; 25 ··· 79 headers: axum::http::HeaderMap, 80 Json(input): Json<ApplyWritesInput>, 81 ) -> Response { 82 let token = match crate::auth::extract_bearer_token_from_header( 83 headers.get("Authorization").and_then(|h| h.to_str().ok()), 84 ) { ··· 147 .into_response(); 148 } 149 150 - if is_oauth { 151 use std::collections::HashSet; 152 let create_collections: HashSet<&str> = input 153 .writes
··· 19 use serde_json::json; 20 use std::str::FromStr; 21 use std::sync::Arc; 22 + use tracing::{error, info}; 23 24 const MAX_BATCH_WRITES: usize = 200; 25 ··· 79 headers: axum::http::HeaderMap, 80 Json(input): Json<ApplyWritesInput>, 81 ) -> Response { 82 + info!( 83 + "apply_writes called: repo={}, writes={}", 84 + input.repo, 85 + input.writes.len() 86 + ); 87 let token = match crate::auth::extract_bearer_token_from_header( 88 headers.get("Authorization").and_then(|h| h.to_str().ok()), 89 ) { ··· 152 .into_response(); 153 } 154 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 { 160 use std::collections::HashSet; 161 let create_collections: HashSet<&str> = input 162 .writes
+4 -4
src/api/repo/record/utils.rs
··· 16 prev: Option<Cid>, 17 signing_key: &SigningKey, 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))?; 23 let unsigned = Commit::new_unsigned(did, data, rev, prev); 24 let signed = unsigned 25 .sign(signing_key)
··· 16 prev: Option<Cid>, 17 signing_key: &SigningKey, 18 ) -> Result<(Vec<u8>, Bytes), String> { 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 let unsigned = Commit::new_unsigned(did, data, rev, prev); 24 let signed = unsigned 25 .sign(signing_key)
+12 -3
src/api/server/app_password.rs
··· 18 pub name: String, 19 pub created_at: String, 20 pub privileged: bool, 21 } 22 23 #[derive(Serialize)] ··· 34 Err(e) => return ApiError::from(e).into_response(), 35 }; 36 match sqlx::query!( 37 - "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 38 user_id 39 ) 40 .fetch_all(&state.db) ··· 47 name: row.name.clone(), 48 created_at: row.created_at.to_rfc3339(), 49 privileged: row.privileged, 50 }) 51 .collect(); 52 Json(ListAppPasswordsOutput { passwords }).into_response() ··· 62 pub struct CreateAppPasswordInput { 63 pub name: String, 64 pub privileged: Option<bool>, 65 } 66 67 #[derive(Serialize)] ··· 71 pub password: String, 72 pub created_at: String, 73 pub privileged: bool, 74 } 75 76 pub async fn create_app_password( ··· 131 } 132 }; 133 let privileged = input.privileged.unwrap_or(false); 134 let created_at = chrono::Utc::now(); 135 match sqlx::query!( 136 - "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", 137 user_id, 138 name, 139 password_hash, 140 created_at, 141 - privileged 142 ) 143 .execute(&state.db) 144 .await ··· 148 password, 149 created_at: created_at.to_rfc3339(), 150 privileged, 151 }) 152 .into_response(), 153 Err(e) => {
··· 18 pub name: String, 19 pub created_at: String, 20 pub privileged: bool, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub scopes: Option<String>, 23 } 24 25 #[derive(Serialize)] ··· 36 Err(e) => return ApiError::from(e).into_response(), 37 }; 38 match sqlx::query!( 39 + "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 40 user_id 41 ) 42 .fetch_all(&state.db) ··· 49 name: row.name.clone(), 50 created_at: row.created_at.to_rfc3339(), 51 privileged: row.privileged, 52 + scopes: row.scopes.clone(), 53 }) 54 .collect(); 55 Json(ListAppPasswordsOutput { passwords }).into_response() ··· 65 pub struct CreateAppPasswordInput { 66 pub name: String, 67 pub privileged: Option<bool>, 68 + pub scopes: Option<String>, 69 } 70 71 #[derive(Serialize)] ··· 75 pub password: String, 76 pub created_at: String, 77 pub privileged: bool, 78 + #[serde(skip_serializing_if = "Option::is_none")] 79 + pub scopes: Option<String>, 80 } 81 82 pub async fn create_app_password( ··· 137 } 138 }; 139 let privileged = input.privileged.unwrap_or(false); 140 + let scopes = input.scopes.clone(); 141 let created_at = chrono::Utc::now(); 142 match sqlx::query!( 143 + "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 144 user_id, 145 name, 146 password_hash, 147 created_at, 148 + privileged, 149 + scopes 150 ) 151 .execute(&state.db) 152 .await ··· 156 password, 157 created_at: created_at.to_rfc3339(), 158 privileged, 159 + scopes, 160 }) 161 .into_response(), 162 Err(e) => {
+33 -19
src/api/server/session.rs
··· 125 return ApiError::InternalError.into_response(); 126 } 127 }; 128 - let password_valid = if row 129 .password_hash 130 .as_ref() 131 .map(|h| verify(&input.password, h).unwrap_or(false)) 132 .unwrap_or(false) 133 { 134 - true 135 } else { 136 let app_passwords = sqlx::query!( 137 - "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 138 row.id 139 ) 140 .fetch_all(&state.db) 141 .await 142 .unwrap_or_default(); 143 - app_passwords 144 .iter() 145 - .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)) 146 }; 147 if !password_valid { 148 warn!("Password verification failed for login attempt"); ··· 177 ) 178 .into_response(); 179 } 180 - let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 181 Ok(m) => m, 182 Err(e) => { 183 error!("Failed to create access token: {:?}", e); ··· 192 } 193 }; 194 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)", 196 row.did, 197 access_meta.jti, 198 refresh_meta.jti, 199 access_meta.expires_at, 200 refresh_meta.expires_at, 201 is_legacy_login, 202 - false 203 ) 204 .execute(&state.db) 205 .await ··· 388 .into_response(); 389 } 390 let session_row = match sqlx::query!( 391 - r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version 392 FROM session_tokens st 393 JOIN users u ON st.did = u.did 394 JOIN user_keys k ON u.id = k.user_id ··· 420 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 421 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); 422 } 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 - }; 431 let new_refresh_meta = 432 match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { 433 Ok(m) => m, ··· 653 return ApiError::InternalError.into_response(); 654 } 655 }; 656 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)", 658 row.did, 659 access_meta.jti, 660 refresh_meta.jti, 661 access_meta.expires_at, 662 refresh_meta.expires_at, 663 false, 664 - false 665 ) 666 .execute(&state.db) 667 .await
··· 125 return ApiError::InternalError.into_response(); 126 } 127 }; 128 + let (password_valid, app_password_scopes) = if row 129 .password_hash 130 .as_ref() 131 .map(|h| verify(&input.password, h).unwrap_or(false)) 132 .unwrap_or(false) 133 { 134 + (true, None) 135 } else { 136 let app_passwords = sqlx::query!( 137 + "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 138 row.id 139 ) 140 .fetch_all(&state.db) 141 .await 142 .unwrap_or_default(); 143 + let matched = app_passwords 144 .iter() 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 + } 150 }; 151 if !password_valid { 152 warn!("Password verification failed for login attempt"); ··· 181 ) 182 .into_response(); 183 } 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 + ) { 189 Ok(m) => m, 190 Err(e) => { 191 error!("Failed to create access token: {:?}", e); ··· 200 } 201 }; 202 if let Err(e) = sqlx::query!( 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)", 204 row.did, 205 access_meta.jti, 206 refresh_meta.jti, 207 access_meta.expires_at, 208 refresh_meta.expires_at, 209 is_legacy_login, 210 + false, 211 + app_password_scopes 212 ) 213 .execute(&state.db) 214 .await ··· 397 .into_response(); 398 } 399 let session_row = match sqlx::query!( 400 + r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version 401 FROM session_tokens st 402 JOIN users u ON st.did = u.did 403 JOIN user_keys k ON u.id = k.user_id ··· 429 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 430 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); 431 } 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 + }; 443 let new_refresh_meta = 444 match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { 445 Ok(m) => m, ··· 665 return ApiError::InternalError.into_response(); 666 } 667 }; 668 + let no_scope: Option<String> = None; 669 if let Err(e) = sqlx::query!( 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)", 671 row.did, 672 access_meta.jti, 673 refresh_meta.jti, 674 access_meta.expires_at, 675 refresh_meta.expires_at, 676 false, 677 + false, 678 + no_scope 679 ) 680 .execute(&state.db) 681 .await
+28 -15
src/auth/mod.rs
··· 24 pub use token::{ 25 SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, 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, 29 }; 30 pub use verify::{ 31 TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token, ··· 66 67 impl AuthenticatedUser { 68 pub fn permissions(&self) -> ScopePermissions { 69 if !self.is_oauth { 70 return ScopePermissions::from_scope_string(Some("atproto")); 71 } ··· 212 } 213 214 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()", 217 did, 218 jti 219 ) ··· 222 .ok() 223 .flatten(); 224 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; 235 } 236 } 237 ··· 241 key_bytes: Some(decrypted_key), 242 is_oauth: false, 243 is_admin, 244 - scope: None, 245 }); 246 } 247 }
··· 24 pub use token::{ 25 SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, 26 TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_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 }; 30 pub use verify::{ 31 TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token, ··· 66 67 impl AuthenticatedUser { 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 + } 74 if !self.is_oauth { 75 return ScopePermissions::from_scope_string(Some("atproto")); 76 } ··· 217 } 218 219 if !session_valid { 220 + let session_row = sqlx::query!( 221 + "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2", 222 did, 223 jti 224 ) ··· 227 .ok() 228 .flatten(); 229 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 => {} 248 } 249 } 250 ··· 254 key_bytes: Some(decrypted_key), 255 is_oauth: false, 256 is_admin, 257 + scope: token_data.claims.scope.clone(), 258 }); 259 } 260 }
+14 -5
src/auth/scope_check.rs
··· 8 AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, 9 }; 10 11 pub fn check_repo_scope( 12 is_oauth: bool, 13 scope: Option<&str>, 14 action: RepoAction, 15 collection: &str, 16 ) -> Result<(), Response> { 17 - if !is_oauth { 18 return Ok(()); 19 } 20 ··· 32 } 33 34 pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { 35 - if !is_oauth { 36 return Ok(()); 37 } 38 ··· 55 aud: &str, 56 lxm: &str, 57 ) -> Result<(), Response> { 58 - if !is_oauth { 59 return Ok(()); 60 } 61 ··· 78 attr: AccountAttr, 79 action: AccountAction, 80 ) -> Result<(), Response> { 81 - if !is_oauth { 82 return Ok(()); 83 } 84 ··· 100 scope: Option<&str>, 101 attr: IdentityAttr, 102 ) -> Result<(), Response> { 103 - if !is_oauth { 104 return Ok(()); 105 } 106
··· 8 AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, 9 }; 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 + 20 pub fn check_repo_scope( 21 is_oauth: bool, 22 scope: Option<&str>, 23 action: RepoAction, 24 collection: &str, 25 ) -> Result<(), Response> { 26 + if !is_oauth && !has_custom_scope(scope) { 27 return Ok(()); 28 } 29 ··· 41 } 42 43 pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { 44 + if !is_oauth && !has_custom_scope(scope) { 45 return Ok(()); 46 } 47 ··· 64 aud: &str, 65 lxm: &str, 66 ) -> Result<(), Response> { 67 + if !is_oauth && !has_custom_scope(scope) { 68 return Ok(()); 69 } 70 ··· 87 attr: AccountAttr, 88 action: AccountAction, 89 ) -> Result<(), Response> { 90 + if !is_oauth && !has_custom_scope(scope) { 91 return Ok(()); 92 } 93 ··· 109 scope: Option<&str>, 110 attr: IdentityAttr, 111 ) -> Result<(), Response> { 112 + if !is_oauth && !has_custom_scope(scope) { 113 return Ok(()); 114 } 115
+10 -1
src/auth/token.rs
··· 33 } 34 35 pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> { 36 create_signed_token_with_metadata( 37 did, 38 - SCOPE_ACCESS, 39 TOKEN_TYPE_ACCESS, 40 key_bytes, 41 Duration::minutes(15),
··· 33 } 34 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); 45 create_signed_token_with_metadata( 46 did, 47 + scope, 48 TOKEN_TYPE_ACCESS, 49 key_bytes, 50 Duration::minutes(15),
+5 -7
src/auth/verify.rs
··· 256 token: &str, 257 key_bytes: &[u8], 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 - ) 265 } 266 267 fn verify_token_typed_internal( ··· 307 let verifying_key = VerifyingKey::from(&signing_key); 308 309 let message = format!("{}.{}", header_b64, claims_b64); 310 - if verifying_key.verify(message.as_bytes(), &signature).is_err() { 311 return Err(TokenVerifyError::Invalid); 312 } 313
··· 256 token: &str, 257 key_bytes: &[u8], 258 ) -> Result<TokenData<Claims>, TokenVerifyError> { 259 + verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None) 260 } 261 262 fn verify_token_typed_internal( ··· 302 let verifying_key = VerifyingKey::from(&signing_key); 303 304 let message = format!("{}.{}", header_b64, claims_b64); 305 + if verifying_key 306 + .verify(message.as_bytes(), &signature) 307 + .is_err() 308 + { 309 return Err(TokenVerifyError::Invalid); 310 } 311
+2 -1
src/oauth/db/mod.rs
··· 26 pub use token::{ 27 check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, 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, 30 }; 31 pub use two_factor::{ 32 TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
··· 26 pub use token::{ 27 check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, 28 delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, 29 + get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user, 30 + revoke_tokens_for_client, rotate_token, 31 }; 32 pub use two_factor::{ 33 TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+47 -3
src/oauth/db/token.rs
··· 122 ) 123 .fetch_one(&mut *tx) 124 .await?; 125 - if let Some(old_rt) = old_refresh { 126 sqlx::query!( 127 r#" 128 INSERT INTO oauth_used_refresh_token (refresh_token, token_id) ··· 137 sqlx::query!( 138 r#" 139 UPDATE oauth_token 140 - SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() 141 WHERE id = $1 142 "#, 143 old_db_id, 144 new_token_id, 145 new_refresh_token, 146 - new_expires_at 147 ) 148 .execute(&mut *tx) 149 .await?; ··· 164 .fetch_optional(pool) 165 .await?; 166 Ok(row) 167 } 168 169 pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
··· 122 ) 123 .fetch_one(&mut *tx) 124 .await?; 125 + if let Some(ref old_rt) = old_refresh { 126 sqlx::query!( 127 r#" 128 INSERT INTO oauth_used_refresh_token (refresh_token, token_id) ··· 137 sqlx::query!( 138 r#" 139 UPDATE oauth_token 140 + SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(), 141 + previous_refresh_token = $5, rotated_at = NOW() 142 WHERE id = $1 143 "#, 144 old_db_id, 145 new_token_id, 146 new_refresh_token, 147 + new_expires_at, 148 + old_refresh 149 ) 150 .execute(&mut *tx) 151 .await?; ··· 166 .fetch_optional(pool) 167 .await?; 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 + } 211 } 212 213 pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
+30
src/oauth/endpoints/token/grants.rs
··· 175 "Refresh token grant requested" 176 ); 177 if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { 178 tracing::warn!( 179 refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], 180 "Refresh token reuse detected, revoking token family"
··· 175 "Refresh token grant requested" 176 ); 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 + } 208 tracing::warn!( 209 refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], 210 "Refresh token reuse detected, revoking token family"
+2 -6
tests/common/mod.rs
··· 309 let verification_code = lines 310 .iter() 311 .enumerate() 312 - .find(|(_, line)| { 313 - line.contains("verification code is:") || line.contains("code is:") 314 - }) 315 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 316 .or_else(|| { 317 body_text 318 .split_whitespace() 319 - .find(|word| { 320 - word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 321 - }) 322 .map(|s| s.to_string()) 323 }) 324 .unwrap_or_else(|| body_text.clone());
··· 309 let verification_code = lines 310 .iter() 311 .enumerate() 312 + .find(|(_, line)| line.contains("verification code is:") || line.contains("code is:")) 313 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 314 .or_else(|| { 315 body_text 316 .split_whitespace() 317 + .find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3) 318 .map(|s| s.to_string()) 319 }) 320 .unwrap_or_else(|| body_text.clone());
+2 -6
tests/jwt_security.rs
··· 696 let code = lines 697 .iter() 698 .enumerate() 699 - .find(|(_, line)| { 700 - line.contains("verification code is:") || line.contains("code is:") 701 - }) 702 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 703 .or_else(|| { 704 body_text 705 .split_whitespace() 706 - .find(|word| { 707 - word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 708 - }) 709 .map(|s| s.to_string()) 710 }) 711 .unwrap_or_else(|| body_text.clone());
··· 696 let code = lines 697 .iter() 698 .enumerate() 699 + .find(|(_, line)| line.contains("verification code is:") || line.contains("code is:")) 700 .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 701 .or_else(|| { 702 body_text 703 .split_whitespace() 704 + .find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3) 705 .map(|s| s.to_string()) 706 }) 707 .unwrap_or_else(|| body_text.clone());