+194
-152
Cargo.lock
+194
-152
Cargo.lock
···
112
112
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
113
113
114
114
[[package]]
115
+
name = "aws-lc-rs"
116
+
version = "1.13.3"
117
+
source = "registry+https://github.com/rust-lang/crates.io-index"
118
+
checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba"
119
+
dependencies = [
120
+
"aws-lc-sys",
121
+
"untrusted 0.7.1",
122
+
"zeroize",
123
+
]
124
+
125
+
[[package]]
126
+
name = "aws-lc-sys"
127
+
version = "0.30.0"
128
+
source = "registry+https://github.com/rust-lang/crates.io-index"
129
+
checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff"
130
+
dependencies = [
131
+
"bindgen",
132
+
"cc",
133
+
"cmake",
134
+
"dunce",
135
+
"fs_extra",
136
+
]
137
+
138
+
[[package]]
115
139
name = "axum"
116
140
version = "0.8.4"
117
141
source = "registry+https://github.com/rust-lang/crates.io-index"
···
217
241
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
218
242
219
243
[[package]]
244
+
name = "bindgen"
245
+
version = "0.69.5"
246
+
source = "registry+https://github.com/rust-lang/crates.io-index"
247
+
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
248
+
dependencies = [
249
+
"bitflags",
250
+
"cexpr",
251
+
"clang-sys",
252
+
"itertools",
253
+
"lazy_static",
254
+
"lazycell",
255
+
"log",
256
+
"prettyplease",
257
+
"proc-macro2",
258
+
"quote",
259
+
"regex",
260
+
"rustc-hash",
261
+
"shlex",
262
+
"syn",
263
+
"which",
264
+
]
265
+
266
+
[[package]]
220
267
name = "bitflags"
221
268
version = "2.9.1"
222
269
source = "registry+https://github.com/rust-lang/crates.io-index"
···
274
321
]
275
322
276
323
[[package]]
324
+
name = "cexpr"
325
+
version = "0.6.0"
326
+
source = "registry+https://github.com/rust-lang/crates.io-index"
327
+
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
328
+
dependencies = [
329
+
"nom 7.1.3",
330
+
]
331
+
332
+
[[package]]
277
333
name = "cfg-if"
278
334
version = "1.0.1"
279
335
source = "registry+https://github.com/rust-lang/crates.io-index"
···
287
343
dependencies = [
288
344
"android-tzdata",
289
345
"iana-time-zone",
346
+
"js-sys",
290
347
"num-traits",
348
+
"wasm-bindgen",
291
349
"windows-link",
292
350
]
293
351
···
339
397
]
340
398
341
399
[[package]]
400
+
name = "clang-sys"
401
+
version = "1.8.1"
402
+
source = "registry+https://github.com/rust-lang/crates.io-index"
403
+
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
404
+
dependencies = [
405
+
"glob",
406
+
"libc",
407
+
"libloading",
408
+
]
409
+
410
+
[[package]]
411
+
name = "cmake"
412
+
version = "0.1.54"
413
+
source = "registry+https://github.com/rust-lang/crates.io-index"
414
+
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
415
+
dependencies = [
416
+
"cc",
417
+
]
418
+
419
+
[[package]]
342
420
name = "concurrent-queue"
343
421
version = "2.5.0"
344
422
source = "registry+https://github.com/rust-lang/crates.io-index"
···
354
432
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
355
433
356
434
[[package]]
357
-
name = "core-foundation"
358
-
version = "0.9.4"
359
-
source = "registry+https://github.com/rust-lang/crates.io-index"
360
-
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
361
-
dependencies = [
362
-
"core-foundation-sys",
363
-
"libc",
364
-
]
365
-
366
-
[[package]]
367
435
name = "core-foundation-sys"
368
436
version = "0.8.7"
369
437
source = "registry+https://github.com/rust-lang/crates.io-index"
···
545
613
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
546
614
547
615
[[package]]
616
+
name = "dunce"
617
+
version = "1.0.5"
618
+
source = "registry+https://github.com/rust-lang/crates.io-index"
619
+
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
620
+
621
+
[[package]]
548
622
name = "either"
549
623
version = "1.15.0"
550
624
source = "registry+https://github.com/rust-lang/crates.io-index"
···
635
709
version = "0.1.5"
636
710
source = "registry+https://github.com/rust-lang/crates.io-index"
637
711
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
638
-
639
-
[[package]]
640
-
name = "foreign-types"
641
-
version = "0.3.2"
642
-
source = "registry+https://github.com/rust-lang/crates.io-index"
643
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
644
-
dependencies = [
645
-
"foreign-types-shared",
646
-
]
647
-
648
-
[[package]]
649
-
name = "foreign-types-shared"
650
-
version = "0.1.1"
651
-
source = "registry+https://github.com/rust-lang/crates.io-index"
652
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
653
712
654
713
[[package]]
655
714
name = "form_urlencoded"
···
671
730
]
672
731
673
732
[[package]]
733
+
name = "fs_extra"
734
+
version = "1.3.0"
735
+
source = "registry+https://github.com/rust-lang/crates.io-index"
736
+
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
737
+
738
+
[[package]]
674
739
name = "futures-channel"
675
740
version = "0.3.31"
676
741
source = "registry+https://github.com/rust-lang/crates.io-index"
···
788
853
version = "0.31.1"
789
854
source = "registry+https://github.com/rust-lang/crates.io-index"
790
855
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
856
+
857
+
[[package]]
858
+
name = "glob"
859
+
version = "0.3.3"
860
+
source = "registry+https://github.com/rust-lang/crates.io-index"
861
+
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
791
862
792
863
[[package]]
793
864
name = "globset"
···
941
1012
]
942
1013
943
1014
[[package]]
944
-
name = "hostname"
945
-
version = "0.4.1"
946
-
source = "registry+https://github.com/rust-lang/crates.io-index"
947
-
checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
948
-
dependencies = [
949
-
"cfg-if",
950
-
"libc",
951
-
"windows-link",
952
-
]
953
-
954
-
[[package]]
955
1015
name = "http"
956
1016
version = "1.3.1"
957
1017
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1220
1280
]
1221
1281
1222
1282
[[package]]
1283
+
name = "itertools"
1284
+
version = "0.12.1"
1285
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1286
+
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
1287
+
dependencies = [
1288
+
"either",
1289
+
]
1290
+
1291
+
[[package]]
1223
1292
name = "itoa"
1224
1293
version = "1.0.15"
1225
1294
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1277
1346
]
1278
1347
1279
1348
[[package]]
1349
+
name = "lazycell"
1350
+
version = "1.3.0"
1351
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1352
+
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
1353
+
1354
+
[[package]]
1280
1355
name = "lettre"
1281
1356
version = "0.11.18"
1282
1357
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1290
1365
"fastrand",
1291
1366
"futures-io",
1292
1367
"futures-util",
1293
-
"hostname",
1294
1368
"httpdate",
1295
1369
"idna",
1296
1370
"mime",
1297
-
"native-tls",
1298
-
"nom",
1371
+
"nom 8.0.0",
1299
1372
"percent-encoding",
1300
1373
"quoted_printable",
1374
+
"rustls",
1301
1375
"socket2",
1302
1376
"tokio",
1303
-
"tokio-native-tls",
1377
+
"tokio-rustls",
1304
1378
"url",
1379
+
"webpki-roots 1.0.2",
1305
1380
]
1306
1381
1307
1382
[[package]]
···
1309
1384
version = "0.2.175"
1310
1385
source = "registry+https://github.com/rust-lang/crates.io-index"
1311
1386
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
1387
+
1388
+
[[package]]
1389
+
name = "libloading"
1390
+
version = "0.8.8"
1391
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1392
+
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
1393
+
dependencies = [
1394
+
"cfg-if",
1395
+
"windows-targets 0.52.6",
1396
+
]
1312
1397
1313
1398
[[package]]
1314
1399
name = "libm"
···
1340
1425
1341
1426
[[package]]
1342
1427
name = "linux-raw-sys"
1343
-
version = "0.9.4"
1428
+
version = "0.4.15"
1344
1429
source = "registry+https://github.com/rust-lang/crates.io-index"
1345
-
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
1430
+
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1346
1431
1347
1432
[[package]]
1348
1433
name = "litemap"
···
1404
1489
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1405
1490
1406
1491
[[package]]
1492
+
name = "minimal-lexical"
1493
+
version = "0.2.1"
1494
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1495
+
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1496
+
1497
+
[[package]]
1407
1498
name = "miniz_oxide"
1408
1499
version = "0.8.9"
1409
1500
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1424
1515
]
1425
1516
1426
1517
[[package]]
1427
-
name = "native-tls"
1428
-
version = "0.2.14"
1518
+
name = "nom"
1519
+
version = "7.1.3"
1429
1520
source = "registry+https://github.com/rust-lang/crates.io-index"
1430
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
1521
+
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1431
1522
dependencies = [
1432
-
"libc",
1433
-
"log",
1434
-
"openssl",
1435
-
"openssl-probe",
1436
-
"openssl-sys",
1437
-
"schannel",
1438
-
"security-framework",
1439
-
"security-framework-sys",
1440
-
"tempfile",
1523
+
"memchr",
1524
+
"minimal-lexical",
1441
1525
]
1442
1526
1443
1527
[[package]]
···
1549
1633
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1550
1634
1551
1635
[[package]]
1552
-
name = "openssl"
1553
-
version = "0.10.73"
1554
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1555
-
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
1556
-
dependencies = [
1557
-
"bitflags",
1558
-
"cfg-if",
1559
-
"foreign-types",
1560
-
"libc",
1561
-
"once_cell",
1562
-
"openssl-macros",
1563
-
"openssl-sys",
1564
-
]
1565
-
1566
-
[[package]]
1567
-
name = "openssl-macros"
1568
-
version = "0.1.1"
1569
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1570
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1571
-
dependencies = [
1572
-
"proc-macro2",
1573
-
"quote",
1574
-
"syn",
1575
-
]
1576
-
1577
-
[[package]]
1578
-
name = "openssl-probe"
1579
-
version = "0.1.6"
1580
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1581
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1582
-
1583
-
[[package]]
1584
-
name = "openssl-sys"
1585
-
version = "0.9.109"
1586
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1587
-
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
1588
-
dependencies = [
1589
-
"cc",
1590
-
"libc",
1591
-
"pkg-config",
1592
-
"vcpkg",
1593
-
]
1594
-
1595
-
[[package]]
1596
1636
name = "overload"
1597
1637
version = "0.1.1"
1598
1638
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1650
1690
1651
1691
[[package]]
1652
1692
name = "pds_gatekeeper"
1653
-
version = "0.1.0"
1693
+
version = "0.1.2"
1654
1694
dependencies = [
1695
+
"anyhow",
1696
+
"aws-lc-rs",
1655
1697
"axum",
1656
1698
"axum-template",
1699
+
"chrono",
1657
1700
"dotenvy",
1658
1701
"handlebars",
1659
1702
"hex",
1660
1703
"hyper-util",
1661
1704
"jwt-compact",
1662
1705
"lettre",
1706
+
"rand 0.9.2",
1663
1707
"rust-embed",
1708
+
"rustls",
1664
1709
"scrypt",
1665
1710
"serde",
1666
1711
"serde_json",
1712
+
"sha2",
1667
1713
"sqlx",
1668
1714
"tokio",
1669
1715
"tower-http",
···
1815
1861
]
1816
1862
1817
1863
[[package]]
1864
+
name = "prettyplease"
1865
+
version = "0.2.35"
1866
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1867
+
checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
1868
+
dependencies = [
1869
+
"proc-macro2",
1870
+
"syn",
1871
+
]
1872
+
1873
+
[[package]]
1818
1874
name = "proc-macro2"
1819
1875
version = "1.0.97"
1820
1876
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1999
2055
"cfg-if",
2000
2056
"getrandom 0.2.16",
2001
2057
"libc",
2002
-
"untrusted",
2058
+
"untrusted 0.9.0",
2003
2059
"windows-sys 0.52.0",
2004
2060
]
2005
2061
···
2065
2121
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
2066
2122
2067
2123
[[package]]
2124
+
name = "rustc-hash"
2125
+
version = "1.1.0"
2126
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2127
+
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
2128
+
2129
+
[[package]]
2068
2130
name = "rustix"
2069
-
version = "1.0.8"
2131
+
version = "0.38.44"
2070
2132
source = "registry+https://github.com/rust-lang/crates.io-index"
2071
-
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
2133
+
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
2072
2134
dependencies = [
2073
2135
"bitflags",
2074
2136
"errno",
···
2083
2145
source = "registry+https://github.com/rust-lang/crates.io-index"
2084
2146
checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc"
2085
2147
dependencies = [
2148
+
"aws-lc-rs",
2149
+
"log",
2086
2150
"once_cell",
2087
2151
"ring",
2088
2152
"rustls-pki-types",
···
2106
2170
source = "registry+https://github.com/rust-lang/crates.io-index"
2107
2171
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
2108
2172
dependencies = [
2173
+
"aws-lc-rs",
2109
2174
"ring",
2110
2175
"rustls-pki-types",
2111
-
"untrusted",
2176
+
"untrusted 0.9.0",
2112
2177
]
2113
2178
2114
2179
[[package]]
···
2142
2207
]
2143
2208
2144
2209
[[package]]
2145
-
name = "schannel"
2146
-
version = "0.1.27"
2147
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2148
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
2149
-
dependencies = [
2150
-
"windows-sys 0.59.0",
2151
-
]
2152
-
2153
-
[[package]]
2154
2210
name = "scopeguard"
2155
2211
version = "1.2.0"
2156
2212
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2184
2240
checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb"
2185
2241
dependencies = [
2186
2242
"cc",
2187
-
]
2188
-
2189
-
[[package]]
2190
-
name = "security-framework"
2191
-
version = "2.11.1"
2192
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2193
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
2194
-
dependencies = [
2195
-
"bitflags",
2196
-
"core-foundation",
2197
-
"core-foundation-sys",
2198
-
"libc",
2199
-
"security-framework-sys",
2200
-
]
2201
-
2202
-
[[package]]
2203
-
name = "security-framework-sys"
2204
-
version = "2.14.0"
2205
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2206
-
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
2207
-
dependencies = [
2208
-
"core-foundation-sys",
2209
-
"libc",
2210
2243
]
2211
2244
2212
2245
[[package]]
···
2393
2426
dependencies = [
2394
2427
"base64",
2395
2428
"bytes",
2429
+
"chrono",
2396
2430
"crc",
2397
2431
"crossbeam-queue",
2398
2432
"either",
···
2470
2504
"bitflags",
2471
2505
"byteorder",
2472
2506
"bytes",
2507
+
"chrono",
2473
2508
"crc",
2474
2509
"digest",
2475
2510
"dotenvy",
···
2511
2546
"base64",
2512
2547
"bitflags",
2513
2548
"byteorder",
2549
+
"chrono",
2514
2550
"crc",
2515
2551
"dotenvy",
2516
2552
"etcetera",
···
2545
2581
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
2546
2582
dependencies = [
2547
2583
"atoi",
2584
+
"chrono",
2548
2585
"flume",
2549
2586
"futures-channel",
2550
2587
"futures-core",
···
2633
2670
]
2634
2671
2635
2672
[[package]]
2636
-
name = "tempfile"
2637
-
version = "3.21.0"
2638
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2639
-
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
2640
-
dependencies = [
2641
-
"fastrand",
2642
-
"getrandom 0.3.3",
2643
-
"once_cell",
2644
-
"rustix",
2645
-
"windows-sys 0.59.0",
2646
-
]
2647
-
2648
-
[[package]]
2649
2673
name = "thiserror"
2650
2674
version = "1.0.69"
2651
2675
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2750
2774
]
2751
2775
2752
2776
[[package]]
2753
-
name = "tokio-native-tls"
2754
-
version = "0.3.1"
2777
+
name = "tokio-rustls"
2778
+
version = "0.26.2"
2755
2779
source = "registry+https://github.com/rust-lang/crates.io-index"
2756
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
2780
+
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
2757
2781
dependencies = [
2758
-
"native-tls",
2782
+
"rustls",
2759
2783
"tokio",
2760
2784
]
2761
2785
···
2988
3012
2989
3013
[[package]]
2990
3014
name = "untrusted"
3015
+
version = "0.7.1"
3016
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3017
+
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
3018
+
3019
+
[[package]]
3020
+
name = "untrusted"
2991
3021
version = "0.9.0"
2992
3022
source = "registry+https://github.com/rust-lang/crates.io-index"
2993
3023
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
···
3161
3191
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
3162
3192
dependencies = [
3163
3193
"rustls-pki-types",
3194
+
]
3195
+
3196
+
[[package]]
3197
+
name = "which"
3198
+
version = "4.4.2"
3199
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3200
+
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
3201
+
dependencies = [
3202
+
"either",
3203
+
"home",
3204
+
"once_cell",
3205
+
"rustix",
3164
3206
]
3165
3207
3166
3208
[[package]]
+12
-4
Cargo.toml
+12
-4
Cargo.toml
···
1
1
[package]
2
2
name = "pds_gatekeeper"
3
-
version = "0.1.0"
3
+
version = "0.1.2"
4
4
edition = "2024"
5
+
license = "MIT"
5
6
6
7
[dependencies]
7
8
axum = { version = "0.8.4", features = ["macros", "json"] }
8
9
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] }
9
-
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate"] }
10
+
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] }
10
11
dotenvy = "0.15.7"
11
12
serde = { version = "1.0", features = ["derive"] }
12
13
serde_json = "1.0"
···
14
15
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
15
16
hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
16
17
tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
17
-
tower_governor = "0.8.0"
18
+
tower_governor = { version = "0.8.0", features = ["axum", "tracing"] }
18
19
hex = "0.4"
19
20
jwt-compact = { version = "0.8.0", features = ["es256k"] }
20
21
scrypt = "0.11"
21
-
lettre = { version = "0.11.18", features = ["tokio1", "pool", "tokio1-native-tls"] }
22
+
#Leaveing these two cause I think it is needed by the email crate for ssl
23
+
aws-lc-rs = "1.13.0"
24
+
rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
25
+
lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
22
26
handlebars = { version = "6.3.2", features = ["rust-embed"] }
23
27
rust-embed = "8.7.2"
24
28
axum-template = { version = "3.0.0", features = ["handlebars"] }
29
+
rand = "0.9.2"
30
+
anyhow = "1.0.99"
31
+
chrono = "0.4.41"
32
+
sha2 = "0.10"
+10
Dockerfile
+10
Dockerfile
···
1
+
FROM rust:1.89.0-bookworm AS builder
2
+
WORKDIR /app
3
+
COPY ../ /app
4
+
RUN cargo build --release
5
+
#
6
+
FROM rust:1.89-slim-bookworm AS api
7
+
RUN apt-get update
8
+
RUN apt-get install -y ca-certificates
9
+
COPY --from=builder /app/target/release/pds_gatekeeper /usr/local/bin/pds_gatekeeper
10
+
CMD ["pds_gatekeeper"]
+21
LICENSE.md
+21
LICENSE.md
···
1
+
MIT License
2
+
3
+
Copyright (c) 2025 Bailey Townsend
4
+
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy
6
+
of this software and associated documentation files (the "Software"), to deal
7
+
in the Software without restriction, including without limitation the rights
8
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+
copies of the Software, and to permit persons to whom the Software is
10
+
furnished to do so, subject to the following conditions:
11
+
12
+
The above copyright notice and this permission notice shall be included in all
13
+
copies or substantial portions of the Software.
14
+
15
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+
SOFTWARE.
+139
-17
README.md
+139
-17
README.md
···
12
12
13
13
## 2FA
14
14
15
-
- [x] Ability to turn on/off 2FA
16
-
- [x] getSession overwrite to set the `emailAuthFactor` flag if the user has 2FA turned on
17
-
- [x] send an email using the `PDS_EMAIL_SMTP_URL` with a handlebar email template like Bluesky's 2FA sign in email.
18
-
- [ ] generate a 2FA code
19
-
- [ ] createSession gatekeeping (It does stop logins, just eh, doesn't actually send a real code or check it yet)
20
-
- [ ] oauth endpoint gatekeeping
15
+
- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16
+
- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
21
17
22
18
## Captcha on Create Account
23
19
···
25
21
26
22
# Setup
27
23
28
-
Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
29
-
But I want to run it locally on my own PDS first to test run it a bit.
24
+
PDS Gatekeeper has 2 parts to its setup, docker compose file and a reverse proxy (Caddy in this case). I will be
25
+
assuming you setup the PDS following the directions
26
+
found [here](https://atproto.com/guides/self-hosting), but if yours is different, or you have questions, feel free to
27
+
let
28
+
me know, and we can figure it out.
30
29
31
-
Example Caddyfile (mostly so I don't lose it for now. Will have a better one in the future)
30
+
## Docker compose
31
+
32
+
The pds gatekeeper container can be found on docker hub under the name `fatfingers23/pds_gatekeeper`. The container does
33
+
need access to the `/pds` root folder to access the same db's as your PDS. The part you need to add would look a bit
34
+
like below. You can find a full example of what I use for my pds at [./examples/compose.yml](./examples/compose.yml).
35
+
This is usually found at `/pds/compose.yaml`on your PDS>
32
36
33
-
```caddyfile
34
-
http://localhost {
37
+
```yml
38
+
gatekeeper:
39
+
container_name: gatekeeper
40
+
image: fatfingers23/pds_gatekeeper:latest
41
+
network_mode: host
42
+
restart: unless-stopped
43
+
#This gives the container to the access to the PDS folder. Source is the location on your server of that directory
44
+
volumes:
45
+
- type: bind
46
+
source: /pds
47
+
target: /pds
48
+
depends_on:
49
+
- pds
50
+
```
35
51
52
+
For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml).
53
+
54
+
```yml
55
+
gatekeeper:
56
+
container_name: gatekeeper
57
+
image: 'fatfingers23/pds_gatekeeper:latest'
58
+
restart: unless-stopped
59
+
volumes:
60
+
- '/pds:/pds'
61
+
environment:
62
+
- 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
63
+
- 'PDS_BASE_URL=http://pds:3000'
64
+
- GATEKEEPER_HOST=0.0.0.0
65
+
depends_on:
66
+
- pds
67
+
healthcheck:
68
+
test:
69
+
- CMD
70
+
- timeout
71
+
- '1'
72
+
- bash
73
+
- '-c'
74
+
- 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
75
+
interval: 10s
76
+
timeout: 5s
77
+
retries: 3
78
+
start_period: 10s
79
+
labels:
80
+
- traefik.enable=true
81
+
- 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))'
82
+
- traefik.http.routers.pds-gatekeeper.entrypoints=https
83
+
- traefik.http.routers.pds-gatekeeper.tls=true
84
+
- traefik.http.routers.pds-gatekeeper.priority=100
85
+
- traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
86
+
- traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
87
+
- traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
88
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
89
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
90
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
91
+
- traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
92
+
- traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
93
+
- traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
94
+
```
95
+
96
+
## Caddy setup
97
+
98
+
For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
99
+
in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
100
+
This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
101
+
102
+
```caddyfile
36
103
@gatekeeper {
37
-
path /xrpc/com.atproto.server.getSession
38
-
path /xrpc/com.atproto.server.updateEmail
39
-
path /xrpc/com.atproto.server.createSession
104
+
path /xrpc/com.atproto.server.getSession
105
+
path /xrpc/com.atproto.server.updateEmail
106
+
path /xrpc/com.atproto.server.createSession
107
+
path /xrpc/com.atproto.server.createAccount
108
+
path /@atproto/oauth-provider/~api/sign-in
40
109
}
41
110
42
111
handle @gatekeeper {
43
-
reverse_proxy http://localhost:8080
112
+
reverse_proxy http://localhost:8080
44
113
}
45
114
46
-
reverse_proxy /* http://localhost:3000
115
+
reverse_proxy http://localhost:3000
116
+
```
117
+
118
+
If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
119
+
`localhost:8081` (or w/e port you want).
120
+
121
+
```caddyfile
122
+
http://*.localhost:8082, http://localhost:8082 {
123
+
@gatekeeper {
124
+
path /xrpc/com.atproto.server.getSession
125
+
path /xrpc/com.atproto.server.updateEmail
126
+
path /xrpc/com.atproto.server.createSession
127
+
path /xrpc/com.atproto.server.createAccount
128
+
path /@atproto/oauth-provider/~api/sign-in
129
+
}
130
+
131
+
handle @gatekeeper {
132
+
reverse_proxy http://localhost:8080 {
133
+
#Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper
134
+
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
135
+
}
136
+
}
137
+
138
+
reverse_proxy http://localhost:3000
47
139
}
48
140
49
-
```
141
+
```
142
+
143
+
# Environment variables and bonuses
144
+
145
+
Every environment variable can be set in the `pds.env` and shared between PDS and gatekeeper and the PDS, with the
146
+
exception of `PDS_ENV_LOCATION`. This can be set to load the pds.env, by default it checks `/pds/pds.env` and is
147
+
recommended to mount the `/pds` folder on the server to `/pds` in the pds gatekeeper container.
148
+
149
+
`PDS_DATA_DIRECTORY` - Root directory of the PDS. Same as the one found in `pds.env` this is how pds gatekeeper knows
150
+
knows the rest of the environment variables.
151
+
152
+
`GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY` - The folder for templates of the emails PDS gatekeeper sends. You can find them
153
+
in [./email_templates](./email_templates). You are free to edit them as you please and set this variable to a location
154
+
in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the
155
+
same.
156
+
157
+
`GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT` - Subject of the email sent to the user when they turn on 2FA. Defaults to
158
+
`Sign in to Bluesky`
159
+
160
+
`PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default
161
+
162
+
`GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1`
163
+
164
+
`GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080`
165
+
166
+
`GATEKEEPER_CREATE_ACCOUNT_PER_SECOND` - Sets how often it takes a count off the limiter. example if you hit the rate
167
+
limit of 5 and set to 60, then in 60 seconds you will be able to make one more. Or in 5 minutes be able to make 5 more.
168
+
169
+
`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
170
+
the 5 comes from. Example can set this to 10 to allow for 10 requests in a burst, and after 60 seconds it will drop one
171
+
off.
+30
examples/Caddyfile
+30
examples/Caddyfile
···
1
+
{
2
+
email youremail@myemail.com
3
+
on_demand_tls {
4
+
ask http://localhost:3000/tls-check
5
+
}
6
+
}
7
+
8
+
*.yourpds.com, yourpds.com {
9
+
tls {
10
+
on_demand
11
+
}
12
+
# You'll most likely just want from here to....
13
+
@gatekeeper {
14
+
path /xrpc/com.atproto.server.getSession
15
+
path /xrpc/com.atproto.server.updateEmail
16
+
path /xrpc/com.atproto.server.createSession
17
+
path /xrpc/com.atproto.server.createAccount
18
+
path /@atproto/oauth-provider/~api/sign-in
19
+
}
20
+
21
+
handle @gatekeeper {
22
+
#This is the address for PDS gatekeeper, default is 8080
23
+
reverse_proxy http://localhost:8080
24
+
}
25
+
26
+
reverse_proxy http://localhost:3000
27
+
#..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
28
+
}
29
+
30
+
+51
examples/compose.yml
+51
examples/compose.yml
···
1
+
version: '3.9'
2
+
services:
3
+
caddy:
4
+
container_name: caddy
5
+
image: caddy:2
6
+
network_mode: host
7
+
depends_on:
8
+
- pds
9
+
restart: unless-stopped
10
+
volumes:
11
+
- type: bind
12
+
source: /pds/caddy/data
13
+
target: /data
14
+
- type: bind
15
+
source: /pds/caddy/etc/caddy
16
+
target: /etc/caddy
17
+
pds:
18
+
container_name: pds
19
+
image: ghcr.io/bluesky-social/pds:0.4
20
+
network_mode: host
21
+
restart: unless-stopped
22
+
volumes:
23
+
- type: bind
24
+
source: /pds
25
+
target: /pds
26
+
env_file:
27
+
- /pds/pds.env
28
+
watchtower:
29
+
container_name: watchtower
30
+
image: containrrr/watchtower:latest
31
+
network_mode: host
32
+
volumes:
33
+
- type: bind
34
+
source: /var/run/docker.sock
35
+
target: /var/run/docker.sock
36
+
restart: unless-stopped
37
+
environment:
38
+
WATCHTOWER_CLEANUP: true
39
+
WATCHTOWER_SCHEDULE: "@midnight"
40
+
gatekeeper:
41
+
container_name: gatekeeper
42
+
image: fatfingers23/pds_gatekeeper:latest
43
+
network_mode: host
44
+
restart: unless-stopped
45
+
#This gives the container to the access to the PDS folder. Source is the location on your server of that directory
46
+
volumes:
47
+
- type: bind
48
+
source: /pds
49
+
target: /pds
50
+
depends_on:
51
+
- pds
+73
examples/coolify-compose.yml
+73
examples/coolify-compose.yml
···
1
+
services:
2
+
pds:
3
+
image: 'ghcr.io/bluesky-social/pds:0.4.182'
4
+
volumes:
5
+
- '/pds:/pds'
6
+
environment:
7
+
- SERVICE_URL_PDS_3000
8
+
- 'PDS_HOSTNAME=${SERVICE_FQDN_PDS_3000}'
9
+
- 'PDS_JWT_SECRET=${SERVICE_HEX_32_JWTSECRET}'
10
+
- 'PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}'
11
+
- 'PDS_ADMIN_EMAIL=${PDS_ADMIN_EMAIL}'
12
+
- 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_32_ROTATIONKEY}'
13
+
- 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
14
+
- 'PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATA_DIRECTORY:-/pds}/blocks'
15
+
- 'PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-104857600}'
16
+
- 'PDS_DID_PLC_URL=${PDS_DID_PLC_URL:-https://plc.directory}'
17
+
- 'PDS_EMAIL_FROM_ADDRESS=${PDS_EMAIL_FROM_ADDRESS}'
18
+
- 'PDS_EMAIL_SMTP_URL=${PDS_EMAIL_SMTP_URL}'
19
+
- 'PDS_BSKY_APP_VIEW_URL=${PDS_BSKY_APP_VIEW_URL:-https://api.bsky.app}'
20
+
- 'PDS_BSKY_APP_VIEW_DID=${PDS_BSKY_APP_VIEW_DID:-did:web:api.bsky.app}'
21
+
- 'PDS_REPORT_SERVICE_URL=${PDS_REPORT_SERVICE_URL:-https://mod.bsky.app/xrpc/com.atproto.moderation.createReport}'
22
+
- 'PDS_REPORT_SERVICE_DID=${PDS_REPORT_SERVICE_DID:-did:plc:ar7c4by46qjdydhdevvrndac}'
23
+
- 'PDS_CRAWLERS=${PDS_CRAWLERS:-https://bsky.network}'
24
+
- 'LOG_ENABLED=${LOG_ENABLED:-true}'
25
+
command: "sh -c '\n set -euo pipefail\n echo \"Installing required packages and pdsadmin...\"\n apk add --no-cache openssl curl bash jq coreutils gnupg util-linux-misc >/dev/null\n curl -o /usr/local/bin/pdsadmin.sh https://raw.githubusercontent.com/bluesky-social/pds/main/pdsadmin.sh\n chmod 700 /usr/local/bin/pdsadmin.sh\n ln -sf /usr/local/bin/pdsadmin.sh /usr/local/bin/pdsadmin\n echo \"Creating an empty pds.env file so pdsadmin works...\"\n touch ${PDS_DATA_DIRECTORY}/pds.env\n echo \"Launching PDS, enjoy!...\"\n exec node --enable-source-maps index.js\n'\n"
26
+
healthcheck:
27
+
test:
28
+
- CMD
29
+
- wget
30
+
- '--spider'
31
+
- 'http://127.0.0.1:3000/xrpc/_health'
32
+
interval: 5s
33
+
timeout: 10s
34
+
retries: 10
35
+
gatekeeper:
36
+
container_name: gatekeeper
37
+
image: 'fatfingers23/pds_gatekeeper:latest'
38
+
restart: unless-stopped
39
+
volumes:
40
+
- '/pds:/pds'
41
+
environment:
42
+
- 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
43
+
- 'PDS_BASE_URL=http://pds:3000'
44
+
- GATEKEEPER_HOST=0.0.0.0
45
+
depends_on:
46
+
- pds
47
+
healthcheck:
48
+
test:
49
+
- CMD
50
+
- timeout
51
+
- '1'
52
+
- bash
53
+
- '-c'
54
+
- 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
55
+
interval: 10s
56
+
timeout: 5s
57
+
retries: 3
58
+
start_period: 10s
59
+
labels:
60
+
- traefik.enable=true
61
+
- 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))'
62
+
- traefik.http.routers.pds-gatekeeper.entrypoints=https
63
+
- traefik.http.routers.pds-gatekeeper.tls=true
64
+
- traefik.http.routers.pds-gatekeeper.priority=100
65
+
- traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
66
+
- traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
67
+
- traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
68
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
69
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
70
+
- 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
71
+
- traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
72
+
- traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
73
+
- traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
+6
justfile
+6
justfile
-3
migrations_bells_and_whistles/.keep
-3
migrations_bells_and_whistles/.keep
+525
src/helpers.rs
+525
src/helpers.rs
···
1
+
use crate::AppState;
2
+
use crate::helpers::TokenCheckError::InvalidToken;
3
+
use anyhow::anyhow;
4
+
use axum::body::{Body, to_bytes};
5
+
use axum::extract::Request;
6
+
use axum::http::header::CONTENT_TYPE;
7
+
use axum::http::{HeaderMap, StatusCode, Uri};
8
+
use axum::response::{IntoResponse, Response};
9
+
use axum_template::TemplateEngine;
10
+
use chrono::Utc;
11
+
use lettre::message::{MultiPart, SinglePart, header};
12
+
use lettre::{AsyncTransport, Message};
13
+
use rand::Rng;
14
+
use serde::de::DeserializeOwned;
15
+
use serde_json::{Map, Value};
16
+
use sha2::{Digest, Sha256};
17
+
use sqlx::SqlitePool;
18
+
use std::env;
19
+
use tracing::{error, log};
20
+
21
+
///Used to generate the email 2fa code
22
+
const UPPERCASE_BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
23
+
24
+
/// The result of a proxied call that attempts to parse JSON.
25
+
pub enum ProxiedResult<T> {
26
+
/// Successfully parsed JSON body along with original response headers.
27
+
Parsed { value: T, _headers: HeaderMap },
28
+
/// Could not or should not parse: return the original (or rebuilt) response as-is.
29
+
Passthrough(Response<Body>),
30
+
}
31
+
32
+
/// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse
33
+
/// the successful response body as JSON into `T`.
34
+
///
35
+
pub async fn proxy_get_json<T>(
36
+
state: &AppState,
37
+
mut req: Request,
38
+
path: &str,
39
+
) -> Result<ProxiedResult<T>, StatusCode>
40
+
where
41
+
T: DeserializeOwned,
42
+
{
43
+
let uri = format!("{}{}", state.pds_base_url, path);
44
+
*req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
45
+
46
+
let result = state
47
+
.reverse_proxy_client
48
+
.request(req)
49
+
.await
50
+
.map_err(|_| StatusCode::BAD_REQUEST)?
51
+
.into_response();
52
+
53
+
if result.status() != StatusCode::OK {
54
+
return Ok(ProxiedResult::Passthrough(result));
55
+
}
56
+
57
+
let response_headers = result.headers().clone();
58
+
let body = result.into_body();
59
+
let body_bytes = to_bytes(body, usize::MAX)
60
+
.await
61
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
62
+
63
+
match serde_json::from_slice::<T>(&body_bytes) {
64
+
Ok(value) => Ok(ProxiedResult::Parsed {
65
+
value,
66
+
_headers: response_headers,
67
+
}),
68
+
Err(err) => {
69
+
error!(%err, "failed to parse proxied JSON response; returning original body");
70
+
let mut builder = Response::builder().status(StatusCode::OK);
71
+
if let Some(headers) = builder.headers_mut() {
72
+
*headers = response_headers;
73
+
}
74
+
let resp = builder
75
+
.body(Body::from(body_bytes))
76
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
77
+
Ok(ProxiedResult::Passthrough(resp))
78
+
}
79
+
}
80
+
}
81
+
82
+
/// Build a JSON error response with the required Content-Type header
83
+
/// Content-Type: application/json;charset=utf-8
84
+
/// Body shape: { "error": string, "message": string }
85
+
pub fn json_error_response(
86
+
status: StatusCode,
87
+
error: impl Into<String>,
88
+
message: impl Into<String>,
89
+
) -> Result<Response<Body>, StatusCode> {
90
+
let body_str = match serde_json::to_string(&serde_json::json!({
91
+
"error": error.into(),
92
+
"message": message.into(),
93
+
})) {
94
+
Ok(s) => s,
95
+
Err(_) => return Err(StatusCode::BAD_REQUEST),
96
+
};
97
+
98
+
Response::builder()
99
+
.status(status)
100
+
.header(CONTENT_TYPE, "application/json;charset=utf-8")
101
+
.body(Body::from(body_str))
102
+
.map_err(|_| StatusCode::BAD_REQUEST)
103
+
}
104
+
105
+
/// Build a JSON error response with the required Content-Type header
106
+
/// Content-Type: application/json (oauth endpoint does not like utf ending)
107
+
/// Body shape: { "error": string, "error_description": string }
108
+
pub fn oauth_json_error_response(
109
+
status: StatusCode,
110
+
error: impl Into<String>,
111
+
message: impl Into<String>,
112
+
) -> Result<Response<Body>, StatusCode> {
113
+
let body_str = match serde_json::to_string(&serde_json::json!({
114
+
"error": error.into(),
115
+
"error_description": message.into(),
116
+
})) {
117
+
Ok(s) => s,
118
+
Err(_) => return Err(StatusCode::BAD_REQUEST),
119
+
};
120
+
121
+
Response::builder()
122
+
.status(status)
123
+
.header(CONTENT_TYPE, "application/json")
124
+
.body(Body::from(body_str))
125
+
.map_err(|_| StatusCode::BAD_REQUEST)
126
+
}
127
+
128
+
/// Creates a random token of 10 characters for email 2FA
129
+
pub fn get_random_token() -> String {
130
+
let mut rng = rand::rng();
131
+
132
+
let mut full_code = String::with_capacity(10);
133
+
for _ in 0..10 {
134
+
let idx = rng.random_range(0..UPPERCASE_BASE32_CHARS.len());
135
+
full_code.push(UPPERCASE_BASE32_CHARS[idx] as char);
136
+
}
137
+
138
+
let slice_one = &full_code[0..5];
139
+
let slice_two = &full_code[5..10];
140
+
format!("{slice_one}-{slice_two}")
141
+
}
142
+
143
+
pub enum TokenCheckError {
144
+
InvalidToken,
145
+
ExpiredToken,
146
+
}
147
+
148
+
pub enum AuthResult {
149
+
WrongIdentityOrPassword,
150
+
/// The string here is the email address to create a hint for oauth
151
+
TwoFactorRequired(String),
152
+
/// User does not have 2FA enabled, or using an app password, or passes it
153
+
ProxyThrough,
154
+
TokenCheckFailed(TokenCheckError),
155
+
}
156
+
157
+
pub enum IdentifierType {
158
+
Email,
159
+
Did,
160
+
Handle,
161
+
}
162
+
163
+
impl IdentifierType {
164
+
fn what_is_it(identifier: String) -> Self {
165
+
if identifier.contains("@") {
166
+
IdentifierType::Email
167
+
} else if identifier.contains("did:") {
168
+
IdentifierType::Did
169
+
} else {
170
+
IdentifierType::Handle
171
+
}
172
+
}
173
+
}
174
+
175
+
/// Creates a hex string from the password and salt to find app passwords
176
+
fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> {
177
+
let params = scrypt::Params::new(14, 8, 1, 64)?;
178
+
let mut derived = [0u8; 64];
179
+
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut derived)?;
180
+
Ok(hex::encode(derived))
181
+
}
182
+
183
+
/// Hashes the app password. did is used as the salt.
184
+
pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> {
185
+
let mut hasher = Sha256::new();
186
+
hasher.update(did.as_bytes());
187
+
let sha = hasher.finalize();
188
+
let salt = hex::encode(&sha[..16]);
189
+
let hash_hex = scrypt_hex(password, &salt)?;
190
+
Ok(format!("{salt}:{hash_hex}"))
191
+
}
192
+
193
+
async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> {
194
+
// Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes)
195
+
let mut parts = password_scrypt.splitn(2, ':');
196
+
let salt = match parts.next() {
197
+
Some(s) if !s.is_empty() => s,
198
+
_ => return Ok(false),
199
+
};
200
+
let stored_hash_hex = match parts.next() {
201
+
Some(h) if !h.is_empty() => h,
202
+
_ => return Ok(false),
203
+
};
204
+
205
+
// Derive using the shared helper and compare
206
+
let derived_hex = match scrypt_hex(password, salt) {
207
+
Ok(h) => h,
208
+
Err(_) => return Ok(false),
209
+
};
210
+
211
+
Ok(derived_hex.as_str() == stored_hash_hex)
212
+
}
213
+
214
+
/// Handles the auth checks along with sending a 2fa email
215
+
pub async fn preauth_check(
216
+
state: &AppState,
217
+
identifier: &str,
218
+
password: &str,
219
+
two_factor_code: Option<String>,
220
+
oauth: bool,
221
+
) -> anyhow::Result<AuthResult> {
222
+
// Determine identifier type
223
+
let id_type = IdentifierType::what_is_it(identifier.to_string());
224
+
225
+
// Query account DB for did and passwordScrypt based on identifier type
226
+
let account_row: Option<(String, String, String, String)> = match id_type {
227
+
IdentifierType::Email => {
228
+
sqlx::query_as::<_, (String, String, String, String)>(
229
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
230
+
FROM actor
231
+
LEFT JOIN account ON actor.did = account.did
232
+
where account.email = ? LIMIT 1",
233
+
)
234
+
.bind(identifier)
235
+
.fetch_optional(&state.account_pool)
236
+
.await?
237
+
}
238
+
IdentifierType::Handle => {
239
+
sqlx::query_as::<_, (String, String, String, String)>(
240
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
241
+
FROM actor
242
+
LEFT JOIN account ON actor.did = account.did
243
+
where actor.handle = ? LIMIT 1",
244
+
)
245
+
.bind(identifier)
246
+
.fetch_optional(&state.account_pool)
247
+
.await?
248
+
}
249
+
IdentifierType::Did => {
250
+
sqlx::query_as::<_, (String, String, String, String)>(
251
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
252
+
FROM actor
253
+
LEFT JOIN account ON actor.did = account.did
254
+
where account.did = ? LIMIT 1",
255
+
)
256
+
.bind(identifier)
257
+
.fetch_optional(&state.account_pool)
258
+
.await?
259
+
}
260
+
};
261
+
262
+
if let Some((did, password_scrypt, email, handle)) = account_row {
263
+
// Verify password before proceeding to 2FA email step
264
+
let verified = verify_password(password, &password_scrypt).await?;
265
+
if !verified {
266
+
if oauth {
267
+
//OAuth does not allow app password logins so just go ahead and send it along it's way
268
+
return Ok(AuthResult::WrongIdentityOrPassword);
269
+
}
270
+
//Theres a chance it could be an app password so check that as well
271
+
return match verify_app_password(&state.account_pool, &did, password).await {
272
+
Ok(valid) => {
273
+
if valid {
274
+
//Was a valid app password up to the PDS now
275
+
Ok(AuthResult::ProxyThrough)
276
+
} else {
277
+
Ok(AuthResult::WrongIdentityOrPassword)
278
+
}
279
+
}
280
+
Err(err) => {
281
+
log::error!("Error checking the app password: {err}");
282
+
Err(err)
283
+
}
284
+
};
285
+
}
286
+
287
+
// Check two-factor requirement for this DID in the gatekeeper DB
288
+
let required_opt = sqlx::query_as::<_, (u8,)>(
289
+
"SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1",
290
+
)
291
+
.bind(did.clone())
292
+
.fetch_optional(&state.pds_gatekeeper_pool)
293
+
.await?;
294
+
295
+
let two_factor_required = match required_opt {
296
+
Some(row) => row.0 != 0,
297
+
None => false,
298
+
};
299
+
300
+
if two_factor_required {
301
+
//Two factor is required and a taken was provided
302
+
if let Some(two_factor_code) = two_factor_code {
303
+
//if the two_factor_code is set need to see if we have a valid token
304
+
if !two_factor_code.is_empty() {
305
+
return match assert_valid_token(
306
+
&state.account_pool,
307
+
did.clone(),
308
+
two_factor_code,
309
+
)
310
+
.await
311
+
{
312
+
Ok(_) => {
313
+
let result_of_cleanup =
314
+
delete_all_email_tokens(&state.account_pool, did.clone()).await;
315
+
if result_of_cleanup.is_err() {
316
+
log::error!(
317
+
"There was an error deleting the email tokens after login: {:?}",
318
+
result_of_cleanup.err()
319
+
)
320
+
}
321
+
Ok(AuthResult::ProxyThrough)
322
+
}
323
+
Err(err) => Ok(AuthResult::TokenCheckFailed(err)),
324
+
};
325
+
}
326
+
}
327
+
328
+
return match create_two_factor_token(&state.account_pool, did).await {
329
+
Ok(code) => {
330
+
let mut email_data = Map::new();
331
+
email_data.insert("token".to_string(), Value::from(code.clone()));
332
+
email_data.insert("handle".to_string(), Value::from(handle.clone()));
333
+
let email_body = state
334
+
.template_engine
335
+
.render("two_factor_code.hbs", email_data)?;
336
+
let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT")
337
+
.unwrap_or("Sign in to Bluesky".to_string());
338
+
339
+
let email_message = Message::builder()
340
+
//TODO prob get the proper type in the state
341
+
.from(state.mailer_from.parse()?)
342
+
.to(email.parse()?)
343
+
.subject(email_subject)
344
+
.multipart(
345
+
MultiPart::alternative() // This is composed of two parts.
346
+
.singlepart(
347
+
SinglePart::builder()
348
+
.header(header::ContentType::TEXT_PLAIN)
349
+
.body(format!("We received a sign-in request for the account @{handle}. Use the code: {code} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.")), // Every message should have a plain text fallback.
350
+
)
351
+
.singlepart(
352
+
SinglePart::builder()
353
+
.header(header::ContentType::TEXT_HTML)
354
+
.body(email_body),
355
+
),
356
+
)?;
357
+
match state.mailer.send(email_message).await {
358
+
Ok(_) => Ok(AuthResult::TwoFactorRequired(mask_email(email))),
359
+
Err(err) => {
360
+
log::error!("Error sending the 2FA email: {err}");
361
+
Err(anyhow!(err))
362
+
}
363
+
}
364
+
}
365
+
Err(err) => {
366
+
log::error!("error on creating a 2fa token: {err}");
367
+
Err(anyhow!(err))
368
+
}
369
+
};
370
+
}
371
+
}
372
+
373
+
// No local 2FA requirement (or account not found)
374
+
Ok(AuthResult::ProxyThrough)
375
+
}
376
+
377
+
pub async fn create_two_factor_token(
378
+
account_db: &SqlitePool,
379
+
did: String,
380
+
) -> anyhow::Result<String> {
381
+
let purpose = "2fa_code";
382
+
383
+
let token = get_random_token();
384
+
let right_now = Utc::now();
385
+
386
+
let res = sqlx::query(
387
+
"INSERT INTO email_token (purpose, did, token, requestedAt)
388
+
VALUES (?, ?, ?, ?)
389
+
ON CONFLICT(purpose, did) DO UPDATE SET
390
+
token=excluded.token,
391
+
requestedAt=excluded.requestedAt
392
+
WHERE did=excluded.did",
393
+
)
394
+
.bind(purpose)
395
+
.bind(&did)
396
+
.bind(&token)
397
+
.bind(right_now)
398
+
.execute(account_db)
399
+
.await;
400
+
401
+
match res {
402
+
Ok(_) => Ok(token),
403
+
Err(err) => {
404
+
log::error!("Error creating a two factor token: {err}");
405
+
Err(anyhow::anyhow!(err))
406
+
}
407
+
}
408
+
}
409
+
410
+
pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> {
411
+
sqlx::query("DELETE FROM email_token WHERE did = ?")
412
+
.bind(did)
413
+
.execute(account_db)
414
+
.await?;
415
+
Ok(())
416
+
}
417
+
418
+
pub async fn assert_valid_token(
419
+
account_db: &SqlitePool,
420
+
did: String,
421
+
token: String,
422
+
) -> Result<(), TokenCheckError> {
423
+
let token_upper = token.to_ascii_uppercase();
424
+
let purpose = "2fa_code";
425
+
426
+
let row: Option<(String,)> = sqlx::query_as(
427
+
"SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1",
428
+
)
429
+
.bind(purpose)
430
+
.bind(did)
431
+
.bind(token_upper)
432
+
.fetch_optional(account_db)
433
+
.await
434
+
.map_err(|err| {
435
+
log::error!("Error getting the 2fa token: {err}");
436
+
InvalidToken
437
+
})?;
438
+
439
+
match row {
440
+
None => Err(InvalidToken),
441
+
Some(row) => {
442
+
// Token lives for 15 minutes
443
+
let expiration_ms = 15 * 60_000;
444
+
445
+
let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) {
446
+
Ok(dt) => dt.with_timezone(&Utc),
447
+
Err(_) => {
448
+
return Err(TokenCheckError::InvalidToken);
449
+
}
450
+
};
451
+
452
+
let now = Utc::now();
453
+
let age_ms = (now - requested_at_utc).num_milliseconds();
454
+
let expired = age_ms > expiration_ms;
455
+
if expired {
456
+
return Err(TokenCheckError::ExpiredToken);
457
+
}
458
+
459
+
Ok(())
460
+
}
461
+
}
462
+
}
463
+
464
+
/// We just need to confirm if it's there or not. Will let the PDS do the actual figuring of permissions
465
+
pub async fn verify_app_password(
466
+
account_db: &SqlitePool,
467
+
did: &str,
468
+
password: &str,
469
+
) -> anyhow::Result<bool> {
470
+
let password_scrypt = hash_app_password(did, password)?;
471
+
472
+
let row: Option<(i64,)> = sqlx::query_as(
473
+
"SELECT Count(*) FROM app_password WHERE did = ? AND passwordScrypt = ? LIMIT 1",
474
+
)
475
+
.bind(did)
476
+
.bind(password_scrypt)
477
+
.fetch_optional(account_db)
478
+
.await?;
479
+
480
+
Ok(match row {
481
+
None => false,
482
+
Some((count,)) => count > 0,
483
+
})
484
+
}
485
+
486
+
/// Mask an email address into a hint like "2***0@p***m".
487
+
pub fn mask_email(email: String) -> String {
488
+
// Basic split on first '@'
489
+
let mut parts = email.splitn(2, '@');
490
+
let local = match parts.next() {
491
+
Some(l) => l,
492
+
None => return email.to_string(),
493
+
};
494
+
let domain_rest = match parts.next() {
495
+
Some(d) if !d.is_empty() => d,
496
+
_ => return email.to_string(),
497
+
};
498
+
499
+
// Helper to mask a single label (keep first and last, middle becomes ***).
500
+
fn mask_label(s: &str) -> String {
501
+
let chars: Vec<char> = s.chars().collect();
502
+
match chars.len() {
503
+
0 => String::new(),
504
+
1 => format!("{}***", chars[0]),
505
+
2 => format!("{}***{}", chars[0], chars[1]),
506
+
_ => format!("{}***{}", chars[0], chars[chars.len() - 1]),
507
+
}
508
+
}
509
+
510
+
// Mask local
511
+
let masked_local = mask_label(local);
512
+
513
+
// Mask first domain label only, keep the rest of the domain intact
514
+
let mut dom_parts = domain_rest.splitn(2, '.');
515
+
let first_label = dom_parts.next().unwrap_or("");
516
+
let rest = dom_parts.next();
517
+
let masked_first = mask_label(first_label);
518
+
let masked_domain = if let Some(rest) = rest {
519
+
format!("{}.{rest}", masked_first)
520
+
} else {
521
+
masked_first
522
+
};
523
+
524
+
format!("{masked_local}@{masked_domain}")
525
+
}
+108
-33
src/main.rs
+108
-33
src/main.rs
···
1
-
use crate::xrpc::com_atproto_server::{create_session, get_session, update_email};
2
-
use axum::middleware as ax_middleware;
3
-
mod middleware;
1
+
#![warn(clippy::unwrap_used)]
2
+
use crate::oauth_provider::sign_in;
3
+
use crate::xrpc::com_atproto_server::{create_account, create_session, get_session, update_email};
4
4
use axum::body::Body;
5
5
use axum::handler::Handler;
6
6
use axum::http::{Method, header};
7
+
use axum::middleware as ax_middleware;
7
8
use axum::routing::post;
8
9
use axum::{Router, routing::get};
9
10
use axum_template::engine::Engine;
···
19
20
use std::{env, net::SocketAddr};
20
21
use tower_governor::GovernorLayer;
21
22
use tower_governor::governor::GovernorConfigBuilder;
23
+
use tower_governor::key_extractor::SmartIpKeyExtractor;
22
24
use tower_http::compression::CompressionLayer;
23
25
use tower_http::cors::{Any, CorsLayer};
24
-
use tracing::{error, log};
26
+
use tracing::log;
25
27
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
26
28
29
+
pub mod helpers;
30
+
mod middleware;
31
+
mod oauth_provider;
27
32
mod xrpc;
28
33
29
34
type HyperUtilClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
···
34
39
struct EmailTemplates;
35
40
36
41
#[derive(Clone)]
37
-
struct AppState {
42
+
pub struct AppState {
38
43
account_pool: SqlitePool,
39
44
pds_gatekeeper_pool: SqlitePool,
40
45
reverse_proxy_client: HyperUtilClient,
···
73
78
74
79
let intro = "\n\nThis is a PDS gatekeeper\n\nCode: https://tangled.sh/@baileytownsend.dev/pds-gatekeeper\n";
75
80
76
-
let banner = format!(" {}\n{}", body, intro);
81
+
let banner = format!(" {body}\n{intro}");
77
82
78
83
(
79
84
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
···
84
89
#[tokio::main]
85
90
async fn main() -> Result<(), Box<dyn std::error::Error>> {
86
91
setup_tracing();
87
-
//TODO prod
88
-
dotenvy::from_path(Path::new("./pds.env"))?;
89
-
let pds_root = env::var("PDS_DATA_DIRECTORY")?;
90
-
// let pds_root = "/home/baileytownsend/Documents/code/docker_compose/pds/pds_data";
91
-
let account_db_url = format!("{}/account.sqlite", pds_root);
92
-
log::info!("accounts_db_url: {}", account_db_url);
92
+
let pds_env_location =
93
+
env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string());
94
+
95
+
let result_of_finding_pds_env = dotenvy::from_path(Path::new(&pds_env_location));
96
+
if let Err(e) = result_of_finding_pds_env {
97
+
log::error!(
98
+
"Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}"
99
+
);
100
+
}
101
+
102
+
let pds_root =
103
+
env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file");
104
+
let account_db_url = format!("{pds_root}/account.sqlite");
93
105
94
106
let account_options = SqliteConnectOptions::new()
95
-
.journal_mode(SqliteJournalMode::Wal)
96
-
.filename(account_db_url);
107
+
.filename(account_db_url)
108
+
.busy_timeout(Duration::from_secs(5));
97
109
98
110
let account_pool = SqlitePoolOptions::new()
99
111
.max_connections(5)
100
112
.connect_with(account_options)
101
113
.await?;
102
114
103
-
let bells_db_url = format!("{}/pds_gatekeeper.sqlite", pds_root);
115
+
let bells_db_url = format!("{pds_root}/pds_gatekeeper.sqlite");
104
116
let options = SqliteConnectOptions::new()
105
117
.journal_mode(SqliteJournalMode::Wal)
106
118
.filename(bells_db_url)
107
-
.create_if_missing(true);
119
+
.create_if_missing(true)
120
+
.busy_timeout(Duration::from_secs(5));
108
121
let pds_gatekeeper_pool = SqlitePoolOptions::new()
109
122
.max_connections(5)
110
123
.connect_with(options)
111
124
.await?;
112
125
113
-
// Run migrations for the bells_and_whistles database
126
+
// Run migrations for the extra database
114
127
// Note: the migrations are embedded at compile time from the given directory
115
128
// sqlx
116
129
sqlx::migrate!("./migrations")
···
126
139
env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
127
140
let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS")
128
141
.expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
142
+
129
143
let mailer: AsyncSmtpTransport<Tokio1Executor> =
130
144
AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
131
145
//Email templates setup
132
146
let mut hbs = Handlebars::new();
133
-
let _ = hbs.register_embed_templates::<EmailTemplates>();
147
+
148
+
let users_email_directory = env::var("GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY");
149
+
if let Ok(users_email_directory) = users_email_directory {
150
+
hbs.register_template_file(
151
+
"two_factor_code.hbs",
152
+
format!("{users_email_directory}/two_factor_code.hbs"),
153
+
)?;
154
+
} else {
155
+
let _ = hbs.register_embed_templates::<EmailTemplates>();
156
+
}
157
+
158
+
let pds_base_url =
159
+
env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
134
160
135
161
let state = AppState {
136
162
account_pool,
137
163
pds_gatekeeper_pool,
138
164
reverse_proxy_client: client,
139
-
//TODO should be env prob
140
-
pds_base_url: "http://localhost:3000".to_string(),
165
+
pds_base_url,
141
166
mailer,
142
167
mailer_from: sent_from,
143
168
template_engine: Engine::from(hbs),
···
145
170
146
171
// Rate limiting
147
172
//Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
148
-
let governor_conf = GovernorConfigBuilder::default()
173
+
let create_session_governor_conf = GovernorConfigBuilder::default()
174
+
.per_second(60)
175
+
.burst_size(5)
176
+
.key_extractor(SmartIpKeyExtractor)
177
+
.finish()
178
+
.expect("failed to create governor config for create session. this should not happen and is a bug");
179
+
180
+
// Create a second config with the same settings for the other endpoint
181
+
let sign_in_governor_conf = GovernorConfigBuilder::default()
149
182
.per_second(60)
150
183
.burst_size(5)
184
+
.key_extractor(SmartIpKeyExtractor)
151
185
.finish()
152
-
.unwrap();
153
-
let governor_limiter = governor_conf.limiter().clone();
186
+
.expect(
187
+
"failed to create governor config for sign in. this should not happen and is a bug",
188
+
);
189
+
190
+
let create_account_limiter_time: Option<String> =
191
+
env::var("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND").ok();
192
+
let create_account_limiter_burst: Option<String> =
193
+
env::var("GATEKEEPER_CREATE_ACCOUNT_BURST").ok();
194
+
195
+
//Default should be 608 requests per 5 minutes, PDS is 300 per 500 so will never hit it ideally
196
+
let mut create_account_governor_conf = GovernorConfigBuilder::default();
197
+
if create_account_limiter_time.is_some() {
198
+
let time = create_account_limiter_time
199
+
.expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND not set")
200
+
.parse::<u64>()
201
+
.expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND must be a valid integer");
202
+
create_account_governor_conf.per_second(time);
203
+
}
204
+
205
+
if create_account_limiter_burst.is_some() {
206
+
let burst = create_account_limiter_burst
207
+
.expect("GATEKEEPER_CREATE_ACCOUNT_BURST not set")
208
+
.parse::<u32>()
209
+
.expect("GATEKEEPER_CREATE_ACCOUNT_BURST must be a valid integer");
210
+
create_account_governor_conf.burst_size(burst);
211
+
}
212
+
213
+
let create_account_governor_conf = create_account_governor_conf
214
+
.key_extractor(SmartIpKeyExtractor)
215
+
.finish().expect(
216
+
"failed to create governor config for create account. this should not happen and is a bug",
217
+
);
218
+
219
+
let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
220
+
let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
221
+
let create_account_governor_limiter = create_account_governor_conf.limiter().clone();
222
+
154
223
let interval = Duration::from_secs(60);
155
224
// a separate background task to clean up
156
225
std::thread::spawn(move || {
157
226
loop {
158
227
std::thread::sleep(interval);
159
-
tracing::info!("rate limiting storage size: {}", governor_limiter.len());
160
-
governor_limiter.retain_recent();
228
+
create_session_governor_limiter.retain_recent();
229
+
sign_in_governor_limiter.retain_recent();
230
+
create_account_governor_limiter.retain_recent();
161
231
}
162
232
});
163
233
···
168
238
169
239
let app = Router::new()
170
240
.route("/", get(root_handler))
241
+
.route("/xrpc/com.atproto.server.getSession", get(get_session))
171
242
.route(
172
-
"/xrpc/com.atproto.server.getSession",
173
-
get(get_session).layer(ax_middleware::from_fn(middleware::extract_did)),
243
+
"/xrpc/com.atproto.server.updateEmail",
244
+
post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)),
174
245
)
175
246
.route(
176
-
"/xrpc/com.atproto.server.updateEmail",
177
-
post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)),
247
+
"/@atproto/oauth-provider/~api/sign-in",
248
+
post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
178
249
)
179
250
.route(
180
251
"/xrpc/com.atproto.server.createSession",
181
-
post(create_session.layer(GovernorLayer::new(governor_conf))),
252
+
post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
253
+
)
254
+
.route(
255
+
"/xrpc/com.atproto.server.createAccount",
256
+
post(create_account).layer(GovernorLayer::new(create_account_governor_conf)),
182
257
)
183
258
.layer(CompressionLayer::new())
184
259
.layer(cors)
185
260
.with_state(state);
186
261
187
-
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
188
-
let port: u16 = env::var("PORT")
262
+
let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
263
+
let port: u16 = env::var("GATEKEEPER_PORT")
189
264
.ok()
190
265
.and_then(|s| s.parse().ok())
191
266
.unwrap_or(8080);
···
202
277
.with_graceful_shutdown(shutdown_signal());
203
278
204
279
if let Err(err) = server.await {
205
-
error!(error = %err, "server error");
280
+
log::error!("server error:{err}");
206
281
}
207
282
208
283
Ok(())
+80
-61
src/middleware.rs
+80
-61
src/middleware.rs
···
1
-
use crate::xrpc::helpers::json_error_response;
1
+
use crate::helpers::json_error_response;
2
2
use axum::extract::Request;
3
3
use axum::http::{HeaderMap, StatusCode};
4
4
use axum::middleware::Next;
···
7
7
use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError};
8
8
use serde::{Deserialize, Serialize};
9
9
use std::env;
10
+
use tracing::log;
10
11
11
12
#[derive(Clone, Debug)]
12
13
pub struct Did(pub Option<String>);
14
+
15
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16
+
pub enum AuthScheme {
17
+
Bearer,
18
+
DPoP,
19
+
}
13
20
14
21
#[derive(Serialize, Deserialize)]
15
22
pub struct TokenClaims {
···
17
24
}
18
25
19
26
pub async fn extract_did(mut req: Request, next: Next) -> impl IntoResponse {
20
-
let token = extract_bearer(req.headers());
27
+
let auth = extract_auth(req.headers());
21
28
22
-
match token {
23
-
Ok(token) => {
24
-
match token {
25
-
None => {
26
-
return json_error_response(
27
-
StatusCode::BAD_REQUEST,
28
-
"TokenRequired",
29
-
"",
30
-
).unwrap();
31
-
}
32
-
Some(token) => {
33
-
let token = UntrustedToken::new(&token);
34
-
//Doing weird unwraps cause I can't do Result for middleware?
35
-
if token.is_err() {
36
-
return json_error_response(
37
-
StatusCode::BAD_REQUEST,
38
-
"TokenRequired",
39
-
"",
40
-
).unwrap();
41
-
}
42
-
let parsed_token = token.unwrap();
43
-
let claims: Result<Claims<TokenClaims>, ValidationError> =
44
-
parsed_token.deserialize_claims_unchecked();
45
-
if claims.is_err() {
46
-
return json_error_response(
47
-
StatusCode::BAD_REQUEST,
48
-
"TokenRequired",
49
-
"",
50
-
).unwrap();
51
-
}
29
+
match auth {
30
+
Ok(auth_opt) => {
31
+
match auth_opt {
32
+
None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
33
+
.expect("Error creating an error response"),
34
+
Some((scheme, token_str)) => {
35
+
// For Bearer, validate JWT and extract DID from `sub`.
36
+
// For DPoP, we currently only pass through and do not validate here; insert None DID.
37
+
match scheme {
38
+
AuthScheme::Bearer => {
39
+
let token = UntrustedToken::new(&token_str);
40
+
if token.is_err() {
41
+
return json_error_response(
42
+
StatusCode::BAD_REQUEST,
43
+
"TokenRequired",
44
+
"",
45
+
)
46
+
.expect("Error creating an error response");
47
+
}
48
+
let parsed_token = token.expect("Already checked for error");
49
+
let claims: Result<Claims<TokenClaims>, ValidationError> =
50
+
parsed_token.deserialize_claims_unchecked();
51
+
if claims.is_err() {
52
+
return json_error_response(
53
+
StatusCode::BAD_REQUEST,
54
+
"TokenRequired",
55
+
"",
56
+
)
57
+
.expect("Error creating an error response");
58
+
}
52
59
53
-
let key = Hs256Key::new(env::var("PDS_JWT_SECRET").unwrap());
54
-
let token: Result<Token<TokenClaims>, ValidationError> =
55
-
Hs256.validator(&key).validate(&parsed_token);
56
-
if token.is_err() {
57
-
return json_error_response(
58
-
StatusCode::BAD_REQUEST,
59
-
"InvalidToken",
60
-
"",
61
-
).unwrap();
60
+
let key = Hs256Key::new(
61
+
env::var("PDS_JWT_SECRET")
62
+
.expect("PDS_JWT_SECRET not set in the pds.env"),
63
+
);
64
+
let token: Result<Token<TokenClaims>, ValidationError> =
65
+
Hs256.validator(&key).validate(&parsed_token);
66
+
if token.is_err() {
67
+
return json_error_response(
68
+
StatusCode::BAD_REQUEST,
69
+
"InvalidToken",
70
+
"",
71
+
)
72
+
.expect("Error creating an error response");
73
+
}
74
+
let token = token.expect("Already checked for error,");
75
+
req.extensions_mut()
76
+
.insert(Did(Some(token.claims().custom.sub.clone())));
77
+
}
78
+
AuthScheme::DPoP => {
79
+
//Not going to worry about oauth email update for now, just always forward to the PDS
80
+
req.extensions_mut().insert(Did(None));
81
+
}
62
82
}
63
-
let token = token.unwrap();
64
-
//Not going to worry about expiration since it still goes to the PDS
65
83
66
-
req.extensions_mut()
67
-
.insert(Did(Some(token.claims().custom.sub.clone())));
68
84
next.run(req).await
69
85
}
70
86
}
71
87
}
72
-
Err(_) => {
73
-
return json_error_response(
74
-
StatusCode::BAD_REQUEST,
75
-
"InvalidToken",
76
-
"",
77
-
).unwrap();
88
+
Err(err) => {
89
+
log::error!("Error extracting token: {err}");
90
+
json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
91
+
.expect("Error creating an error response")
78
92
}
79
93
}
80
94
}
81
95
82
-
fn extract_bearer(headers: &HeaderMap) -> Result<Option<String>, String> {
96
+
fn extract_auth(headers: &HeaderMap) -> Result<Option<(AuthScheme, String)>, String> {
83
97
match headers.get(axum::http::header::AUTHORIZATION) {
84
98
None => Ok(None),
85
-
Some(hv) => match hv.to_str() {
86
-
Err(_) => Err("Authorization header is not valid".into()),
87
-
Ok(s) => {
88
-
// Accept forms like: "Bearer <token>" (case-sensitive for the scheme here)
89
-
let mut parts = s.splitn(2, ' ');
90
-
match (parts.next(), parts.next()) {
91
-
(Some("Bearer"), Some(tok)) if !tok.is_empty() => Ok(Some(tok.to_string())),
92
-
_ => Err("Authorization header must be in format 'Bearer <token>'".into()),
99
+
Some(hv) => {
100
+
match hv.to_str() {
101
+
Err(_) => Err("Authorization header is not valid".into()),
102
+
Ok(s) => {
103
+
// Accept forms like: "Bearer <token>" or "DPoP <token>" (case-sensitive for the scheme here)
104
+
let mut parts = s.splitn(2, ' ');
105
+
match (parts.next(), parts.next()) {
106
+
(Some("Bearer"), Some(tok)) if !tok.is_empty() =>
107
+
Ok(Some((AuthScheme::Bearer, tok.to_string()))),
108
+
(Some("DPoP"), Some(tok)) if !tok.is_empty() =>
109
+
Ok(Some((AuthScheme::DPoP, tok.to_string()))),
110
+
_ => Err("Authorization header must be in format 'Bearer <token>' or 'DPoP <token>'".into()),
111
+
}
93
112
}
94
113
}
95
-
},
114
+
}
96
115
}
97
116
}
+139
src/oauth_provider.rs
+139
src/oauth_provider.rs
···
1
+
use crate::AppState;
2
+
use crate::helpers::{AuthResult, oauth_json_error_response, preauth_check};
3
+
use axum::body::Body;
4
+
use axum::extract::State;
5
+
use axum::http::header::CONTENT_TYPE;
6
+
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
7
+
use axum::response::{IntoResponse, Response};
8
+
use axum::{Json, extract};
9
+
use serde::{Deserialize, Serialize};
10
+
use tracing::log;
11
+
12
+
#[derive(Serialize, Deserialize, Clone)]
13
+
pub struct SignInRequest {
14
+
pub username: String,
15
+
pub password: String,
16
+
#[serde(skip_serializing_if = "Option::is_none")]
17
+
pub remember: Option<bool>,
18
+
pub locale: String,
19
+
#[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")]
20
+
pub email_otp: Option<String>,
21
+
}
22
+
23
+
pub async fn sign_in(
24
+
State(state): State<AppState>,
25
+
headers: HeaderMap,
26
+
Json(mut payload): extract::Json<SignInRequest>,
27
+
) -> Result<Response<Body>, StatusCode> {
28
+
let identifier = payload.username.clone();
29
+
let password = payload.password.clone();
30
+
let auth_factor_token = payload.email_otp.clone();
31
+
32
+
match preauth_check(&state, &identifier, &password, auth_factor_token, true).await {
33
+
Ok(result) => match result {
34
+
AuthResult::WrongIdentityOrPassword => oauth_json_error_response(
35
+
StatusCode::BAD_REQUEST,
36
+
"invalid_request",
37
+
"Invalid identifier or password",
38
+
),
39
+
AuthResult::TwoFactorRequired(masked_email) => {
40
+
let body_str = match serde_json::to_string(&serde_json::json!({
41
+
"error": "second_authentication_factor_required",
42
+
"error_description": format!("emailOtp authentication factor required (hint: {})", masked_email),
43
+
"type": "emailOtp",
44
+
"hint": masked_email,
45
+
})) {
46
+
Ok(s) => s,
47
+
Err(_) => return Err(StatusCode::BAD_REQUEST),
48
+
};
49
+
50
+
Response::builder()
51
+
.status(StatusCode::BAD_REQUEST)
52
+
.header(CONTENT_TYPE, "application/json")
53
+
.body(Body::from(body_str))
54
+
.map_err(|_| StatusCode::BAD_REQUEST)
55
+
}
56
+
AuthResult::ProxyThrough => {
57
+
//No 2FA or already passed
58
+
let uri = format!(
59
+
"{}{}",
60
+
state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
61
+
);
62
+
63
+
let mut req = axum::http::Request::post(uri);
64
+
if let Some(req_headers) = req.headers_mut() {
65
+
// Copy headers but remove problematic ones. There was an issue with the PDS not parsing the body fully if i forwarded all headers
66
+
copy_filtered_headers(&headers, req_headers);
67
+
//Setting the content type to application/json manually
68
+
req_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
69
+
}
70
+
71
+
//Clears the email_otp because the pds will reject a request with it.
72
+
payload.email_otp = None;
73
+
let payload_bytes =
74
+
serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
75
+
76
+
let req = req
77
+
.body(Body::from(payload_bytes))
78
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
79
+
80
+
let proxied = state
81
+
.reverse_proxy_client
82
+
.request(req)
83
+
.await
84
+
.map_err(|_| StatusCode::BAD_REQUEST)?
85
+
.into_response();
86
+
87
+
Ok(proxied)
88
+
}
89
+
//Ignoring the type of token check failure. Looks like oauth on the entry treads them the same.
90
+
AuthResult::TokenCheckFailed(_) => oauth_json_error_response(
91
+
StatusCode::BAD_REQUEST,
92
+
"invalid_request",
93
+
"Unable to sign-in due to an unexpected server error",
94
+
),
95
+
},
96
+
Err(err) => {
97
+
log::error!(
98
+
"Error during pre-auth check. This happens on the oauth signin endpoint when trying to decide if the user has access:\n {err}"
99
+
);
100
+
oauth_json_error_response(
101
+
StatusCode::BAD_REQUEST,
102
+
"pds_gatekeeper_error",
103
+
"This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.",
104
+
)
105
+
}
106
+
}
107
+
}
108
+
109
+
fn is_disallowed_header(name: &HeaderName) -> bool {
110
+
// possible problematic headers with proxying
111
+
matches!(
112
+
name.as_str(),
113
+
"connection"
114
+
| "keep-alive"
115
+
| "proxy-authenticate"
116
+
| "proxy-authorization"
117
+
| "te"
118
+
| "trailer"
119
+
| "transfer-encoding"
120
+
| "upgrade"
121
+
| "host"
122
+
| "content-length"
123
+
| "content-encoding"
124
+
| "expect"
125
+
| "accept-encoding"
126
+
)
127
+
}
128
+
129
+
fn copy_filtered_headers(src: &HeaderMap, dst: &mut HeaderMap) {
130
+
for (name, value) in src.iter() {
131
+
if is_disallowed_header(name) {
132
+
continue;
133
+
}
134
+
// Only copy valid headers
135
+
if let Ok(hv) = HeaderValue::from_bytes(value.as_bytes()) {
136
+
dst.insert(name.clone(), hv);
137
+
}
138
+
}
139
+
}
+159
-258
src/xrpc/com_atproto_server.rs
+159
-258
src/xrpc/com_atproto_server.rs
···
1
1
use crate::AppState;
2
+
use crate::helpers::{
3
+
AuthResult, ProxiedResult, TokenCheckError, json_error_response, preauth_check, proxy_get_json,
4
+
};
2
5
use crate::middleware::Did;
3
-
use crate::xrpc::helpers::{ProxiedResult, json_error_response, proxy_get_json};
4
6
use axum::body::Body;
5
7
use axum::extract::State;
6
8
use axum::http::{HeaderMap, StatusCode};
7
9
use axum::response::{IntoResponse, Response};
8
10
use axum::{Extension, Json, debug_handler, extract, extract::Request};
9
-
use axum_template::TemplateEngine;
10
-
use lettre::message::{MultiPart, SinglePart, header};
11
-
use lettre::{AsyncTransport, Message};
12
11
use serde::{Deserialize, Serialize};
13
12
use serde_json;
14
-
use serde_json::Value;
15
-
use serde_json::value::Map;
16
13
use tracing::log;
17
14
18
15
#[derive(Serialize, Deserialize, Debug, Clone)]
···
58
55
pub struct CreateSessionRequest {
59
56
identifier: String,
60
57
password: String,
61
-
auth_factor_token: String,
62
-
allow_takendown: bool,
63
-
}
64
-
65
-
pub enum AuthResult {
66
-
WrongIdentityOrPassword,
67
-
TwoFactorRequired,
68
-
TwoFactorFailed,
69
-
/// User does not have 2FA enabled, or passes it
70
-
ProxyThrough,
71
-
}
72
-
73
-
pub enum IdentifierType {
74
-
Email,
75
-
DID,
76
-
Handle,
77
-
}
78
-
79
-
impl IdentifierType {
80
-
fn what_is_it(identifier: String) -> Self {
81
-
if identifier.contains("@") {
82
-
IdentifierType::Email
83
-
} else if identifier.contains("did:") {
84
-
IdentifierType::DID
85
-
} else {
86
-
IdentifierType::Handle
87
-
}
88
-
}
89
-
}
90
-
91
-
async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> {
92
-
// Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes)
93
-
let mut parts = password_scrypt.splitn(2, ':');
94
-
let salt = match parts.next() {
95
-
Some(s) if !s.is_empty() => s,
96
-
_ => return Ok(false),
97
-
};
98
-
let stored_hash_hex = match parts.next() {
99
-
Some(h) if !h.is_empty() => h,
100
-
_ => return Ok(false),
101
-
};
102
-
103
-
//Sets up scrypt to mimic node's scrypt
104
-
let params = match scrypt::Params::new(14, 8, 1, 64) {
105
-
Ok(p) => p,
106
-
Err(_) => return Ok(false),
107
-
};
108
-
let mut derived = [0u8; 64];
109
-
if scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut derived).is_err() {
110
-
return Ok(false);
111
-
}
112
-
113
-
let stored_bytes = match hex::decode(stored_hash_hex) {
114
-
Ok(b) => b,
115
-
Err(e) => {
116
-
log::error!("Error decoding stored hash: {}", e);
117
-
return Ok(false);
118
-
}
119
-
};
120
-
121
-
Ok(derived.as_slice() == stored_bytes.as_slice())
122
-
}
123
-
124
-
async fn preauth_check(
125
-
state: &AppState,
126
-
identifier: &str,
127
-
password: &str,
128
-
) -> Result<AuthResult, StatusCode> {
129
-
// Determine identifier type
130
-
let id_type = IdentifierType::what_is_it(identifier.to_string());
131
-
132
-
// Query account DB for did and passwordScrypt based on identifier type
133
-
let account_row: Option<(String, String, String)> = match id_type {
134
-
IdentifierType::Email => sqlx::query_as::<_, (String, String, String)>(
135
-
"SELECT did, passwordScrypt, account.email FROM account WHERE email = ? LIMIT 1",
136
-
)
137
-
.bind(identifier)
138
-
.fetch_optional(&state.account_pool)
139
-
.await
140
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
141
-
IdentifierType::Handle => sqlx::query_as::<_, (String, String, String)>(
142
-
"SELECT account.did, account.passwordScrypt, account.email
143
-
FROM actor
144
-
LEFT JOIN account ON actor.did = account.did
145
-
where actor.handle =? LIMIT 1",
146
-
)
147
-
.bind(identifier)
148
-
.fetch_optional(&state.account_pool)
149
-
.await
150
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
151
-
IdentifierType::DID => sqlx::query_as::<_, (String, String, String)>(
152
-
"SELECT did, passwordScrypt, account.email FROM account WHERE did = ? LIMIT 1",
153
-
)
154
-
.bind(identifier)
155
-
.fetch_optional(&state.account_pool)
156
-
.await
157
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
158
-
};
159
-
160
-
if let Some((did, password_scrypt, email)) = account_row {
161
-
// Check two-factor requirement for this DID in the gatekeeper DB
162
-
let required_opt = sqlx::query_as::<_, (u8,)>(
163
-
"SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1",
164
-
)
165
-
.bind(&did)
166
-
.fetch_optional(&state.pds_gatekeeper_pool)
167
-
.await
168
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
169
-
170
-
let two_factor_required = match required_opt {
171
-
Some(row) => row.0 != 0,
172
-
None => false,
173
-
};
174
-
175
-
if two_factor_required {
176
-
// Verify password before proceeding to 2FA email step
177
-
let verified = verify_password(password, &password_scrypt).await?;
178
-
if !verified {
179
-
return Ok(AuthResult::WrongIdentityOrPassword);
180
-
}
181
-
let mut email_data = Map::new();
182
-
//TODO these need real values
183
-
let token = "test".to_string();
184
-
let handle = "baileytownsend.dev".to_string();
185
-
email_data.insert("token".to_string(), Value::from(token.clone()));
186
-
email_data.insert("handle".to_string(), Value::from(handle.clone()));
187
-
//TODO bad unwrap
188
-
let email_body = state
189
-
.template_engine
190
-
.render("two_factor_code.hbs", email_data)
191
-
.unwrap();
192
-
193
-
let email = Message::builder()
194
-
//TODO prob get the proper type in the state
195
-
.from(state.mailer_from.parse().unwrap())
196
-
.to(email.parse().unwrap())
197
-
.subject("Sign in to Bluesky")
198
-
.multipart(
199
-
MultiPart::alternative() // This is composed of two parts.
200
-
.singlepart(
201
-
SinglePart::builder()
202
-
.header(header::ContentType::TEXT_PLAIN)
203
-
.body(format!("We received a sign-in request for the account @{}. Use the code: {} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.", handle, token)), // Every message should have a plain text fallback.
204
-
)
205
-
.singlepart(
206
-
SinglePart::builder()
207
-
.header(header::ContentType::TEXT_HTML)
208
-
.body(email_body),
209
-
),
210
-
)
211
-
//TODO bad
212
-
.unwrap();
213
-
return match state.mailer.send(email).await {
214
-
Ok(_) => Ok(AuthResult::TwoFactorRequired),
215
-
Err(err) => {
216
-
log::error!("Error sending the 2FA email: {}", err);
217
-
Err(StatusCode::BAD_REQUEST)
218
-
}
219
-
};
220
-
}
221
-
}
222
-
223
-
// No local 2FA requirement (or account not found)
224
-
Ok(AuthResult::ProxyThrough)
58
+
#[serde(skip_serializing_if = "Option::is_none")]
59
+
auth_factor_token: Option<String>,
60
+
#[serde(skip_serializing_if = "Option::is_none")]
61
+
allow_takendown: Option<bool>,
225
62
}
226
63
227
64
pub async fn create_session(
···
231
68
) -> Result<Response<Body>, StatusCode> {
232
69
let identifier = payload.identifier.clone();
233
70
let password = payload.password.clone();
71
+
let auth_factor_token = payload.auth_factor_token.clone();
234
72
235
73
// Run the shared pre-auth logic to validate and check 2FA requirement
236
-
match preauth_check(&state, &identifier, &password).await? {
237
-
AuthResult::WrongIdentityOrPassword => json_error_response(
238
-
StatusCode::UNAUTHORIZED,
239
-
"AuthenticationRequired",
240
-
"Invalid identifier or password",
241
-
),
242
-
AuthResult::TwoFactorRequired => {
243
-
// Email sending step can be handled here if needed in the future.
244
-
json_error_response(
74
+
match preauth_check(&state, &identifier, &password, auth_factor_token, false).await {
75
+
Ok(result) => match result {
76
+
AuthResult::WrongIdentityOrPassword => json_error_response(
245
77
StatusCode::UNAUTHORIZED,
246
-
"AuthFactorTokenRequired",
247
-
"A sign in code has been sent to your email address",
248
-
)
249
-
}
250
-
AuthResult::TwoFactorFailed => {
251
-
//Not sure what the errors are for this response is yet
252
-
json_error_response(StatusCode::UNAUTHORIZED, "PLACEHOLDER", "PLACEHOLDER")
253
-
}
254
-
AuthResult::ProxyThrough => {
255
-
//No 2FA or already passed
256
-
let uri = format!(
257
-
"{}{}",
258
-
state.pds_base_url, "/xrpc/com.atproto.server.createSession"
259
-
);
78
+
"AuthenticationRequired",
79
+
"Invalid identifier or password",
80
+
),
81
+
AuthResult::TwoFactorRequired(_) => {
82
+
// Email sending step can be handled here if needed in the future.
83
+
json_error_response(
84
+
StatusCode::UNAUTHORIZED,
85
+
"AuthFactorTokenRequired",
86
+
"A sign in code has been sent to your email address",
87
+
)
88
+
}
89
+
AuthResult::ProxyThrough => {
90
+
//No 2FA or already passed
91
+
let uri = format!(
92
+
"{}{}",
93
+
state.pds_base_url, "/xrpc/com.atproto.server.createSession"
94
+
);
260
95
261
-
let mut req = axum::http::Request::post(uri);
262
-
if let Some(req_headers) = req.headers_mut() {
263
-
req_headers.extend(headers.clone());
264
-
}
96
+
let mut req = axum::http::Request::post(uri);
97
+
if let Some(req_headers) = req.headers_mut() {
98
+
req_headers.extend(headers.clone());
99
+
}
265
100
266
-
let payload_bytes =
267
-
serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
268
-
let req = req
269
-
.body(Body::from(payload_bytes))
270
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
101
+
let payload_bytes =
102
+
serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
103
+
let req = req
104
+
.body(Body::from(payload_bytes))
105
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
271
106
272
-
let proxied = state
273
-
.reverse_proxy_client
274
-
.request(req)
275
-
.await
276
-
.map_err(|_| StatusCode::BAD_REQUEST)?
277
-
.into_response();
107
+
let proxied = state
108
+
.reverse_proxy_client
109
+
.request(req)
110
+
.await
111
+
.map_err(|_| StatusCode::BAD_REQUEST)?
112
+
.into_response();
278
113
279
-
Ok(proxied)
114
+
Ok(proxied)
115
+
}
116
+
AuthResult::TokenCheckFailed(err) => match err {
117
+
TokenCheckError::InvalidToken => {
118
+
json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "Token is invalid")
119
+
}
120
+
TokenCheckError::ExpiredToken => {
121
+
json_error_response(StatusCode::BAD_REQUEST, "ExpiredToken", "Token is expired")
122
+
}
123
+
},
124
+
},
125
+
Err(err) => {
126
+
log::error!(
127
+
"Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}"
128
+
);
129
+
json_error_response(
130
+
StatusCode::INTERNAL_SERVER_ERROR,
131
+
"InternalServerError",
132
+
"This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.",
133
+
)
280
134
}
281
135
}
282
136
}
···
290
144
) -> Result<Response<Body>, StatusCode> {
291
145
//If email auth is not set at all it is a update email address
292
146
let email_auth_not_set = payload.email_auth_factor.is_none();
293
-
//If email aurth is set it is to either turn on or off 2fa
147
+
//If email auth is set it is to either turn on or off 2fa
294
148
let email_auth_update = payload.email_auth_factor.unwrap_or(false);
295
149
296
-
// Email update asked for
297
-
if email_auth_update {
298
-
let email = payload.email.clone();
299
-
let email_confirmed = sqlx::query_as::<_, (String,)>(
300
-
"SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?",
301
-
)
302
-
.bind(&email)
303
-
.fetch_optional(&state.account_pool)
304
-
.await
305
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
306
-
307
-
//Since the email is already confirmed we can enable 2fa
308
-
return match email_confirmed {
309
-
None => Err(StatusCode::BAD_REQUEST),
310
-
Some(did_row) => {
311
-
let _ = sqlx::query(
312
-
"INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1",
313
-
)
314
-
.bind(&did_row.0)
315
-
.execute(&state.pds_gatekeeper_pool)
316
-
.await
317
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
318
-
319
-
Ok(StatusCode::OK.into_response())
320
-
}
321
-
};
322
-
}
150
+
//This means the middleware successfully extracted a did from the request, if not it just needs to be forward to the PDS
151
+
//This is also empty if it is an oauth request, which is not supported by gatekeeper turning on 2fa since the dpop stuff needs to be implemented
152
+
let did_is_not_empty = did.0.is_some();
323
153
324
-
// User wants auth turned off
325
-
if !email_auth_update && !email_auth_not_set {
326
-
//User wants auth turned off and has a token
327
-
if let Some(token) = &payload.token {
328
-
let token_found = sqlx::query_as::<_, (String,)>(
329
-
"SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'",
154
+
if did_is_not_empty {
155
+
// Email update asked for
156
+
if email_auth_update {
157
+
let email = payload.email.clone();
158
+
let email_confirmed = match sqlx::query_as::<_, (String,)>(
159
+
"SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?",
330
160
)
331
-
.bind(token)
332
-
.bind(&did.0)
161
+
.bind(&email)
333
162
.fetch_optional(&state.account_pool)
334
163
.await
335
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
164
+
{
165
+
Ok(row) => row,
166
+
Err(err) => {
167
+
log::error!("Error checking if email is confirmed: {err}");
168
+
return Err(StatusCode::BAD_REQUEST);
169
+
}
170
+
};
336
171
337
-
if token_found.is_some() {
338
-
let _ = sqlx::query(
339
-
"INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0",
172
+
//Since the email is already confirmed we can enable 2fa
173
+
return match email_confirmed {
174
+
None => Err(StatusCode::BAD_REQUEST),
175
+
Some(did_row) => {
176
+
let _ = sqlx::query(
177
+
"INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1",
178
+
)
179
+
.bind(&did_row.0)
180
+
.execute(&state.pds_gatekeeper_pool)
181
+
.await
182
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
183
+
184
+
Ok(StatusCode::OK.into_response())
185
+
}
186
+
};
187
+
}
188
+
189
+
// User wants auth turned off
190
+
if !email_auth_update && !email_auth_not_set {
191
+
//User wants auth turned off and has a token
192
+
if let Some(token) = &payload.token {
193
+
let token_found = match sqlx::query_as::<_, (String,)>(
194
+
"SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'",
340
195
)
341
-
.bind(&did.0)
342
-
.execute(&state.pds_gatekeeper_pool)
343
-
.await
344
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
196
+
.bind(token)
197
+
.bind(&did.0)
198
+
.fetch_optional(&state.account_pool)
199
+
.await{
200
+
Ok(token) => token,
201
+
Err(err) => {
202
+
log::error!("Error checking if token is valid: {err}");
203
+
return Err(StatusCode::BAD_REQUEST);
204
+
}
205
+
};
345
206
346
-
return Ok(StatusCode::OK.into_response());
347
-
} else {
348
-
return Err(StatusCode::BAD_REQUEST);
207
+
return if token_found.is_some() {
208
+
//TODO I think there may be a bug here and need to do some retry logic
209
+
// First try was erroring, seconds was allowing
210
+
match sqlx::query(
211
+
"INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0",
212
+
)
213
+
.bind(&did.0)
214
+
.execute(&state.pds_gatekeeper_pool)
215
+
.await {
216
+
Ok(_) => {}
217
+
Err(err) => {
218
+
log::error!("Error updating email auth: {err}");
219
+
return Err(StatusCode::BAD_REQUEST);
220
+
}
221
+
}
222
+
223
+
Ok(StatusCode::OK.into_response())
224
+
} else {
225
+
Err(StatusCode::BAD_REQUEST)
226
+
};
349
227
}
350
228
}
351
229
}
352
-
353
-
// Updating the acutal email address
230
+
// Updating the actual email address by sending it on to the PDS
354
231
let uri = format!(
355
232
"{}{}",
356
233
state.pds_base_url, "/xrpc/com.atproto.server.updateEmail"
···
405
282
ProxiedResult::Passthrough(resp) => Ok(resp),
406
283
}
407
284
}
285
+
286
+
pub async fn create_account(
287
+
State(state): State<AppState>,
288
+
mut req: Request,
289
+
) -> Result<Response<Body>, StatusCode> {
290
+
//TODO if I add the block of only accounts authenticated just take the body as json here and grab the lxm token. No middle ware is needed
291
+
292
+
let uri = format!(
293
+
"{}{}",
294
+
state.pds_base_url, "/xrpc/com.atproto.server.createAccount"
295
+
);
296
+
297
+
// Rewrite the URI to point at the upstream PDS; keep headers, method, and body intact
298
+
*req.uri_mut() = uri.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
299
+
300
+
let proxied = state
301
+
.reverse_proxy_client
302
+
.request(req)
303
+
.await
304
+
.map_err(|_| StatusCode::BAD_REQUEST)?
305
+
.into_response();
306
+
307
+
Ok(proxied)
308
+
}
-150
src/xrpc/helpers.rs
-150
src/xrpc/helpers.rs
···
1
-
use axum::body::{Body, to_bytes};
2
-
use axum::extract::Request;
3
-
use axum::http::{HeaderMap, Method, StatusCode, Uri};
4
-
use axum::http::header::CONTENT_TYPE;
5
-
use axum::response::{IntoResponse, Response};
6
-
use serde::de::DeserializeOwned;
7
-
use tracing::error;
8
-
9
-
use crate::AppState;
10
-
11
-
/// The result of a proxied call that attempts to parse JSON.
12
-
pub enum ProxiedResult<T> {
13
-
/// Successfully parsed JSON body along with original response headers.
14
-
Parsed { value: T, _headers: HeaderMap },
15
-
/// Could not or should not parse: return the original (or rebuilt) response as-is.
16
-
Passthrough(Response<Body>),
17
-
}
18
-
19
-
/// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse
20
-
/// the successful response body as JSON into `T`.
21
-
///
22
-
/// Behavior:
23
-
/// - If the proxied response is non-200, returns Passthrough with the original response.
24
-
/// - If the response is 200 but JSON parsing fails, returns Passthrough with the original body and headers.
25
-
/// - If parsing succeeds, returns Parsed { value, headers }.
26
-
pub async fn proxy_get_json<T>(
27
-
state: &AppState,
28
-
mut req: Request,
29
-
path: &str,
30
-
) -> Result<ProxiedResult<T>, StatusCode>
31
-
where
32
-
T: DeserializeOwned,
33
-
{
34
-
let uri = format!("{}{}", state.pds_base_url, path);
35
-
*req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
36
-
37
-
let result = state
38
-
.reverse_proxy_client
39
-
.request(req)
40
-
.await
41
-
.map_err(|_| StatusCode::BAD_REQUEST)?
42
-
.into_response();
43
-
44
-
if result.status() != StatusCode::OK {
45
-
return Ok(ProxiedResult::Passthrough(result));
46
-
}
47
-
48
-
let response_headers = result.headers().clone();
49
-
let body = result.into_body();
50
-
let body_bytes = to_bytes(body, usize::MAX)
51
-
.await
52
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
53
-
54
-
match serde_json::from_slice::<T>(&body_bytes) {
55
-
Ok(value) => Ok(ProxiedResult::Parsed {
56
-
value,
57
-
_headers: response_headers,
58
-
}),
59
-
Err(err) => {
60
-
error!(%err, "failed to parse proxied JSON response; returning original body");
61
-
let mut builder = Response::builder().status(StatusCode::OK);
62
-
if let Some(headers) = builder.headers_mut() {
63
-
*headers = response_headers;
64
-
}
65
-
let resp = builder
66
-
.body(Body::from(body_bytes))
67
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
68
-
Ok(ProxiedResult::Passthrough(resp))
69
-
}
70
-
}
71
-
}
72
-
73
-
/// Proxy the incoming request as a POST to the PDS base URL plus the provided path and attempt to parse
74
-
/// the successful response body as JSON into `T`.
75
-
///
76
-
/// Behavior mirrors `proxy_get_json`:
77
-
/// - If the proxied response is non-200, returns Passthrough with the original response.
78
-
/// - If the response is 200 but JSON parsing fails, returns Passthrough with the original body and headers.
79
-
/// - If parsing succeeds, returns Parsed { value, headers }.
80
-
pub async fn _proxy_post_json<T>(
81
-
state: &AppState,
82
-
mut req: Request,
83
-
path: &str,
84
-
) -> Result<ProxiedResult<T>, StatusCode>
85
-
where
86
-
T: DeserializeOwned,
87
-
{
88
-
let uri = format!("{}{}", state.pds_base_url, path);
89
-
*req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
90
-
*req.method_mut() = Method::POST;
91
-
92
-
let result = state
93
-
.reverse_proxy_client
94
-
.request(req)
95
-
.await
96
-
.map_err(|_| StatusCode::BAD_REQUEST)?
97
-
.into_response();
98
-
99
-
if result.status() != StatusCode::OK {
100
-
return Ok(ProxiedResult::Passthrough(result));
101
-
}
102
-
103
-
let response_headers = result.headers().clone();
104
-
let body = result.into_body();
105
-
let body_bytes = to_bytes(body, usize::MAX)
106
-
.await
107
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
108
-
109
-
match serde_json::from_slice::<T>(&body_bytes) {
110
-
Ok(value) => Ok(ProxiedResult::Parsed {
111
-
value,
112
-
_headers: response_headers,
113
-
}),
114
-
Err(err) => {
115
-
error!(%err, "failed to parse proxied JSON response (POST); returning original body");
116
-
let mut builder = Response::builder().status(StatusCode::OK);
117
-
if let Some(headers) = builder.headers_mut() {
118
-
*headers = response_headers;
119
-
}
120
-
let resp = builder
121
-
.body(Body::from(body_bytes))
122
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
123
-
Ok(ProxiedResult::Passthrough(resp))
124
-
}
125
-
}
126
-
}
127
-
128
-
129
-
/// Build a JSON error response with the required Content-Type header
130
-
/// Content-Type: application/json;charset=utf-8
131
-
/// Body shape: { "error": string, "message": string }
132
-
pub fn json_error_response(
133
-
status: StatusCode,
134
-
error: impl Into<String>,
135
-
message: impl Into<String>,
136
-
) -> Result<Response<Body>, StatusCode> {
137
-
let body_str = match serde_json::to_string(&serde_json::json!({
138
-
"error": error.into(),
139
-
"message": message.into(),
140
-
})) {
141
-
Ok(s) => s,
142
-
Err(_) => return Err(StatusCode::BAD_REQUEST),
143
-
};
144
-
145
-
Response::builder()
146
-
.status(status)
147
-
.header(CONTENT_TYPE, "application/json;charset=utf-8")
148
-
.body(Body::from(body_str))
149
-
.map_err(|_| StatusCode::BAD_REQUEST)
150
-
}