From b3ec7feb96fdf82a2ff74db4889d5ee1b89a3a0c Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 18 Jan 2026 01:15:13 +0200 Subject: [PATCH] sso signup & login --- .env.example | 40 + ...7cdf535cd70e09fde0a8a28249df0070eb2fc.json | 22 + ...236391290c2e63d37d2bb1cd89ea822950a82.json | 77 + ...e2e5feaa9c59c38ec9175568abdacda167107.json | 15 + ...445b65c8cc8c723baca221d85f5e4f2478b99.json | 22 + ...d4aec23c32a9378764b8d6d7eb2cc89c562b1.json | 22 + ...0a7ba5cfca75af5cc4e7280fea230cf46af7e.json | 16 + ...2d877df337e823d4c2c2b077f695bbfc9e9ac.json | 22 + ...f30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json | 28 + ...3eb74afae2d04f5ea0ec17a4d433983e6d71c.json | 38 + ...4abcf8bc8268a348d3be0da9840d1708d20b5.json | 14 + ...4f50c35c53025a55ccf5e9933dc3795d29313.json | 32 + ...8e116cfae848ecd76ea5d367585eb5117f2ad.json | 22 + ...06603c50eb2af92957994abaf1c89c0294c12.json | 16 + ...79a3c411917144a011f50849b737130b24dbe.json | 54 + ...d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json | 33 + ...70b3360a3ac71e649b293efb88d92c3254068.json | 22 + ...dac592efa7f1bedcb29824ce8792093872722.json | 40 + ...6c3e802ca3988426863424e2bc2f627d9b758.json | 15 + ...294cd58f64b4fb431b54b5deda13d64525e88.json | 28 + ...9580ff03b717586c4ca2d5343709e2dac86b6.json | 22 + ...2c901d1ecf392545a3a032438b2c1859d46cc.json | 22 + ...ff9cbba4c48dd8a7546882ab5647114ffab4a.json | 77 + ...f4ee3296806581b1765dfb91a84ffab347f15.json | 15 + ...61fa7898b6832e58c0f647f382b23b81d350e.json | 33 + ...12efef733d4afaa4ea09974daf8303805e5d2.json | 81 + ...92881ba343c73a9a6e513e205c801c5943ec0.json | 28 + ...bd148bcf541baab6a84c8649666a695f9c4d1.json | 22 + ...c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json | 14 + ...2649cf3a8f068105f44a8dca9625427affc06.json | 43 + ...baddd1dd953c828a5656db2ee39dea996f890.json | 33 + ...2e973875cd8e9176792d79a4bec91d703bbf2.json | 34 + ...785a6b9f7d37c12b31a6ff13f7c5214eef19e.json | 28 + ...162e1d95788b372370a7ad0d7540338bb73ee.json | 66 + ...6f729d27e7a342f7718ee4ac07f0ca94412ba.json | 22 + ...1057e86e93ef756a784ed12beb453b03c5da1.json | 33 + ...d3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json | 22 + ...6f6fba9fe338e4a6a10636b5389d426df1631.json | 22 + ...b745ce8915ab3715b3eff7efc2d84f27735d0.json | 28 + ...413743ba168d1e0d8b85566711e54d4048f81.json | 28 + ...e304fced4bf56b4f2287fe9aeb3fc97e1b191.json | 34 + ...a532f1838f2441079c11d471f2eddf24f5375.json | 40 + ...67dd022cc515720fb742cf4bf363895d71cd8.json | 84 ++ ...6bd4e368b06ce4af15fed16b8a0bfc5328c36.json | 84 ++ ...c561c9dc537a848bc94c4740d5a83bf8d2861.json | 14 + ...94246ead214ca794760d3fe77bb5cf4f27be5.json | 22 + ...2ac0f84c73de8c244ba4560f004ee3f4b7002.json | 28 + ...a7175db3429f39df4f25e212f38f33882fc65.json | 22 + ...ef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json | 66 + ...d5171e70fa25e3b8393e142cebcbff752f0f5.json | 34 + ...739067623f275de0740bd576ee288f4444496.json | 15 + ...b610f7b122b485ab0fd0d0646d68ae8e64fe6.json | 31 + ...7b633cec7287c1cc177f0e1d47ec6571564d5.json | 22 + ...3217446fa0ed25144755f0da6f4825478506c.json | 99 ++ ...4f8bb2ddef833eb03326103c2d0a17ee56154.json | 28 + ...29364a2df9631fe98817e7bfacd3f3f51f6cb.json | 31 + ...7d54190260deba2d2ade177dbdaa507ab275b.json | 12 + ...3b979f0f7c04f4401b5840f96ff0e47144075.json | 84 ++ ...b29f7abfe18ee6f559bd94ab16274b1cfdfee.json | 22 + ...ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json | 22 + ...99b49b87e45dcfc6edb7a3e73067ed61b312b.json | 34 + ...efaa032e1d004c775e2b6077c5050d7d62041.json | 31 + ...a3b40041b043bae57e95002d4bf5df46a4ab4.json | 14 + ...ffaccf974e52fb6ac368f716409c55f9d3ab0.json | 40 + ...0bc88f5535dedae244f7b6e4afa95769b8f1a.json | 32 + ...80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json | 22 + ...5a1a627809f444b0faff7e611d985f31b90e9.json | 22 + ...9b19e5f0513f47173b2738fc01a57082d7abb.json | 30 + ...a4d4c45ccb9bd15b025602c64967bd4c85fd3.json | 12 + ...288e144d83364129491de6156760616666d3d.json | 15 + ...bf8b34ca6a2246c20fc96f76f0e95530762a7.json | 22 + ...fac26435b0feec65cf1c56f851d1c4d6b1814.json | 14 + ...3a760a5036640291666daf6f51d32ab4f3d2d.json | 14 + ...03699bb3a90f39bc9c4c0f738a37827e8f382.json | 28 + ...9c734fc4a794cefa9f76286facda88bf22f18.json | 15 + ...89548178bac3cbe6ffa73c22cafab61d05ba4.json | 84 ++ Cargo.lock | 47 + crates/tranquil-db-traits/src/lib.rs | 16 +- crates/tranquil-db-traits/src/sso.rs | 176 +++ crates/tranquil-db-traits/src/user.rs | 38 +- crates/tranquil-db/src/postgres/mod.rs | 7 +- crates/tranquil-db/src/postgres/sso.rs | 337 +++++ crates/tranquil-db/src/postgres/user.rs | 239 ++- crates/tranquil-pds/Cargo.toml | 2 + crates/tranquil-pds/build.rs | 12 + crates/tranquil-pds/src/api/error.rs | 44 +- .../src/api/server/account_status.rs | 5 +- crates/tranquil-pds/src/api/server/email.rs | 255 +++- crates/tranquil-pds/src/api/server/mod.rs | 5 +- .../tranquil-pds/src/api/server/password.rs | 33 +- crates/tranquil-pds/src/comms/service.rs | 11 +- crates/tranquil-pds/src/lib.rs | 31 +- crates/tranquil-pds/src/main.rs | 10 +- .../src/oauth/endpoints/delegation.rs | 4 +- .../src/oauth/endpoints/metadata.rs | 2 +- crates/tranquil-pds/src/rate_limit.rs | 19 + crates/tranquil-pds/src/scheduled.rs | 34 +- crates/tranquil-pds/src/sso/config.rs | 211 +++ crates/tranquil-pds/src/sso/endpoints.rs | 1306 +++++++++++++++++ crates/tranquil-pds/src/sso/mod.rs | 6 + crates/tranquil-pds/src/sso/providers.rs | 1126 ++++++++++++++ crates/tranquil-pds/src/state.rs | 21 +- crates/tranquil-pds/src/sync/commit.rs | 19 +- crates/tranquil-pds/tests/apple_sso_unit.rs | 131 ++ crates/tranquil-pds/tests/sso.rs | 1159 +++++++++++++++ crates/tranquil-pds/tests/sync_repo.rs | 104 +- frontend/src/App.svelte | 6 + .../src/components/AccountTypeSwitcher.svelte | 24 +- frontend/src/components/SsoIcon.svelte | 52 + frontend/src/lib/api.ts | 25 +- frontend/src/lib/migration/blob-migration.ts | 37 +- frontend/src/lib/migration/flow.svelte.ts | 14 +- frontend/src/lib/oauth.ts | 14 +- frontend/src/lib/registration/flow.svelte.ts | 19 +- frontend/src/lib/registration/storage.ts | 49 +- frontend/src/lib/types/routes.ts | 3 + frontend/src/locales/en.json | 47 +- frontend/src/locales/fi.json | 37 +- frontend/src/locales/ja.json | 37 +- frontend/src/locales/ko.json | 37 +- frontend/src/locales/sv.json | 37 +- frontend/src/locales/zh.json | 37 +- frontend/src/routes/OAuthLogin.svelte | 318 +++- frontend/src/routes/OAuthSsoRegister.svelte | 614 ++++++++ frontend/src/routes/Register.svelte | 16 +- frontend/src/routes/RegisterPasskey.svelte | 16 +- frontend/src/routes/RegisterSso.svelte | 293 ++++ frontend/src/routes/Security.svelte | 327 +++++ frontend/src/routes/Settings.svelte | 105 +- frontend/src/routes/Verify.svelte | 45 +- frontend/src/tests/AppPasswords.test.ts | 7 +- frontend/src/tests/Comms.test.ts | 16 +- frontend/src/tests/Dashboard.test.ts | 5 +- frontend/src/tests/Settings.test.ts | 89 +- frontend/src/tests/mocks.ts | 6 +- frontend/src/tests/oauth.test.ts | 23 +- .../20260115_sso_external_identities.sql | 44 + migrations/20260116_sso_auth_state_did.sql | 1 + migrations/20260117_handle_reservations.sql | 8 + ...60118_external_identity_email_verified.sql | 3 + .../20260119_add_apple_sso_provider.sql | 1 + 141 files changed, 9982 insertions(+), 314 deletions(-) create mode 100644 .sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json create mode 100644 .sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json create mode 100644 .sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json create mode 100644 .sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json create mode 100644 .sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json create mode 100644 .sqlx/query-1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e.json create mode 100644 .sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json create mode 100644 .sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json create mode 100644 .sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json create mode 100644 .sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json create mode 100644 .sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json create mode 100644 .sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json create mode 100644 .sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json create mode 100644 .sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json create mode 100644 .sqlx/query-44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json create mode 100644 .sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json create mode 100644 .sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json create mode 100644 .sqlx/query-47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758.json create mode 100644 .sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json create mode 100644 .sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json create mode 100644 .sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json create mode 100644 .sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json create mode 100644 .sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json create mode 100644 .sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json create mode 100644 .sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json create mode 100644 .sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json create mode 100644 .sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json create mode 100644 .sqlx/query-5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json create mode 100644 .sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json create mode 100644 .sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json create mode 100644 .sqlx/query-630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2.json create mode 100644 .sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json create mode 100644 .sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json create mode 100644 .sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json create mode 100644 .sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json create mode 100644 .sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json create mode 100644 .sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json create mode 100644 .sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json create mode 100644 .sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json create mode 100644 .sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json create mode 100644 .sqlx/query-8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375.json create mode 100644 .sqlx/query-8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8.json create mode 100644 .sqlx/query-9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36.json create mode 100644 .sqlx/query-946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861.json create mode 100644 .sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json create mode 100644 .sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json create mode 100644 .sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json create mode 100644 .sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json create mode 100644 .sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json create mode 100644 .sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json create mode 100644 .sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json create mode 100644 .sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json create mode 100644 .sqlx/query-a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c.json create mode 100644 .sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json create mode 100644 .sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json create mode 100644 .sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json create mode 100644 .sqlx/query-bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075.json create mode 100644 .sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json create mode 100644 .sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json create mode 100644 .sqlx/query-cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b.json create mode 100644 .sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json create mode 100644 .sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json create mode 100644 .sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json create mode 100644 .sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json create mode 100644 .sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json create mode 100644 .sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json create mode 100644 .sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json create mode 100644 .sqlx/query-eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3.json create mode 100644 .sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json create mode 100644 .sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json create mode 100644 .sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json create mode 100644 .sqlx/query-f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d.json create mode 100644 .sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json create mode 100644 .sqlx/query-ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18.json create mode 100644 .sqlx/query-ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4.json create mode 100644 crates/tranquil-db-traits/src/sso.rs create mode 100644 crates/tranquil-db/src/postgres/sso.rs create mode 100644 crates/tranquil-pds/build.rs create mode 100644 crates/tranquil-pds/src/sso/config.rs create mode 100644 crates/tranquil-pds/src/sso/endpoints.rs create mode 100644 crates/tranquil-pds/src/sso/mod.rs create mode 100644 crates/tranquil-pds/src/sso/providers.rs create mode 100644 crates/tranquil-pds/tests/apple_sso_unit.rs create mode 100644 crates/tranquil-pds/tests/sso.rs create mode 100644 frontend/src/components/SsoIcon.svelte create mode 100644 frontend/src/routes/OAuthSsoRegister.svelte create mode 100644 frontend/src/routes/RegisterSso.svelte create mode 100644 migrations/20260115_sso_external_identities.sql create mode 100644 migrations/20260116_sso_auth_state_did.sql create mode 100644 migrations/20260117_handle_reservations.sql create mode 100644 migrations/20260118_external_identity_email_verified.sql create mode 100644 migrations/20260119_add_apple_sso_provider.sql diff --git a/.env.example b/.env.example index 4c6d869..e63edaf 100644 --- a/.env.example +++ b/.env.example @@ -160,6 +160,46 @@ AWS_SECRET_ACCESS_KEY=minioadmin # ALLOW_HTTP_PROXY=1 # Custom frontend directory (defaults to ./frontend/dist) # FRONTEND_DIR=/path/to/frontend/dist +# ============================================================================= +# SSO / Social Login +# ============================================================================= +# Each provider requires ENABLED=true plus CLIENT_ID and CLIENT_SECRET. +# Register your PDS as an OAuth application with each provider to get credentials. + +# GitHub +# SSO_GITHUB_ENABLED=true +# SSO_GITHUB_CLIENT_ID= +# SSO_GITHUB_CLIENT_SECRET= + +# Discord +# SSO_DISCORD_ENABLED=true +# SSO_DISCORD_CLIENT_ID= +# SSO_DISCORD_CLIENT_SECRET= + +# Google +# SSO_GOOGLE_ENABLED=true +# SSO_GOOGLE_CLIENT_ID= +# SSO_GOOGLE_CLIENT_SECRET= + +# GitLab (set ISSUER for self-hosted instances) +# SSO_GITLAB_ENABLED=false +# SSO_GITLAB_CLIENT_ID= +# SSO_GITLAB_CLIENT_SECRET= +# SSO_GITLAB_ISSUER=https://gitlab.com + +# Generic OIDC +# SSO_OIDC_ENABLED=false +# SSO_OIDC_CLIENT_ID= +# SSO_OIDC_CLIENT_SECRET= +# SSO_OIDC_ISSUER=https://your-identity-provider.com +# SSO_OIDC_NAME=Custom Provider + +# Apple Sign-in +# SSO_APPLE_ENABLED=true +# SSO_APPLE_CLIENT_ID=com.example.signin # Services ID from Apple Developer Portal +# SSO_APPLE_TEAM_ID=XXXXXXXXXX # 10-character Team ID +# SSO_APPLE_KEY_ID=XXXXXXXXXX # Key ID from portal +# SSO_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" CARGO_MOMMYS_LITTLE=mister CARGO_MOMMYS_PRONOUNS=his CARGO_MOMMYS_ROLES=daddy diff --git a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json new file mode 100644 index 0000000..15ba9ce --- /dev/null +++ b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" +} diff --git a/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json b/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json new file mode 100644 index 0000000..6e51788 --- /dev/null +++ b/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" +} diff --git a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json new file mode 100644 index 0000000..734848a --- /dev/null +++ b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" +} diff --git a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json new file mode 100644 index 0000000..9e5e897 --- /dev/null +++ b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" +} diff --git a/.sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json b/.sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json new file mode 100644 index 0000000..7da18e9 --- /dev/null +++ b/.sqlx/query-0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM sso_pending_registration WHERE token = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0fae1be7a75bdc58c69a9af97cad4aec23c32a9378764b8d6d7eb2cc89c562b1" +} diff --git a/.sqlx/query-1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e.json b/.sqlx/query-1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e.json new file mode 100644 index 0000000..c24732e --- /dev/null +++ b/.sqlx/query-1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE external_identities\n SET provider_username = COALESCE($2, provider_username),\n provider_email = COALESCE($3, provider_email),\n last_login_at = NOW(),\n updated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1abfd9ff7ae1de0ca048b6a67a60a7ba5cfca75af5cc4e7280fea230cf46af7e" +} diff --git a/.sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json b/.sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json new file mode 100644 index 0000000..d2228ff --- /dev/null +++ b/.sqlx/query-1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1c84643fd6bc57c76517849a64d2d877df337e823d4c2c2b077f695bbfc9e9ac" +} diff --git a/.sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json b/.sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json new file mode 100644 index 0000000..06e8ff2 --- /dev/null +++ b/.sqlx/query-24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "24b823043ab60f36c29029137fef30dfe33922bb06067f2fdbfc1fbb4b0a2a81" +} diff --git a/.sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json b/.sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json new file mode 100644 index 0000000..ffaa87b --- /dev/null +++ b/.sqlx/query-2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2841093a67480e75e1e9e4046bf3eb74afae2d04f5ea0ec17a4d433983e6d71c" +} diff --git a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json new file mode 100644 index 0000000..8d0d055 --- /dev/null +++ b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" +} diff --git a/.sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json b/.sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json new file mode 100644 index 0000000..621ea6b --- /dev/null +++ b/.sqlx/query-376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "376b72306b50f747bc9161985ff4f50c35c53025a55ccf5e9933dc3795d29313" +} diff --git a/.sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json b/.sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json new file mode 100644 index 0000000..8192722 --- /dev/null +++ b/.sqlx/query-3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM external_identities WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3933ea5b147ab6294936de147b98e116cfae848ecd76ea5d367585eb5117f2ad" +} diff --git a/.sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json b/.sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json new file mode 100644 index 0000000..097e2cc --- /dev/null +++ b/.sqlx/query-3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3bed8d4843545f4a9676207513806603c50eb2af92957994abaf1c89c0294c12" +} diff --git a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json new file mode 100644 index 0000000..7004d69 --- /dev/null +++ b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "subject", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "comms_type: String", + "type_info": { + "Custom": { + "name": "comms_type", + "kind": { + "Enum": [ + "welcome", + "email_verification", + "password_reset", + "email_update", + "account_deletion", + "admin_email", + "plc_operation", + "two_factor_code", + "channel_verification", + "passkey_recovery", + "legacy_login_alert", + "migration_verification" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" +} diff --git a/.sqlx/query-44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json b/.sqlx/query-44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json new file mode 100644 index 0000000..8f03856 --- /dev/null +++ b/.sqlx/query-44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "44a1f3f4c515e904e9d5c616a48d7d6a59bcb5e2f415122c1bb1e5f54cacc12f" +} diff --git a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json new file mode 100644 index 0000000..b81fee7 --- /dev/null +++ b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" +} diff --git a/.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json b/.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json new file mode 100644 index 0000000..b6e8c16 --- /dev/null +++ b/.sqlx/query-45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT preferred_comms_channel as \"preferred_comms_channel: String\", discord_id FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "preferred_comms_channel: String", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "discord_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "45fac6171726d2c1990a3bb37a6dac592efa7f1bedcb29824ce8792093872722" +} diff --git a/.sqlx/query-47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758.json b/.sqlx/query-47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758.json new file mode 100644 index 0000000..85efd93 --- /dev/null +++ b/.sqlx/query-47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO handle_reservations (handle, reserved_by)\n SELECT $1, $2\n WHERE NOT EXISTS (\n SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL\n )\n AND NOT EXISTS (\n SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "47faf3cd805673aab801d23dee46c3e802ca3988426863424e2bc2f627d9b758" +} diff --git a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json new file mode 100644 index 0000000..e232907 --- /dev/null +++ b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" +} diff --git a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json new file mode 100644 index 0000000..873f0f7 --- /dev/null +++ b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" +} diff --git a/.sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json b/.sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json new file mode 100644 index 0000000..3780e70 --- /dev/null +++ b/.sqlx/query-4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4fef326fa2d03d04869af3fec702c901d1ecf392545a3a032438b2c1859d46cc" +} diff --git a/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json b/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json new file mode 100644 index 0000000..fa4098d --- /dev/null +++ b/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" +} diff --git a/.sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json b/.sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json new file mode 100644 index 0000000..e11b68f --- /dev/null +++ b/.sqlx/query-575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at)\n VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour')\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "575c1e5529874f8f523e6fe22ccf4ee3296806581b1765dfb91a84ffab347f15" +} diff --git a/.sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json b/.sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json new file mode 100644 index 0000000..dfa5ed9 --- /dev/null +++ b/.sqlx/query-596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "596c3400a60c77c7645fd46fcea61fa7898b6832e58c0f647f382b23b81d350e" +} diff --git a/.sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json b/.sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json new file mode 100644 index 0000000..6b1501f --- /dev/null +++ b/.sqlx/query-59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2.json @@ -0,0 +1,81 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id, provider_username, provider_email\n FROM external_identities\n WHERE provider = $1 AND provider_user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "59e63c5cf92985714e9586d1ce012efef733d4afaa4ea09974daf8303805e5d2" +} diff --git a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json new file mode 100644 index 0000000..32bd1aa --- /dev/null +++ b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" +} diff --git a/.sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json b/.sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json new file mode 100644 index 0000000..0f764da --- /dev/null +++ b/.sqlx/query-5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5af4a386c1632903ad7102551a5bd148bcf541baab6a84c8649666a695f9c4d1" +} diff --git a/.sqlx/query-5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json b/.sqlx/query-5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json new file mode 100644 index 0000000..b480fee --- /dev/null +++ b/.sqlx/query-5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_auth_state\n WHERE expires_at < $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "5dc0d09cea2415c4053518b7cb5c41da4a8cae66c8c9cd151eee4ea29c0e1c45" +} diff --git a/.sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json b/.sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json new file mode 100644 index 0000000..525f4ef --- /dev/null +++ b/.sqlx/query-5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT provider_user_id, provider_email_verified\n FROM external_identities\n WHERE did = $1 AND provider = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "provider_email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "5e4c0dd92ac3c4b5e2eae5d129f2649cf3a8f068105f44a8dca9625427affc06" +} diff --git a/.sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json b/.sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json new file mode 100644 index 0000000..f109216 --- /dev/null +++ b/.sqlx/query-5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5e9c6ec72c2c0ea1c8dff551d01baddd1dd953c828a5656db2ee39dea996f890" +} diff --git a/.sqlx/query-630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2.json b/.sqlx/query-630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2.json new file mode 100644 index 0000000..1bcb339 --- /dev/null +++ b/.sqlx/query-630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "630c1fabbf37946cbf2f3f77faa2e973875cd8e9176792d79a4bec91d703bbf2" +} diff --git a/.sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json b/.sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json new file mode 100644 index 0000000..cb057f9 --- /dev/null +++ b/.sqlx/query-63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did, email_verified FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email_verified", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "63f6f2a89650794fe90e10ce7fc785a6b9f7d37c12b31a6ff13f7c5214eef19e" +} diff --git a/.sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json b/.sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json new file mode 100644 index 0000000..eed954b --- /dev/null +++ b/.sqlx/query-6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT state, request_uri, provider as \"provider: SsoProviderType\", action, nonce, code_verifier\n FROM sso_auth_state\n WHERE state = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "action", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "nonce", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "code_verifier", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "6c7ace2a64848adc757af6c93b9162e1d95788b372370a7ad0d7540338bb73ee" +} diff --git a/.sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json b/.sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json new file mode 100644 index 0000000..dbd52e6 --- /dev/null +++ b/.sqlx/query-6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT state FROM sso_auth_state WHERE state = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6fbcff0206599484bfb6cef165b6f729d27e7a342f7718ee4ac07f0ca94412ba" +} diff --git a/.sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json b/.sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json new file mode 100644 index 0000000..e831be8 --- /dev/null +++ b/.sqlx/query-712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "712459c27fc037f45389e2766cf1057e86e93ef756a784ed12beb453b03c5da1" +} diff --git a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json new file mode 100644 index 0000000..af80ee3 --- /dev/null +++ b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "subject", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" +} diff --git a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json new file mode 100644 index 0000000..416f681 --- /dev/null +++ b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" +} diff --git a/.sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json b/.sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json new file mode 100644 index 0000000..25e1b1d --- /dev/null +++ b/.sqlx/query-7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT provider_username, last_login_at FROM external_identities WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "7d24e744a4e63570b1410e50b45b745ce8915ab3715b3eff7efc2d84f27735d0" +} diff --git a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json new file mode 100644 index 0000000..8d5a9b7 --- /dev/null +++ b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" +} diff --git a/.sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json b/.sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json new file mode 100644 index 0000000..7938c39 --- /dev/null +++ b/.sqlx/query-85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "85ffc37a77af832d7795f5f37efe304fced4bf56b4f2287fe9aeb3fc97e1b191" +} diff --git a/.sqlx/query-8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375.json b/.sqlx/query-8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375.json new file mode 100644 index 0000000..99384e6 --- /dev/null +++ b/.sqlx/query-8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8d4753d81bdd340b97c816e160ba532f1838f2441079c11d471f2eddf24f5375" +} diff --git a/.sqlx/query-8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8.json b/.sqlx/query-8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8.json new file mode 100644 index 0000000..3076e7e --- /dev/null +++ b/.sqlx/query-8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, provider_email_verified,\n created_at, expires_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "provider_email_verified", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "8f070e3bdc3b1bb8cfce9a9b1dd67dd022cc515720fb742cf4bf363895d71cd8" +} diff --git a/.sqlx/query-9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36.json b/.sqlx/query-9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36.json new file mode 100644 index 0000000..0519bac --- /dev/null +++ b/.sqlx/query-9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state, request_uri, provider as \"provider: SsoProviderType\", action,\n nonce, code_verifier, did, created_at, expires_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "action", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "nonce", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "code_verifier", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "9468c5af2fb0e06e600e6c67e236bd4e368b06ce4af15fed16b8a0bfc5328c36" +} diff --git a/.sqlx/query-946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861.json b/.sqlx/query-946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861.json new file mode 100644 index 0000000..89fbd33 --- /dev/null +++ b/.sqlx/query-946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_pending_registration\n WHERE expires_at < $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "946e30fee0e45a99f3fe1ec3671c561c9dc537a848bc94c4740d5a83bf8d2861" +} diff --git a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json new file mode 100644 index 0000000..ef52899 --- /dev/null +++ b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "body", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" +} diff --git a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json new file mode 100644 index 0000000..178bc9e --- /dev/null +++ b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "public_key_did_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" +} diff --git a/.sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json b/.sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json new file mode 100644 index 0000000..ea2847f --- /dev/null +++ b/.sqlx/query-9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM external_identities WHERE did = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9dba64081d4f95b5490c9a9bf30a7175db3429f39df4f25e212f38f33882fc65" +} diff --git a/.sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json b/.sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json new file mode 100644 index 0000000..c77d51a --- /dev/null +++ b/.sqlx/query-9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "9fd56986c1c843d386d1e5884acef8573eb55a3e9f5cb0122fcf8b93d6d667a5" +} diff --git a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json new file mode 100644 index 0000000..2640f70 --- /dev/null +++ b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "private_key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" +} diff --git a/.sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json b/.sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json new file mode 100644 index 0000000..06c9dee --- /dev/null +++ b/.sqlx/query-a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE external_identities\n SET provider_username = $2, last_login_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a3d549a32e76c24e265c73a98dd739067623f275de0740bd576ee288f4444496" +} diff --git a/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json b/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json new file mode 100644 index 0000000..a7fb42d --- /dev/null +++ b/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" +} diff --git a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json new file mode 100644 index 0000000..f10b2bc --- /dev/null +++ b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM account_deletion_requests WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" +} diff --git a/.sqlx/query-a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c.json b/.sqlx/query-a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c.json new file mode 100644 index 0000000..200a92e --- /dev/null +++ b/.sqlx/query-a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email, created_at, updated_at, last_login_at\n FROM external_identities\n WHERE provider = $1 AND provider_user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "a87afce2ff68221df2e3e1051293217446fa0ed25144755f0da6f4825478506c" +} diff --git a/.sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json b/.sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json new file mode 100644 index 0000000..3e281aa --- /dev/null +++ b/.sqlx/query-aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sso_auth_state\n WHERE state = $1 AND expires_at > NOW()\n RETURNING state, request_uri\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "state", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "aee3e8e1d8924d41bec7d866e274f8bb2ddef833eb03326103c2d0a17ee56154" +} diff --git a/.sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json b/.sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json new file mode 100644 index 0000000..5a2db77 --- /dev/null +++ b/.sqlx/query-ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "ba9684872fad5201b8504c2606c29364a2df9631fe98817e7bfacd3f3f51f6cb" +} diff --git a/.sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json b/.sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json new file mode 100644 index 0000000..97297bf --- /dev/null +++ b/.sqlx/query-bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM sso_auth_state WHERE expires_at < NOW()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b" +} diff --git a/.sqlx/query-bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075.json b/.sqlx/query-bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075.json new file mode 100644 index 0000000..14484db --- /dev/null +++ b/.sqlx/query-bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, did, provider as \"provider: SsoProviderType\", provider_user_id,\n provider_username, provider_email, created_at, updated_at, last_login_at\n FROM external_identities\n WHERE did = $1\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "bf7e32cc58dfe85e08d52595f0c3b979f0f7c04f4401b5840f96ff0e47144075" +} diff --git a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json new file mode 100644 index 0000000..6b53208 --- /dev/null +++ b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_reset_code FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_reset_code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" +} diff --git a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json new file mode 100644 index 0000000..08e8e23 --- /dev/null +++ b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" +} diff --git a/.sqlx/query-cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b.json b/.sqlx/query-cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b.json new file mode 100644 index 0000000..0c39aeb --- /dev/null +++ b/.sqlx/query-cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cdba2cc5219e52ee1c23d52c1e099b49b87e45dcfc6edb7a3e73067ed61b312b" +} diff --git a/.sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json b/.sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json new file mode 100644 index 0000000..d122744 --- /dev/null +++ b/.sqlx/query-d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_auth_state (state, request_uri, provider, action, expires_at)\n VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour')\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "d0d4fb4b44cda3442b20037b4d5efaa032e1d004c775e2b6077c5050d7d62041" +} diff --git a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json new file mode 100644 index 0000000..be751e8 --- /dev/null +++ b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" +} diff --git a/.sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json b/.sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json new file mode 100644 index 0000000..03f2acb --- /dev/null +++ b/.sqlx/query-dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dd7d80d4d118a5fc95b574e2ca9ffaccf974e52fb6ac368f716409c55f9d3ab0" +} diff --git a/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json b/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json new file mode 100644 index 0000000..fa8b7de --- /dev/null +++ b/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc" + ] + } + } + }, + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" +} diff --git a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json new file mode 100644 index 0000000..0576d91 --- /dev/null +++ b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" +} diff --git a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json new file mode 100644 index 0000000..edb2f4b --- /dev/null +++ b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" +} diff --git a/.sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json b/.sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json new file mode 100644 index 0000000..494c6cb --- /dev/null +++ b/.sqlx/query-eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO external_identities (did, provider, provider_user_id)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb54d2ce02cab7c2e7f9926bd469b19e5f0513f47173b2738fc01a57082d7abb" +} diff --git a/.sqlx/query-eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3.json b/.sqlx/query-eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3.json new file mode 100644 index 0000000..2286f7a --- /dev/null +++ b/.sqlx/query-eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM handle_reservations WHERE expires_at <= NOW()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3" +} diff --git a/.sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json b/.sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json new file mode 100644 index 0000000..8cc56b0 --- /dev/null +++ b/.sqlx/query-ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM external_identities WHERE id = $1 AND did = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ec22a8cc89e480c403a239eac44288e144d83364129491de6156760616666d3d" +} diff --git a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json new file mode 100644 index 0000000..e8c8434 --- /dev/null +++ b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" +} diff --git a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json new file mode 100644 index 0000000..db8bde6 --- /dev/null +++ b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" +} diff --git a/.sqlx/query-f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d.json b/.sqlx/query-f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d.json new file mode 100644 index 0000000..88e2ce6 --- /dev/null +++ b/.sqlx/query-f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM handle_reservations WHERE handle = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d" +} diff --git a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json new file mode 100644 index 0000000..32320a7 --- /dev/null +++ b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_reset_code", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "password_reset_code_expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" +} diff --git a/.sqlx/query-ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18.json b/.sqlx/query-ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18.json new file mode 100644 index 0000000..83c72de --- /dev/null +++ b/.sqlx/query-ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM external_identities\n WHERE id = $1 AND did = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ff903cc1839ee69b3c217bc713f9c734fc4a794cefa9f76286facda88bf22f18" +} diff --git a/.sqlx/query-ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4.json b/.sqlx/query-ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4.json new file mode 100644 index 0000000..1966ad7 --- /dev/null +++ b/.sqlx/query-ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, provider_email_verified,\n created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "request_uri", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "provider: SsoProviderType", + "type_info": { + "Custom": { + "name": "sso_provider_type", + "kind": { + "Enum": [ + "github", + "discord", + "google", + "gitlab", + "oidc", + "apple" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "provider_user_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "provider_username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "provider_email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "provider_email_verified", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "ff93791f03c093deff1fdf4a86989548178bac3cbe6ffa73c22cafab61d05ba4" +} diff --git a/Cargo.lock b/Cargo.lock index 7d69d33..cd0cd39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3192,6 +3192,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256 0.13.2", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature 2.2.0", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.4" @@ -3883,6 +3906,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5029,6 +5062,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "sketches-ddsketch" version = "0.3.0" @@ -6040,6 +6085,7 @@ version = "0.1.0" dependencies = [ "aes-gcm", "anyhow", + "async-trait", "aws-config", "aws-sdk-s3", "axum", @@ -6067,6 +6113,7 @@ dependencies = [ "iroh-car", "jacquard-common", "jacquard-repo", + "jsonwebtoken", "k256", "metrics", "metrics-exporter-prometheus", diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index 1d25f63..bf8e086 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -7,6 +7,7 @@ mod infra; mod oauth; mod repo; mod session; +mod sso; mod user; pub use backlink::{Backlink, BacklinkRepository}; @@ -40,15 +41,18 @@ pub use session::{ AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, }; +pub use sso::{ + ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, +}; pub use user::{ AccountSearchResult, CompletePasskeySetupInput, CreateAccountError, CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, - CreatePasswordAccountResult, DidWebOverrides, MigrationReactivationError, - MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, - ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, - ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, TotpRecord, User2faStatus, - UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, - UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, + CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides, + MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, + PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput, + RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, + TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, + UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, diff --git a/crates/tranquil-db-traits/src/sso.rs b/crates/tranquil-db-traits/src/sso.rs new file mode 100644 index 0000000..596c561 --- /dev/null +++ b/crates/tranquil-db-traits/src/sso.rs @@ -0,0 +1,176 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tranquil_types::Did; +use uuid::Uuid; + +use crate::DbError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] +pub enum SsoProviderType { + Github, + Discord, + Google, + Gitlab, + Oidc, + Apple, +} + +impl SsoProviderType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Github => "github", + Self::Discord => "discord", + Self::Google => "google", + Self::Gitlab => "gitlab", + Self::Oidc => "oidc", + Self::Apple => "apple", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "github" => Some(Self::Github), + "discord" => Some(Self::Discord), + "google" => Some(Self::Google), + "gitlab" => Some(Self::Gitlab), + "oidc" => Some(Self::Oidc), + "apple" => Some(Self::Apple), + _ => None, + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::Github => "GitHub", + Self::Discord => "Discord", + Self::Google => "Google", + Self::Gitlab => "GitLab", + Self::Oidc => "SSO", + Self::Apple => "Apple", + } + } + + pub fn icon_name(&self) -> &'static str { + match self { + Self::Github => "github", + Self::Discord => "discord", + Self::Google => "google", + Self::Gitlab => "gitlab", + Self::Oidc => "oidc", + Self::Apple => "apple", + } + } +} + +#[derive(Debug, Clone)] +pub struct ExternalIdentity { + pub id: Uuid, + pub did: Did, + pub provider: SsoProviderType, + pub provider_user_id: String, + pub provider_username: Option, + pub provider_email: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login_at: Option>, +} + +#[derive(Debug, Clone)] +pub struct SsoAuthState { + pub state: String, + pub request_uri: String, + pub provider: SsoProviderType, + pub action: String, + pub nonce: Option, + pub code_verifier: Option, + pub did: Option, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct SsoPendingRegistration { + pub token: String, + pub request_uri: String, + pub provider: SsoProviderType, + pub provider_user_id: String, + pub provider_username: Option, + pub provider_email: Option, + pub provider_email_verified: bool, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +#[async_trait] +pub trait SsoRepository: Send + Sync { + async fn create_external_identity( + &self, + did: &Did, + provider: SsoProviderType, + provider_user_id: &str, + provider_username: Option<&str>, + provider_email: Option<&str>, + ) -> Result; + + async fn get_external_identity_by_provider( + &self, + provider: SsoProviderType, + provider_user_id: &str, + ) -> Result, DbError>; + + async fn get_external_identities_by_did( + &self, + did: &Did, + ) -> Result, DbError>; + + async fn update_external_identity_login( + &self, + id: Uuid, + provider_username: Option<&str>, + provider_email: Option<&str>, + ) -> Result<(), DbError>; + + async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result; + + #[allow(clippy::too_many_arguments)] + async fn create_sso_auth_state( + &self, + state: &str, + request_uri: &str, + provider: SsoProviderType, + action: &str, + nonce: Option<&str>, + code_verifier: Option<&str>, + did: Option<&Did>, + ) -> Result<(), DbError>; + + async fn consume_sso_auth_state(&self, state: &str) -> Result, DbError>; + + async fn cleanup_expired_sso_auth_states(&self) -> Result; + + #[allow(clippy::too_many_arguments)] + async fn create_pending_registration( + &self, + token: &str, + request_uri: &str, + provider: SsoProviderType, + provider_user_id: &str, + provider_username: Option<&str>, + provider_email: Option<&str>, + provider_email_verified: bool, + ) -> Result<(), DbError>; + + async fn get_pending_registration( + &self, + token: &str, + ) -> Result, DbError>; + + async fn consume_pending_registration( + &self, + token: &str, + ) -> Result, DbError>; + + async fn cleanup_expired_pending_registrations(&self) -> Result; +} diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 5cebf25..88376a6 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use tranquil_types::{Did, Handle}; use uuid::Uuid; -use crate::{CommsChannel, DbError}; +use crate::{CommsChannel, DbError, SsoProviderType}; #[derive(Debug, Clone)] pub struct UserRow { @@ -480,6 +480,11 @@ pub trait UserRepository: Send + Sync { input: &CreatePasskeyAccountInput, ) -> Result; + async fn create_sso_account( + &self, + input: &CreateSsoAccountInput, + ) -> Result; + async fn reactivate_migration_account( &self, input: &MigrationReactivationInput, @@ -490,6 +495,12 @@ pub trait UserRepository: Send + Sync { handle: &Handle, ) -> Result; + async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result; + + async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError>; + + async fn cleanup_expired_handle_reservations(&self) -> Result; + async fn check_and_consume_invite_code(&self, code: &str) -> Result; async fn complete_passkey_setup( @@ -842,6 +853,7 @@ pub enum CreateAccountError { HandleTaken, EmailTaken, DidExists, + InvalidToken, Database(String), } @@ -882,6 +894,30 @@ pub struct CreatePasskeyAccountInput { pub birthdate_pref: Option, } +#[derive(Debug, Clone)] +pub struct CreateSsoAccountInput { + pub handle: Handle, + pub email: Option, + pub did: Did, + pub preferred_comms_channel: CommsChannel, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, + pub encrypted_key_bytes: Vec, + pub encryption_version: i32, + pub commit_cid: String, + pub repo_rev: String, + pub genesis_block_cids: Vec>, + pub invite_code: Option, + pub birthdate_pref: Option, + pub sso_provider: SsoProviderType, + pub sso_provider_user_id: String, + pub sso_provider_username: Option, + pub sso_provider_email: Option, + pub sso_provider_email_verified: bool, + pub pending_registration_token: String, +} + #[derive(Debug, Clone)] pub struct CompletePasskeySetupInput { pub user_id: Uuid, diff --git a/crates/tranquil-db/src/postgres/mod.rs b/crates/tranquil-db/src/postgres/mod.rs index c6d95c8..ba173fa 100644 --- a/crates/tranquil-db/src/postgres/mod.rs +++ b/crates/tranquil-db/src/postgres/mod.rs @@ -7,6 +7,7 @@ mod infra; mod oauth; mod repo; mod session; +mod sso; mod user; use sqlx::PgPool; @@ -21,9 +22,11 @@ pub use infra::PostgresInfraRepository; pub use oauth::PostgresOAuthRepository; pub use repo::PostgresRepoRepository; pub use session::PostgresSessionRepository; +pub use sso::PostgresSsoRepository; use tranquil_db_traits::{ BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, - OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, UserRepository, + OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, SsoRepository, + UserRepository, }; pub use user::PostgresUserRepository; @@ -38,6 +41,7 @@ pub struct PostgresRepositories { pub infra: Arc, pub backup: Arc, pub backlink: Arc, + pub sso: Arc, pub event_notifier: Arc, } @@ -54,6 +58,7 @@ impl PostgresRepositories { infra: Arc::new(PostgresInfraRepository::new(pool.clone())), backup: Arc::new(PostgresBackupRepository::new(pool.clone())), backlink: Arc::new(PostgresBacklinkRepository::new(pool.clone())), + sso: Arc::new(PostgresSsoRepository::new(pool.clone())), event_notifier: Arc::new(PostgresRepoEventNotifier::new(pool)), } } diff --git a/crates/tranquil-db/src/postgres/sso.rs b/crates/tranquil-db/src/postgres/sso.rs new file mode 100644 index 0000000..a662cbc --- /dev/null +++ b/crates/tranquil-db/src/postgres/sso.rs @@ -0,0 +1,337 @@ +use async_trait::async_trait; +use chrono::Utc; +use sqlx::PgPool; +use tranquil_db_traits::{ + DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository, +}; +use tranquil_types::Did; +use uuid::Uuid; + +use super::user::map_sqlx_error; + +pub struct PostgresSsoRepository { + pool: PgPool, +} + +impl PostgresSsoRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl SsoRepository for PostgresSsoRepository { + async fn create_external_identity( + &self, + did: &Did, + provider: SsoProviderType, + provider_user_id: &str, + provider_username: Option<&str>, + provider_email: Option<&str>, + ) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + "#, + did.as_str(), + provider as SsoProviderType, + provider_user_id, + provider_username, + provider_email, + ) + .fetch_one(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(id) + } + + async fn get_external_identity_by_provider( + &self, + provider: SsoProviderType, + provider_user_id: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, + provider_username, provider_email, created_at, updated_at, last_login_at + FROM external_identities + WHERE provider = $1 AND provider_user_id = $2 + "#, + provider as SsoProviderType, + provider_user_id, + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| ExternalIdentity { + id: r.id, + did: Did::new_unchecked(&r.did), + provider: r.provider, + provider_user_id: r.provider_user_id, + provider_username: r.provider_username, + provider_email: r.provider_email, + created_at: r.created_at, + updated_at: r.updated_at, + last_login_at: r.last_login_at, + })) + } + + async fn get_external_identities_by_did( + &self, + did: &Did, + ) -> Result, DbError> { + let rows = sqlx::query!( + r#" + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, + provider_username, provider_email, created_at, updated_at, last_login_at + FROM external_identities + WHERE did = $1 + ORDER BY created_at ASC + "#, + did.as_str(), + ) + .fetch_all(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(rows + .into_iter() + .map(|r| ExternalIdentity { + id: r.id, + did: Did::new_unchecked(&r.did), + provider: r.provider, + provider_user_id: r.provider_user_id, + provider_username: r.provider_username, + provider_email: r.provider_email, + created_at: r.created_at, + updated_at: r.updated_at, + last_login_at: r.last_login_at, + }) + .collect()) + } + + async fn update_external_identity_login( + &self, + id: Uuid, + provider_username: Option<&str>, + provider_email: Option<&str>, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + UPDATE external_identities + SET provider_username = COALESCE($2, provider_username), + provider_email = COALESCE($3, provider_email), + last_login_at = NOW(), + updated_at = NOW() + WHERE id = $1 + "#, + id, + provider_username, + provider_email, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM external_identities + WHERE id = $1 AND did = $2 + "#, + id, + did.as_str(), + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn create_sso_auth_state( + &self, + state: &str, + request_uri: &str, + provider: SsoProviderType, + action: &str, + nonce: Option<&str>, + code_verifier: Option<&str>, + did: Option<&Did>, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + state, + request_uri, + provider as SsoProviderType, + action, + nonce, + code_verifier, + did.map(|d| d.as_str()), + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn consume_sso_auth_state(&self, state: &str) -> Result, DbError> { + let row = sqlx::query!( + r#" + DELETE FROM sso_auth_state + WHERE state = $1 AND expires_at > NOW() + RETURNING state, request_uri, provider as "provider: SsoProviderType", action, + nonce, code_verifier, did, created_at, expires_at + "#, + state, + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SsoAuthState { + state: r.state, + request_uri: r.request_uri, + provider: r.provider, + action: r.action, + nonce: r.nonce, + code_verifier: r.code_verifier, + did: r.did.map(|d| Did::new_unchecked(&d)), + created_at: r.created_at, + expires_at: r.expires_at, + })) + } + + async fn cleanup_expired_sso_auth_states(&self) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM sso_auth_state + WHERE expires_at < $1 + "#, + Utc::now(), + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + + async fn create_pending_registration( + &self, + token: &str, + request_uri: &str, + provider: SsoProviderType, + provider_user_id: &str, + provider_username: Option<&str>, + provider_email: Option<&str>, + provider_email_verified: bool, + ) -> Result<(), DbError> { + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + token, + request_uri, + provider as SsoProviderType, + provider_user_id, + provider_username, + provider_email, + provider_email_verified, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn get_pending_registration( + &self, + token: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + SELECT token, request_uri, provider as "provider: SsoProviderType", + provider_user_id, provider_username, provider_email, provider_email_verified, + created_at, expires_at + FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + "#, + token, + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SsoPendingRegistration { + token: r.token, + request_uri: r.request_uri, + provider: r.provider, + provider_user_id: r.provider_user_id, + provider_username: r.provider_username, + provider_email: r.provider_email, + provider_email_verified: r.provider_email_verified, + created_at: r.created_at, + expires_at: r.expires_at, + })) + } + + async fn consume_pending_registration( + &self, + token: &str, + ) -> Result, DbError> { + let row = sqlx::query!( + r#" + DELETE FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + RETURNING token, request_uri, provider as "provider: SsoProviderType", + provider_user_id, provider_username, provider_email, provider_email_verified, + created_at, expires_at + "#, + token, + ) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(row.map(|r| SsoPendingRegistration { + token: r.token, + request_uri: r.request_uri, + provider: r.provider, + provider_user_id: r.provider_user_id, + provider_username: r.provider_username, + provider_email: r.provider_email, + provider_email_verified: r.provider_email_verified, + created_at: r.created_at, + expires_at: r.expires_at, + })) + } + + async fn cleanup_expired_pending_registrations(&self) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM sso_pending_registration + WHERE expires_at < $1 + "#, + Utc::now(), + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } +} diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index 2f28fe9..99bc887 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -6,9 +6,9 @@ use uuid::Uuid; use tranquil_db_traits::{ AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs, - OAuthTokenWithUser, PasswordResetResult, StoredBackupCode, StoredPasskey, TotpRecord, - User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, - UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, + OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey, + TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, + UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, @@ -2671,6 +2671,173 @@ impl UserRepository for PostgresUserRepository { }) } + async fn create_sso_account( + &self, + input: &tranquil_db_traits::CreateSsoAccountInput, + ) -> Result< + tranquil_db_traits::CreatePasswordAccountResult, + tranquil_db_traits::CreateAccountError, + > { + let mut tx = self.pool.begin().await.map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + let token_consumed: Option<(String,)> = sqlx::query_as( + r#" + DELETE FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + RETURNING token + "#, + ) + .bind(&input.pending_registration_token) + .fetch_optional(&mut *tx) + .await + .map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + if token_consumed.is_none() { + return Err(tranquil_db_traits::CreateAccountError::InvalidToken); + } + + let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") + .fetch_one(&mut *tx) + .await + .map(|c| c.unwrap_or(0) == 0) + .unwrap_or(false); + + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( + r#"INSERT INTO users ( + handle, email, did, password_hash, password_required, + preferred_comms_channel, discord_id, telegram_username, signal_number, + is_admin + ) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#, + ) + .bind(input.handle.as_str()) + .bind(&input.email) + .bind(input.did.as_str()) + .bind(input.preferred_comms_channel) + .bind(&input.discord_id) + .bind(&input.telegram_username) + .bind(&input.signal_number) + .bind(is_first_user) + .fetch_one(&mut *tx) + .await; + + let user_id = match user_insert { + Ok((id,)) => id, + Err(e) => { + if let Some(db_err) = e.as_database_error() + && db_err.code().as_deref() == Some("23505") + { + let constraint = db_err.constraint().unwrap_or(""); + if constraint.contains("handle") { + return Err(tranquil_db_traits::CreateAccountError::HandleTaken); + } else if constraint.contains("email") { + return Err(tranquil_db_traits::CreateAccountError::EmailTaken); + } + } + return Err(tranquil_db_traits::CreateAccountError::Database( + e.to_string(), + )); + } + }; + + sqlx::query!( + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", + user_id, + &input.encrypted_key_bytes[..], + input.encryption_version + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?; + + sqlx::query!( + "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)", + user_id, + input.commit_cid, + input.repo_rev + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + sqlx::query( + r#" + INSERT INTO user_blocks (user_id, block_cid, repo_rev) + SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid) + ON CONFLICT (user_id, block_cid) DO NOTHING + "#, + ) + .bind(user_id) + .bind(&input.genesis_block_cids) + .bind(&input.repo_rev) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + if let Some(code) = &input.invite_code { + let _ = sqlx::query!( + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", + code + ) + .execute(&mut *tx) + .await; + + let _ = sqlx::query!( + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", + code, + user_id + ) + .execute(&mut *tx) + .await; + } + + if let Some(birthdate_pref) = &input.birthdate_pref { + let _ = sqlx::query!( + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) + ON CONFLICT (user_id, name) DO NOTHING", + user_id, + "app.bsky.actor.defs#personalDetailsPref", + birthdate_pref + ) + .execute(&mut *tx) + .await; + } + + sqlx::query!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + input.did.as_str(), + input.sso_provider as SsoProviderType, + &input.sso_provider_user_id, + input.sso_provider_username.as_deref(), + input.sso_provider_email.as_deref(), + input.sso_provider_email_verified, + ) + .execute(&mut *tx) + .await + .map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + tx.commit().await.map_err(|e: sqlx::Error| { + tranquil_db_traits::CreateAccountError::Database(e.to_string()) + })?; + + Ok(tranquil_db_traits::CreatePasswordAccountResult { + user_id, + is_admin: is_first_user, + }) + } + async fn reactivate_migration_account( &self, input: &tranquil_db_traits::MigrationReactivationInput, @@ -2744,16 +2911,70 @@ impl UserRepository for PostgresUserRepository { &self, handle: &Handle, ) -> Result { - let exists: Option<(i32,)> = - sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") - .bind(handle.as_str()) - .fetch_optional(&self.pool) - .await - .map_err(map_sqlx_error)?; + let exists: Option<(i32,)> = sqlx::query_as( + r#" + SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL + UNION ALL + SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW() + LIMIT 1 + "#, + ) + .bind(handle.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; Ok(exists.is_none()) } + async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result { + sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()") + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + let result = sqlx::query!( + r#" + INSERT INTO handle_reservations (handle, reserved_by) + SELECT $1, $2 + WHERE NOT EXISTS ( + SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW() + ) + "#, + handle.as_str(), + reserved_by, + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected() > 0) + } + + async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError> { + sqlx::query!( + "DELETE FROM handle_reservations WHERE handle = $1", + handle.as_str() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(()) + } + + async fn cleanup_expired_handle_reservations(&self) -> Result { + let result = sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()") + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(result.rows_affected()) + } + async fn check_and_consume_invite_code(&self, code: &str) -> Result { let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 04c4dac..4cdf9aa 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -18,6 +18,7 @@ tranquil-db = { workspace = true } tranquil-db-traits = { workspace = true } aes-gcm = { workspace = true } +async-trait = { workspace = true } backon = { workspace = true } anyhow = { workspace = true } aws-config = { workspace = true } @@ -44,6 +45,7 @@ ipld-core = { workspace = true } iroh-car = { workspace = true } jacquard-common = { workspace = true } jacquard-repo = { workspace = true } +jsonwebtoken = { workspace = true } k256 = { workspace = true } metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } diff --git a/crates/tranquil-pds/build.rs b/crates/tranquil-pds/build.rs new file mode 100644 index 0000000..09dec18 --- /dev/null +++ b/crates/tranquil-pds/build.rs @@ -0,0 +1,12 @@ +use std::process::Command; + +fn main() { + let timestamp = Command::new("date") + .arg("+%Y-%m-%d %H:%M:%S UTC") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + println!("cargo:rustc-env=BUILD_TIMESTAMP={}", timestamp); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 701c8cc..0af81d4 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -107,6 +107,13 @@ pub enum ApiError { error: Option, message: Option, }, + SsoProviderNotFound, + SsoProviderNotEnabled, + SsoInvalidAction, + SsoNotAuthenticated, + SsoSessionExpired, + SsoAlreadyLinked, + SsoLinkNotFound, } impl ApiError { @@ -197,8 +204,14 @@ impl ApiError { | Self::InvalidVerificationChannel | Self::SelfHostedDidWebDisabled | Self::AccountAlreadyExists - | Self::TokenRequired => StatusCode::BAD_REQUEST, - Self::PasskeyNotFound => StatusCode::NOT_FOUND, + | Self::TokenRequired + | Self::SsoProviderNotFound + | Self::SsoProviderNotEnabled + | Self::SsoInvalidAction + | Self::SsoNotAuthenticated + | Self::SsoSessionExpired + | Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST, + Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND, } } fn error_name(&self) -> Cow<'static, str> { @@ -293,6 +306,13 @@ impl ApiError { Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"), Self::HandleNotFound => Cow::Borrowed("HandleNotFound"), Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"), + Self::SsoProviderNotFound => Cow::Borrowed("SsoProviderNotFound"), + Self::SsoProviderNotEnabled => Cow::Borrowed("SsoProviderNotEnabled"), + Self::SsoInvalidAction => Cow::Borrowed("SsoInvalidAction"), + Self::SsoNotAuthenticated => Cow::Borrowed("SsoNotAuthenticated"), + Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"), + Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"), + Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"), } } fn message(&self) -> Option { @@ -392,6 +412,19 @@ impl ApiError { Self::AccountAlreadyExists => Some("Account already exists".to_string()), Self::HandleNotFound => Some("Unable to resolve handle".to_string()), Self::SubjectNotFound => Some("Subject not found".to_string()), + Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()), + Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()), + Self::SsoInvalidAction => { + Some("Action must be login, link, or register".to_string()) + } + Self::SsoNotAuthenticated => { + Some("Must be authenticated to link SSO account".to_string()) + } + Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()), + Self::SsoAlreadyLinked => { + Some("This SSO account is already linked to a different user".to_string()) + } + Self::SsoLinkNotFound => Some("Linked account not found".to_string()), Self::IdentifierMismatch => { Some("The identifier does not match the verification token".to_string()) } @@ -467,6 +500,13 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(e: tranquil_db_traits::DbError) -> Self { + tracing::error!("Database error: {:?}", e); + Self::DatabaseError + } +} + impl From for ApiError { fn from(e: crate::auth::TokenValidationError) -> Self { match e { diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 49aad07..7c8b6b1 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -428,7 +428,10 @@ pub async fn activate_account( let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; let _ = state.cache.delete(&format!("plc:data:{}", did)).await; if state.did_resolver.refresh_did(did.as_str()).await.is_none() { - warn!("[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did); + warn!( + "[MIGRATION] activateAccount: Failed to refresh DID cache for {}", + did + ); } info!( "[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}", diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index cb7363e..86064e9 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -7,14 +7,45 @@ use axum::{ extract::State, response::{IntoResponse, Response}, }; -use serde::Deserialize; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; use serde_json::json; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use subtle::ConstantTimeEq; use tracing::{error, info, warn}; +const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60); + +fn email_update_cache_key(did: &str) -> String { + format!("email_update:{}", did) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + URL_SAFE_NO_PAD.encode(hasher.finalize()) +} + +#[derive(Serialize, Deserialize)] +struct PendingEmailUpdate { + new_email: String, + token_hash: String, + authorized: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestEmailUpdateInput { + #[serde(default)] + pub new_email: Option, +} + pub async fn request_email_update( State(state): State, headers: axum::http::HeaderMap, auth: BearerAuth, + input: Option>, ) -> Response { let client_ip = crate::rate_limit::extract_client_ip(&headers, None); if !state @@ -60,11 +91,30 @@ pub async fn request_email_update( ); let formatted_code = crate::auth::verification_token::format_token_for_display(&code); + if let Some(Json(ref inp)) = input + && let Some(ref new_email) = inp.new_email { + let new_email = new_email.trim().to_lowercase(); + if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) { + let pending = PendingEmailUpdate { + new_email, + token_hash: hash_token(&code), + authorized: false, + }; + if let Ok(json) = serde_json::to_string(&pending) { + let cache_key = email_update_cache_key(&auth.0.did); + if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { + warn!("Failed to cache pending email update: {:?}", e); + } + } + } + } + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token( state.user_repo.as_ref(), state.infra_repo.as_ref(), user.id, + &code, &formatted_code, &hostname, ) @@ -223,34 +273,48 @@ pub async fn update_email( } if email_verified { - let Some(ref t) = input.token else { - return ApiError::TokenRequired.into_response(); - }; - let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim()); + let mut authorized_via_link = false; - let current_email_lower = current_email - .as_ref() - .map(|e| e.to_lowercase()) - .unwrap_or_default(); + let cache_key = email_update_cache_key(did); + if let Some(pending_json) = state.cache.get(&cache_key).await + && let Ok(pending) = serde_json::from_str::(&pending_json) + && pending.authorized && pending.new_email == new_email { + authorized_via_link = true; + let _ = state.cache.delete(&cache_key).await; + info!(did = %did, "Email update completed via link authorization"); + } - let verified = crate::auth::verification_token::verify_channel_update_token( - &confirmation_token, - "email_update", - ¤t_email_lower, - ); + if !authorized_via_link { + let Some(ref t) = input.token else { + return ApiError::TokenRequired.into_response(); + }; + let confirmation_token = + crate::auth::verification_token::normalize_token_input(t.trim()); - match verified { - Ok(token_data) => { - if token_data.did != did.as_str() { + let current_email_lower = current_email + .as_ref() + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + + let verified = crate::auth::verification_token::verify_channel_update_token( + &confirmation_token, + "email_update", + ¤t_email_lower, + ); + + match verified { + Ok(token_data) => { + if token_data.did != did.as_str() { + return ApiError::InvalidToken(None).into_response(); + } + } + Err(crate::auth::verification_token::VerifyError::Expired) => { + return ApiError::ExpiredToken(None).into_response(); + } + Err(_) => { return ApiError::InvalidToken(None).into_response(); } } - Err(crate::auth::verification_token::VerifyError::Expired) => { - return ApiError::ExpiredToken(None).into_response(); - } - Err(_) => { - return ApiError::InvalidToken(None).into_response(); - } } } @@ -332,3 +396,148 @@ pub async fn check_email_verified( } } } + +#[derive(Deserialize)] +pub struct AuthorizeEmailUpdateQuery { + pub token: String, +} + +pub async fn authorize_email_update( + State(state): State, + headers: axum::http::HeaderMap, + axum::extract::Query(query): axum::extract::Query, +) -> Response { + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) + .await + { + return ApiError::RateLimitExceeded(None).into_response(); + } + + let verified = crate::auth::verification_token::verify_token_signature(&query.token); + + let token_data = match verified { + Ok(data) => data, + Err(crate::auth::verification_token::VerifyError::Expired) => { + warn!("authorize_email_update: token expired"); + return ApiError::ExpiredToken(None).into_response(); + } + Err(e) => { + warn!("authorize_email_update: token verification failed: {:?}", e); + return ApiError::InvalidToken(None).into_response(); + } + }; + + if token_data.purpose != crate::auth::verification_token::VerificationPurpose::ChannelUpdate { + warn!( + "authorize_email_update: wrong purpose: {:?}", + token_data.purpose + ); + return ApiError::InvalidToken(None).into_response(); + } + if token_data.channel != "email_update" { + warn!( + "authorize_email_update: wrong channel: {}", + token_data.channel + ); + return ApiError::InvalidToken(None).into_response(); + } + + let did = token_data.did; + info!("authorize_email_update: token valid for did={}", did); + + let cache_key = email_update_cache_key(&did); + let pending_json = match state.cache.get(&cache_key).await { + Some(json) => json, + None => { + warn!( + "authorize_email_update: no pending email update in cache for did={}", + did + ); + return ApiError::InvalidRequest("No pending email update found".into()) + .into_response(); + } + }; + + let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) { + Ok(p) => p, + Err(_) => { + return ApiError::InternalError(None).into_response(); + } + }; + + let token_hash = hash_token(&query.token); + if pending + .token_hash + .as_bytes() + .ct_eq(token_hash.as_bytes()) + .unwrap_u8() + != 1 + { + warn!("authorize_email_update: token hash mismatch"); + return ApiError::InvalidToken(None).into_response(); + } + + pending.authorized = true; + if let Ok(json) = serde_json::to_string(&pending) + && let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await { + warn!("Failed to update pending email authorization: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + + info!(did = %did, "Email update authorized via link click"); + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let redirect_url = format!( + "https://{}/app/verify?type=email-authorize-success", + hostname + ); + + axum::response::Redirect::to(&redirect_url).into_response() +} + +pub async fn check_email_update_status( + State(state): State, + headers: axum::http::HeaderMap, + auth: BearerAuth, +) -> Response { + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) + .await + { + return ApiError::RateLimitExceeded(None).into_response(); + } + + if let Err(e) = crate::auth::scope_check::check_account_scope( + auth.0.is_oauth, + auth.0.scope.as_deref(), + crate::oauth::scopes::AccountAttr::Email, + crate::oauth::scopes::AccountAction::Read, + ) { + return e; + } + + let cache_key = email_update_cache_key(&auth.0.did); + let pending_json = match state.cache.get(&cache_key).await { + Some(json) => json, + None => { + return Json(json!({ "pending": false, "authorized": false })).into_response(); + } + }; + + let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) { + Ok(p) => p, + Err(_) => { + return Json(json!({ "pending": false, "authorized": false })).into_response(); + } + }; + + Json(json!({ + "pending": true, + "authorized": pending.authorized, + "newEmail": pending.new_email, + })) + .into_response() +} diff --git a/crates/tranquil-pds/src/api/server/mod.rs b/crates/tranquil-pds/src/api/server/mod.rs index 9f07a05..2a6f074 100644 --- a/crates/tranquil-pds/src/api/server/mod.rs +++ b/crates/tranquil-pds/src/api/server/mod.rs @@ -22,7 +22,10 @@ pub use account_status::{ request_account_delete, }; pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; -pub use email::{check_email_verified, confirm_email, request_email_update, update_email}; +pub use email::{ + authorize_email_update, check_email_update_status, check_email_verified, confirm_email, + request_email_update, update_email, +}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use logo::get_logo; pub use meta::{describe_server, health, robots_txt}; diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 6c9d50d..6020df0 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -366,12 +366,33 @@ pub async fn set_password( auth: BearerAuth, Json(input): Json, ) -> Response { - if crate::api::server::reauth::check_reauth_required_cached( - &*state.session_repo, - &state.cache, - &auth.0.did, - ) - .await + let has_password = state + .user_repo + .has_password_by_did(&auth.0.did) + .await + .ok() + .flatten() + .unwrap_or(false); + let has_passkeys = state + .user_repo + .has_passkeys(&auth.0.did) + .await + .unwrap_or(false); + let has_totp = state + .user_repo + .has_totp_enabled(&auth.0.did) + .await + .unwrap_or(false); + + let has_any_reauth_method = has_password || has_passkeys || has_totp; + + if has_any_reauth_method + && crate::api::server::reauth::check_reauth_required_cached( + &*state.session_repo, + &state.cache, + &auth.0.did, + ) + .await { return crate::api::server::reauth::reauth_required_response( &*state.user_repo, diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index b2a4a13..f203aa0 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -366,7 +366,8 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - code: &str, + raw_token: &str, + display_code: &str, hostname: &str, ) -> Result { let prefs = user_repo @@ -375,17 +376,17 @@ pub mod repo { .ok_or(DbError::NotFound)?; let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); let current_email = prefs.email.unwrap_or_default(); - let verify_page = format!("https://{}/app/verify?type=email-update", hostname); + let verify_page = format!("https://{}/app/settings", hostname); let verify_link = format!( - "https://{}/app/verify?type=email-update&token={}", + "https://{}/xrpc/_account.authorizeEmailUpdate?token={}", hostname, - urlencoding::encode(code) + urlencoding::encode(raw_token) ); let body = format_message( strings.email_update_body, &[ ("handle", &prefs.handle), - ("code", code), + ("code", display_code), ("verify_page", &verify_page), ("verify_link", &verify_link), ], diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index d46364f..bb2bba4 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -16,6 +16,7 @@ pub mod plc; pub mod rate_limit; pub mod repo; pub mod scheduled; +pub mod sso; pub mod state; pub mod storage; pub mod sync; @@ -287,6 +288,14 @@ pub fn app(state: AppState) -> Router { "/com.atproto.server.updateEmail", post(api::server::update_email), ) + .route( + "/_account.authorizeEmailUpdate", + get(api::server::authorize_email_update), + ) + .route( + "/_account.checkEmailUpdateStatus", + get(api::server::check_email_update_status), + ) .route( "/com.atproto.server.reserveSigningKey", post(api::server::reserve_signing_key), @@ -569,7 +578,27 @@ pub fn app(state: AppState) -> Router { ) .route("/token", post(oauth::endpoints::token_endpoint)) .route("/revoke", post(oauth::endpoints::revoke_token)) - .route("/introspect", post(oauth::endpoints::introspect_token)); + .route("/introspect", post(oauth::endpoints::introspect_token)) + .route("/sso/providers", get(sso::endpoints::get_sso_providers)) + .route("/sso/initiate", post(sso::endpoints::sso_initiate)) + .route( + "/sso/callback", + get(sso::endpoints::sso_callback).post(sso::endpoints::sso_callback_post), + ) + .route("/sso/linked", get(sso::endpoints::get_linked_accounts)) + .route("/sso/unlink", post(sso::endpoints::unlink_account)) + .route( + "/sso/pending-registration", + get(sso::endpoints::get_pending_registration), + ) + .route( + "/sso/complete-registration", + post(sso::endpoints::complete_registration), + ) + .route( + "/sso/check-handle-available", + get(sso::endpoints::check_handle_available), + ); let well_known_router = Router::new() .route("/did.json", get(api::identity::well_known_did)) diff --git a/crates/tranquil-pds/src/main.rs b/crates/tranquil-pds/src/main.rs index c44bfdb..e4e6bab 100644 --- a/crates/tranquil-pds/src/main.rs +++ b/crates/tranquil-pds/src/main.rs @@ -4,6 +4,13 @@ use std::sync::Arc; use tokio::sync::watch; use tracing::{error, info, warn}; use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender}; + +const BUILD_VERSION: &str = concat!( + env!("CARGO_PKG_VERSION"), + " (built ", + env!("BUILD_TIMESTAMP"), + ")" +); use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; use tranquil_pds::scheduled::{ backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, @@ -106,6 +113,7 @@ async fn run() -> Result<(), Box> { state.user_repo.clone(), state.blob_repo.clone(), state.blob_store.clone(), + state.sso_repo.clone(), shutdown_rx, )); @@ -121,7 +129,7 @@ async fn run() -> Result<(), Box> { .parse() .map_err(|e| format!("Invalid SERVER_HOST or SERVER_PORT: {}", e))?; - info!("listening on {}", addr); + info!("tranquil-pds {} listening on {}", BUILD_VERSION, addr); let listener = tokio::net::TcpListener::bind(addr) .await diff --git a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs index 6436019..30a43c4 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs @@ -459,9 +459,7 @@ pub async fn delegation_auth_token( headers: HeaderMap, Json(form): Json, ) -> Response { - let auth_header = headers - .get("authorization") - .and_then(|v| v.to_str().ok()); + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); let extracted = match extract_auth_token_from_header(auth_header) { Some(e) => e, diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index cc04ea1..6de629c 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -176,7 +176,7 @@ pub async fn frontend_client_metadata( "refresh_token".to_string(), ], response_types: vec!["code".to_string()], - scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*" + scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*" .to_string(), token_endpoint_auth_method: "none".to_string(), application_type: "web".to_string(), diff --git a/crates/tranquil-pds/src/rate_limit.rs b/crates/tranquil-pds/src/rate_limit.rs index 2c2855c..69b4e98 100644 --- a/crates/tranquil-pds/src/rate_limit.rs +++ b/crates/tranquil-pds/src/rate_limit.rs @@ -33,6 +33,9 @@ pub struct RateLimiters { pub handle_update: Arc, pub handle_update_daily: Arc, pub verification_check: Arc, + pub sso_initiate: Arc, + pub sso_callback: Arc, + pub sso_unlink: Arc, } impl Default for RateLimiters { @@ -95,6 +98,15 @@ impl RateLimiters { verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( NonZeroU32::new(60).unwrap(), ))), + sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), + sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(30).unwrap(), + ))), + sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(10).unwrap(), + ))), } } @@ -139,6 +151,13 @@ impl RateLimiters { ))); self } + + pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self { + self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute( + NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + ))); + self + } } pub fn extract_client_ip(headers: &HeaderMap, addr: Option) -> String { diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs index b063053..faca3ae 100644 --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -9,7 +9,8 @@ use tokio::sync::watch; use tokio::time::interval; use tracing::{debug, error, info, warn}; use tranquil_db_traits::{ - BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, UserRepository, + BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository, + UserRepository, }; use tranquil_types::{AtUri, CidLink, Did}; @@ -390,6 +391,7 @@ pub async fn start_scheduled_tasks( user_repo: Arc, blob_repo: Arc, blob_store: Arc, + sso_repo: Arc, mut shutdown_rx: watch::Receiver, ) { let check_interval = Duration::from_secs( @@ -423,6 +425,36 @@ pub async fn start_scheduled_tasks( ).await { error!("Error processing scheduled deletions: {}", e); } + + match sso_repo.cleanup_expired_sso_auth_states().await { + Ok(count) if count > 0 => { + info!(count = count, "Cleaned up expired SSO auth states"); + } + Ok(_) => {} + Err(e) => { + error!("Error cleaning up SSO auth states: {:?}", e); + } + } + + match sso_repo.cleanup_expired_pending_registrations().await { + Ok(count) if count > 0 => { + info!(count = count, "Cleaned up expired SSO pending registrations"); + } + Ok(_) => {} + Err(e) => { + error!("Error cleaning up SSO pending registrations: {:?}", e); + } + } + + match user_repo.cleanup_expired_handle_reservations().await { + Ok(count) if count > 0 => { + info!(count = count, "Cleaned up expired handle reservations"); + } + Ok(_) => {} + Err(e) => { + error!("Error cleaning up handle reservations: {:?}", e); + } + } } } } diff --git a/crates/tranquil-pds/src/sso/config.rs b/crates/tranquil-pds/src/sso/config.rs new file mode 100644 index 0000000..6118605 --- /dev/null +++ b/crates/tranquil-pds/src/sso/config.rs @@ -0,0 +1,211 @@ +use std::sync::OnceLock; +use tranquil_db_traits::SsoProviderType; + +static SSO_CONFIG: OnceLock = OnceLock::new(); +static SSO_REDIRECT_URI: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub client_id: String, + pub client_secret: String, + pub issuer: Option, + pub display_name: Option, +} + +#[derive(Debug, Clone)] +pub struct AppleProviderConfig { + pub client_id: String, + pub team_id: String, + pub key_id: String, + pub private_key_pem: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SsoConfig { + pub github: Option, + pub discord: Option, + pub google: Option, + pub gitlab: Option, + pub oidc: Option, + pub apple: Option, +} + +impl SsoConfig { + pub fn init() -> &'static Self { + SSO_CONFIG.get_or_init(|| { + let github = Self::load_provider("GITHUB", false); + let discord = Self::load_provider("DISCORD", false); + let google = Self::load_provider("GOOGLE", false); + let gitlab = Self::load_provider("GITLAB", true); + let oidc = Self::load_provider("OIDC", true); + let apple = Self::load_apple_provider(); + + let config = SsoConfig { + github, + discord, + google, + gitlab, + oidc, + apple, + }; + + if config.is_any_enabled() { + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_default(); + if hostname.is_empty() || hostname == "localhost" { + panic!( + "PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \ + SSO redirect URIs require a proper hostname for security." + ); + } + SSO_REDIRECT_URI + .set(format!("https://{}/oauth/sso/callback", hostname)) + .expect("SSO_REDIRECT_URI already set"); + tracing::info!( + hostname = %hostname, + providers = ?config.enabled_providers().iter().map(|p| p.as_str()).collect::>(), + "SSO initialized" + ); + } + + config + }) + } + + pub fn get_redirect_uri() -> &'static str { + SSO_REDIRECT_URI + .get() + .map(|s| s.as_str()) + .expect("SSO redirect URI not initialized - call SsoConfig::init() first") + } + + fn load_provider(name: &str, needs_issuer: bool) -> Option { + let enabled = std::env::var(format!("SSO_{}_ENABLED", name)) + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + if !enabled { + return None; + } + + let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?; + let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?; + + if client_id.is_empty() || client_secret.is_empty() { + tracing::warn!( + "SSO_{} enabled but missing client_id or client_secret", + name + ); + return None; + } + + let issuer = if needs_issuer { + let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok(); + if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) { + tracing::warn!("SSO_{} requires ISSUER but none provided", name); + return None; + } + issuer_val + } else { + None + }; + + let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok(); + + Some(ProviderConfig { + client_id, + client_secret, + issuer, + display_name, + }) + } + + fn load_apple_provider() -> Option { + let enabled = std::env::var("SSO_APPLE_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + if !enabled { + return None; + } + + let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?; + let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?; + let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?; + let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?; + + if client_id.is_empty() { + tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID"); + return None; + } + if team_id.is_empty() || team_id.len() != 10 { + tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)"); + return None; + } + if key_id.is_empty() { + tracing::warn!("SSO_APPLE enabled but missing KEY_ID"); + return None; + } + if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") { + tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid"); + return None; + } + + Some(AppleProviderConfig { + client_id, + team_id, + key_id, + private_key_pem, + }) + } + + pub fn get() -> &'static Self { + SSO_CONFIG.get_or_init(SsoConfig::default) + } + + pub fn get_provider_config(&self, provider: SsoProviderType) -> Option<&ProviderConfig> { + match provider { + SsoProviderType::Github => self.github.as_ref(), + SsoProviderType::Discord => self.discord.as_ref(), + SsoProviderType::Google => self.google.as_ref(), + SsoProviderType::Gitlab => self.gitlab.as_ref(), + SsoProviderType::Oidc => self.oidc.as_ref(), + SsoProviderType::Apple => None, + } + } + + pub fn get_apple_config(&self) -> Option<&AppleProviderConfig> { + self.apple.as_ref() + } + + pub fn enabled_providers(&self) -> Vec { + let mut providers = Vec::new(); + if self.github.is_some() { + providers.push(SsoProviderType::Github); + } + if self.discord.is_some() { + providers.push(SsoProviderType::Discord); + } + if self.google.is_some() { + providers.push(SsoProviderType::Google); + } + if self.gitlab.is_some() { + providers.push(SsoProviderType::Gitlab); + } + if self.oidc.is_some() { + providers.push(SsoProviderType::Oidc); + } + if self.apple.is_some() { + providers.push(SsoProviderType::Apple); + } + providers + } + + pub fn is_any_enabled(&self) -> bool { + self.github.is_some() + || self.discord.is_some() + || self.google.is_some() + || self.gitlab.is_some() + || self.oidc.is_some() + || self.apple.is_some() + } +} diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs new file mode 100644 index 0000000..6458f53 --- /dev/null +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -0,0 +1,1306 @@ +use axum::{ + Form, Json, + extract::{Query, State}, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; +use tranquil_db_traits::SsoProviderType; +use tranquil_types::RequestId; + +use super::config::SsoConfig; +use crate::api::error::ApiError; +use crate::auth::extractor::extract_bearer_token_from_header; +use crate::auth::validate_bearer_token_cached; +use crate::rate_limit::extract_client_ip; +use crate::state::{AppState, RateLimitKind}; + +fn generate_state() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn generate_nonce() -> String { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +#[derive(Debug, Serialize)] +pub struct SsoProviderInfo { + pub provider: String, + pub name: String, + pub icon: String, +} + +#[derive(Debug, Serialize)] +pub struct SsoProvidersResponse { + pub providers: Vec, +} + +pub async fn get_sso_providers(State(state): State) -> Json { + let providers = state + .sso_manager + .enabled_providers() + .iter() + .map(|(t, name, icon)| SsoProviderInfo { + provider: t.as_str().to_string(), + name: name.to_string(), + icon: icon.to_string(), + }) + .collect(); + + Json(SsoProvidersResponse { providers }) +} + +#[derive(Debug, Deserialize)] +pub struct SsoInitiateRequest { + pub provider: String, + pub request_uri: Option, + pub action: Option, +} + +#[derive(Debug, Serialize)] +pub struct SsoInitiateResponse { + pub redirect_url: String, +} + +pub async fn sso_initiate( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, ApiError> { + let client_ip = extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::SsoInitiate, &client_ip) + .await + { + tracing::warn!(ip = %client_ip, "SSO initiate rate limit exceeded"); + return Err(ApiError::RateLimitExceeded(None)); + } + + if input.provider.len() > 20 { + return Err(ApiError::SsoProviderNotFound); + } + if let Some(ref uri) = input.request_uri + && uri.len() > 500 { + return Err(ApiError::InvalidRequest("Request URI too long".into())); + } + if let Some(ref action) = input.action + && action.len() > 20 { + return Err(ApiError::SsoInvalidAction); + } + + let provider_type = + SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?; + + let provider = state + .sso_manager + .get_provider(provider_type) + .ok_or(ApiError::SsoProviderNotEnabled)?; + + let action = input.action.as_deref().unwrap_or("login"); + if !["login", "link", "register"].contains(&action) { + return Err(ApiError::SsoInvalidAction); + } + + let is_standalone = action == "register" && input.request_uri.is_none(); + let request_uri = input + .request_uri + .clone() + .unwrap_or_else(|| "standalone".to_string()); + + let auth_did = match action { + "link" => { + let auth_header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()); + let token = extract_bearer_token_from_header(auth_header) + .ok_or(ApiError::SsoNotAuthenticated)?; + let auth_user = validate_bearer_token_cached( + state.user_repo.as_ref(), + state.cache.as_ref(), + &token, + ) + .await + .map_err(|_| ApiError::SsoNotAuthenticated)?; + Some(auth_user.did) + } + "register" if is_standalone => None, + _ => { + let request_id = RequestId::new(request_uri.clone()); + let _request_data = state + .oauth_repo + .get_authorization_request(&request_id) + .await? + .ok_or(ApiError::InvalidRequest( + "Authorization request not found or expired".into(), + ))?; + None + } + }; + + let sso_state = generate_state(); + let nonce = generate_nonce(); + let redirect_uri = SsoConfig::get_redirect_uri(); + + let auth_result = provider + .build_auth_url(&sso_state, redirect_uri, Some(&nonce)) + .await + .map_err(|e| { + tracing::error!("Failed to build auth URL: {:?}", e); + ApiError::InternalError(Some("Failed to build authorization URL".into())) + })?; + + state + .sso_repo + .create_sso_auth_state( + &sso_state, + &request_uri, + provider_type, + action, + Some(&nonce), + auth_result.code_verifier.as_deref(), + auth_did.as_ref(), + ) + .await?; + + tracing::debug!( + provider = %provider_type.as_str(), + action = %action, + "SSO flow initiated" + ); + + Ok(Json(SsoInitiateResponse { + redirect_url: auth_result.url, + })) +} + +#[derive(Debug, Deserialize)] +pub struct SsoCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SsoCallbackForm { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, + #[serde(default)] + pub user: Option, +} + +fn redirect_to_error(message: &str) -> Response { + let encoded = urlencoding::encode(message); + Redirect::to(&format!("/app/oauth/error?error={}", encoded)).into_response() +} + +fn redirect_to_login_with_error(request_uri: &str, message: &str) -> Response { + let uri_encoded = urlencoding::encode(request_uri); + let msg_encoded = urlencoding::encode(message); + Redirect::to(&format!( + "/app/oauth/login?request_uri={}&error={}", + uri_encoded, msg_encoded + )) + .into_response() +} + +pub async fn sso_callback( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + tracing::debug!( + has_code = query.code.is_some(), + has_state = query.state.is_some(), + has_error = query.error.is_some(), + "SSO callback received" + ); + + let client_ip = extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) + .await + { + tracing::warn!(ip = %client_ip, "SSO callback rate limit exceeded"); + return redirect_to_error("Too many requests. Please try again later."); + } + + if let Some(ref error) = query.error { + tracing::warn!( + error = %error, + error_description = ?query.error_description, + "SSO provider returned error" + ); + if error.len() > 100 { + return redirect_to_error("Invalid error response"); + } + let desc = query + .error_description + .as_ref() + .map(|d| if d.len() > 500 { "Error" } else { d.as_str() }) + .unwrap_or_default(); + return redirect_to_error(&format!("{}: {}", error, desc)); + } + + let (code, sso_state) = match (&query.code, &query.state) { + (Some(c), Some(s)) if c.len() <= 2000 && s.len() <= 100 => (c.clone(), s.clone()), + (Some(_), Some(_)) => return redirect_to_error("Invalid callback parameters"), + _ => return redirect_to_error("Missing code or state parameter"), + }; + + let auth_state = match state.sso_repo.consume_sso_auth_state(&sso_state).await { + Ok(Some(s)) => s, + Ok(None) => return redirect_to_error("SSO session expired or invalid"), + Err(e) => { + tracing::error!("SSO state lookup failed: {:?}", e); + return redirect_to_error("Database error"); + } + }; + + tracing::debug!( + provider = %auth_state.provider.as_str(), + action = %auth_state.action, + request_uri = %auth_state.request_uri, + "SSO auth state retrieved" + ); + + let is_standalone = auth_state.request_uri == "standalone"; + + let provider = match state.sso_manager.get_provider(auth_state.provider) { + Some(p) => p, + None => return redirect_to_error("Provider no longer available"), + }; + + let redirect_uri = SsoConfig::get_redirect_uri(); + + let token_resp = match provider + .exchange_code(&code, redirect_uri, auth_state.code_verifier.as_deref()) + .await + { + Ok(t) => t, + Err(e) => { + tracing::error!("SSO token exchange failed: {:?}", e); + if is_standalone { + return redirect_to_error( + "Failed to exchange authorization code. Please try again.", + ); + } + return redirect_to_login_with_error( + &auth_state.request_uri, + "Failed to exchange authorization code", + ); + } + }; + + let user_info = match provider + .get_user_info( + &token_resp.access_token, + token_resp.id_token.as_deref(), + auth_state.nonce.as_deref(), + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!("SSO user info fetch failed: {:?}", e); + if is_standalone { + return redirect_to_error( + "Failed to get user information from provider. Please try again.", + ); + } + return redirect_to_login_with_error( + &auth_state.request_uri, + "Failed to get user information from provider", + ); + } + }; + + match auth_state.action.as_str() { + "login" => { + handle_sso_login( + &state, + &auth_state.request_uri, + auth_state.provider, + &user_info, + ) + .await + } + "link" => { + let did = match auth_state.did { + Some(d) => d, + None => return redirect_to_error("Not authenticated"), + }; + handle_sso_link(&state, did, auth_state.provider, &user_info).await + } + "register" => { + handle_sso_register( + &state, + &auth_state.request_uri, + auth_state.provider, + &user_info, + ) + .await + } + _ => redirect_to_error("Unknown SSO action"), + } +} + +pub async fn sso_callback_post( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + tracing::debug!( + has_code = form.code.is_some(), + has_state = form.state.is_some(), + has_error = form.error.is_some(), + has_user = form.user.is_some(), + "SSO callback (POST/form_post) received" + ); + + let query = SsoCallbackQuery { + code: form.code, + state: form.state, + error: form.error, + error_description: form.error_description, + }; + + sso_callback(State(state), headers, Query(query)).await +} + +fn generate_registration_token() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +async fn handle_sso_login( + state: &AppState, + request_uri: &str, + provider: SsoProviderType, + user_info: &crate::sso::providers::SsoUserInfo, +) -> Response { + let identity = match state + .sso_repo + .get_external_identity_by_provider(provider, &user_info.provider_user_id) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + let token = generate_registration_token(); + if let Err(e) = state + .sso_repo + .create_pending_registration( + &token, + request_uri, + provider, + &user_info.provider_user_id, + user_info.username.as_deref(), + user_info.email.as_deref(), + user_info.email_verified.unwrap_or(false), + ) + .await + { + tracing::error!("Failed to create pending registration: {:?}", e); + return redirect_to_error("Database error"); + } + return Redirect::to(&format!( + "/app/oauth/sso-register?token={}", + urlencoding::encode(&token), + )) + .into_response(); + } + Err(e) => { + tracing::error!("SSO identity lookup failed: {:?}", e); + return redirect_to_error("Database error"); + } + }; + + if let Err(e) = state + .sso_repo + .update_external_identity_login( + identity.id, + user_info.username.as_deref(), + user_info.email.as_deref(), + ) + .await + { + tracing::warn!("Failed to update external identity last login: {:?}", e); + } + + let request_id = RequestId::new(request_uri.to_string()); + if let Err(e) = state + .oauth_repo + .set_authorization_did(&request_id, &identity.did, None) + .await + { + tracing::error!("Failed to set authorization DID: {:?}", e); + return redirect_to_error("Failed to authenticate"); + } + + tracing::info!( + did = %identity.did, + provider = %provider.as_str(), + provider_user_id = %user_info.provider_user_id, + "SSO login successful" + ); + + let has_totp = match state.user_repo.get_totp_record(&identity.did).await { + Ok(Some(record)) => record.verified, + _ => false, + }; + + if has_totp { + return Redirect::to(&format!( + "/app/oauth/totp?request_uri={}", + urlencoding::encode(request_uri) + )) + .into_response(); + } + + Redirect::to(&format!( + "/app/oauth/consent?request_uri={}", + urlencoding::encode(request_uri) + )) + .into_response() +} + +async fn handle_sso_link( + state: &AppState, + did: tranquil_types::Did, + provider: SsoProviderType, + user_info: &crate::sso::providers::SsoUserInfo, +) -> Response { + let existing = state + .sso_repo + .get_external_identity_by_provider(provider, &user_info.provider_user_id) + .await; + + match existing { + Ok(Some(existing_id)) => { + if existing_id.did != did { + tracing::warn!( + provider = %provider.as_str(), + provider_user_id = %user_info.provider_user_id, + existing_did = %existing_id.did, + requested_did = %did, + "SSO account already linked to different user" + ); + return Redirect::to(&format!( + "/app/security?error={}", + urlencoding::encode("This SSO account is already linked to a different user") + )) + .into_response(); + } + tracing::info!( + did = %did, + provider = %provider.as_str(), + "SSO account already linked to this user" + ); + return Redirect::to("/app/security?sso_linked=true").into_response(); + } + Ok(None) => {} + Err(e) => { + tracing::error!("Failed to check existing identity: {:?}", e); + return Redirect::to(&format!( + "/app/security?error={}", + urlencoding::encode("Database error") + )) + .into_response(); + } + } + + if let Err(e) = state + .sso_repo + .create_external_identity( + &did, + provider, + &user_info.provider_user_id, + user_info.username.as_deref(), + user_info.email.as_deref(), + ) + .await + { + tracing::error!("Failed to create external identity: {:?}", e); + return Redirect::to(&format!( + "/app/security?error={}", + urlencoding::encode("Failed to link account") + )) + .into_response(); + } + + tracing::info!( + did = %did, + provider = %provider.as_str(), + provider_user_id = %user_info.provider_user_id, + "Successfully linked SSO account" + ); + Redirect::to("/app/security?sso_linked=true").into_response() +} + +async fn handle_sso_register( + state: &AppState, + request_uri: &str, + provider: SsoProviderType, + user_info: &crate::sso::providers::SsoUserInfo, +) -> Response { + match state + .sso_repo + .get_external_identity_by_provider(provider, &user_info.provider_user_id) + .await + { + Ok(Some(_)) => { + return redirect_to_error( + "This account is already linked to an existing user. Please sign in instead.", + ); + } + Ok(None) => {} + Err(e) => { + tracing::error!("SSO identity lookup failed: {:?}", e); + return redirect_to_error("Database error"); + } + } + + let token = generate_registration_token(); + if let Err(e) = state + .sso_repo + .create_pending_registration( + &token, + request_uri, + provider, + &user_info.provider_user_id, + user_info.username.as_deref(), + user_info.email.as_deref(), + user_info.email_verified.unwrap_or(false), + ) + .await + { + tracing::error!("Failed to create pending registration: {:?}", e); + return redirect_to_error("Database error"); + } + Redirect::to(&format!( + "/app/oauth/sso-register?token={}", + urlencoding::encode(&token), + )) + .into_response() +} + +#[derive(Debug, Serialize)] +pub struct LinkedAccountInfo { + pub id: String, + pub provider: String, + pub provider_name: String, + pub provider_username: Option, + pub provider_email: Option, + pub created_at: String, + pub last_login_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct LinkedAccountsResponse { + pub accounts: Vec, +} + +pub async fn get_linked_accounts( + State(state): State, + crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth, +) -> Result, ApiError> { + let identities = state + .sso_repo + .get_external_identities_by_did(&auth.did) + .await?; + + let accounts = identities + .into_iter() + .map(|id| LinkedAccountInfo { + id: id.id.to_string(), + provider: id.provider.as_str().to_string(), + provider_name: id.provider.display_name().to_string(), + provider_username: id.provider_username, + provider_email: id.provider_email, + created_at: id.created_at.to_rfc3339(), + last_login_at: id.last_login_at.map(|t| t.to_rfc3339()), + }) + .collect(); + + Ok(Json(LinkedAccountsResponse { accounts })) +} + +#[derive(Debug, Deserialize)] +pub struct UnlinkAccountRequest { + pub id: String, +} + +#[derive(Debug, Serialize)] +pub struct UnlinkAccountResponse { + pub success: bool, +} + +pub async fn unlink_account( + State(state): State, + crate::auth::extractor::BearerAuth(auth): crate::auth::extractor::BearerAuth, + Json(input): Json, +) -> Result, ApiError> { + if !state + .check_rate_limit(RateLimitKind::SsoUnlink, auth.did.as_str()) + .await + { + tracing::warn!(did = %auth.did, "SSO unlink rate limit exceeded"); + return Err(ApiError::RateLimitExceeded(None)); + } + + let id = uuid::Uuid::parse_str(&input.id).map_err(|_| ApiError::InvalidId)?; + + let has_password = state + .user_repo + .has_password_by_did(&auth.did) + .await? + .unwrap_or(false); + + let passkeys = state.user_repo.get_passkeys_for_user(&auth.did).await?; + let has_passkeys = !passkeys.is_empty(); + + if !has_password && !has_passkeys { + let identities = state + .sso_repo + .get_external_identities_by_did(&auth.did) + .await?; + + if identities.len() <= 1 { + return Err(ApiError::InvalidRequest( + "Cannot unlink your only login method. Add a password or passkey first." + .to_string(), + )); + } + } + + let deleted = state + .sso_repo + .delete_external_identity(id, &auth.did) + .await?; + + if !deleted { + return Err(ApiError::SsoLinkNotFound); + } + + tracing::info!(did = %auth.did, identity_id = %id, "SSO account unlinked"); + + Ok(Json(UnlinkAccountResponse { success: true })) +} + +#[derive(Debug, Deserialize)] +pub struct PendingRegistrationQuery { + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct PendingRegistrationResponse { + pub request_uri: String, + pub provider: String, + pub provider_user_id: String, + pub provider_username: Option, + pub provider_email: Option, + pub provider_email_verified: bool, +} + +pub async fn get_pending_registration( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, ApiError> { + let client_ip = extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::SsoCallback, &client_ip) + .await + { + tracing::warn!(ip = %client_ip, "SSO pending registration rate limit exceeded"); + return Err(ApiError::RateLimitExceeded(None)); + } + + if query.token.len() > 100 { + return Err(ApiError::InvalidRequest("Invalid token".into())); + } + + let pending = state + .sso_repo + .get_pending_registration(&query.token) + .await? + .ok_or(ApiError::SsoSessionExpired)?; + + Ok(Json(PendingRegistrationResponse { + request_uri: pending.request_uri, + provider: pending.provider.as_str().to_string(), + provider_user_id: pending.provider_user_id, + provider_username: pending.provider_username, + provider_email: pending.provider_email, + provider_email_verified: pending.provider_email_verified, + })) +} + +#[derive(Debug, Deserialize)] +pub struct CheckHandleQuery { + pub handle: String, +} + +#[derive(Debug, Serialize)] +pub struct CheckHandleResponse { + pub available: bool, + pub reason: Option, +} + +pub async fn check_handle_available( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { + if query.handle.len() > 100 { + return Ok(Json(CheckHandleResponse { + available: false, + reason: Some("Handle too long".into()), + })); + } + + let validated = match crate::api::validation::validate_short_handle(&query.handle) { + Ok(h) => h, + Err(e) => { + return Ok(Json(CheckHandleResponse { + available: false, + reason: Some(e.to_string()), + })); + } + }; + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + let full_handle = format!("{}.{}", validated, hostname_for_handles); + let handle_typed = crate::types::Handle::new_unchecked(&full_handle); + + let db_available = state + .user_repo + .check_handle_available_for_new_account(&handle_typed) + .await + .unwrap_or(false); + + if !db_available { + return Ok(Json(CheckHandleResponse { + available: false, + reason: Some("Handle is already taken".into()), + })); + } + + Ok(Json(CheckHandleResponse { + available: true, + reason: None, + })) +} + +#[derive(Debug, Deserialize)] +pub struct CompleteRegistrationInput { + pub token: String, + pub handle: String, + pub email: Option, + pub invite_code: Option, + pub verification_channel: Option, + pub discord_id: Option, + pub telegram_username: Option, + pub signal_number: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteRegistrationResponse { + pub did: String, + pub handle: String, + pub redirect_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_jwt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_jwt: Option, +} + +pub async fn complete_registration( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> Result, ApiError> { + use jacquard_common::types::{integer::LimitedU32, string::Tid}; + use jacquard_repo::{mst::Mst, storage::BlockStore}; + use k256::ecdsa::SigningKey; + use rand::rngs::OsRng; + use serde_json::json; + use std::sync::Arc; + + let client_ip = extract_client_ip(&headers, None); + if !state + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) + .await + { + tracing::warn!(ip = %client_ip, "SSO registration rate limit exceeded"); + return Err(ApiError::RateLimitExceeded(None)); + } + + if input.token.len() > 100 { + return Err(ApiError::InvalidRequest("Invalid token".into())); + } + + if input.handle.len() > 100 { + return Err(ApiError::InvalidHandle(None)); + } + + let pending_preview = state + .sso_repo + .get_pending_registration(&input.token) + .await? + .ok_or(ApiError::SsoSessionExpired)?; + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let hostname_for_handles = hostname.split(':').next().unwrap_or(&hostname); + + let handle = match crate::api::validation::validate_short_handle(&input.handle) { + Ok(h) => format!("{}.{}", h, hostname_for_handles), + Err(_) => return Err(ApiError::InvalidHandle(None)), + }; + + let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); + let verification_recipient = match verification_channel { + "email" => { + let email = input + .email + .clone() + .or_else(|| pending_preview.provider_email.clone()) + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + match email { + Some(e) if !e.is_empty() => e, + _ => return Err(ApiError::MissingEmail), + } + } + "discord" => match &input.discord_id { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => return Err(ApiError::MissingDiscordId), + }, + "telegram" => match &input.telegram_username { + Some(username) if !username.trim().is_empty() => username.trim().to_string(), + _ => return Err(ApiError::MissingTelegramUsername), + }, + "signal" => match &input.signal_number { + Some(number) if !number.trim().is_empty() => number.trim().to_string(), + _ => return Err(ApiError::MissingSignalNumber), + }, + _ => return Err(ApiError::InvalidVerificationChannel), + }; + + let email = input + .email + .clone() + .or_else(|| pending_preview.provider_email.clone()) + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()); + + let email = match &email { + Some(e) => { + if e.len() > 254 { + return Err(ApiError::InvalidEmail); + } + if !crate::api::validation::is_valid_email(e) { + return Err(ApiError::InvalidEmail); + } + let email_exists = state + .user_repo + .check_email_exists(e, uuid::Uuid::nil()) + .await + .unwrap_or(true); + if email_exists { + return Err(ApiError::EmailTaken); + } + Some(e.clone()) + } + None => None, + }; + + if let Some(ref code) = input.invite_code { + let valid = state + .infra_repo + .is_invite_code_valid(code) + .await + .unwrap_or(false); + if !valid { + return Err(ApiError::InvalidInviteCode); + } + } else { + let invite_required = std::env::var("INVITE_CODE_REQUIRED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if invite_required { + return Err(ApiError::InviteCodeRequired); + } + } + + let handle_typed = crate::types::Handle::new_unchecked(&handle); + let reserved = state + .user_repo + .reserve_handle(&handle_typed, &client_ip) + .await + .unwrap_or(false); + + if !reserved { + return Err(ApiError::HandleNotAvailable(None)); + } + + let secret_key = k256::SecretKey::random(&mut OsRng); + let secret_key_bytes = secret_key.to_bytes().to_vec(); + let signing_key = match SigningKey::from_slice(&secret_key_bytes) { + Ok(k) => k, + Err(e) => { + tracing::error!("Error creating signing key: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let pds_endpoint = format!("https://{}", hostname); + let rotation_key = std::env::var("PLC_ROTATION_KEY") + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); + + let genesis_result = match crate::plc::create_genesis_operation( + &signing_key, + &rotation_key, + &handle, + &pds_endpoint, + ) { + Ok(r) => r, + Err(e) => { + tracing::error!("Error creating PLC genesis operation: {:?}", e); + return Err(ApiError::InternalError(Some( + "Failed to create PLC operation".into(), + ))); + } + }; + + let plc_client = crate::plc::PlcClient::with_cache(None, Some(state.cache.clone())); + if let Err(e) = plc_client + .send_operation(&genesis_result.did, &genesis_result.signed_operation) + .await + { + tracing::error!("Failed to submit PLC genesis operation: {:?}", e); + return Err(ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + ))); + } + + let did = genesis_result.did; + tracing::info!(did = %did, handle = %handle, provider = %pending_preview.provider.as_str(), "Created DID for SSO account"); + + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { + Ok(bytes) => bytes, + Err(e) => { + tracing::error!("Error encrypting signing key: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let mst = Mst::new(Arc::new(state.block_store.clone())); + let mst_root = match mst.persist().await { + Ok(c) => c, + Err(e) => { + tracing::error!("Error persisting MST: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let rev = Tid::now(LimitedU32::MIN); + let did_typed = crate::types::Did::new_unchecked(&did); + let (commit_bytes, _sig) = match crate::api::repo::record::utils::create_signed_commit( + &did_typed, + mst_root, + rev.as_ref(), + None, + &signing_key, + ) { + Ok(result) => result, + Err(e) => { + tracing::error!("Error creating genesis commit: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Error saving genesis commit: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; + + let birthdate_pref = std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").ok().map(|_| { + json!({ + "$type": "app.bsky.actor.defs#personalDetailsPref", + "birthDate": "1998-05-06T00:00:00.000Z" + }) + }); + + let preferred_comms_channel = match verification_channel { + "email" => tranquil_db_traits::CommsChannel::Email, + "discord" => tranquil_db_traits::CommsChannel::Discord, + "telegram" => tranquil_db_traits::CommsChannel::Telegram, + "signal" => tranquil_db_traits::CommsChannel::Signal, + _ => tranquil_db_traits::CommsChannel::Email, + }; + + let create_input = tranquil_db_traits::CreateSsoAccountInput { + handle: handle_typed.clone(), + email: email.clone(), + did: did_typed.clone(), + preferred_comms_channel, + discord_id: input + .discord_id + .clone() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + telegram_username: input + .telegram_username + .clone() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + signal_number: input + .signal_number + .clone() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + encrypted_key_bytes: encrypted_key_bytes.clone(), + encryption_version: crate::config::ENCRYPTION_VERSION, + commit_cid: commit_cid.to_string(), + repo_rev: rev.as_ref().to_string(), + genesis_block_cids, + invite_code: input.invite_code.clone(), + birthdate_pref, + sso_provider: pending_preview.provider, + sso_provider_user_id: pending_preview.provider_user_id.clone(), + sso_provider_username: pending_preview.provider_username.clone(), + sso_provider_email: pending_preview.provider_email.clone(), + sso_provider_email_verified: pending_preview.provider_email_verified, + pending_registration_token: input.token.clone(), + }; + + let _create_result = match state.user_repo.create_sso_account(&create_input).await { + Ok(r) => r, + Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { + return Err(ApiError::HandleNotAvailable(None)); + } + Err(tranquil_db_traits::CreateAccountError::EmailTaken) => { + return Err(ApiError::EmailTaken); + } + Err(tranquil_db_traits::CreateAccountError::InvalidToken) => { + return Err(ApiError::SsoSessionExpired); + } + Err(e) => { + tracing::error!("Error creating SSO account: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let _ = state + .user_repo + .release_handle_reservation(&handle_typed) + .await; + + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did_typed, Some(&handle_typed)) + .await + { + tracing::warn!("Failed to sequence identity event for {}: {}", did, e); + } + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, &did_typed, true, None).await + { + tracing::warn!("Failed to sequence account event for {}: {}", did, e); + } + + let profile_record = json!({ + "$type": "app.bsky.actor.profile", + "displayName": handle_typed.as_str() + }); + let profile_collection = crate::types::Nsid::new_unchecked("app.bsky.actor.profile"); + let profile_rkey = crate::types::Rkey::new_unchecked("self"); + if let Err(e) = crate::api::repo::record::create_record_internal( + &state, + &did_typed, + &profile_collection, + &profile_rkey, + &profile_record, + ) + .await + { + tracing::warn!("Failed to create default profile for {}: {}", did, e); + } + + let is_standalone = pending_preview.request_uri == "standalone"; + + if !is_standalone { + let request_id = RequestId::new(pending_preview.request_uri.clone()); + if let Err(e) = state + .oauth_repo + .set_authorization_did(&request_id, &did_typed, None) + .await + { + tracing::error!("Failed to set authorization DID: {:?}", e); + return Err(ApiError::InternalError(None)); + } + } + + tracing::info!( + did = %did, + handle = %handle, + provider = %pending_preview.provider.as_str(), + provider_user_id = %pending_preview.provider_user_id, + standalone = %is_standalone, + "SSO registration completed successfully" + ); + + let user_id = state + .user_repo + .get_id_by_did(&did_typed) + .await + .unwrap_or(None); + + let channel_auto_verified = verification_channel == "email" + && pending_preview.provider_email_verified + && pending_preview.provider_email.as_ref() == email.as_ref(); + + if channel_auto_verified { + let _ = state + .user_repo + .set_channel_verified(&did_typed, tranquil_db_traits::CommsChannel::Email) + .await; + tracing::info!(did = %did, "Auto-verified email from SSO provider"); + + if is_standalone { + let key_bytes = match crate::config::decrypt_key( + &encrypted_key_bytes, + Some(crate::config::ENCRYPTION_VERSION), + ) { + Ok(k) => k, + Err(e) => { + tracing::error!("Failed to decrypt user key: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let access_meta = match crate::auth::create_access_token_with_metadata(&did, &key_bytes) + { + Ok(m) => m, + Err(e) => { + tracing::error!("Failed to create access token: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + let refresh_meta = + match crate::auth::create_refresh_token_with_metadata(&did, &key_bytes) { + Ok(m) => m, + Err(e) => { + tracing::error!("Failed to create refresh token: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + + let session_data = tranquil_db_traits::SessionTokenCreate { + did: did_typed.clone(), + access_jti: access_meta.jti.clone(), + refresh_jti: refresh_meta.jti.clone(), + access_expires_at: access_meta.expires_at, + refresh_expires_at: refresh_meta.expires_at, + legacy_login: false, + mfa_verified: false, + scope: None, + controller_did: None, + app_password_name: None, + }; + if let Err(e) = state.session_repo.create_session(&session_data).await { + tracing::error!("Failed to insert session: {:?}", e); + return Err(ApiError::InternalError(None)); + } + + let hostname = + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = crate::comms::comms_repo::enqueue_welcome( + state.user_repo.as_ref(), + state.infra_repo.as_ref(), + user_id.unwrap_or(uuid::Uuid::nil()), + &hostname, + ) + .await + { + tracing::warn!("Failed to enqueue welcome notification: {:?}", e); + } + + return Ok(Json(CompleteRegistrationResponse { + did, + handle, + redirect_url: "/app/dashboard".to_string(), + access_jwt: Some(access_meta.token), + refresh_jwt: Some(refresh_meta.token), + })); + } + + return Ok(Json(CompleteRegistrationResponse { + did, + handle, + redirect_url: format!( + "/app/oauth/consent?request_uri={}", + urlencoding::encode(&pending_preview.request_uri) + ), + access_jwt: None, + refresh_jwt: None, + })); + } + + if let Some(uid) = user_id { + let verification_token = crate::auth::verification_token::generate_signup_token( + &did, + verification_channel, + &verification_recipient, + ); + let formatted_token = + crate::auth::verification_token::format_token_for_display(&verification_token); + if let Err(e) = crate::comms::comms_repo::enqueue_signup_verification( + state.infra_repo.as_ref(), + uid, + verification_channel, + &verification_recipient, + &formatted_token, + &hostname, + ) + .await + { + tracing::warn!("Failed to enqueue signup verification: {:?}", e); + } + } + + let redirect_url = if is_standalone { + format!("/app/verify?did={}", urlencoding::encode(&did)) + } else { + format!( + "/app/oauth/verify?request_uri={}", + urlencoding::encode(&pending_preview.request_uri) + ) + }; + + Ok(Json(CompleteRegistrationResponse { + did, + handle, + redirect_url, + access_jwt: None, + refresh_jwt: None, + })) +} diff --git a/crates/tranquil-pds/src/sso/mod.rs b/crates/tranquil-pds/src/sso/mod.rs new file mode 100644 index 0000000..56fd438 --- /dev/null +++ b/crates/tranquil-pds/src/sso/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod endpoints; +pub mod providers; + +pub use config::SsoConfig; +pub use providers::{AuthUrlResult, SsoError, SsoManager, SsoProvider, SsoUserInfo}; diff --git a/crates/tranquil-pds/src/sso/providers.rs b/crates/tranquil-pds/src/sso/providers.rs new file mode 100644 index 0000000..74daf76 --- /dev/null +++ b/crates/tranquil-pds/src/sso/providers.rs @@ -0,0 +1,1126 @@ +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, jwk::JwkSet}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use tokio::sync::{OnceCell, RwLock}; +use tranquil_db_traits::SsoProviderType; + +use super::config::{AppleProviderConfig, ProviderConfig, SsoConfig}; + +const SSO_HTTP_TIMEOUT: Duration = Duration::from_secs(15); + +fn create_http_client() -> Client { + Client::builder() + .timeout(SSO_HTTP_TIMEOUT) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("Failed to create HTTP client") +} + +#[derive(Debug, Error)] +pub enum SsoError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("Provider error: {0}")] + Provider(String), + + #[error("Invalid response: {0}")] + InvalidResponse(String), + + #[error("OIDC discovery failed: {0}")] + Discovery(String), + + #[error("JWT validation failed: {0}")] + JwtValidation(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SsoTokenResponse { + pub access_token: String, + pub token_type: Option, + pub id_token: Option, +} + +#[derive(Debug, Clone)] +pub struct SsoUserInfo { + pub provider_user_id: String, + pub username: Option, + pub email: Option, + pub email_verified: Option, +} + +pub struct AuthUrlResult { + pub url: String, + pub code_verifier: Option, +} + +#[async_trait] +pub trait SsoProvider: Send + Sync { + fn provider_type(&self) -> SsoProviderType; + fn display_name(&self) -> &str; + fn icon_name(&self) -> &str; + + async fn build_auth_url( + &self, + state: &str, + redirect_uri: &str, + nonce: Option<&str>, + ) -> Result; + + async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + code_verifier: Option<&str>, + ) -> Result; + + async fn get_user_info( + &self, + access_token: &str, + id_token: Option<&str>, + expected_nonce: Option<&str>, + ) -> Result; +} + +pub struct GitHubProvider { + client_id: String, + client_secret: String, + http_client: Client, +} + +impl GitHubProvider { + pub fn new(config: &ProviderConfig) -> Self { + Self { + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + http_client: create_http_client(), + } + } +} + +#[derive(Debug, Deserialize)] +struct GitHubTokenResponse { + access_token: String, + token_type: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubUser { + id: i64, + login: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubEmail { + email: String, + primary: bool, + verified: bool, +} + +#[async_trait] +impl SsoProvider for GitHubProvider { + fn provider_type(&self) -> SsoProviderType { + SsoProviderType::Github + } + + fn display_name(&self) -> &str { + "GitHub" + } + + fn icon_name(&self) -> &str { + "github" + } + + async fn build_auth_url( + &self, + state: &str, + redirect_uri: &str, + _nonce: Option<&str>, + ) -> Result { + let url = format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}&scope=read:user%20user:email", + urlencoding::encode(&self.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + ); + Ok(AuthUrlResult { + url, + code_verifier: None, + }) + } + + async fn exchange_code( + &self, + code: &str, + _redirect_uri: &str, + _code_verifier: Option<&str>, + ) -> Result { + let resp = self + .http_client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(&[ + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ("code", &code.to_string()), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(SsoError::Provider(format!("GitHub token error: {}", text))); + } + + let data: GitHubTokenResponse = resp.json().await?; + Ok(SsoTokenResponse { + access_token: data.access_token, + token_type: data.token_type, + id_token: None, + }) + } + + async fn get_user_info( + &self, + access_token: &str, + _id_token: Option<&str>, + _expected_nonce: Option<&str>, + ) -> Result { + let user: GitHubUser = self + .http_client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "tranquil-pds") + .send() + .await? + .json() + .await?; + + let emails_result: Result, _> = self + .http_client + .get("https://api.github.com/user/emails") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "tranquil-pds") + .send() + .await? + .json() + .await; + + let emails = match emails_result { + Ok(e) => e, + Err(e) => { + tracing::warn!( + github_user_id = %user.id, + error = %e, + "Failed to fetch GitHub user emails, continuing without email" + ); + Vec::new() + } + }; + + let primary_email = emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| e.email.clone()); + + Ok(SsoUserInfo { + provider_user_id: user.id.to_string(), + username: Some(user.login), + email: primary_email, + email_verified: Some(true), + }) + } +} + +pub struct DiscordProvider { + client_id: String, + client_secret: String, + http_client: Client, +} + +impl DiscordProvider { + pub fn new(config: &ProviderConfig) -> Self { + Self { + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + http_client: create_http_client(), + } + } +} + +#[derive(Debug, Deserialize)] +struct DiscordTokenResponse { + access_token: String, + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct DiscordUser { + id: String, + username: String, + email: Option, + verified: Option, +} + +#[async_trait] +impl SsoProvider for DiscordProvider { + fn provider_type(&self) -> SsoProviderType { + SsoProviderType::Discord + } + + fn display_name(&self) -> &str { + "Discord" + } + + fn icon_name(&self) -> &str { + "discord" + } + + async fn build_auth_url( + &self, + state: &str, + redirect_uri: &str, + _nonce: Option<&str>, + ) -> Result { + let url = format!( + "https://discord.com/api/oauth2/authorize?client_id={}&redirect_uri={}&state={}&response_type=code&scope=identify%20email", + urlencoding::encode(&self.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + ); + Ok(AuthUrlResult { + url, + code_verifier: None, + }) + } + + async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + _code_verifier: Option<&str>, + ) -> Result { + let resp = self + .http_client + .post("https://discord.com/api/oauth2/token") + .form(&[ + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ("code", &code.to_string()), + ("grant_type", &"authorization_code".to_string()), + ("redirect_uri", &redirect_uri.to_string()), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(SsoError::Provider(format!("Discord token error: {}", text))); + } + + let data: DiscordTokenResponse = resp.json().await?; + Ok(SsoTokenResponse { + access_token: data.access_token, + token_type: Some(data.token_type), + id_token: None, + }) + } + + async fn get_user_info( + &self, + access_token: &str, + _id_token: Option<&str>, + _expected_nonce: Option<&str>, + ) -> Result { + let user: DiscordUser = self + .http_client + .get("https://discord.com/api/users/@me") + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await? + .json() + .await?; + + Ok(SsoUserInfo { + provider_user_id: user.id, + username: Some(user.username), + email: user.email, + email_verified: user.verified, + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OidcDiscoveryConfig { + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: Option, + pub jwks_uri: Option, +} + +struct OidcDiscoveryCache { + config: OidcDiscoveryConfig, + jwks: Option, +} + +pub struct OidcProvider { + provider_type: SsoProviderType, + client_id: String, + client_secret: String, + issuer: String, + display_name: String, + http_client: Client, + discovery_cache: OnceCell, +} + +impl OidcProvider { + pub fn new( + provider_type: SsoProviderType, + config: &ProviderConfig, + default_issuer: Option<&str>, + default_name: &str, + ) -> Option { + let issuer = config + .issuer + .clone() + .or_else(|| default_issuer.map(String::from))?; + + Some(Self { + provider_type, + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + issuer, + display_name: config + .display_name + .clone() + .unwrap_or_else(|| default_name.to_string()), + http_client: create_http_client(), + discovery_cache: OnceCell::new(), + }) + } + + async fn get_discovery(&self) -> Result<&OidcDiscoveryCache, SsoError> { + self.discovery_cache + .get_or_try_init(|| async { + let discovery_url = format!( + "{}/.well-known/openid-configuration", + self.issuer.trim_end_matches('/') + ); + + tracing::debug!( + provider = %self.provider_type.as_str(), + url = %discovery_url, + "Fetching OIDC discovery document" + ); + + let resp = self + .http_client + .get(&discovery_url) + .send() + .await + .map_err(|e| SsoError::Discovery(e.to_string()))?; + + if !resp.status().is_success() { + return Err(SsoError::Discovery(format!( + "Discovery endpoint returned {}", + resp.status() + ))); + } + + let config: OidcDiscoveryConfig = resp + .json() + .await + .map_err(|e| SsoError::Discovery(e.to_string()))?; + + let jwks = match &config.jwks_uri { + Some(jwks_uri) => { + tracing::debug!( + provider = %self.provider_type.as_str(), + url = %jwks_uri, + "Fetching JWKS" + ); + let jwks_resp = + self.http_client.get(jwks_uri).send().await.map_err(|e| { + SsoError::Discovery(format!("JWKS fetch failed: {}", e)) + })?; + + if jwks_resp.status().is_success() { + Some(jwks_resp.json::().await.map_err(|e| { + SsoError::Discovery(format!("JWKS parse failed: {}", e)) + })?) + } else { + tracing::warn!( + provider = %self.provider_type.as_str(), + status = %jwks_resp.status(), + "JWKS fetch returned non-success status" + ); + None + } + } + None => None, + }; + + Ok(OidcDiscoveryCache { config, jwks }) + }) + .await + } + + fn generate_pkce() -> (String, String) { + use rand::RngCore; + let mut verifier_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut verifier_bytes); + let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + + use sha2::{Digest, Sha256}; + let challenge_bytes = Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(challenge_bytes); + + (verifier, challenge) + } + + fn validate_id_token( + &self, + id_token: &str, + jwks: &JwkSet, + expected_nonce: Option<&str>, + ) -> Result { + let header = jsonwebtoken::decode_header(id_token) + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWT header: {}", e)))?; + + let kid = header + .kid + .as_ref() + .ok_or_else(|| SsoError::JwtValidation("JWT missing kid header".to_string()))?; + + let jwk = jwks + .find(kid) + .ok_or_else(|| SsoError::JwtValidation(format!("No matching JWK for kid: {}", kid)))?; + + let decoding_key = DecodingKey::from_jwk(jwk) + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWK: {}", e)))?; + + let algorithm = match header.alg { + jsonwebtoken::Algorithm::RS256 => Algorithm::RS256, + jsonwebtoken::Algorithm::RS384 => Algorithm::RS384, + jsonwebtoken::Algorithm::RS512 => Algorithm::RS512, + jsonwebtoken::Algorithm::ES256 => Algorithm::ES256, + jsonwebtoken::Algorithm::ES384 => Algorithm::ES384, + alg => { + return Err(SsoError::JwtValidation(format!( + "Unsupported algorithm: {:?}", + alg + ))); + } + }; + + let mut validation = Validation::new(algorithm); + validation.set_audience(&[&self.client_id]); + validation.set_issuer(&[&self.issuer]); + + let token_data = + jsonwebtoken::decode::(id_token, &decoding_key, &validation) + .map_err(|e| SsoError::JwtValidation(format!("JWT validation failed: {}", e)))?; + + if let Some(expected) = expected_nonce { + match &token_data.claims.nonce { + Some(actual) if actual == expected => {} + Some(actual) => { + return Err(SsoError::JwtValidation(format!( + "Nonce mismatch: expected {}, got {}", + expected, actual + ))); + } + None => { + return Err(SsoError::JwtValidation( + "Missing nonce in id_token".to_string(), + )); + } + } + } + + Ok(token_data.claims) + } +} + +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + sub: String, + #[serde(default)] + email: Option, + #[serde(default)] + email_verified: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + name: Option, + #[serde(default)] + nonce: Option, +} + +#[async_trait] +impl SsoProvider for OidcProvider { + fn provider_type(&self) -> SsoProviderType { + self.provider_type + } + + fn display_name(&self) -> &str { + &self.display_name + } + + fn icon_name(&self) -> &str { + self.provider_type.icon_name() + } + + async fn build_auth_url( + &self, + state: &str, + redirect_uri: &str, + nonce: Option<&str>, + ) -> Result { + let (verifier, challenge) = Self::generate_pkce(); + + let auth_endpoint = match self.provider_type { + SsoProviderType::Google => "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + SsoProviderType::Gitlab => { + format!("{}/oauth/authorize", self.issuer.trim_end_matches('/')) + } + _ => { + let discovery = self.get_discovery().await?; + discovery.config.authorization_endpoint.clone() + } + }; + + let mut url = format!( + "{}?client_id={}&redirect_uri={}&state={}&response_type=code&scope=openid%20email%20profile&code_challenge={}&code_challenge_method=S256", + auth_endpoint, + urlencoding::encode(&self.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + urlencoding::encode(&challenge), + ); + + if let Some(n) = nonce { + url.push_str(&format!("&nonce={}", urlencoding::encode(n))); + } + + Ok(AuthUrlResult { + url, + code_verifier: Some(verifier), + }) + } + + async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + code_verifier: Option<&str>, + ) -> Result { + let token_endpoint = match self.provider_type { + SsoProviderType::Google => "https://oauth2.googleapis.com/token".to_string(), + SsoProviderType::Gitlab => format!("{}/oauth/token", self.issuer.trim_end_matches('/')), + _ => { + let discovery = self.get_discovery().await?; + discovery.config.token_endpoint.clone() + } + }; + + let mut params: HashMap<&str, &str> = HashMap::new(); + params.insert("client_id", &self.client_id); + params.insert("client_secret", &self.client_secret); + params.insert("code", code); + params.insert("redirect_uri", redirect_uri); + params.insert("grant_type", "authorization_code"); + + if let Some(verifier) = code_verifier { + params.insert("code_verifier", verifier); + } + + let resp = self + .http_client + .post(&token_endpoint) + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(SsoError::Provider(format!("OIDC token error: {}", text))); + } + + #[derive(Deserialize)] + struct TokenResp { + access_token: String, + token_type: Option, + id_token: Option, + } + + let data: TokenResp = resp.json().await?; + Ok(SsoTokenResponse { + access_token: data.access_token, + token_type: data.token_type, + id_token: data.id_token, + }) + } + + async fn get_user_info( + &self, + access_token: &str, + id_token: Option<&str>, + expected_nonce: Option<&str>, + ) -> Result { + if let Some(token) = id_token { + let discovery = self.get_discovery().await?; + if let Some(ref jwks) = discovery.jwks { + match self.validate_id_token(token, jwks, expected_nonce) { + Ok(claims) => { + tracing::debug!( + provider = %self.provider_type.as_str(), + sub = %claims.sub, + "Successfully validated id_token" + ); + return Ok(SsoUserInfo { + provider_user_id: claims.sub, + username: claims.preferred_username.or(claims.name), + email: claims.email, + email_verified: claims.email_verified, + }); + } + Err(e) => { + tracing::warn!( + provider = %self.provider_type.as_str(), + error = %e, + "id_token validation failed, falling back to userinfo endpoint" + ); + } + } + } + } + + let userinfo_endpoint = match self.provider_type { + SsoProviderType::Google => { + "https://openidconnect.googleapis.com/v1/userinfo".to_string() + } + SsoProviderType::Gitlab => { + format!("{}/oauth/userinfo", self.issuer.trim_end_matches('/')) + } + _ => { + let discovery = self.get_discovery().await?; + discovery + .config + .userinfo_endpoint + .clone() + .ok_or_else(|| SsoError::Discovery("No userinfo endpoint".to_string()))? + } + }; + + let resp = self + .http_client + .get(&userinfo_endpoint) + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(SsoError::Provider(format!("Userinfo error: {}", text))); + } + + #[derive(Deserialize)] + struct UserInfo { + sub: String, + preferred_username: Option, + name: Option, + email: Option, + email_verified: Option, + } + + let info: UserInfo = resp.json().await?; + Ok(SsoUserInfo { + provider_user_id: info.sub, + username: info.preferred_username.or(info.name), + email: info.email, + email_verified: info.email_verified, + }) + } +} + +struct CachedClientSecret { + secret: String, + expires_at: u64, +} + +pub struct AppleProvider { + client_id: String, + team_id: String, + key_id: String, + private_key_pem: String, + http_client: Client, + client_secret_cache: RwLock>, + jwks_cache: OnceCell, +} + +impl AppleProvider { + pub fn new(config: &AppleProviderConfig) -> Result { + let key_pem = config.private_key_pem.replace("\\n", "\n"); + + jsonwebtoken::EncodingKey::from_ec_pem(key_pem.as_bytes()) + .map_err(|e| SsoError::Provider(format!("Invalid Apple private key: {}", e)))?; + + Ok(Self { + client_id: config.client_id.clone(), + team_id: config.team_id.clone(), + key_id: config.key_id.clone(), + private_key_pem: key_pem, + http_client: create_http_client(), + client_secret_cache: RwLock::new(None), + jwks_cache: OnceCell::new(), + }) + } + + fn generate_client_secret(&self) -> Result<(String, u64), SsoError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let exp = now + (150 * 24 * 60 * 60); + + #[derive(Serialize)] + struct AppleClientSecretClaims { + iss: String, + iat: u64, + exp: u64, + aud: String, + sub: String, + } + + let claims = AppleClientSecretClaims { + iss: self.team_id.clone(), + iat: now, + exp, + aud: "https://appleid.apple.com".to_string(), + sub: self.client_id.clone(), + }; + + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(self.key_id.clone()); + + let encoding_key = + EncodingKey::from_ec_pem(self.private_key_pem.as_bytes()).map_err(|e| { + SsoError::Provider(format!("Invalid Apple private key for encoding: {}", e)) + })?; + + let token = jsonwebtoken::encode(&header, &claims, &encoding_key).map_err(|e| { + SsoError::Provider(format!("Failed to generate Apple client secret: {}", e)) + })?; + + Ok((token, exp)) + } + + async fn get_client_secret(&self) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + { + let cache = self.client_secret_cache.read().await; + if let Some(ref cached) = *cache + && cached.expires_at > now + 3600 { + return Ok(cached.secret.clone()); + } + } + + let (secret, expires_at) = self.generate_client_secret()?; + + { + let mut cache = self.client_secret_cache.write().await; + *cache = Some(CachedClientSecret { + secret: secret.clone(), + expires_at, + }); + } + + Ok(secret) + } + + async fn get_jwks(&self) -> Result<&JwkSet, SsoError> { + self.jwks_cache + .get_or_try_init(|| async { + tracing::debug!("Fetching Apple JWKS"); + let resp = self + .http_client + .get("https://appleid.apple.com/auth/keys") + .send() + .await + .map_err(|e| SsoError::Discovery(format!("Apple JWKS fetch failed: {}", e)))?; + + if !resp.status().is_success() { + return Err(SsoError::Discovery(format!( + "Apple JWKS returned {}", + resp.status() + ))); + } + + resp.json::() + .await + .map_err(|e| SsoError::Discovery(format!("Apple JWKS parse failed: {}", e))) + }) + .await + } + + fn validate_id_token( + &self, + id_token: &str, + jwks: &JwkSet, + expected_nonce: Option<&str>, + ) -> Result { + let header = jsonwebtoken::decode_header(id_token) + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWT header: {}", e)))?; + + let kid = header + .kid + .as_ref() + .ok_or_else(|| SsoError::JwtValidation("JWT missing kid header".to_string()))?; + + let jwk = jwks + .find(kid) + .ok_or_else(|| SsoError::JwtValidation(format!("No matching JWK for kid: {}", kid)))?; + + let decoding_key = DecodingKey::from_jwk(jwk) + .map_err(|e| SsoError::JwtValidation(format!("Invalid JWK: {}", e)))?; + + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.client_id]); + validation.set_issuer(&["https://appleid.apple.com"]); + + let token_data = + jsonwebtoken::decode::(id_token, &decoding_key, &validation) + .map_err(|e| SsoError::JwtValidation(format!("JWT validation failed: {}", e)))?; + + if let Some(expected) = expected_nonce { + match &token_data.claims.nonce { + Some(actual) if actual == expected => {} + Some(actual) => { + return Err(SsoError::JwtValidation(format!( + "Nonce mismatch: expected {}, got {}", + expected, actual + ))); + } + None => { + return Err(SsoError::JwtValidation( + "Missing nonce in id_token".to_string(), + )); + } + } + } + + Ok(token_data.claims) + } +} + +#[derive(Debug, Deserialize)] +struct AppleIdTokenClaims { + sub: String, + #[serde(default)] + email: Option, + #[serde(default)] + email_verified: Option, + #[serde(default)] + nonce: Option, +} + +#[async_trait] +impl SsoProvider for AppleProvider { + fn provider_type(&self) -> SsoProviderType { + SsoProviderType::Apple + } + + fn display_name(&self) -> &str { + "Apple" + } + + fn icon_name(&self) -> &str { + "apple" + } + + async fn build_auth_url( + &self, + state: &str, + redirect_uri: &str, + nonce: Option<&str>, + ) -> Result { + let mut url = format!( + "https://appleid.apple.com/auth/authorize?client_id={}&redirect_uri={}&state={}&response_type=code&scope=name%20email&response_mode=form_post", + urlencoding::encode(&self.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + ); + + if let Some(n) = nonce { + url.push_str(&format!("&nonce={}", urlencoding::encode(n))); + } + + Ok(AuthUrlResult { + url, + code_verifier: None, + }) + } + + async fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + _code_verifier: Option<&str>, + ) -> Result { + let client_secret = self.get_client_secret().await?; + + let resp = self + .http_client + .post("https://appleid.apple.com/auth/token") + .form(&[ + ("client_id", &self.client_id), + ("client_secret", &client_secret), + ("code", &code.to_string()), + ("grant_type", &"authorization_code".to_string()), + ("redirect_uri", &redirect_uri.to_string()), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(SsoError::Provider(format!("Apple token error: {}", text))); + } + + #[derive(Deserialize)] + struct AppleTokenResp { + access_token: String, + token_type: Option, + id_token: Option, + } + + let data: AppleTokenResp = resp.json().await?; + Ok(SsoTokenResponse { + access_token: data.access_token, + token_type: data.token_type, + id_token: data.id_token, + }) + } + + async fn get_user_info( + &self, + _access_token: &str, + id_token: Option<&str>, + expected_nonce: Option<&str>, + ) -> Result { + let id_token = id_token.ok_or_else(|| { + SsoError::InvalidResponse("Apple did not return an id_token".to_string()) + })?; + + let jwks = self.get_jwks().await?; + let claims = self.validate_id_token(id_token, jwks, expected_nonce)?; + + tracing::debug!( + sub = %claims.sub, + email = ?claims.email, + "Successfully validated Apple id_token" + ); + + Ok(SsoUserInfo { + provider_user_id: claims.sub, + username: None, + email: claims.email, + email_verified: claims.email_verified, + }) + } +} + +#[derive(Clone)] +pub struct SsoManager { + providers: HashMap>, +} + +impl SsoManager { + pub fn from_config(config: &SsoConfig) -> Self { + let mut providers: HashMap> = HashMap::new(); + + if let Some(ref cfg) = config.github { + providers.insert(SsoProviderType::Github, Arc::new(GitHubProvider::new(cfg))); + } + + if let Some(ref cfg) = config.discord { + providers.insert( + SsoProviderType::Discord, + Arc::new(DiscordProvider::new(cfg)), + ); + } + + if let Some(ref cfg) = config.google + && let Some(provider) = OidcProvider::new( + SsoProviderType::Google, + cfg, + Some("https://accounts.google.com"), + "Google", + ) { + providers.insert(SsoProviderType::Google, Arc::new(provider)); + } + + if let Some(ref cfg) = config.gitlab + && let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab") + { + providers.insert(SsoProviderType::Gitlab, Arc::new(provider)); + } + + if let Some(ref cfg) = config.oidc + && let Some(provider) = OidcProvider::new( + SsoProviderType::Oidc, + cfg, + None, + cfg.display_name.as_deref().unwrap_or("SSO"), + ) { + providers.insert(SsoProviderType::Oidc, Arc::new(provider)); + } + + if let Some(ref cfg) = config.apple { + match AppleProvider::new(cfg) { + Ok(provider) => { + providers.insert(SsoProviderType::Apple, Arc::new(provider)); + } + Err(e) => { + tracing::error!(error = %e, "Failed to initialize Apple SSO provider"); + } + } + } + + Self { providers } + } + + pub fn get_provider(&self, provider_type: SsoProviderType) -> Option> { + self.providers.get(&provider_type).cloned() + } + + pub fn enabled_providers(&self) -> Vec<(SsoProviderType, &str, &str)> { + self.providers + .iter() + .map(|(t, p)| (*t, p.display_name(), p.icon_name())) + .collect() + } + + pub fn is_any_enabled(&self) -> bool { + !self.providers.is_empty() + } +} + +impl Default for SsoManager { + fn default() -> Self { + Self::from_config(SsoConfig::get()) + } +} diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index dc17cd6..720c8df 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -4,6 +4,7 @@ use crate::circuit_breaker::CircuitBreakers; use crate::config::AuthConfig; use crate::rate_limit::RateLimiters; use crate::repo::PostgresBlockStore; +use crate::sso::{SsoConfig, SsoManager}; use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; use crate::sync::firehose::SequencedEvent; use sqlx::PgPool; @@ -13,7 +14,7 @@ use tokio::sync::broadcast; use tranquil_db::{ BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, OAuthRepository, PostgresRepositories, RepoEventNotifier, RepoRepository, SessionRepository, - UserRepository, + SsoRepository, UserRepository, }; #[derive(Clone)] @@ -38,6 +39,8 @@ pub struct AppState { pub cache: Arc, pub distributed_rate_limiter: Arc, pub did_resolver: Arc, + pub sso_repo: Arc, + pub sso_manager: SsoManager, } pub enum RateLimitKind { @@ -56,6 +59,9 @@ pub enum RateLimitKind { HandleUpdate, HandleUpdateDaily, VerificationCheck, + SsoInitiate, + SsoCallback, + SsoUnlink, } impl RateLimitKind { @@ -76,6 +82,9 @@ impl RateLimitKind { Self::HandleUpdate => "handle_update", Self::HandleUpdateDaily => "handle_update_daily", Self::VerificationCheck => "verification_check", + Self::SsoInitiate => "sso_initiate", + Self::SsoCallback => "sso_callback", + Self::SsoUnlink => "sso_unlink", } } @@ -96,6 +105,9 @@ impl RateLimitKind { Self::HandleUpdate => (10, 300_000), Self::HandleUpdateDaily => (50, 86_400_000), Self::VerificationCheck => (60, 60_000), + Self::SsoInitiate => (10, 60_000), + Self::SsoCallback => (30, 60_000), + Self::SsoUnlink => (10, 60_000), } } } @@ -163,6 +175,8 @@ impl AppState { let circuit_breakers = Arc::new(CircuitBreakers::new()); let (cache, distributed_rate_limiter) = create_cache().await; let did_resolver = Arc::new(DidResolver::new()); + let sso_config = SsoConfig::init(); + let sso_manager = SsoManager::from_config(sso_config); Self { user_repo: repos.user.clone(), @@ -175,6 +189,7 @@ impl AppState { backup_repo: repos.backup.clone(), backlink_repo: repos.backlink.clone(), event_notifier: repos.event_notifier.clone(), + sso_repo: repos.sso.clone(), repos, block_store, blob_store: Arc::new(blob_store), @@ -185,6 +200,7 @@ impl AppState { cache, distributed_rate_limiter, did_resolver, + sso_manager, } } @@ -232,6 +248,9 @@ impl AppState { RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, + RateLimitKind::SsoInitiate => &self.rate_limiters.sso_initiate, + RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback, + RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink, }; let ok = limiter.check_key(&client_ip.to_string()).is_ok(); diff --git a/crates/tranquil-pds/src/sync/commit.rs b/crates/tranquil-pds/src/sync/commit.rs index 5a37bab..0559a88 100644 --- a/crates/tranquil-pds/src/sync/commit.rs +++ b/crates/tranquil-pds/src/sync/commit.rs @@ -114,21 +114,10 @@ pub async fn list_repos( let mut repos: Vec = Vec::new(); for row in rows.iter().take(limit as usize) { let cid_str = row.repo_root_cid.to_string(); - let rev = match get_rev_from_commit(&state, &cid_str).await { - Some(r) => r, - None => { - if let Some(ref stored_rev) = row.repo_rev { - stored_rev.clone() - } else { - tracing::warn!( - "Failed to parse commit for DID {} in list_repos: CID {}", - row.did, - row.repo_root_cid - ); - continue; - } - } - }; + let rev = get_rev_from_commit(&state, &cid_str) + .await + .or_else(|| row.repo_rev.clone()) + .unwrap_or_default(); let status = if row.takedown_ref.is_some() { AccountStatus::Takendown } else if row.deactivated_at.is_some() { diff --git a/crates/tranquil-pds/tests/apple_sso_unit.rs b/crates/tranquil-pds/tests/apple_sso_unit.rs new file mode 100644 index 0000000..b2c6f2a --- /dev/null +++ b/crates/tranquil-pds/tests/apple_sso_unit.rs @@ -0,0 +1,131 @@ +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header}; +use serde::{Deserialize, Serialize}; + +const TEST_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1G9/WIOAqDBWQd/v +fu+G8OdNg3cVx9sdnp90JRpm8j6hRANCAAR9NOwKON6tu9NG1jtyqqsAuDDq18lc +z+h/EEbR9hbfBEuCzxKhLrlYFLDLNrE/N3KkIPlQm38hnjUO3QXW0ZhY +-----END PRIVATE KEY-----"; + +const TEST_PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfTTsCjjerbvTRtY7cqqrALgw6tfJ +XM/ofxBG0fYW3wRLgs8SoS65WBSwyzaxPzdypCD5UJt/IZ41Dt0F1tGYWA== +-----END PUBLIC KEY-----"; + +const TEST_CLIENT_ID: &str = "com.example.test"; +const TEST_TEAM_ID: &str = "ABCDE12345"; +const TEST_KEY_ID: &str = "KEY123ABCD"; + +#[derive(Debug, Serialize, Deserialize)] +struct AppleClientSecretClaims { + iss: String, + iat: u64, + exp: u64, + aud: String, + sub: String, +} + +fn generate_test_client_secret() -> Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let exp = now + (150 * 24 * 60 * 60); + + let claims = AppleClientSecretClaims { + iss: TEST_TEAM_ID.to_string(), + iat: now, + exp, + aud: "https://appleid.apple.com".to_string(), + sub: TEST_CLIENT_ID.to_string(), + }; + + let mut header = jsonwebtoken::Header::new(Algorithm::ES256); + header.kid = Some(TEST_KEY_ID.to_string()); + + let encoding_key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()) + .map_err(|e| format!("Failed to create encoding key: {}", e))?; + + jsonwebtoken::encode(&header, &claims, &encoding_key) + .map_err(|e| format!("Failed to encode JWT: {}", e)) +} + +#[test] +fn test_apple_client_secret_generation() { + let token = generate_test_client_secret().expect("Failed to generate client secret"); + + assert!(!token.is_empty()); + + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have 3 parts"); + + let header = decode_header(&token).expect("Failed to decode header"); + assert_eq!(header.alg, Algorithm::ES256); + assert_eq!(header.kid, Some(TEST_KEY_ID.to_string())); +} + +#[test] +fn test_apple_client_secret_claims() { + let token = generate_test_client_secret().expect("Failed to generate client secret"); + + let parts: Vec<&str> = token.split('.').collect(); + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .expect("Failed to decode payload"); + let claims: AppleClientSecretClaims = + serde_json::from_slice(&payload_bytes).expect("Failed to parse claims"); + + assert_eq!(claims.iss, TEST_TEAM_ID); + assert_eq!(claims.sub, TEST_CLIENT_ID); + assert_eq!(claims.aud, "https://appleid.apple.com"); + assert!(claims.exp > claims.iat); + + let expected_exp_days = (claims.exp - claims.iat) / (24 * 60 * 60); + assert_eq!(expected_exp_days, 150, "Token should expire in 150 days"); +} + +#[test] +fn test_apple_client_secret_signature_valid() { + let token = generate_test_client_secret().expect("Failed to generate client secret"); + + let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()) + .expect("Failed to create decoding key"); + + let mut validation = Validation::new(Algorithm::ES256); + validation.set_audience(&["https://appleid.apple.com"]); + validation.set_issuer(&[TEST_TEAM_ID]); + + let token_data = + jsonwebtoken::decode::(&token, &decoding_key, &validation) + .expect("Failed to decode and verify token"); + + assert_eq!(token_data.claims.iss, TEST_TEAM_ID); + assert_eq!(token_data.claims.sub, TEST_CLIENT_ID); + assert_eq!(token_data.claims.aud, "https://appleid.apple.com"); +} + +#[test] +fn test_apple_private_key_validation() { + let result = jsonwebtoken::EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()); + assert!( + result.is_ok(), + "Should parse valid PKCS#8 P-256 private key" + ); + + let invalid_pem = "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----"; + let result = jsonwebtoken::EncodingKey::from_ec_pem(invalid_pem.as_bytes()); + assert!(result.is_err(), "Should reject invalid private key"); +} + +#[test] +fn test_apple_private_key_escaped_newlines() { + let escaped_pem = TEST_PRIVATE_KEY_PEM.replace('\n', "\\n"); + let unescaped = escaped_pem.replace("\\n", "\n"); + + let result = jsonwebtoken::EncodingKey::from_ec_pem(unescaped.as_bytes()); + assert!(result.is_ok(), "Should handle escaped newlines in PEM"); +} diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs new file mode 100644 index 0000000..0e69a0e --- /dev/null +++ b/crates/tranquil-pds/tests/sso.rs @@ -0,0 +1,1159 @@ +mod common; + +use common::{base_url, client, create_account_and_login, get_test_db_pool}; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use tranquil_db_traits::SsoProviderType; +use tranquil_types::Did; + +#[tokio::test] +async fn test_sso_providers_endpoint() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/oauth/sso/providers", url)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert!(body["providers"].is_array()); +} + +#[tokio::test] +async fn test_sso_initiate_invalid_provider() { + let url = base_url().await; + let client = client(); + + let res = client + .post(format!("{}/oauth/sso/initiate", url)) + .json(&json!({ + "provider": "nonexistent_provider", + "request_uri": "urn:test:request", + "action": "login" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "SsoProviderNotFound"); +} + +#[tokio::test] +async fn test_sso_initiate_invalid_action() { + let url = base_url().await; + let client = client(); + + let res = client + .post(format!("{}/oauth/sso/initiate", url)) + .json(&json!({ + "provider": "github", + "request_uri": "urn:test:request", + "action": "invalid_action" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert!( + body["error"] == "SsoInvalidAction" || body["error"] == "SsoProviderNotEnabled", + "Expected SsoInvalidAction or SsoProviderNotEnabled, got: {}", + body["error"] + ); +} + +#[tokio::test] +async fn test_sso_linked_requires_auth() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!("{}/oauth/sso/linked", url)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_sso_linked_returns_empty_for_new_user() { + let url = base_url().await; + let client = client(); + + let (token, _did) = create_account_and_login(&client).await; + + let res = client + .get(format!("{}/oauth/sso/linked", url)) + .bearer_auth(&token) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert!(body["accounts"].is_array()); + assert_eq!(body["accounts"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_sso_unlink_requires_auth() { + let url = base_url().await; + let client = client(); + + let res = client + .post(format!("{}/oauth/sso/unlink", url)) + .json(&json!({ + "id": "00000000-0000-0000-0000-000000000000" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_sso_unlink_invalid_id() { + let url = base_url().await; + let client = client(); + + let (token, _did) = create_account_and_login(&client).await; + + let res = client + .post(format!("{}/oauth/sso/unlink", url)) + .bearer_auth(&token) + .json(&json!({ + "id": "not-a-uuid" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidId"); +} + +#[tokio::test] +async fn test_sso_unlink_not_found() { + let url = base_url().await; + let client = client(); + + let (token, _did) = create_account_and_login(&client).await; + + let res = client + .post(format!("{}/oauth/sso/unlink", url)) + .bearer_auth(&token) + .json(&json!({ + "id": "00000000-0000-0000-0000-000000000000" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "SsoLinkNotFound"); +} + +#[tokio::test] +async fn test_sso_callback_missing_params() { + let url = base_url().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let res = client + .get(format!("{}/oauth/sso/callback", url)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::SEE_OTHER); + let location = res.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("/app/oauth/error")); +} + +#[tokio::test] +async fn test_sso_callback_with_error() { + let url = base_url().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let res = client + .get(format!( + "{}/oauth/sso/callback?error=access_denied&error_description=User%20cancelled", + url + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::SEE_OTHER); + let location = res.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("/app/oauth/error")); + assert!(location.contains("access_denied")); +} + +#[tokio::test] +async fn test_sso_callback_invalid_state() { + let url = base_url().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let res = client + .get(format!( + "{}/oauth/sso/callback?code=fake_code&state=invalid_state_token", + url + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::SEE_OTHER); + let location = res.headers().get("location").unwrap().to_str().unwrap(); + assert!(location.contains("/app/oauth/error")); +} + +#[tokio::test] +async fn test_external_identity_repository_crud() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let did = Did::new_unchecked(format!( + "did:plc:test{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + )); + let provider = SsoProviderType::Github; + let provider_user_id = format!("github_user_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", + did.as_str(), + format!("test{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + format!( + "test{}@example.com", + &uuid::Uuid::new_v4().simple().to_string()[..8] + ) + ) + .execute(pool) + .await + .unwrap(); + + let id: uuid::Uuid = sqlx::query_scalar!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + "#, + did.as_str(), + provider as SsoProviderType, + &provider_user_id, + Some("testuser"), + Some("test@github.com"), + ) + .fetch_one(pool) + .await + .unwrap(); + + let found = sqlx::query!( + r#" + SELECT id, did, provider as "provider: SsoProviderType", provider_user_id, provider_username, provider_email + FROM external_identities + WHERE provider = $1 AND provider_user_id = $2 + "#, + provider as SsoProviderType, + &provider_user_id, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(found.is_some()); + let found = found.unwrap(); + assert_eq!(found.id, id); + assert_eq!(found.did, did.as_str()); + assert_eq!(found.provider_username, Some("testuser".to_string())); + + let identities = sqlx::query!( + r#" + SELECT id FROM external_identities WHERE did = $1 + "#, + did.as_str(), + ) + .fetch_all(pool) + .await + .unwrap(); + + assert_eq!(identities.len(), 1); + + sqlx::query!( + r#" + UPDATE external_identities + SET provider_username = $2, last_login_at = NOW() + WHERE id = $1 + "#, + id, + "updated_username", + ) + .execute(pool) + .await + .unwrap(); + + let updated = sqlx::query!( + r#"SELECT provider_username, last_login_at FROM external_identities WHERE id = $1"#, + id, + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!( + updated.provider_username, + Some("updated_username".to_string()) + ); + assert!(updated.last_login_at.is_some()); + + let deleted = sqlx::query!( + r#"DELETE FROM external_identities WHERE id = $1 AND did = $2"#, + id, + did.as_str(), + ) + .execute(pool) + .await + .unwrap(); + + assert_eq!(deleted.rows_affected(), 1); + + let not_found = sqlx::query!(r#"SELECT id FROM external_identities WHERE id = $1"#, id,) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(not_found.is_none()); +} + +#[tokio::test] +async fn test_external_identity_unique_constraints() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let did1 = Did::new_unchecked(format!( + "did:plc:uc1{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )); + let did2 = Did::new_unchecked(format!( + "did:plc:uc2{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )); + let provider_user_id = format!("unique_test_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", + did1.as_str(), + format!("uc1{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + format!( + "uc1{}@example.com", + &uuid::Uuid::new_v4().simple().to_string()[..8] + ) + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query!( + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", + did2.as_str(), + format!("uc2{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + format!( + "uc2{}@example.com", + &uuid::Uuid::new_v4().simple().to_string()[..8] + ) + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id) + VALUES ($1, $2, $3) + "#, + did1.as_str(), + SsoProviderType::Github as SsoProviderType, + &provider_user_id, + ) + .execute(pool) + .await + .unwrap(); + + let duplicate_provider_user = sqlx::query!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id) + VALUES ($1, $2, $3) + "#, + did2.as_str(), + SsoProviderType::Github as SsoProviderType, + &provider_user_id, + ) + .execute(pool) + .await; + + assert!(duplicate_provider_user.is_err()); + + let duplicate_did_provider = sqlx::query!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id) + VALUES ($1, $2, $3) + "#, + did1.as_str(), + SsoProviderType::Github as SsoProviderType, + "different_user_id", + ) + .execute(pool) + .await; + + assert!(duplicate_did_provider.is_err()); + + let discord_user_id = format!("discord_user_{}", uuid::Uuid::new_v4().simple()); + let different_provider = sqlx::query!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id) + VALUES ($1, $2, $3) + "#, + did1.as_str(), + SsoProviderType::Discord as SsoProviderType, + &discord_user_id, + ) + .execute(pool) + .await; + + assert!( + different_provider.is_ok(), + "Expected OK but got: {:?}", + different_provider.err() + ); +} + +#[tokio::test] +async fn test_sso_auth_state_lifecycle() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let state = format!("test_state_{}", uuid::Uuid::new_v4().simple()); + let request_uri = "urn:ietf:params:oauth:request_uri:test123"; + + sqlx::query!( + r#" + INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + &state, + request_uri, + SsoProviderType::Github as SsoProviderType, + "login", + Some("test_nonce"), + Some("test_verifier"), + ) + .execute(pool) + .await + .unwrap(); + + let found = sqlx::query!( + r#" + SELECT state, request_uri, provider as "provider: SsoProviderType", action, nonce, code_verifier + FROM sso_auth_state + WHERE state = $1 + "#, + &state, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(found.is_some()); + let found = found.unwrap(); + assert_eq!(found.request_uri, request_uri); + assert_eq!(found.action, "login"); + assert_eq!(found.nonce, Some("test_nonce".to_string())); + assert_eq!(found.code_verifier, Some("test_verifier".to_string())); + + let consumed = sqlx::query!( + r#" + DELETE FROM sso_auth_state + WHERE state = $1 AND expires_at > NOW() + RETURNING state, request_uri + "#, + &state, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(consumed.is_some()); + + let not_found = sqlx::query!( + r#"SELECT state FROM sso_auth_state WHERE state = $1"#, + &state, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(not_found.is_none()); + + let double_consume = sqlx::query!( + r#" + DELETE FROM sso_auth_state + WHERE state = $1 AND expires_at > NOW() + RETURNING state + "#, + &state, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(double_consume.is_none()); +} + +#[tokio::test] +async fn test_sso_auth_state_expiration() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let state = format!("expired_state_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + r#" + INSERT INTO sso_auth_state (state, request_uri, provider, action, expires_at) + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') + "#, + &state, + "urn:test:expired", + SsoProviderType::Github as SsoProviderType, + "login", + ) + .execute(pool) + .await + .unwrap(); + + let consumed = sqlx::query!( + r#" + DELETE FROM sso_auth_state + WHERE state = $1 AND expires_at > NOW() + RETURNING state + "#, + &state, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(consumed.is_none()); + + let cleaned = sqlx::query!(r#"DELETE FROM sso_auth_state WHERE expires_at < NOW()"#,) + .execute(pool) + .await + .unwrap(); + + assert!(cleaned.rows_affected() >= 1); +} + +#[tokio::test] +async fn test_delete_external_identity_wrong_did() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let did = Did::new_unchecked(format!( + "did:plc:del{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + )); + let wrong_did = Did::new_unchecked("did:plc:wrongdid12345"); + + sqlx::query!( + "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", + did.as_str(), + format!("del{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + format!( + "del{}@example.com", + &uuid::Uuid::new_v4().simple().to_string()[..8] + ) + ) + .execute(pool) + .await + .unwrap(); + + let id: uuid::Uuid = sqlx::query_scalar!( + r#" + INSERT INTO external_identities (did, provider, provider_user_id) + VALUES ($1, $2, $3) + RETURNING id + "#, + did.as_str(), + SsoProviderType::Github as SsoProviderType, + format!("delete_test_{}", uuid::Uuid::new_v4().simple()), + ) + .fetch_one(pool) + .await + .unwrap(); + + let wrong_delete = sqlx::query!( + r#"DELETE FROM external_identities WHERE id = $1 AND did = $2"#, + id, + wrong_did.as_str(), + ) + .execute(pool) + .await + .unwrap(); + + assert_eq!(wrong_delete.rows_affected(), 0); + + let still_exists = sqlx::query!(r#"SELECT id FROM external_identities WHERE id = $1"#, id,) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(still_exists.is_some()); +} + +#[tokio::test] +async fn test_sso_pending_registration_lifecycle() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let token = format!("pending_token_{}", uuid::Uuid::new_v4().simple()); + let request_uri = "urn:ietf:params:oauth:request_uri:pendingtest"; + let provider_user_id = format!("pending_user_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + &token, + request_uri, + SsoProviderType::Github as SsoProviderType, + &provider_user_id, + Some("pendinguser"), + Some("pending@github.com"), + ) + .execute(pool) + .await + .unwrap(); + + let found = sqlx::query!( + r#" + SELECT token, request_uri, provider as "provider: SsoProviderType", provider_user_id, + provider_username, provider_email + FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + "#, + &token, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(found.is_some()); + let found = found.unwrap(); + assert_eq!(found.request_uri, request_uri); + assert_eq!(found.provider_username, Some("pendinguser".to_string())); + assert_eq!(found.provider_email, Some("pending@github.com".to_string())); + + let consumed = sqlx::query!( + r#" + DELETE FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + RETURNING token, request_uri + "#, + &token, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(consumed.is_some()); + + let double_consume = sqlx::query!( + r#" + DELETE FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + RETURNING token + "#, + &token, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(double_consume.is_none()); +} + +#[tokio::test] +async fn test_sso_pending_registration_expiration() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let token = format!("expired_pending_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at) + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') + "#, + &token, + "urn:test:expired_pending", + SsoProviderType::Github as SsoProviderType, + "expired_provider_user", + ) + .execute(pool) + .await + .unwrap(); + + let consumed = sqlx::query!( + r#" + SELECT token FROM sso_pending_registration + WHERE token = $1 AND expires_at > NOW() + "#, + &token, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(consumed.is_none()); +} + +#[tokio::test] +async fn test_sso_complete_registration_invalid_token() { + let url = base_url().await; + let client = client(); + + let res = client + .post(format!("{}/oauth/sso/complete-registration", url)) + .json(&json!({ + "token": "nonexistent_token_12345", + "handle": "newuser" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "SsoSessionExpired"); +} + +#[tokio::test] +async fn test_sso_complete_registration_expired_token() { + let _url = base_url().await; + let pool = get_test_db_pool().await; + + let token = format!("expired_reg_token_{}", uuid::Uuid::new_v4().simple()); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, expires_at) + VALUES ($1, $2, $3, $4, NOW() - INTERVAL '1 hour') + "#, + &token, + "urn:test:expired_registration", + SsoProviderType::Github as SsoProviderType, + "expired_user_123", + ) + .execute(pool) + .await + .unwrap(); + + let client = client(); + let res = client + .post(format!("{}/oauth/sso/complete-registration", _url)) + .json(&json!({ + "token": token, + "handle": "newuser" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "SsoSessionExpired"); +} + +#[tokio::test] +async fn test_sso_get_pending_registration_invalid_token() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!( + "{}/oauth/sso/pending-registration?token=nonexistent_token", + url + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "SsoSessionExpired"); +} + +#[tokio::test] +async fn test_sso_get_pending_registration_token_too_long() { + let url = base_url().await; + let client = client(); + + let long_token = "a".repeat(200); + let res = client + .get(format!( + "{}/oauth/sso/pending-registration?token={}", + url, long_token + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "InvalidRequest"); +} + +#[tokio::test] +async fn test_sso_complete_registration_success() { + let url = base_url().await; + let pool = get_test_db_pool().await; + let client = client(); + + let token = format!("success_reg_token_{}", uuid::Uuid::new_v4().simple()); + let handle_prefix = format!("ssoreg{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); + let provider_user_id = format!("success_user_{}", uuid::Uuid::new_v4().simple()); + let provider_email = format!("sso_{}@example.com", uuid::Uuid::new_v4().simple()); + + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); + + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') + "#, + &request_uri, + serde_json::json!({ + "redirect_uri": "https://test.example.com/callback", + "scope": "atproto", + "state": "teststate", + "code_challenge": "testchallenge", + "code_challenge_method": "S256" + }), + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified) + VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + &token, + &request_uri, + SsoProviderType::Github as SsoProviderType, + &provider_user_id, + Some("ssouser"), + Some(&provider_email), + true, + ) + .execute(pool) + .await + .unwrap(); + + let res = client + .post(format!("{}/oauth/sso/complete-registration", url)) + .json(&json!({ + "token": token, + "handle": handle_prefix, + "email": provider_email, + "verification_channel": "email" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert!( + body.get("did").is_some(), + "Expected did in response, got: {:?}", + body + ); + assert!( + body.get("handle").is_some(), + "Expected handle in response, got: {:?}", + body + ); + assert!( + body.get("redirectUrl").is_some(), + "Expected redirectUrl in response, got: {:?}", + body + ); + + let did_str = body["did"].as_str().unwrap(); + assert!(did_str.starts_with("did:plc:")); + + let redirect_url = body["redirectUrl"].as_str().unwrap(); + assert!( + redirect_url.contains("/app/oauth/consent"), + "Auto-verified email should redirect to consent, got: {}", + redirect_url + ); + + let pending_consumed = sqlx::query!( + r#"SELECT token FROM sso_pending_registration WHERE token = $1"#, + &token, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!( + pending_consumed.is_none(), + "Pending registration should be consumed after successful registration" + ); + + let user_exists = sqlx::query!( + r#"SELECT did, email_verified FROM users WHERE did = $1"#, + did_str, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!(user_exists.is_some(), "User should exist in database"); + let user = user_exists.unwrap(); + assert!( + user.email_verified, + "Email should be auto-verified when provider verified it" + ); + + let external_identity = sqlx::query!( + r#" + SELECT provider_user_id, provider_email_verified + FROM external_identities + WHERE did = $1 AND provider = $2 + "#, + did_str, + SsoProviderType::Github as SsoProviderType, + ) + .fetch_optional(pool) + .await + .unwrap(); + + assert!( + external_identity.is_some(), + "External identity should be created" + ); + let ext_id = external_identity.unwrap(); + assert_eq!(ext_id.provider_user_id, provider_user_id); + assert!(ext_id.provider_email_verified); +} + +#[tokio::test] +async fn test_sso_complete_registration_multichannel_discord() { + let url = base_url().await; + let pool = get_test_db_pool().await; + let client = client(); + + let token = format!("discord_reg_token_{}", uuid::Uuid::new_v4().simple()); + let handle_prefix = format!( + "discordreg{}", + &uuid::Uuid::new_v4().simple().to_string()[..4] + ); + let provider_user_id = format!("discord_prov_{}", uuid::Uuid::new_v4().simple()); + let discord_id = "123456789012345678"; + + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); + + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') + "#, + &request_uri, + serde_json::json!({ + "redirect_uri": "https://test.example.com/callback", + "scope": "atproto", + "state": "teststate", + "code_challenge": "testchallenge", + "code_challenge_method": "S256" + }), + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email_verified) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + &token, + &request_uri, + SsoProviderType::Discord as SsoProviderType, + &provider_user_id, + Some("discorduser"), + false, + ) + .execute(pool) + .await + .unwrap(); + + let res = client + .post(format!("{}/oauth/sso/complete-registration", url)) + .json(&json!({ + "token": token, + "handle": handle_prefix, + "verification_channel": "discord", + "discord_id": discord_id + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert!(body.get("did").is_some()); + + let redirect_url = body["redirectUrl"].as_str().unwrap(); + assert!( + redirect_url.contains("/app/oauth/verify"), + "Non-auto-verified channel should redirect to verify, got: {}", + redirect_url + ); + + let did_str = body["did"].as_str().unwrap(); + let user = sqlx::query!( + r#"SELECT preferred_comms_channel as "preferred_comms_channel: String", discord_id FROM users WHERE did = $1"#, + did_str, + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(user.preferred_comms_channel, "discord"); + assert_eq!(user.discord_id, Some(discord_id.to_string())); +} + +#[tokio::test] +async fn test_sso_check_handle_available() { + let url = base_url().await; + let client = client(); + + let unique_handle = format!("avail{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); + let res = client + .get(format!( + "{}/oauth/sso/check-handle-available?handle={}", + url, unique_handle + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["available"], true); + assert!(body["reason"].is_null()); +} + +#[tokio::test] +async fn test_sso_check_handle_invalid() { + let url = base_url().await; + let client = client(); + + let res = client + .get(format!( + "{}/oauth/sso/check-handle-available?handle=ab", + url + )) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["available"], false); + assert!(body["reason"].is_string()); +} + +#[tokio::test] +async fn test_sso_complete_registration_missing_channel_data() { + let url = base_url().await; + let pool = get_test_db_pool().await; + let client = client(); + + let token = format!("missing_channel_{}", uuid::Uuid::new_v4().simple()); + let handle_prefix = format!("missch{}", &uuid::Uuid::new_v4().simple().to_string()[..6]); + + let request_uri = format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()); + + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request (id, client_id, parameters, expires_at) + VALUES ($1, 'https://test.example.com', $2, NOW() + INTERVAL '1 hour') + "#, + &request_uri, + serde_json::json!({ + "redirect_uri": "https://test.example.com/callback", + "scope": "atproto", + "state": "teststate", + "code_challenge": "testchallenge", + "code_challenge_method": "S256" + }), + ) + .execute(pool) + .await + .unwrap(); + + sqlx::query!( + r#" + INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_email_verified) + VALUES ($1, $2, $3, $4, $5) + "#, + &token, + &request_uri, + SsoProviderType::Github as SsoProviderType, + "missing_channel_user", + false, + ) + .execute(pool) + .await + .unwrap(); + + let res = client + .post(format!("{}/oauth/sso/complete-registration", url)) + .json(&json!({ + "token": token, + "handle": handle_prefix, + "verification_channel": "discord" + })) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"], "MissingDiscordId"); +} diff --git a/crates/tranquil-pds/tests/sync_repo.rs b/crates/tranquil-pds/tests/sync_repo.rs index 814f092..24ddc7f 100644 --- a/crates/tranquil-pds/tests/sync_repo.rs +++ b/crates/tranquil-pds/tests/sync_repo.rs @@ -110,49 +110,77 @@ async fn test_list_repos_pagination() { let (_, did2) = create_account_and_login(&client).await; let (_, did3) = create_account_and_login(&client).await; let our_dids: std::collections::HashSet = [did1, did2, did3].into_iter().collect(); - let mut all_dids_seen: std::collections::HashSet = std::collections::HashSet::new(); - let mut cursor: Option = None; - let mut page_count = 0; - let max_pages = 100; - loop { - let mut params: Vec<(&str, String)> = vec![("limit", "10".into())]; - if let Some(ref c) = cursor { - params.push(("cursor", c.clone())); - } - let res = client - .get(format!( - "{}/xrpc/com.atproto.sync.listRepos", - base_url().await - )) - .query(¶ms) - .send() - .await - .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::OK); - let body: Value = res.json().await.expect("Response was not valid JSON"); - let repos = body["repos"].as_array().unwrap(); - for repo in repos { - let did = repo["did"].as_str().unwrap().to_string(); - assert!( - !all_dids_seen.contains(&did), - "Pagination returned duplicate DID: {}", + let base = base_url().await; + let verify_futures = our_dids.iter().map(|did| { + let client = &client; + let base = &base; + async move { + let res = client + .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", base)) + .query(&[("did", did.as_str())]) + .send() + .await + .expect("Failed to send request"); + assert_eq!( + res.status(), + StatusCode::OK, + "Account {} should exist and be queryable via getRepoStatus", did ); - all_dids_seen.insert(did); } - cursor = body["cursor"].as_str().map(String::from); - page_count += 1; - if cursor.is_none() || page_count >= max_pages { - break; + }); + futures::future::join_all(verify_futures).await; + async fn paginate_repos( + client: &reqwest::Client, + base: &str, + ) -> std::collections::HashSet { + let mut all_dids = std::collections::HashSet::new(); + let mut cursor: Option = None; + let mut pages = 0; + while pages < 1000 { + let params: Vec<(&str, String)> = cursor + .as_ref() + .map(|c| vec![("limit", "100".into()), ("cursor", c.clone())]) + .unwrap_or_else(|| vec![("limit", "100".into())]); + let res = client + .get(format!("{}/xrpc/com.atproto.sync.listRepos", base)) + .query(¶ms) + .send() + .await + .expect("Failed to send request"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response was not valid JSON"); + body["repos"] + .as_array() + .unwrap() + .iter() + .map(|r| r["did"].as_str().unwrap().to_string()) + .for_each(|did| { + assert!( + !all_dids.contains(&did), + "Pagination returned duplicate DID: {}", + did + ); + all_dids.insert(did); + }); + cursor = body["cursor"].as_str().map(String::from); + pages += 1; + if cursor.is_none() { + break; + } } + all_dids } - for did in &our_dids { - assert!( - all_dids_seen.contains(did), - "Our created DID {} was not found in paginated results", - did - ); - } + let all_dids_seen = paginate_repos(&client, base).await; + let missing: Vec<_> = our_dids + .iter() + .filter(|did| !all_dids_seen.contains(*did)) + .collect(); + assert!( + missing.is_empty(), + "DIDs not found in paginated results: {:?}", + missing + ); } #[tokio::test] diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e04f900..875bf77 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -8,6 +8,7 @@ import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' import RegisterPasskey from './routes/RegisterPasskey.svelte' + import RegisterSso from './routes/RegisterSso.svelte' import Verify from './routes/Verify.svelte' import ResetPassword from './routes/ResetPassword.svelte' import RecoverPasskey from './routes/RecoverPasskey.svelte' @@ -28,6 +29,7 @@ import OAuthPasskey from './routes/OAuthPasskey.svelte' import OAuthDelegation from './routes/OAuthDelegation.svelte' import OAuthError from './routes/OAuthError.svelte' + import OAuthSsoRegister from './routes/OAuthSsoRegister.svelte' import Security from './routes/Security.svelte' import TrustedDevices from './routes/TrustedDevices.svelte' import Controllers from './routes/Controllers.svelte' @@ -100,6 +102,8 @@ return RegisterPasskey case '/register-password': return Register + case '/register-sso': + return RegisterSso case '/verify': return Verify case '/reset-password': @@ -140,6 +144,8 @@ return OAuthDelegation case '/oauth/error': return OAuthError + case '/oauth/sso-register': + return OAuthSsoRegister case '/security': return Security case '/trusted-devices': diff --git a/frontend/src/components/AccountTypeSwitcher.svelte b/frontend/src/components/AccountTypeSwitcher.svelte index 9a099f5..fe80493 100644 --- a/frontend/src/components/AccountTypeSwitcher.svelte +++ b/frontend/src/components/AccountTypeSwitcher.svelte @@ -4,10 +4,11 @@ import { routes } from '../lib/types/routes' interface Props { - active: 'passkey' | 'password' + active: 'passkey' | 'password' | 'sso' + ssoAvailable?: boolean } - let { active }: Props = $props() + let { active, ssoAvailable = true }: Props = $props() diff --git a/frontend/src/components/SsoIcon.svelte b/frontend/src/components/SsoIcon.svelte new file mode 100644 index 0000000..c879a7f --- /dev/null +++ b/frontend/src/components/SsoIcon.svelte @@ -0,0 +1,52 @@ + + +{#if provider === 'github'} + + + +{:else if provider === 'discord'} + + + +{:else if provider === 'google'} + + + + + + +{:else if provider === 'gitlab'} + + + + + + + + + + +{:else if provider === 'apple'} + + + +{:else} + + + + + +{/if} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ee53039..ba45c6e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -143,10 +143,13 @@ async function xrpc(method: string, options?: XrpcOptions): Promise { return xrpc(method, { ...options, token: newToken, skipRetry: true }); } } + const message = res.status === 429 + ? (errData.message || "Too many requests. Please try again later.") + : errData.message; throw new ApiError( res.status, errData.error as ApiErrorCode, - errData.message, + message, errData.did, errData.reauthMethods, ); @@ -382,10 +385,14 @@ export const api = { }); }, - requestEmailUpdate(token: AccessToken): Promise { + requestEmailUpdate( + token: AccessToken, + newEmail?: string, + ): Promise { return xrpc("com.atproto.server.requestEmailUpdate", { method: "POST", token, + body: newEmail ? { newEmail } : undefined, }); }, @@ -401,6 +408,15 @@ export const api = { }); }, + checkEmailUpdateStatus( + token: AccessToken, + ): Promise<{ pending: boolean; authorized: boolean; newEmail?: string }> { + return xrpc("_account.checkEmailUpdateStatus", { + method: "GET", + token, + }); + }, + async updateHandle(token: AccessToken, handle: Handle): Promise { await xrpc("com.atproto.identity.updateHandle", { method: "POST", @@ -540,7 +556,10 @@ export const api = { }); }, - setPassword(token: AccessToken, newPassword: string): Promise { + setPassword( + token: AccessToken, + newPassword: string, + ): Promise { return xrpc("_account.setPassword", { method: "POST", token, diff --git a/frontend/src/lib/migration/blob-migration.ts b/frontend/src/lib/migration/blob-migration.ts index 132a5b2..997fea6 100644 --- a/frontend/src/lib/migration/blob-migration.ts +++ b/frontend/src/lib/migration/blob-migration.ts @@ -40,7 +40,9 @@ const migrateSingleBlob = async ( ): Promise => { try { console.log( - `[blob-migration] Fetching blob ${cid} from source (attempt ${attempt + 1})`, + `[blob-migration] Fetching blob ${cid} from source (attempt ${ + attempt + 1 + })`, ); const { data: blobData, contentType } = await sourceClient .getBlobWithContentType(userDid, cid); @@ -59,7 +61,9 @@ const migrateSingleBlob = async ( } catch (e) { const errorMessage = (e as Error).message || String(e); console.error( - `[blob-migration] Failed to migrate blob ${cid} (attempt ${attempt + 1}):`, + `[blob-migration] Failed to migrate blob ${cid} (attempt ${ + attempt + 1 + }):`, errorMessage, ); @@ -115,15 +119,22 @@ export async function migrateBlobs( console.log("[blob-migration] Starting blob migration for", userDid); console.log( "[blob-migration] Source client:", - sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE", + sourceClient + ? `available (baseUrl: ${sourceClient.getBaseUrl()})` + : "NOT AVAILABLE", + ); + console.log( + "[blob-migration] Local client baseUrl:", + localClient.getBaseUrl(), ); - console.log("[blob-migration] Local client baseUrl:", localClient.getBaseUrl()); console.log( "[blob-migration] Local client has access token:", localClient.getAccessToken() ? "yes" : "NO", ); - safeProgress(onProgress, { currentOperation: "Checking for missing blobs..." }); + safeProgress(onProgress, { + currentOperation: "Checking for missing blobs...", + }); const missingBlobs = await collectMissingBlobs(localClient); @@ -137,7 +148,9 @@ export async function migrateBlobs( } if (!sourceClient) { - console.warn("[blob-migration] No source client available, cannot fetch blobs"); + console.warn( + "[blob-migration] No source client available, cannot fetch blobs", + ); safeProgress(onProgress, { currentOperation: `${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`, @@ -161,7 +174,9 @@ export async function migrateBlobs( const acc = await accPromise; safeProgress(onProgress, { - currentOperation: `Migrating blob ${index + 1}/${missingBlobs.length}...`, + currentOperation: `Migrating blob ${ + index + 1 + }/${missingBlobs.length}...`, blobsMigrated: acc.migrated, }); @@ -186,12 +201,14 @@ export async function migrateBlobs( const statusMessage = migrated === missingBlobs.length ? `All ${migrated} blobs migrated successfully` : migrated > 0 - ? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.` - : `Could not migrate blobs (${failed.length} missing)`; + ? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.` + : `Could not migrate blobs (${failed.length} missing)`; safeProgress(onProgress, { currentOperation: statusMessage }); - console.log(`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`); + console.log( + `[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`, + ); failed.length > 0 && console.log("[blob-migration] Failed CIDs:", failed); return { diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index 7fb8e38..687b4c8 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -479,18 +479,24 @@ export function createInboundMigrationFlow() { async function migrateBlobs(): Promise { if (!sourceClient) { - console.error("[migration] migrateBlobs: sourceClient is null, skipping blob migration"); + console.error( + "[migration] migrateBlobs: sourceClient is null, skipping blob migration", + ); migrationLog("migrateBlobs SKIPPED: sourceClient is null"); setProgress({ - currentOperation: "Warning: Could not migrate blobs - source PDS connection lost", + currentOperation: + "Warning: Could not migrate blobs - source PDS connection lost", }); return; } if (!localClient) { - console.error("[migration] migrateBlobs: localClient is null, skipping blob migration"); + console.error( + "[migration] migrateBlobs: localClient is null, skipping blob migration", + ); migrationLog("migrateBlobs SKIPPED: localClient is null"); setProgress({ - currentOperation: "Warning: Could not migrate blobs - local PDS connection lost", + currentOperation: + "Warning: Could not migrate blobs - local PDS connection lost", }); return; } diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index 1ff22bf..7fb3a8f 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -10,7 +10,7 @@ const SCOPES = [ "repo:*?action=delete", "blob:*/*", "identity:*", - "account:*", + "account:*?action=manage", ].join(" "); const CLIENT_ID = !(import.meta.env.DEV) @@ -346,7 +346,9 @@ async function tokenRequest( extractDPoPNonceFromResponse(response); if (!response.ok) { - const error = await response.json().catch(() => ({ error: "Unknown error" })); + const error = await response.json().catch(() => ({ + error: "Unknown error", + })); if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { return tokenRequest(params, false); @@ -431,5 +433,11 @@ export async function createDPoPProofForRequest( const keyPair = await getOrCreateDPoPKeyPair(); const tokenHash = await sha256(accessToken); const ath = base64UrlEncode(tokenHash); - return createDPoPProof(keyPair, method, url, getDPoPNonce() ?? undefined, ath); + return createDPoPProof( + keyPair, + method, + url, + getDPoPNonce() ?? undefined, + ath, + ); } diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts index 4d95ee6..f7385fa 100644 --- a/frontend/src/lib/registration/flow.svelte.ts +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -19,9 +19,9 @@ import type { SessionState, } from "./types.ts"; import { - saveRegistrationState, - loadRegistrationState, clearRegistrationState, + loadRegistrationState, + saveRegistrationState, } from "./storage.ts"; export interface RegistrationFlowState { @@ -433,7 +433,9 @@ export type RegistrationFlow = ReturnType; export function restoreRegistrationFlow(): RegistrationFlow | null { const saved = loadRegistrationState(); - if (!saved || saved.step === "info" || saved.step === "redirect-to-dashboard") { + if ( + !saved || saved.step === "info" || saved.step === "redirect-to-dashboard" + ) { return null; } @@ -441,11 +443,18 @@ export function restoreRegistrationFlow(): RegistrationFlow | null { flow.state.step = saved.step; flow.state.info = { ...flow.state.info, ...saved.info }; - flow.state.externalDidWeb = { ...flow.state.externalDidWeb, ...saved.externalDidWeb }; + flow.state.externalDidWeb = { + ...flow.state.externalDidWeb, + ...saved.externalDidWeb, + }; flow.state.account = saved.account; flow.state.session = saved.session; return flow; } -export { hasPendingRegistration, getRegistrationResumeInfo, clearRegistrationState } from "./storage.ts"; +export { + clearRegistrationState, + getRegistrationResumeInfo, + hasPendingRegistration, +} from "./storage.ts"; diff --git a/frontend/src/lib/registration/storage.ts b/frontend/src/lib/registration/storage.ts index 58d5a70..b9f0eed 100644 --- a/frontend/src/lib/registration/storage.ts +++ b/frontend/src/lib/registration/storage.ts @@ -1,9 +1,9 @@ import type { + AccountResult, + ExternalDidWebState, + RegistrationInfo, RegistrationMode, RegistrationStep, - RegistrationInfo, - ExternalDidWebState, - AccountResult, SessionState, } from "./types.ts"; @@ -81,18 +81,18 @@ export function saveRegistrationState( }, account: account ? { - did: account.did, - handle: account.handle, - setupToken: account.setupToken, - appPassword: account.appPassword, - appPasswordName: account.appPasswordName, - } + did: account.did, + handle: account.handle, + setupToken: account.setupToken, + appPassword: account.appPassword, + appPasswordName: account.appPasswordName, + } : null, session: session ? { - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - } + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + } : null, }; @@ -144,18 +144,18 @@ export function loadRegistrationState(): { }, account: state.account ? { - did: state.account.did as AccountResult["did"], - handle: state.account.handle as AccountResult["handle"], - setupToken: state.account.setupToken, - appPassword: state.account.appPassword, - appPasswordName: state.account.appPasswordName, - } + did: state.account.did as AccountResult["did"], + handle: state.account.handle as AccountResult["handle"], + setupToken: state.account.setupToken, + appPassword: state.account.appPassword, + appPasswordName: state.account.appPasswordName, + } : null, session: state.session ? { - accessJwt: state.session.accessJwt as SessionState["accessJwt"], - refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"], - } + accessJwt: state.session.accessJwt as SessionState["accessJwt"], + refreshJwt: state.session.refreshJwt as SessionState["refreshJwt"], + } : null, }; } catch { @@ -172,7 +172,8 @@ export function clearRegistrationState(): void { export function hasPendingRegistration(): boolean { const state = loadRegistrationState(); - return state !== null && state.step !== "info" && state.step !== "redirect-to-dashboard"; + return state !== null && state.step !== "info" && + state.step !== "redirect-to-dashboard"; } export function getRegistrationResumeInfo(): { @@ -182,7 +183,9 @@ export function getRegistrationResumeInfo(): { did?: string; } | null { const state = loadRegistrationState(); - if (!state || state.step === "info" || state.step === "redirect-to-dashboard") { + if ( + !state || state.step === "info" || state.step === "redirect-to-dashboard" + ) { return null; } diff --git a/frontend/src/lib/types/routes.ts b/frontend/src/lib/types/routes.ts index 38cabcf..0108d22 100644 --- a/frontend/src/lib/types/routes.ts +++ b/frontend/src/lib/types/routes.ts @@ -2,6 +2,7 @@ export const routes = { login: "/login", register: "/register", registerPassword: "/register-password", + registerSso: "/register-sso", dashboard: "/dashboard", settings: "/settings", security: "/security", @@ -29,6 +30,7 @@ export const routes = { oauthPasskey: "/oauth/passkey", oauthDelegation: "/oauth/delegation", oauthError: "/oauth/error", + oauthSsoRegister: "/oauth/sso-register", } as const; export type Route = (typeof routes)[keyof typeof routes]; @@ -52,6 +54,7 @@ export interface RouteParams { [routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }; [routes.oauthError]: { error?: string; error_description?: string }; [routes.migrate]: { code?: string; state?: string }; + [routes.oauthSsoRegister]: { token?: string }; } export type RoutesWithParams = keyof RouteParams; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 15c6c92..89b1d78 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -170,6 +170,11 @@ "signIn": "Sign in", "passkeyAccount": "Passkey", "passwordAccount": "Password", + "ssoAccount": "SSO", + "ssoSubtitle": "Create an account using an external provider", + "noSsoProviders": "No SSO providers are configured on this server.", + "ssoHint": "Choose a provider to create your account:", + "continueWith": "Continue with {provider}", "validation": { "handleRequired": "Handle is required", "handleNoDots": "Handle cannot contain dots. You can set up a custom domain handle after creating your account.", @@ -275,6 +280,8 @@ "verificationCode": "Verification Code", "verificationCodePlaceholder": "Enter verification code", "confirmEmailChange": "Confirm Email Change", + "emailTokenHint": "Enter the code from the email, or click the link in the email on any device.", + "emailUpdateAuthorized": "Email change authorized! Click confirm to complete.", "updating": "Updating...", "changeHandle": "Change Handle", "currentHandle": "Current: @{handle}", @@ -677,7 +684,7 @@ "checkingPasskey": "Checking passkey...", "signInWithPasskey": "Sign in with passkey", "passkeyNotSetUp": "Passkey not set up", - "orUsePassword": "or use password", + "orUsePassword": "Or use password", "password": "Password", "rememberDevice": "Remember this device", "passkeyHintChecking": "Checking passkey status...", @@ -685,7 +692,21 @@ "passkeyHintNotAvailable": "No passkeys registered for this account", "passkeyHint": "Use your device's biometrics or security key", "passwordPlaceholder": "Enter your password", - "usePasskey": "Use Passkey" + "usePasskey": "Use Passkey", + "orContinueWith": "Or continue with", + "orUseCredentials": "Or sign in with credentials" + }, + "sso": { + "linkedAccounts": "Linked Accounts", + "linkedAccountsDesc": "External accounts linked to your identity for single sign-on.", + "noLinkedAccounts": "No linked accounts", + "noLinkedAccountsDesc": "Link an external account to enable quick sign-in with that provider.", + "linkAccount": "Link Account", + "unlinkAccount": "Unlink", + "unlinkConfirm": "Are you sure you want to unlink this account?", + "unlinked": "Unlinked {provider}", + "lastLoginAt": "Last used", + "linkedAt": "Linked" }, "consent": { "title": "Authorize Application", @@ -798,6 +819,24 @@ "backToApp": "Back to Application" } }, + "sso_register": { + "title": "Complete Registration", + "subtitle": "Creating account with {provider}", + "handle_label": "Choose your handle", + "handle_available": "Available", + "handle_taken": "Already taken", + "submit": "Create Account", + "error_expired": "Registration session expired. Please try again.", + "error_handle_required": "Please choose a handle", + "emailVerifiedByProvider": "This email is verified by {provider}. No additional verification needed.", + "emailChangedNeedsVerification": "If you use a different email, you will need to verify it.", + "infoAfterTitle": "After creating your account", + "infoAddPassword": "Add a password for traditional login", + "infoAddPasskey": "Set up a passkey for passwordless sign-in", + "infoLinkProviders": "Link additional SSO providers", + "infoChangeHandle": "Change your handle or use a custom domain", + "tryAgain": "Try again" + }, "verify": { "title": "Verify Your Account", "subtitle": "We've sent a verification code to your {channel}. Enter it below to complete registration.", @@ -834,7 +873,9 @@ "updateEmail": "Update Email", "updating": "Updating...", "emailUpdated": "Your email has been updated successfully.", - "emailUpdatedInfo": "You may need to verify your new email address." + "emailUpdatedInfo": "You may need to verify your new email address.", + "emailAuthorizeSuccess": "Your email update has been authorized.", + "emailAuthorizeInfo": "You can now complete the change on your original device." }, "resetPassword": { "title": "Reset Password", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index dbf11f7..f6cb75b 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -170,6 +170,11 @@ "signIn": "Kirjaudu sisään", "passkeyAccount": "Pääsyavain", "passwordAccount": "Salasana", + "ssoAccount": "SSO", + "ssoSubtitle": "Luo tili ulkoisen palveluntarjoajan kautta", + "noSsoProviders": "Tälle palvelimelle ei ole määritetty SSO-palveluntarjoajia.", + "ssoHint": "Valitse palveluntarjoaja tilin luomiseksi:", + "continueWith": "Jatka palvelulla {provider}", "validation": { "handleRequired": "Käyttäjänimi vaaditaan", "handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.", @@ -275,6 +280,8 @@ "verificationCode": "Vahvistuskoodi", "verificationCodePlaceholder": "Syötä vahvistuskoodi", "confirmEmailChange": "Vahvista sähköpostin vaihto", + "emailTokenHint": "Syötä sähköpostissa oleva koodi tai napsauta linkkiä sähköpostissa millä tahansa laitteella.", + "emailUpdateAuthorized": "Sähköpostin vaihto hyväksytty! Napsauta vahvista viimeistelläksesi.", "updating": "Päivitetään...", "changeHandle": "Vaihda käyttäjänimi", "currentHandle": "Nykyinen: @{handle}", @@ -685,7 +692,21 @@ "passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille", "passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta", "passwordPlaceholder": "Syötä salasanasi", - "usePasskey": "Käytä pääsyavainta" + "usePasskey": "Käytä pääsyavainta", + "orContinueWith": "Tai jatka käyttäen", + "orUseCredentials": "Tai kirjaudu tunnuksilla" + }, + "sso": { + "linkedAccounts": "Linkitetyt tilit", + "linkedAccountsDesc": "Ulkoiset tilit, jotka on linkitetty identiteettiisi kertakirjautumista varten.", + "noLinkedAccounts": "Ei linkitettyjä tilejä", + "noLinkedAccountsDesc": "Linkitä ulkoinen tili ottaaksesi käyttöön nopean kirjautumisen kyseisellä palveluntarjoajalla.", + "linkAccount": "Linkitä tili", + "unlinkAccount": "Poista linkitys", + "unlinkConfirm": "Haluatko varmasti poistaa tämän tilin linkityksen?", + "unlinked": "Linkitys poistettu: {provider}", + "lastLoginAt": "Viimeksi käytetty", + "linkedAt": "Linkitetty" }, "consent": { "title": "Valtuuta sovellus", @@ -798,6 +819,18 @@ "backToApp": "Takaisin sovellukseen" } }, + "sso_register": { + "title": "Viimeistele rekisteröinti", + "subtitle": "Luo tili käyttäen {provider}", + "handle_label": "Valitse käsittelynimi", + "handle_available": "Saatavilla", + "handle_taken": "Jo käytössä", + "submit": "Luo tili", + "error_expired": "Rekisteröintisessio on vanhentunut. Yritä uudelleen.", + "error_handle_required": "Valitse käsittelynimi", + "emailVerifiedByProvider": "Tämä sähköposti on vahvistettu {provider} kautta. Lisävahvistusta ei tarvita.", + "emailChangedNeedsVerification": "Jos käytät eri sähköpostia, sinun täytyy vahvistaa se." + }, "verify": { "title": "Vahvista tilisi", "subtitle": "Olemme lähettäneet vahvistuskoodin {channel}. Syötä se alla viimeistelläksesi rekisteröinnin.", @@ -831,6 +864,8 @@ "emailUpdateTitle": "Päivitä sähköpostiosoite", "emailUpdated": "Sähköpostiosoitteesi on päivitetty.", "emailUpdatedInfo": "Sinun on ehkä vahvistettava uusi sähköpostiosoitteesi.", + "emailAuthorizeSuccess": "Sähköpostipäivityksesi on valtuutettu.", + "emailAuthorizeInfo": "Voit nyt viimeistellä muutoksen alkuperäisellä laitteellasi.", "newEmailLabel": "Uusi sähköpostiosoite", "newEmailPlaceholder": "uusi@esimerkki.fi", "updateEmail": "Päivitä sähköposti", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index b91d7a8..f4f9e23 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -163,6 +163,11 @@ "signIn": "サインイン", "passkeyAccount": "パスキー", "passwordAccount": "パスワード", + "ssoAccount": "SSO", + "ssoSubtitle": "外部プロバイダーを使用してアカウントを作成", + "noSsoProviders": "このサーバーにはSSOプロバイダーが設定されていません。", + "ssoHint": "プロバイダーを選択してアカウントを作成:", + "continueWith": "{provider}で続行", "validation": { "handleRequired": "ハンドルは必須です", "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", @@ -268,6 +273,8 @@ "verificationCode": "確認コード", "verificationCodePlaceholder": "認証コードを入力", "confirmEmailChange": "メール変更を確認", + "emailTokenHint": "メールに記載されたコードを入力するか、任意のデバイスでメール内のリンクをクリックしてください。", + "emailUpdateAuthorized": "メール変更が承認されました!確認をクリックして完了してください。", "updating": "更新中...", "changeHandle": "ハンドル変更", "currentHandle": "現在: @{handle}", @@ -678,7 +685,21 @@ "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません", "passkeyHint": "デバイスの生体認証またはセキュリティキーを使用", "passwordPlaceholder": "パスワードを入力", - "usePasskey": "パスキーを使用" + "usePasskey": "パスキーを使用", + "orContinueWith": "または次の方法で続行", + "orUseCredentials": "または認証情報でサインイン" + }, + "sso": { + "linkedAccounts": "連携アカウント", + "linkedAccountsDesc": "シングルサインオン用に連携された外部アカウント。", + "noLinkedAccounts": "連携アカウントなし", + "noLinkedAccountsDesc": "外部アカウントを連携して、そのプロバイダーでのクイックサインインを有効にします。", + "linkAccount": "アカウントを連携", + "unlinkAccount": "連携解除", + "unlinkConfirm": "このアカウントの連携を解除しますか?", + "unlinked": "{provider} の連携を解除しました", + "lastLoginAt": "最終使用", + "linkedAt": "連携日時" }, "consent": { "title": "アプリを承認", @@ -791,6 +812,18 @@ "backToApp": "アプリに戻る" } }, + "sso_register": { + "title": "登録を完了", + "subtitle": "{provider}でアカウントを作成", + "handle_label": "ハンドルを選択", + "handle_available": "利用可能", + "handle_taken": "既に使用されています", + "submit": "アカウント作成", + "error_expired": "登録セッションが期限切れです。もう一度お試しください。", + "error_handle_required": "ハンドルを選択してください", + "emailVerifiedByProvider": "このメールアドレスは{provider}で確認済みです。追加の確認は不要です。", + "emailChangedNeedsVerification": "別のメールアドレスを使用する場合は、確認が必要です。" + }, "verify": { "title": "アカウント確認", "subtitle": "{channel} に確認コードを送信しました。以下に入力して登録を完了してください。", @@ -824,6 +857,8 @@ "emailUpdateTitle": "メールアドレスの更新", "emailUpdated": "メールアドレスが正常に更新されました。", "emailUpdatedInfo": "新しいメールアドレスの確認が必要な場合があります。", + "emailAuthorizeSuccess": "メールアドレスの更新が承認されました。", + "emailAuthorizeInfo": "元のデバイスで変更を完了できます。", "newEmailLabel": "新しいメールアドレス", "newEmailPlaceholder": "new@example.com", "updateEmail": "メールを更新", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 50280f0..aed4fab 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -163,6 +163,11 @@ "signIn": "로그인", "passkeyAccount": "패스키", "passwordAccount": "비밀번호", + "ssoAccount": "SSO", + "ssoSubtitle": "외부 제공자를 사용하여 계정 만들기", + "noSsoProviders": "이 서버에 SSO 제공자가 설정되어 있지 않습니다.", + "ssoHint": "계정을 만들 제공자를 선택하세요:", + "continueWith": "{provider}로 계속", "validation": { "handleRequired": "핸들은 필수입니다", "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", @@ -268,6 +273,8 @@ "verificationCode": "인증 코드", "verificationCodePlaceholder": "인증 코드 입력", "confirmEmailChange": "이메일 변경 확인", + "emailTokenHint": "이메일의 코드를 입력하거나 다른 기기에서 이메일의 링크를 클릭하세요.", + "emailUpdateAuthorized": "이메일 변경이 승인되었습니다! 확인을 클릭하여 완료하세요.", "updating": "업데이트 중...", "changeHandle": "핸들 변경", "currentHandle": "현재: @{handle}", @@ -678,7 +685,21 @@ "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다", "passkeyHint": "기기의 생체 인식 또는 보안 키 사용", "passwordPlaceholder": "비밀번호 입력", - "usePasskey": "패스키 사용" + "usePasskey": "패스키 사용", + "orContinueWith": "또는 다음으로 계속", + "orUseCredentials": "또는 자격 증명으로 로그인" + }, + "sso": { + "linkedAccounts": "연결된 계정", + "linkedAccountsDesc": "싱글 사인온을 위해 연결된 외부 계정입니다.", + "noLinkedAccounts": "연결된 계정 없음", + "noLinkedAccountsDesc": "외부 계정을 연결하여 해당 제공자로 빠르게 로그인하세요.", + "linkAccount": "계정 연결", + "unlinkAccount": "연결 해제", + "unlinkConfirm": "이 계정의 연결을 해제하시겠습니까?", + "unlinked": "{provider} 연결 해제됨", + "lastLoginAt": "마지막 사용", + "linkedAt": "연결됨" }, "consent": { "title": "앱 승인", @@ -791,6 +812,18 @@ "backToApp": "앱으로 돌아가기" } }, + "sso_register": { + "title": "등록 완료", + "subtitle": "{provider}로 계정 생성", + "handle_label": "핸들 선택", + "handle_available": "사용 가능", + "handle_taken": "이미 사용 중", + "submit": "계정 생성", + "error_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.", + "error_handle_required": "핸들을 선택해 주세요", + "emailVerifiedByProvider": "이 이메일은 {provider}에서 인증되었습니다. 추가 인증이 필요하지 않습니다.", + "emailChangedNeedsVerification": "다른 이메일을 사용하시면 인증이 필요합니다." + }, "verify": { "title": "계정 인증", "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 입력하여 등록을 완료하세요.", @@ -824,6 +857,8 @@ "emailUpdateTitle": "이메일 주소 업데이트", "emailUpdated": "이메일 주소가 성공적으로 업데이트되었습니다.", "emailUpdatedInfo": "새 이메일 주소를 인증해야 할 수 있습니다.", + "emailAuthorizeSuccess": "이메일 업데이트가 승인되었습니다.", + "emailAuthorizeInfo": "이제 원래 기기에서 변경을 완료할 수 있습니다.", "newEmailLabel": "새 이메일 주소", "newEmailPlaceholder": "new@example.com", "updateEmail": "이메일 업데이트", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 943ddc2..20f78c2 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -163,6 +163,11 @@ "signIn": "Logga in", "passkeyAccount": "Nyckel", "passwordAccount": "Lösenord", + "ssoAccount": "SSO", + "ssoSubtitle": "Skapa ett konto med en extern leverantör", + "noSsoProviders": "Inga SSO-leverantörer är konfigurerade på denna server.", + "ssoHint": "Välj en leverantör för att skapa ditt konto:", + "continueWith": "Fortsätt med {provider}", "validation": { "handleRequired": "Användarnamn krävs", "handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.", @@ -268,6 +273,8 @@ "verificationCode": "Verifieringskod", "verificationCodePlaceholder": "Ange verifieringskod", "confirmEmailChange": "Bekräfta e-poständring", + "emailTokenHint": "Ange koden från e-postmeddelandet, eller klicka på länken i e-postmeddelandet på valfri enhet.", + "emailUpdateAuthorized": "E-poständring godkänd! Klicka på bekräfta för att slutföra.", "updating": "Uppdaterar...", "changeHandle": "Ändra användarnamn", "currentHandle": "Nuvarande: @{handle}", @@ -678,7 +685,21 @@ "passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto", "passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel", "passwordPlaceholder": "Ange ditt lösenord", - "usePasskey": "Använd nyckel" + "usePasskey": "Använd nyckel", + "orContinueWith": "Eller fortsätt med", + "orUseCredentials": "Eller logga in med uppgifter" + }, + "sso": { + "linkedAccounts": "Länkade konton", + "linkedAccountsDesc": "Externa konton länkade till din identitet för enkel inloggning.", + "noLinkedAccounts": "Inga länkade konton", + "noLinkedAccountsDesc": "Länka ett externt konto för att aktivera snabb inloggning med den leverantören.", + "linkAccount": "Länka konto", + "unlinkAccount": "Ta bort länk", + "unlinkConfirm": "Är du säker på att du vill ta bort länken till detta konto?", + "unlinked": "Länk till {provider} borttagen", + "lastLoginAt": "Senast använd", + "linkedAt": "Länkad" }, "consent": { "title": "Auktorisera applikation", @@ -791,6 +812,18 @@ "backToApp": "Tillbaka till applikationen" } }, + "sso_register": { + "title": "Slutför registrering", + "subtitle": "Skapar konto med {provider}", + "handle_label": "Välj ditt användarnamn", + "handle_available": "Tillgängligt", + "handle_taken": "Redan taget", + "submit": "Skapa konto", + "error_expired": "Registreringssessionen har löpt ut. Försök igen.", + "error_handle_required": "Välj ett användarnamn", + "emailVerifiedByProvider": "Denna e-post är verifierad av {provider}. Ingen ytterligare verifiering behövs.", + "emailChangedNeedsVerification": "Om du använder en annan e-post måste du verifiera den." + }, "verify": { "title": "Verifiera ditt konto", "subtitle": "Vi har skickat en verifieringskod till din {channel}. Ange den nedan för att slutföra registreringen.", @@ -824,6 +857,8 @@ "emailUpdateTitle": "Uppdatera e-postadress", "emailUpdated": "Din e-postadress har uppdaterats.", "emailUpdatedInfo": "Du kan behöva verifiera din nya e-postadress.", + "emailAuthorizeSuccess": "Din e-postuppdatering har auktoriserats.", + "emailAuthorizeInfo": "Du kan nu slutföra ändringen på din ursprungliga enhet.", "newEmailLabel": "Ny e-postadress", "newEmailPlaceholder": "ny@exempel.se", "updateEmail": "Uppdatera e-post", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 99d0669..f76b704 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -163,6 +163,11 @@ "signIn": "立即登录", "passkeyAccount": "通行密钥", "passwordAccount": "密码", + "ssoAccount": "SSO", + "ssoSubtitle": "使用外部提供商创建账户", + "noSsoProviders": "此服务器未配置SSO提供商。", + "ssoHint": "选择一个提供商来创建您的账户:", + "continueWith": "使用{provider}继续", "validation": { "handleRequired": "请输入用户名", "handleNoDots": "用户名不能包含点号。您可以在创建账户后设置自定义域名。", @@ -268,6 +273,8 @@ "verificationCode": "验证码", "verificationCodePlaceholder": "输入验证码", "confirmEmailChange": "确认更改邮箱", + "emailTokenHint": "输入邮件中的验证码,或在任意设备上点击邮件中的链接。", + "emailUpdateAuthorized": "邮箱更改已授权!点击确认完成。", "updating": "更新中...", "changeHandle": "更改用户名", "currentHandle": "当前:@{handle}", @@ -678,7 +685,21 @@ "passkeyHintNotAvailable": "此账户未注册通行密钥", "passkeyHint": "使用设备的生物识别或安全密钥", "passwordPlaceholder": "输入您的密码", - "usePasskey": "使用通行密钥" + "usePasskey": "使用通行密钥", + "orContinueWith": "或使用以下方式继续", + "orUseCredentials": "或使用凭证登录" + }, + "sso": { + "linkedAccounts": "已关联账户", + "linkedAccountsDesc": "已关联到您身份的外部账户,用于单点登录。", + "noLinkedAccounts": "暂无关联账户", + "noLinkedAccountsDesc": "关联外部账户以启用该服务商的快速登录。", + "linkAccount": "关联账户", + "unlinkAccount": "取消关联", + "unlinkConfirm": "确定要取消关联此账户吗?", + "unlinked": "已取消关联 {provider}", + "lastLoginAt": "上次使用", + "linkedAt": "关联时间" }, "consent": { "title": "授权应用", @@ -791,6 +812,18 @@ "backToApp": "返回应用" } }, + "sso_register": { + "title": "完成注册", + "subtitle": "使用{provider}创建账户", + "handle_label": "选择您的昵称", + "handle_available": "可用", + "handle_taken": "已被使用", + "submit": "创建账户", + "error_expired": "注册会话已过期。请重试。", + "error_handle_required": "请选择一个昵称", + "emailVerifiedByProvider": "此邮箱已由{provider}验证。无需额外验证。", + "emailChangedNeedsVerification": "如果您使用其他邮箱,则需要进行验证。" + }, "verify": { "title": "验证账户", "subtitle": "我们已将验证码发送到您的{channel}。请在下方输入以完成注册。", @@ -824,6 +857,8 @@ "emailUpdateTitle": "更新邮箱地址", "emailUpdated": "您的邮箱地址已成功更新。", "emailUpdatedInfo": "您可能需要验证新的邮箱地址。", + "emailAuthorizeSuccess": "您的邮箱更新已授权。", + "emailAuthorizeInfo": "您现在可以在原设备上完成更改。", "newEmailLabel": "新邮箱地址", "newEmailPlaceholder": "new@example.com", "updateEmail": "更新邮箱", diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte index a78331b..5f2ba64 100644 --- a/frontend/src/routes/OAuthLogin.svelte +++ b/frontend/src/routes/OAuthLogin.svelte @@ -7,8 +7,17 @@ serializeAssertionResponse, type WebAuthnRequestOptionsResponse, } from '../lib/webauthn' + import SsoIcon from '../components/SsoIcon.svelte' + + interface SsoProvider { + provider: string + name: string + icon: string + } let username = $state('') + let ssoProviders = $state([]) + let ssoLoading = $state(null) let password = $state('') let rememberDevice = $state(false) let submitting = $state(false) @@ -46,8 +55,66 @@ $effect(() => { fetchAuthRequestInfo() + fetchSsoProviders() }) + async function fetchSsoProviders() { + try { + const response = await fetch('/oauth/sso/providers') + if (response.ok) { + const data = await response.json() + ssoProviders = data.providers || [] + } + } catch { + ssoProviders = [] + } + } + + async function handleSsoLogin(provider: string) { + const requestUri = getRequestUri() + if (!requestUri) { + error = $_('common.error') + return + } + + ssoLoading = provider + error = null + + try { + const response = await fetch('/oauth/sso/initiate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + provider, + request_uri: requestUri, + action: 'login' + }) + }) + + const data = await response.json() + + if (!response.ok) { + error = data.error_description || data.error || 'Failed to start SSO login' + ssoLoading = null + return + } + + if (data.redirect_url) { + window.location.href = data.redirect_url + return + } + + error = $_('common.error') + ssoLoading = null + } catch { + error = $_('common.error') + ssoLoading = null + } + } + async function fetchAuthRequestInfo() { const requestUri = getRequestUri() if (!requestUri) return @@ -328,8 +395,33 @@ /> + {#if ssoProviders.length > 0} +
+
+ {#each ssoProviders as provider} + + {/each} +
+
+ {$_('oauth.login.orUseCredentials')} +
+
+ {/if} + {#if passkeySupported && username.length >= 3} -
+

{$_('oauth.login.signInWithPasskey')}

-
- {$_('oauth.login.orUsePassword')} -
- -
-

{$_('oauth.login.password')}

-
- + {#if hasPassword} +
+ {$_('oauth.login.orUsePassword')}
- +
+

{$_('oauth.login.password')}

+
+ +
- -
+ + + +
+ {/if}
-
-
{:else} -
- - -
+ {#if hasPassword || !securityStatusChecked} +
+ + +
- + -
- +
+ {/if} + +
+ -
{/if} @@ -623,16 +722,30 @@ cursor: not-allowed; } - .cancel-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); + .cancel-row { + display: flex; + justify-content: center; + margin-top: var(--space-4); } - .cancel-btn:hover:not(:disabled) { - background: var(--error-bg); - border-color: var(--error-border); - color: var(--error-text); + .cancel-btn-subtle { + padding: var(--space-2) var(--space-4); + background: transparent; + color: var(--text-muted); + border: none; + border-radius: var(--radius-md); + font-size: var(--text-sm); + cursor: pointer; + transition: color var(--transition-fast); + } + + .cancel-btn-subtle:hover:not(:disabled) { + color: var(--text-secondary); + } + + .cancel-btn-subtle:disabled { + opacity: 0.6; + cursor: not-allowed; } .submit-btn { @@ -686,4 +799,99 @@ flex: 1; text-align: left; } + + .sso-section { + margin-top: var(--space-6); + } + + .sso-section-top { + margin-top: var(--space-4); + margin-bottom: 0; + } + + .sso-section-top .sso-divider { + margin-top: var(--space-5); + margin-bottom: 0; + } + + .sso-divider { + display: flex; + align-items: center; + gap: var(--space-4); + margin-bottom: var(--space-4); + color: var(--text-muted); + font-size: var(--text-sm); + } + + .sso-divider::before, + .sso-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-color); + } + + .sso-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: center; + } + + .sso-btn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: var(--text-sm); + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); + } + + .sso-btn-prominent { + padding: var(--space-3) var(--space-5); + font-size: var(--text-base); + font-weight: var(--font-medium); + } + + .sso-btn:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--accent); + } + + .sso-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .auth-methods.single-method { + grid-template-columns: 1fr; + } + + @media (min-width: 600px) { + .auth-methods.single-method { + grid-template-columns: 1fr; + max-width: 400px; + margin: var(--space-4) auto 0; + } + } + + .loading-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } diff --git a/frontend/src/routes/OAuthSsoRegister.svelte b/frontend/src/routes/OAuthSsoRegister.svelte new file mode 100644 index 0000000..3d2e555 --- /dev/null +++ b/frontend/src/routes/OAuthSsoRegister.svelte @@ -0,0 +1,614 @@ + + +
+ {#if loading} +
+
+

{$_('common.loading')}

+
+ {:else if error && !pending} +
+
!
+

{$_('common.error')}

+

{error}

+ {$_('sso_register.tryAgain')} +
+ {:else if pending} + + +
+
+ +
+ {getProviderDisplayName(pending.provider)} + {#if pending.provider_username} + @{pending.provider_username} + {/if} +
+
+
+ + + {/if} +
+ + diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index f3a170f..7d389d1 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -19,6 +19,7 @@ } | null>(null) let loadingServerInfo = $state(true) let serverInfoLoaded = false + let ssoAvailable = $state(false) let flow = $state | null>(null) let confirmPassword = $state('') @@ -27,9 +28,22 @@ if (!serverInfoLoaded) { serverInfoLoaded = true loadServerInfo() + checkSsoAvailable() } }) + async function checkSsoAvailable() { + try { + const response = await fetch('/oauth/sso/providers') + if (response.ok) { + const data = await response.json() + ssoAvailable = (data.providers?.length ?? 0) > 0 + } + } catch { + ssoAvailable = false + } + } + $effect(() => { if (flow?.state.step === 'redirect-to-dashboard') { navigate(routes.dashboard) @@ -187,7 +201,7 @@
- + - +