+4
-3
.sqlx/query-15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1.json
.sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json
+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
+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
+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
+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
+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
+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
-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
+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
-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
-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
-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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+6
-1
frontend/src/locales/zh.json
···
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
+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
+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
+
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
migrations/20251234_app_password_scopes.sql
···
···
1
+
ALTER TABLE app_passwords ADD COLUMN scopes TEXT;
+1
migrations/20251235_session_token_scope.sql
+1
migrations/20251235_session_token_scope.sql
···
···
1
+
ALTER TABLE session_tokens ADD COLUMN scope TEXT;
+2
migrations/20251236_oauth_refresh_grace_period.sql
+2
migrations/20251236_oauth_refresh_grace_period.sql
+10
src/api/proxy.rs
+10
src/api/proxy.rs
···
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
+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
+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
+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
+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
+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
+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
+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_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
+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
+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
+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
+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
+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
+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());