sso signup & login

This commit is contained in:
lewis
2026-01-18 01:15:13 +02:00
parent 4e29861990
commit b3ec7feb96
141 changed files with 9982 additions and 314 deletions

View File

@@ -160,6 +160,46 @@ AWS_SECRET_ACCESS_KEY=minioadmin
# ALLOW_HTTP_PROXY=1
# Custom frontend directory (defaults to ./frontend/dist)
# FRONTEND_DIR=/path/to/frontend/dist
# =============================================================================
# SSO / Social Login
# =============================================================================
# Each provider requires ENABLED=true plus CLIENT_ID and CLIENT_SECRET.
# Register your PDS as an OAuth application with each provider to get credentials.
# GitHub
# SSO_GITHUB_ENABLED=true
# SSO_GITHUB_CLIENT_ID=
# SSO_GITHUB_CLIENT_SECRET=
# Discord
# SSO_DISCORD_ENABLED=true
# SSO_DISCORD_CLIENT_ID=
# SSO_DISCORD_CLIENT_SECRET=
# Google
# SSO_GOOGLE_ENABLED=true
# SSO_GOOGLE_CLIENT_ID=
# SSO_GOOGLE_CLIENT_SECRET=
# GitLab (set ISSUER for self-hosted instances)
# SSO_GITLAB_ENABLED=false
# SSO_GITLAB_CLIENT_ID=
# SSO_GITLAB_CLIENT_SECRET=
# SSO_GITLAB_ISSUER=https://gitlab.com
# Generic OIDC
# SSO_OIDC_ENABLED=false
# SSO_OIDC_CLIENT_ID=
# SSO_OIDC_CLIENT_SECRET=
# SSO_OIDC_ISSUER=https://your-identity-provider.com
# SSO_OIDC_NAME=Custom Provider
# Apple Sign-in
# SSO_APPLE_ENABLED=true
# SSO_APPLE_CLIENT_ID=com.example.signin # Services ID from Apple Developer Portal
# SSO_APPLE_TEAM_ID=XXXXXXXXXX # 10-character Team ID
# SSO_APPLE_KEY_ID=XXXXXXXXXX # Key ID from portal
# SSO_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
CARGO_MOMMYS_LITTLE=mister
CARGO_MOMMYS_PRONOUNS=his
CARGO_MOMMYS_ROLES=daddy

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM sso_auth_state WHERE expires_at < NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "bb4460f75d30f48b79d71b97f2c7d54190260deba2d2ade177dbdaa507ab275b"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM handle_reservations WHERE expires_at <= NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "eb82195792193f432e9abfe5e6ea4d4c45ccb9bd15b025602c64967bd4c85fd3"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM handle_reservations WHERE handle = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f4d0d7fbb138a2c3c285d829ffd3a760a5036640291666daf6f51d32ab4f3d2d"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

47
Cargo.lock generated
View File

@@ -3192,6 +3192,29 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "10.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"getrandom 0.2.16",
"hmac",
"js-sys",
"p256 0.13.2",
"p384",
"pem",
"rand 0.8.5",
"rsa",
"serde",
"serde_json",
"sha2",
"signature 2.2.0",
"simple_asn1",
]
[[package]]
name = "k256"
version = "0.13.4"
@@ -3883,6 +3906,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -5029,6 +5062,18 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple_asn1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.17",
"time",
]
[[package]]
name = "sketches-ddsketch"
version = "0.3.0"
@@ -6040,6 +6085,7 @@ version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"async-trait",
"aws-config",
"aws-sdk-s3",
"axum",
@@ -6067,6 +6113,7 @@ dependencies = [
"iroh-car",
"jacquard-common",
"jacquard-repo",
"jsonwebtoken",
"k256",
"metrics",
"metrics-exporter-prometheus",

View File

@@ -7,6 +7,7 @@ mod infra;
mod oauth;
mod repo;
mod session;
mod sso;
mod user;
pub use backlink::{Backlink, BacklinkRepository};
@@ -40,15 +41,18 @@ pub use session::{
AppPasswordCreate, AppPasswordRecord, RefreshSessionResult, SessionForRefresh, SessionListItem,
SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
};
pub use sso::{
ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
};
pub use user::{
AccountSearchResult, CompletePasskeySetupInput, CreateAccountError,
CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput,
CreatePasswordAccountResult, DidWebOverrides, MigrationReactivationError,
MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult,
ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult,
ScheduledDeletionAccount, StoredBackupCode, StoredPasskey, TotpRecord, User2faStatus,
UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo,
UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
CreatePasswordAccountResult, CreateSsoAccountInput, DidWebOverrides,
MigrationReactivationError, MigrationReactivationInput, NotificationPrefs, OAuthTokenWithUser,
PasswordResetResult, ReactivatedAccountInfo, RecoverPasskeyAccountInput,
RecoverPasskeyAccountResult, ScheduledDeletionAccount, StoredBackupCode, StoredPasskey,
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,

View File

@@ -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<Self> {
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<String>,
pub provider_email: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct SsoAuthState {
pub state: String,
pub request_uri: String,
pub provider: SsoProviderType,
pub action: String,
pub nonce: Option<String>,
pub code_verifier: Option<String>,
pub did: Option<Did>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[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<String>,
pub provider_email: Option<String>,
pub provider_email_verified: bool,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
#[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<Uuid, DbError>;
async fn get_external_identity_by_provider(
&self,
provider: SsoProviderType,
provider_user_id: &str,
) -> Result<Option<ExternalIdentity>, DbError>;
async fn get_external_identities_by_did(
&self,
did: &Did,
) -> Result<Vec<ExternalIdentity>, 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<bool, DbError>;
#[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<Option<SsoAuthState>, DbError>;
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError>;
#[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<Option<SsoPendingRegistration>, DbError>;
async fn consume_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError>;
async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError>;
}

View File

@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
use tranquil_types::{Did, Handle};
use uuid::Uuid;
use crate::{CommsChannel, DbError};
use crate::{CommsChannel, DbError, SsoProviderType};
#[derive(Debug, Clone)]
pub struct UserRow {
@@ -480,6 +480,11 @@ pub trait UserRepository: Send + Sync {
input: &CreatePasskeyAccountInput,
) -> Result<CreatePasswordAccountResult, CreateAccountError>;
async fn create_sso_account(
&self,
input: &CreateSsoAccountInput,
) -> Result<CreatePasswordAccountResult, CreateAccountError>;
async fn reactivate_migration_account(
&self,
input: &MigrationReactivationInput,
@@ -490,6 +495,12 @@ pub trait UserRepository: Send + Sync {
handle: &Handle,
) -> Result<bool, DbError>;
async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError>;
async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError>;
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError>;
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError>;
async fn complete_passkey_setup(
@@ -842,6 +853,7 @@ pub enum CreateAccountError {
HandleTaken,
EmailTaken,
DidExists,
InvalidToken,
Database(String),
}
@@ -882,6 +894,30 @@ pub struct CreatePasskeyAccountInput {
pub birthdate_pref: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct CreateSsoAccountInput {
pub handle: Handle,
pub email: Option<String>,
pub did: Did,
pub preferred_comms_channel: CommsChannel,
pub discord_id: Option<String>,
pub telegram_username: Option<String>,
pub signal_number: Option<String>,
pub encrypted_key_bytes: Vec<u8>,
pub encryption_version: i32,
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<String>,
pub birthdate_pref: Option<serde_json::Value>,
pub sso_provider: SsoProviderType,
pub sso_provider_user_id: String,
pub sso_provider_username: Option<String>,
pub sso_provider_email: Option<String>,
pub sso_provider_email_verified: bool,
pub pending_registration_token: String,
}
#[derive(Debug, Clone)]
pub struct CompletePasskeySetupInput {
pub user_id: Uuid,

View File

@@ -7,6 +7,7 @@ mod infra;
mod oauth;
mod repo;
mod session;
mod sso;
mod user;
use sqlx::PgPool;
@@ -21,9 +22,11 @@ pub use infra::PostgresInfraRepository;
pub use oauth::PostgresOAuthRepository;
pub use repo::PostgresRepoRepository;
pub use session::PostgresSessionRepository;
pub use sso::PostgresSsoRepository;
use tranquil_db_traits::{
BacklinkRepository, BackupRepository, BlobRepository, DelegationRepository, InfraRepository,
OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, UserRepository,
OAuthRepository, RepoEventNotifier, RepoRepository, SessionRepository, SsoRepository,
UserRepository,
};
pub use user::PostgresUserRepository;
@@ -38,6 +41,7 @@ pub struct PostgresRepositories {
pub infra: Arc<dyn InfraRepository>,
pub backup: Arc<dyn BackupRepository>,
pub backlink: Arc<dyn BacklinkRepository>,
pub sso: Arc<dyn SsoRepository>,
pub event_notifier: Arc<dyn RepoEventNotifier>,
}
@@ -54,6 +58,7 @@ impl PostgresRepositories {
infra: Arc::new(PostgresInfraRepository::new(pool.clone())),
backup: Arc::new(PostgresBackupRepository::new(pool.clone())),
backlink: Arc::new(PostgresBacklinkRepository::new(pool.clone())),
sso: Arc::new(PostgresSsoRepository::new(pool.clone())),
event_notifier: Arc::new(PostgresRepoEventNotifier::new(pool)),
}
}

View File

@@ -0,0 +1,337 @@
use async_trait::async_trait;
use chrono::Utc;
use sqlx::PgPool;
use tranquil_db_traits::{
DbError, ExternalIdentity, SsoAuthState, SsoPendingRegistration, SsoProviderType, SsoRepository,
};
use tranquil_types::Did;
use uuid::Uuid;
use super::user::map_sqlx_error;
pub struct PostgresSsoRepository {
pool: PgPool,
}
impl PostgresSsoRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl SsoRepository for PostgresSsoRepository {
async fn create_external_identity(
&self,
did: &Did,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<Uuid, DbError> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
did.as_str(),
provider as SsoProviderType,
provider_user_id,
provider_username,
provider_email,
)
.fetch_one(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(id)
}
async fn get_external_identity_by_provider(
&self,
provider: SsoProviderType,
provider_user_id: &str,
) -> Result<Option<ExternalIdentity>, DbError> {
let row = sqlx::query!(
r#"
SELECT id, did, provider as "provider: SsoProviderType", provider_user_id,
provider_username, provider_email, created_at, updated_at, last_login_at
FROM external_identities
WHERE provider = $1 AND provider_user_id = $2
"#,
provider as SsoProviderType,
provider_user_id,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
}))
}
async fn get_external_identities_by_did(
&self,
did: &Did,
) -> Result<Vec<ExternalIdentity>, DbError> {
let rows = sqlx::query!(
r#"
SELECT id, did, provider as "provider: SsoProviderType", provider_user_id,
provider_username, provider_email, created_at, updated_at, last_login_at
FROM external_identities
WHERE did = $1
ORDER BY created_at ASC
"#,
did.as_str(),
)
.fetch_all(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| ExternalIdentity {
id: r.id,
did: Did::new_unchecked(&r.did),
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
created_at: r.created_at,
updated_at: r.updated_at,
last_login_at: r.last_login_at,
})
.collect())
}
async fn update_external_identity_login(
&self,
id: Uuid,
provider_username: Option<&str>,
provider_email: Option<&str>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
UPDATE external_identities
SET provider_username = COALESCE($2, provider_username),
provider_email = COALESCE($3, provider_email),
last_login_at = NOW(),
updated_at = NOW()
WHERE id = $1
"#,
id,
provider_username,
provider_email,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM external_identities
WHERE id = $1 AND did = $2
"#,
id,
did.as_str(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn create_sso_auth_state(
&self,
state: &str,
request_uri: &str,
provider: SsoProviderType,
action: &str,
nonce: Option<&str>,
code_verifier: Option<&str>,
did: Option<&Did>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
INSERT INTO sso_auth_state (state, request_uri, provider, action, nonce, code_verifier, did)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
state,
request_uri,
provider as SsoProviderType,
action,
nonce,
code_verifier,
did.map(|d| d.as_str()),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn consume_sso_auth_state(&self, state: &str) -> Result<Option<SsoAuthState>, DbError> {
let row = sqlx::query!(
r#"
DELETE FROM sso_auth_state
WHERE state = $1 AND expires_at > NOW()
RETURNING state, request_uri, provider as "provider: SsoProviderType", action,
nonce, code_verifier, did, created_at, expires_at
"#,
state,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoAuthState {
state: r.state,
request_uri: r.request_uri,
provider: r.provider,
action: r.action,
nonce: r.nonce,
code_verifier: r.code_verifier,
did: r.did.map(|d| Did::new_unchecked(&d)),
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn cleanup_expired_sso_auth_states(&self) -> Result<u64, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM sso_auth_state
WHERE expires_at < $1
"#,
Utc::now(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn create_pending_registration(
&self,
token: &str,
request_uri: &str,
provider: SsoProviderType,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
provider_email_verified: bool,
) -> Result<(), DbError> {
sqlx::query!(
r#"
INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email, provider_email_verified)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
token,
request_uri,
provider as SsoProviderType,
provider_user_id,
provider_username,
provider_email,
provider_email_verified,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError> {
let row = sqlx::query!(
r#"
SELECT token, request_uri, provider as "provider: SsoProviderType",
provider_user_id, provider_username, provider_email, provider_email_verified,
created_at, expires_at
FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
"#,
token,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoPendingRegistration {
token: r.token,
request_uri: r.request_uri,
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn consume_pending_registration(
&self,
token: &str,
) -> Result<Option<SsoPendingRegistration>, DbError> {
let row = sqlx::query!(
r#"
DELETE FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
RETURNING token, request_uri, provider as "provider: SsoProviderType",
provider_user_id, provider_username, provider_email, provider_email_verified,
created_at, expires_at
"#,
token,
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(|r| SsoPendingRegistration {
token: r.token,
request_uri: r.request_uri,
provider: r.provider,
provider_user_id: r.provider_user_id,
provider_username: r.provider_username,
provider_email: r.provider_email,
provider_email_verified: r.provider_email_verified,
created_at: r.created_at,
expires_at: r.expires_at,
}))
}
async fn cleanup_expired_pending_registrations(&self) -> Result<u64, DbError> {
let result = sqlx::query!(
r#"
DELETE FROM sso_pending_registration
WHERE expires_at < $1
"#,
Utc::now(),
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
}

View File

@@ -6,9 +6,9 @@ use uuid::Uuid;
use tranquil_db_traits::{
AccountSearchResult, CommsChannel, DbError, DidWebOverrides, NotificationPrefs,
OAuthTokenWithUser, PasswordResetResult, StoredBackupCode, StoredPasskey, TotpRecord,
User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo,
UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
OAuthTokenWithUser, PasswordResetResult, SsoProviderType, StoredBackupCode, StoredPasskey,
TotpRecord, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, UserDidWebInfo,
UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery,
UserForPasskeySetup, UserForRecovery, UserForVerification, UserIdAndHandle,
UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId,
UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo,
@@ -2671,6 +2671,173 @@ impl UserRepository for PostgresUserRepository {
})
}
async fn create_sso_account(
&self,
input: &tranquil_db_traits::CreateSsoAccountInput,
) -> Result<
tranquil_db_traits::CreatePasswordAccountResult,
tranquil_db_traits::CreateAccountError,
> {
let mut tx = self.pool.begin().await.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
let token_consumed: Option<(String,)> = sqlx::query_as(
r#"
DELETE FROM sso_pending_registration
WHERE token = $1 AND expires_at > NOW()
RETURNING token
"#,
)
.bind(&input.pending_registration_token)
.fetch_optional(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
if token_consumed.is_none() {
return Err(tranquil_db_traits::CreateAccountError::InvalidToken);
}
let is_first_user: bool = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut *tx)
.await
.map(|c| c.unwrap_or(0) == 0)
.unwrap_or(false);
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
r#"INSERT INTO users (
handle, email, did, password_hash, password_required,
preferred_comms_channel, discord_id, telegram_username, signal_number,
is_admin
) VALUES ($1, $2, $3, NULL, FALSE, $4, $5, $6, $7, $8) RETURNING id"#,
)
.bind(input.handle.as_str())
.bind(&input.email)
.bind(input.did.as_str())
.bind(input.preferred_comms_channel)
.bind(&input.discord_id)
.bind(&input.telegram_username)
.bind(&input.signal_number)
.bind(is_first_user)
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("23505")
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") {
return Err(tranquil_db_traits::CreateAccountError::HandleTaken);
} else if constraint.contains("email") {
return Err(tranquil_db_traits::CreateAccountError::EmailTaken);
}
}
return Err(tranquil_db_traits::CreateAccountError::Database(
e.to_string(),
));
}
};
sqlx::query!(
"INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
user_id,
&input.encrypted_key_bytes[..],
input.encryption_version
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string()))?;
sqlx::query!(
"INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
user_id,
input.commit_cid,
input.repo_rev
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
sqlx::query(
r#"
INSERT INTO user_blocks (user_id, block_cid, repo_rev)
SELECT $1, block_cid, $3 FROM UNNEST($2::bytea[]) AS t(block_cid)
ON CONFLICT (user_id, block_cid) DO NOTHING
"#,
)
.bind(user_id)
.bind(&input.genesis_block_cids)
.bind(&input.repo_rev)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
}
if let Some(birthdate_pref) = &input.birthdate_pref {
let _ = sqlx::query!(
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)
ON CONFLICT (user_id, name) DO NOTHING",
user_id,
"app.bsky.actor.defs#personalDetailsPref",
birthdate_pref
)
.execute(&mut *tx)
.await;
}
sqlx::query!(
r#"
INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email, provider_email_verified)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
input.did.as_str(),
input.sso_provider as SsoProviderType,
&input.sso_provider_user_id,
input.sso_provider_username.as_deref(),
input.sso_provider_email.as_deref(),
input.sso_provider_email_verified,
)
.execute(&mut *tx)
.await
.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
tx.commit().await.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
Ok(tranquil_db_traits::CreatePasswordAccountResult {
user_id,
is_admin: is_first_user,
})
}
async fn reactivate_migration_account(
&self,
input: &tranquil_db_traits::MigrationReactivationInput,
@@ -2744,16 +2911,70 @@ impl UserRepository for PostgresUserRepository {
&self,
handle: &Handle,
) -> Result<bool, DbError> {
let exists: Option<(i32,)> =
sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL")
.bind(handle.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
let exists: Option<(i32,)> = sqlx::query_as(
r#"
SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL
UNION ALL
SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()
LIMIT 1
"#,
)
.bind(handle.as_str())
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(exists.is_none())
}
async fn reserve_handle(&self, handle: &Handle, reserved_by: &str) -> Result<bool, DbError> {
sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()")
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
let result = sqlx::query!(
r#"
INSERT INTO handle_reservations (handle, reserved_by)
SELECT $1, $2
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM handle_reservations WHERE handle = $1 AND expires_at > NOW()
)
"#,
handle.as_str(),
reserved_by,
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
async fn release_handle_reservation(&self, handle: &Handle) -> Result<(), DbError> {
sqlx::query!(
"DELETE FROM handle_reservations WHERE handle = $1",
handle.as_str()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError> {
let result = sqlx::query!("DELETE FROM handle_reservations WHERE expires_at <= NOW()")
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;

View File

@@ -18,6 +18,7 @@ tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
aes-gcm = { workspace = true }
async-trait = { workspace = true }
backon = { workspace = true }
anyhow = { workspace = true }
aws-config = { workspace = true }
@@ -44,6 +45,7 @@ ipld-core = { workspace = true }
iroh-car = { workspace = true }
jacquard-common = { workspace = true }
jacquard-repo = { workspace = true }
jsonwebtoken = { workspace = true }
k256 = { workspace = true }
metrics = { workspace = true }
metrics-exporter-prometheus = { workspace = true }

View File

@@ -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");
}

View File

@@ -107,6 +107,13 @@ pub enum ApiError {
error: Option<String>,
message: Option<String>,
},
SsoProviderNotFound,
SsoProviderNotEnabled,
SsoInvalidAction,
SsoNotAuthenticated,
SsoSessionExpired,
SsoAlreadyLinked,
SsoLinkNotFound,
}
impl ApiError {
@@ -197,8 +204,14 @@ impl ApiError {
| Self::InvalidVerificationChannel
| Self::SelfHostedDidWebDisabled
| Self::AccountAlreadyExists
| Self::TokenRequired => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
| Self::TokenRequired
| Self::SsoProviderNotFound
| Self::SsoProviderNotEnabled
| Self::SsoInvalidAction
| Self::SsoNotAuthenticated
| Self::SsoSessionExpired
| Self::SsoAlreadyLinked => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound | Self::SsoLinkNotFound => StatusCode::NOT_FOUND,
}
}
fn error_name(&self) -> Cow<'static, str> {
@@ -293,6 +306,13 @@ impl ApiError {
Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"),
Self::HandleNotFound => Cow::Borrowed("HandleNotFound"),
Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"),
Self::SsoProviderNotFound => Cow::Borrowed("SsoProviderNotFound"),
Self::SsoProviderNotEnabled => Cow::Borrowed("SsoProviderNotEnabled"),
Self::SsoInvalidAction => Cow::Borrowed("SsoInvalidAction"),
Self::SsoNotAuthenticated => Cow::Borrowed("SsoNotAuthenticated"),
Self::SsoSessionExpired => Cow::Borrowed("SsoSessionExpired"),
Self::SsoAlreadyLinked => Cow::Borrowed("SsoAlreadyLinked"),
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
}
}
fn message(&self) -> Option<String> {
@@ -392,6 +412,19 @@ impl ApiError {
Self::AccountAlreadyExists => Some("Account already exists".to_string()),
Self::HandleNotFound => Some("Unable to resolve handle".to_string()),
Self::SubjectNotFound => Some("Subject not found".to_string()),
Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()),
Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()),
Self::SsoInvalidAction => {
Some("Action must be login, link, or register".to_string())
}
Self::SsoNotAuthenticated => {
Some("Must be authenticated to link SSO account".to_string())
}
Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()),
Self::SsoAlreadyLinked => {
Some("This SSO account is already linked to a different user".to_string())
}
Self::SsoLinkNotFound => Some("Linked account not found".to_string()),
Self::IdentifierMismatch => {
Some("The identifier does not match the verification token".to_string())
}
@@ -467,6 +500,13 @@ impl From<sqlx::Error> for ApiError {
}
}
impl From<tranquil_db_traits::DbError> for ApiError {
fn from(e: tranquil_db_traits::DbError) -> Self {
tracing::error!("Database error: {:?}", e);
Self::DatabaseError
}
}
impl From<crate::auth::TokenValidationError> for ApiError {
fn from(e: crate::auth::TokenValidationError) -> Self {
match e {

View File

@@ -428,7 +428,10 @@ pub async fn activate_account(
let _ = state.cache.delete(&format!("plc:doc:{}", did)).await;
let _ = state.cache.delete(&format!("plc:data:{}", did)).await;
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
warn!("[MIGRATION] activateAccount: Failed to refresh DID cache for {}", did);
warn!(
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
did
);
}
info!(
"[MIGRATION] activateAccount: Sequencing account event (active=true) for did={}",

View File

@@ -7,14 +7,45 @@ use axum::{
extract::State,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use std::time::Duration;
use subtle::ConstantTimeEq;
use tracing::{error, info, warn};
const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60);
fn email_update_cache_key(did: &str) -> String {
format!("email_update:{}", did)
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
URL_SAFE_NO_PAD.encode(hasher.finalize())
}
#[derive(Serialize, Deserialize)]
struct PendingEmailUpdate {
new_email: String,
token_hash: String,
authorized: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
#[serde(default)]
pub new_email: Option<String>,
}
pub async fn request_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
input: Option<Json<RequestEmailUpdateInput>>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
@@ -60,11 +91,30 @@ pub async fn request_email_update(
);
let formatted_code = crate::auth::verification_token::format_token_for_display(&code);
if let Some(Json(ref inp)) = input
&& let Some(ref new_email) = inp.new_email {
let new_email = new_email.trim().to_lowercase();
if !new_email.is_empty() && crate::api::validation::is_valid_email(&new_email) {
let pending = PendingEmailUpdate {
new_email,
token_hash: hash_token(&code),
authorized: false,
};
if let Ok(json) = serde_json::to_string(&pending) {
let cache_key = email_update_cache_key(&auth.0.did);
if let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to cache pending email update: {:?}", e);
}
}
}
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::comms_repo::enqueue_email_update_token(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user.id,
&code,
&formatted_code,
&hostname,
)
@@ -223,34 +273,48 @@ pub async fn update_email(
}
if email_verified {
let Some(ref t) = input.token else {
return ApiError::TokenRequired.into_response();
};
let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim());
let mut authorized_via_link = false;
let current_email_lower = current_email
.as_ref()
.map(|e| e.to_lowercase())
.unwrap_or_default();
let cache_key = email_update_cache_key(did);
if let Some(pending_json) = state.cache.get(&cache_key).await
&& let Ok(pending) = serde_json::from_str::<PendingEmailUpdate>(&pending_json)
&& pending.authorized && pending.new_email == new_email {
authorized_via_link = true;
let _ = state.cache.delete(&cache_key).await;
info!(did = %did, "Email update completed via link authorization");
}
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
&current_email_lower,
);
if !authorized_via_link {
let Some(ref t) = input.token else {
return ApiError::TokenRequired.into_response();
};
let confirmation_token =
crate::auth::verification_token::normalize_token_input(t.trim());
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
let current_email_lower = current_email
.as_ref()
.map(|e| e.to_lowercase())
.unwrap_or_default();
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
&current_email_lower,
);
match verified {
Ok(token_data) => {
if token_data.did != did.as_str() {
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return ApiError::InvalidToken(None).into_response();
}
}
}
@@ -332,3 +396,148 @@ pub async fn check_email_verified(
}
}
}
#[derive(Deserialize)]
pub struct AuthorizeEmailUpdateQuery {
pub token: String,
}
pub async fn authorize_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
axum::extract::Query(query): axum::extract::Query<AuthorizeEmailUpdateQuery>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
let verified = crate::auth::verification_token::verify_token_signature(&query.token);
let token_data = match verified {
Ok(data) => data,
Err(crate::auth::verification_token::VerifyError::Expired) => {
warn!("authorize_email_update: token expired");
return ApiError::ExpiredToken(None).into_response();
}
Err(e) => {
warn!("authorize_email_update: token verification failed: {:?}", e);
return ApiError::InvalidToken(None).into_response();
}
};
if token_data.purpose != crate::auth::verification_token::VerificationPurpose::ChannelUpdate {
warn!(
"authorize_email_update: wrong purpose: {:?}",
token_data.purpose
);
return ApiError::InvalidToken(None).into_response();
}
if token_data.channel != "email_update" {
warn!(
"authorize_email_update: wrong channel: {}",
token_data.channel
);
return ApiError::InvalidToken(None).into_response();
}
let did = token_data.did;
info!("authorize_email_update: token valid for did={}", did);
let cache_key = email_update_cache_key(&did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
None => {
warn!(
"authorize_email_update: no pending email update in cache for did={}",
did
);
return ApiError::InvalidRequest("No pending email update found".into())
.into_response();
}
};
let mut pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let token_hash = hash_token(&query.token);
if pending
.token_hash
.as_bytes()
.ct_eq(token_hash.as_bytes())
.unwrap_u8()
!= 1
{
warn!("authorize_email_update: token hash mismatch");
return ApiError::InvalidToken(None).into_response();
}
pending.authorized = true;
if let Ok(json) = serde_json::to_string(&pending)
&& let Err(e) = state.cache.set(&cache_key, &json, EMAIL_UPDATE_TTL).await {
warn!("Failed to update pending email authorization: {:?}", e);
return ApiError::InternalError(None).into_response();
}
info!(did = %did, "Email update authorized via link click");
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let redirect_url = format!(
"https://{}/app/verify?type=email-authorize-success",
hostname
);
axum::response::Redirect::to(&redirect_url).into_response()
}
pub async fn check_email_update_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return ApiError::RateLimitExceeded(None).into_response();
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth.0.is_oauth,
auth.0.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Read,
) {
return e;
}
let cache_key = email_update_cache_key(&auth.0.did);
let pending_json = match state.cache.get(&cache_key).await {
Some(json) => json,
None => {
return Json(json!({ "pending": false, "authorized": false })).into_response();
}
};
let pending: PendingEmailUpdate = match serde_json::from_str(&pending_json) {
Ok(p) => p,
Err(_) => {
return Json(json!({ "pending": false, "authorized": false })).into_response();
}
};
Json(json!({
"pending": true,
"authorized": pending.authorized,
"newEmail": pending.new_email,
}))
.into_response()
}

View File

@@ -22,7 +22,10 @@ pub use account_status::{
request_account_delete,
};
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
pub use email::{check_email_verified, confirm_email, request_email_update, update_email};
pub use email::{
authorize_email_update, check_email_update_status, check_email_verified, confirm_email,
request_email_update, update_email,
};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};

View File

@@ -366,12 +366,33 @@ pub async fn set_password(
auth: BearerAuth,
Json(input): Json<SetPasswordInput>,
) -> Response {
if crate::api::server::reauth::check_reauth_required_cached(
&*state.session_repo,
&state.cache,
&auth.0.did,
)
.await
let has_password = state
.user_repo
.has_password_by_did(&auth.0.did)
.await
.ok()
.flatten()
.unwrap_or(false);
let has_passkeys = state
.user_repo
.has_passkeys(&auth.0.did)
.await
.unwrap_or(false);
let has_totp = state
.user_repo
.has_totp_enabled(&auth.0.did)
.await
.unwrap_or(false);
let has_any_reauth_method = has_password || has_passkeys || has_totp;
if has_any_reauth_method
&& crate::api::server::reauth::check_reauth_required_cached(
&*state.session_repo,
&state.cache,
&auth.0.did,
)
.await
{
return crate::api::server::reauth::reauth_required_response(
&*state.user_repo,

View File

@@ -366,7 +366,8 @@ pub mod repo {
user_repo: &dyn UserRepository,
infra_repo: &dyn InfraRepository,
user_id: Uuid,
code: &str,
raw_token: &str,
display_code: &str,
hostname: &str,
) -> Result<Uuid, DbError> {
let prefs = user_repo
@@ -375,17 +376,17 @@ pub mod repo {
.ok_or(DbError::NotFound)?;
let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en"));
let current_email = prefs.email.unwrap_or_default();
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
let verify_page = format!("https://{}/app/settings", hostname);
let verify_link = format!(
"https://{}/app/verify?type=email-update&token={}",
"https://{}/xrpc/_account.authorizeEmailUpdate?token={}",
hostname,
urlencoding::encode(code)
urlencoding::encode(raw_token)
);
let body = format_message(
strings.email_update_body,
&[
("handle", &prefs.handle),
("code", code),
("code", display_code),
("verify_page", &verify_page),
("verify_link", &verify_link),
],

View File

@@ -16,6 +16,7 @@ pub mod plc;
pub mod rate_limit;
pub mod repo;
pub mod scheduled;
pub mod sso;
pub mod state;
pub mod storage;
pub mod sync;
@@ -287,6 +288,14 @@ pub fn app(state: AppState) -> Router {
"/com.atproto.server.updateEmail",
post(api::server::update_email),
)
.route(
"/_account.authorizeEmailUpdate",
get(api::server::authorize_email_update),
)
.route(
"/_account.checkEmailUpdateStatus",
get(api::server::check_email_update_status),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(api::server::reserve_signing_key),
@@ -569,7 +578,27 @@ pub fn app(state: AppState) -> Router {
)
.route("/token", post(oauth::endpoints::token_endpoint))
.route("/revoke", post(oauth::endpoints::revoke_token))
.route("/introspect", post(oauth::endpoints::introspect_token));
.route("/introspect", post(oauth::endpoints::introspect_token))
.route("/sso/providers", get(sso::endpoints::get_sso_providers))
.route("/sso/initiate", post(sso::endpoints::sso_initiate))
.route(
"/sso/callback",
get(sso::endpoints::sso_callback).post(sso::endpoints::sso_callback_post),
)
.route("/sso/linked", get(sso::endpoints::get_linked_accounts))
.route("/sso/unlink", post(sso::endpoints::unlink_account))
.route(
"/sso/pending-registration",
get(sso::endpoints::get_pending_registration),
)
.route(
"/sso/complete-registration",
post(sso::endpoints::complete_registration),
)
.route(
"/sso/check-handle-available",
get(sso::endpoints::check_handle_available),
);
let well_known_router = Router::new()
.route("/did.json", get(api::identity::well_known_did))

View File

@@ -4,6 +4,13 @@ use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info, warn};
use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender, TelegramSender};
const BUILD_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (built ",
env!("BUILD_TIMESTAMP"),
")"
);
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
use tranquil_pds::scheduled::{
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
@@ -106,6 +113,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
state.user_repo.clone(),
state.blob_repo.clone(),
state.blob_store.clone(),
state.sso_repo.clone(),
shutdown_rx,
));
@@ -121,7 +129,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.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

View File

@@ -459,9 +459,7 @@ pub async fn delegation_auth_token(
headers: HeaderMap,
Json(form): Json<DelegationTokenAuthSubmit>,
) -> Response {
let auth_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok());
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
let extracted = match extract_auth_token_from_header(auth_header) {
Some(e) => e,

View File

@@ -176,7 +176,7 @@ pub async fn frontend_client_metadata(
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:* identity:*"
scope: "atproto transition:generic repo:* blob:*/* rpc:* rpc:com.atproto.server.createAccount?aud=* account:*?action=manage identity:*"
.to_string(),
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),

View File

@@ -33,6 +33,9 @@ pub struct RateLimiters {
pub handle_update: Arc<KeyedRateLimiter>,
pub handle_update_daily: Arc<KeyedRateLimiter>,
pub verification_check: Arc<KeyedRateLimiter>,
pub sso_initiate: Arc<KeyedRateLimiter>,
pub sso_callback: Arc<KeyedRateLimiter>,
pub sso_unlink: Arc<KeyedRateLimiter>,
}
impl Default for RateLimiters {
@@ -95,6 +98,15 @@ impl RateLimiters {
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(60).unwrap(),
))),
sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
))),
sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
}
}
@@ -139,6 +151,13 @@ impl RateLimiters {
)));
self
}
pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self {
self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
)));
self
}
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {

View File

@@ -9,7 +9,8 @@ use tokio::sync::watch;
use tokio::time::interval;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::{
BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, UserRepository,
BackupRepository, BlobRepository, BrokenGenesisCommit, RepoRepository, SsoRepository,
UserRepository,
};
use tranquil_types::{AtUri, CidLink, Did};
@@ -390,6 +391,7 @@ pub async fn start_scheduled_tasks(
user_repo: Arc<dyn UserRepository>,
blob_repo: Arc<dyn BlobRepository>,
blob_store: Arc<dyn BlobStorage>,
sso_repo: Arc<dyn SsoRepository>,
mut shutdown_rx: watch::Receiver<bool>,
) {
let check_interval = Duration::from_secs(
@@ -423,6 +425,36 @@ pub async fn start_scheduled_tasks(
).await {
error!("Error processing scheduled deletions: {}", e);
}
match sso_repo.cleanup_expired_sso_auth_states().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired SSO auth states");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up SSO auth states: {:?}", e);
}
}
match sso_repo.cleanup_expired_pending_registrations().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired SSO pending registrations");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up SSO pending registrations: {:?}", e);
}
}
match user_repo.cleanup_expired_handle_reservations().await {
Ok(count) if count > 0 => {
info!(count = count, "Cleaned up expired handle reservations");
}
Ok(_) => {}
Err(e) => {
error!("Error cleaning up handle reservations: {:?}", e);
}
}
}
}
}

View File

@@ -0,0 +1,211 @@
use std::sync::OnceLock;
use tranquil_db_traits::SsoProviderType;
static SSO_CONFIG: OnceLock<SsoConfig> = OnceLock::new();
static SSO_REDIRECT_URI: OnceLock<String> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct ProviderConfig {
pub client_id: String,
pub client_secret: String,
pub issuer: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AppleProviderConfig {
pub client_id: String,
pub team_id: String,
pub key_id: String,
pub private_key_pem: String,
}
#[derive(Debug, Clone, Default)]
pub struct SsoConfig {
pub github: Option<ProviderConfig>,
pub discord: Option<ProviderConfig>,
pub google: Option<ProviderConfig>,
pub gitlab: Option<ProviderConfig>,
pub oidc: Option<ProviderConfig>,
pub apple: Option<AppleProviderConfig>,
}
impl SsoConfig {
pub fn init() -> &'static Self {
SSO_CONFIG.get_or_init(|| {
let github = Self::load_provider("GITHUB", false);
let discord = Self::load_provider("DISCORD", false);
let google = Self::load_provider("GOOGLE", false);
let gitlab = Self::load_provider("GITLAB", true);
let oidc = Self::load_provider("OIDC", true);
let apple = Self::load_apple_provider();
let config = SsoConfig {
github,
discord,
google,
gitlab,
oidc,
apple,
};
if config.is_any_enabled() {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_default();
if hostname.is_empty() || hostname == "localhost" {
panic!(
"PDS_HOSTNAME must be set to a valid hostname when SSO is enabled. \
SSO redirect URIs require a proper hostname for security."
);
}
SSO_REDIRECT_URI
.set(format!("https://{}/oauth/sso/callback", hostname))
.expect("SSO_REDIRECT_URI already set");
tracing::info!(
hostname = %hostname,
providers = ?config.enabled_providers().iter().map(|p| p.as_str()).collect::<Vec<_>>(),
"SSO initialized"
);
}
config
})
}
pub fn get_redirect_uri() -> &'static str {
SSO_REDIRECT_URI
.get()
.map(|s| s.as_str())
.expect("SSO redirect URI not initialized - call SsoConfig::init() first")
}
fn load_provider(name: &str, needs_issuer: bool) -> Option<ProviderConfig> {
let enabled = std::env::var(format!("SSO_{}_ENABLED", name))
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if !enabled {
return None;
}
let client_id = std::env::var(format!("SSO_{}_CLIENT_ID", name)).ok()?;
let client_secret = std::env::var(format!("SSO_{}_CLIENT_SECRET", name)).ok()?;
if client_id.is_empty() || client_secret.is_empty() {
tracing::warn!(
"SSO_{} enabled but missing client_id or client_secret",
name
);
return None;
}
let issuer = if needs_issuer {
let issuer_val = std::env::var(format!("SSO_{}_ISSUER", name)).ok();
if issuer_val.is_none() || issuer_val.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
tracing::warn!("SSO_{} requires ISSUER but none provided", name);
return None;
}
issuer_val
} else {
None
};
let display_name = std::env::var(format!("SSO_{}_NAME", name)).ok();
Some(ProviderConfig {
client_id,
client_secret,
issuer,
display_name,
})
}
fn load_apple_provider() -> Option<AppleProviderConfig> {
let enabled = std::env::var("SSO_APPLE_ENABLED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if !enabled {
return None;
}
let client_id = std::env::var("SSO_APPLE_CLIENT_ID").ok()?;
let team_id = std::env::var("SSO_APPLE_TEAM_ID").ok()?;
let key_id = std::env::var("SSO_APPLE_KEY_ID").ok()?;
let private_key_pem = std::env::var("SSO_APPLE_PRIVATE_KEY").ok()?;
if client_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing CLIENT_ID");
return None;
}
if team_id.is_empty() || team_id.len() != 10 {
tracing::warn!("SSO_APPLE enabled but TEAM_ID is invalid (must be 10 characters)");
return None;
}
if key_id.is_empty() {
tracing::warn!("SSO_APPLE enabled but missing KEY_ID");
return None;
}
if private_key_pem.is_empty() || !private_key_pem.contains("PRIVATE KEY") {
tracing::warn!("SSO_APPLE enabled but PRIVATE_KEY is invalid");
return None;
}
Some(AppleProviderConfig {
client_id,
team_id,
key_id,
private_key_pem,
})
}
pub fn get() -> &'static Self {
SSO_CONFIG.get_or_init(SsoConfig::default)
}
pub fn get_provider_config(&self, provider: SsoProviderType) -> Option<&ProviderConfig> {
match provider {
SsoProviderType::Github => self.github.as_ref(),
SsoProviderType::Discord => self.discord.as_ref(),
SsoProviderType::Google => self.google.as_ref(),
SsoProviderType::Gitlab => self.gitlab.as_ref(),
SsoProviderType::Oidc => self.oidc.as_ref(),
SsoProviderType::Apple => None,
}
}
pub fn get_apple_config(&self) -> Option<&AppleProviderConfig> {
self.apple.as_ref()
}
pub fn enabled_providers(&self) -> Vec<SsoProviderType> {
let mut providers = Vec::new();
if self.github.is_some() {
providers.push(SsoProviderType::Github);
}
if self.discord.is_some() {
providers.push(SsoProviderType::Discord);
}
if self.google.is_some() {
providers.push(SsoProviderType::Google);
}
if self.gitlab.is_some() {
providers.push(SsoProviderType::Gitlab);
}
if self.oidc.is_some() {
providers.push(SsoProviderType::Oidc);
}
if self.apple.is_some() {
providers.push(SsoProviderType::Apple);
}
providers
}
pub fn is_any_enabled(&self) -> bool {
self.github.is_some()
|| self.discord.is_some()
|| self.google.is_some()
|| self.gitlab.is_some()
|| self.oidc.is_some()
|| self.apple.is_some()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
pub mod config;
pub mod endpoints;
pub mod providers;
pub use config::SsoConfig;
pub use providers::{AuthUrlResult, SsoError, SsoManager, SsoProvider, SsoUserInfo};

Some files were not shown because too many files have changed in this diff Show More