diff --git a/.env.example b/.env.example --- a/.env.example +++ b/.env.example @@ -160,6 +160,46 @@ # 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/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -3193,6 +3193,29 @@ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3882,6 +3905,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" @@ -5030,6 +5063,18 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6040,6 +6085,7 @@ dependencies = [ "aes-gcm", "anyhow", + "async-trait", "aws-config", "aws-sdk-s3", "axum", @@ -6067,6 +6113,7 @@ "iroh-car", "jacquard-common", "jacquard-repo", + "jsonwebtoken", "k256", "metrics", "metrics-exporter-prometheus", diff --git a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json new file mode 100644 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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 --- /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/migrations/20260115_sso_external_identities.sql b/migrations/20260115_sso_external_identities.sql new file mode 100644 --- /dev/null +++ b/migrations/20260115_sso_external_identities.sql @@ -0,0 +1,44 @@ +CREATE TYPE sso_provider_type AS ENUM ('github', 'discord', 'google', 'gitlab', 'oidc'); + +CREATE TABLE external_identities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, + provider sso_provider_type NOT NULL, + provider_user_id TEXT NOT NULL, + provider_username TEXT, + provider_email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + UNIQUE(provider, provider_user_id), + UNIQUE(did, provider) +); + +CREATE INDEX idx_external_identities_did ON external_identities(did); +CREATE INDEX idx_external_identities_provider_user ON external_identities(provider, provider_user_id); + +CREATE TABLE sso_auth_state ( + state TEXT PRIMARY KEY, + request_uri TEXT NOT NULL, + provider sso_provider_type NOT NULL, + action TEXT NOT NULL, + nonce TEXT, + code_verifier TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' +); + +CREATE INDEX idx_sso_auth_state_expires ON sso_auth_state(expires_at); + +CREATE TABLE sso_pending_registration ( + token TEXT PRIMARY KEY, + request_uri TEXT NOT NULL, + provider sso_provider_type NOT NULL, + provider_user_id TEXT NOT NULL, + provider_username TEXT, + provider_email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes' +); + +CREATE INDEX idx_sso_pending_registration_expires ON sso_pending_registration(expires_at); diff --git a/migrations/20260116_sso_auth_state_did.sql b/migrations/20260116_sso_auth_state_did.sql new file mode 100644 --- /dev/null +++ b/migrations/20260116_sso_auth_state_did.sql @@ -0,0 +1,1 @@ +ALTER TABLE sso_auth_state ADD COLUMN did TEXT; diff --git a/migrations/20260117_handle_reservations.sql b/migrations/20260117_handle_reservations.sql new file mode 100644 --- /dev/null +++ b/migrations/20260117_handle_reservations.sql @@ -0,0 +1,8 @@ +CREATE TABLE handle_reservations ( + handle TEXT PRIMARY KEY, + reserved_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes' +); + +CREATE INDEX idx_handle_reservations_expires ON handle_reservations(expires_at); diff --git a/migrations/20260118_external_identity_email_verified.sql b/migrations/20260118_external_identity_email_verified.sql new file mode 100644 --- /dev/null +++ b/migrations/20260118_external_identity_email_verified.sql @@ -0,0 +1,3 @@ +ALTER TABLE external_identities ADD COLUMN provider_email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE sso_pending_registration ADD COLUMN provider_email_verified BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/20260119_add_apple_sso_provider.sql b/migrations/20260119_add_apple_sso_provider.sql new file mode 100644 --- /dev/null +++ b/migrations/20260119_add_apple_sso_provider.sql @@ -0,0 +1,1 @@ +ALTER TYPE sso_provider_type ADD VALUE IF NOT EXISTS 'apple'; diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -18,6 +18,7 @@ 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 @@ 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 --- /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/frontend/src/App.svelte b/frontend/src/App.svelte --- 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/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -7,6 +7,7 @@ mod oauth; mod repo; mod session; +mod sso; mod user; pub use backlink::{Backlink, BacklinkRepository}; @@ -40,15 +41,18 @@ 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 --- /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 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -3,7 +3,7 @@ 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 @@ input: &CreatePasskeyAccountInput, ) -> Result; + async fn create_sso_account( + &self, + input: &CreateSsoAccountInput, + ) -> Result; + async fn reactivate_migration_account( &self, input: &MigrationReactivationInput, @@ -489,6 +494,12 @@ &self, 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; @@ -842,6 +853,7 @@ HandleTaken, EmailTaken, DidExists, + InvalidToken, Database(String), } @@ -880,6 +892,30 @@ pub genesis_block_cids: Vec>, pub invite_code: Option, 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)] diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -16,6 +16,7 @@ pub mod rate_limit; pub mod repo; pub mod scheduled; +pub mod sso; pub mod state; pub mod storage; pub mod sync; @@ -286,6 +287,14 @@ .route( "/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", @@ -569,7 +578,27 @@ ) .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 --- a/crates/tranquil-pds/src/main.rs +++ b/crates/tranquil-pds/src/main.rs @@ -4,6 +4,13 @@ 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 @@ state.user_repo.clone(), state.blob_repo.clone(), state.blob_store.clone(), + state.sso_repo.clone(), shutdown_rx, )); @@ -121,7 +129,7 @@ .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/rate_limit.rs b/crates/tranquil-pds/src/rate_limit.rs --- a/crates/tranquil-pds/src/rate_limit.rs +++ b/crates/tranquil-pds/src/rate_limit.rs @@ -33,6 +33,9 @@ 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 @@ 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(), + ))), } } @@ -136,6 +148,13 @@ pub fn with_email_update_limit(mut self, per_hour: u32) -> Self { self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour( NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), + ))); + 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 } diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -9,7 +9,8 @@ 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 @@ user_repo: Arc, blob_repo: Arc, blob_store: Arc, + sso_repo: Arc, mut shutdown_rx: watch::Receiver, ) { let check_interval = Duration::from_secs( @@ -422,6 +424,36 @@ blob_store.as_ref(), ).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/state.rs b/crates/tranquil-pds/src/state.rs --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -4,6 +4,7 @@ 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 tranquil_db::{ BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository, OAuthRepository, PostgresRepositories, RepoEventNotifier, RepoRepository, SessionRepository, - UserRepository, + SsoRepository, UserRepository, }; #[derive(Clone)] @@ -38,6 +39,8 @@ 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 @@ HandleUpdate, HandleUpdateDaily, VerificationCheck, + SsoInitiate, + SsoCallback, + SsoUnlink, } impl RateLimitKind { @@ -76,6 +82,9 @@ 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 @@ 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 @@ 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 @@ 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 @@ cache, distributed_rate_limiter, did_resolver, + sso_manager, } } @@ -232,6 +248,9 @@ 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/tests/apple_sso_unit.rs b/crates/tranquil-pds/tests/apple_sso_unit.rs new file mode 100644 --- /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 --- /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 --- a/crates/tranquil-pds/tests/sync_repo.rs +++ b/crates/tranquil-pds/tests/sync_repo.rs @@ -110,49 +110,77 @@ 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/components/AccountTypeSwitcher.svelte b/frontend/src/components/AccountTypeSwitcher.svelte --- 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 --- /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 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -143,10 +143,13 @@ 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 @@ }); }, - requestEmailUpdate(token: AccessToken): Promise { + requestEmailUpdate( + token: AccessToken, + newEmail?: string, + ): Promise { return xrpc("com.atproto.server.requestEmailUpdate", { method: "POST", token, + body: newEmail ? { newEmail } : undefined, }); }, @@ -398,6 +405,15 @@ method: "POST", token, body: { email, token: emailToken }, + }); + }, + + checkEmailUpdateStatus( + token: AccessToken, + ): Promise<{ pending: boolean; authorized: boolean; newEmail?: string }> { + return xrpc("_account.checkEmailUpdateStatus", { + method: "GET", + token, }); }, @@ -540,7 +556,10 @@ }); }, - 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/oauth.ts b/frontend/src/lib/oauth.ts --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -10,7 +10,7 @@ "repo:*?action=delete", "blob:*/*", "identity:*", - "account:*", + "account:*?action=manage", ].join(" "); const CLIENT_ID = !(import.meta.env.DEV) @@ -346,7 +346,9 @@ 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 @@ 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/locales/en.json b/frontend/src/locales/en.json --- 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 --- 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 --- 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 --- 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 --- 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 --- 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 --- 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,7 +55,65 @@ $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() @@ -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 { @@ -685,5 +798,100 @@ .passkey-text { 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 --- /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 --- 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,8 +28,21 @@ 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') { @@ -187,7 +201,7 @@
- + - +