WIP SSO OTP bridge

This commit is contained in:
Brandon Thomas committed 2026-09-25 23:23:00 -07:00
1 parent f0a877c988
commit 4f1c9134ee
71 files changed
+3087 -19

No files matched your search

@@ -0,0 +1,25 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "expired",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | BINARY",
"max_size": 1,
"type": "LongLong"
}
}
],
"nullable": [
false
],
"parameters": {
"Right": 1
}
},
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
}
@@ -0,0 +1,36 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "session_token",
"ordinal": 0,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
}
],
"nullable": [
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"hash": "465853868fd16271a922960fa382fa062fc64572fc1c39529ff7dbd8951c3153",
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"hash": "50b3c3734a0f679cb9308d382656c6a81b14bb5b2874fa8ce5b27d56722db4a3",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )\n "
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"hash": "5d762de2f24028e9b35b2fcba76dc5680d532ea8de6443c60311821b6a3e6395",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, 'impersonation', ?)\n "
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "confirmation_code",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 32,
"type": "String"
}
},
{
"name": "status",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "maybe_deciding_user_token",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "MULTIPLE_KEY | BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "maybe_redeemed_user_session_id",
"ordinal": 4,
"type_info": {
"char_set": 63,
"flags": "UNIQUE_KEY",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "maybe_failure_type",
"ordinal": 5,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "ip_address_creation",
"ordinal": 6,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 160,
"type": "VarString"
}
},
{
"name": "expires_at",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"max_size": 19,
"type": "Timestamp"
}
}
],
"nullable": [
false,
false,
false,
true,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "7c110983a6f4836b451ef288ce375122ca25ff104c31495f9defefae0252953c",
"query": "\nSELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
}
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "confirmation_code",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 32,
"type": "String"
}
},
{
"name": "status",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "maybe_deciding_user_token",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "MULTIPLE_KEY | BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "maybe_redeemed_user_session_id",
"ordinal": 4,
"type_info": {
"char_set": 63,
"flags": "UNIQUE_KEY",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "maybe_failure_type",
"ordinal": 5,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "ip_address_creation",
"ordinal": 6,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 160,
"type": "VarString"
}
},
{
"name": "expires_at",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"max_size": 19,
"type": "Timestamp"
}
}
],
"nullable": [
false,
false,
false,
true,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "860645e70bdf20ba6ee272cb506095bb5439c5b4b98e4ee8bc0e4695a057819c",
"query": "\nSELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"hash": "86d8d6a7ffbf6880bcc6d74f31f220ac2e27d9b9b01ee98030a8f4acab849adb",
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )\n "
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
}
@@ -0,0 +1,454 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "user_token",
"ordinal": 0,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
},
{
"name": "display_name",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
},
{
"name": "email_address",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "email_gravatar_hash",
"ordinal": 4,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "String"
}
},
{
"name": "email_confirmed",
"ordinal": 5,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "email_confirmed_by_google",
"ordinal": 6,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "email_is_synthetic",
"ordinal": 7,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "is_without_password",
"ordinal": 8,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "username_is_not_customized",
"ordinal": 9,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "maybe_stripe_customer_id",
"ordinal": 10,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "maybe_loyalty_program_key",
"ordinal": 11,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "disable_gravatar",
"ordinal": 12,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "auto_play_audio_preference",
"ordinal": 13,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "auto_play_video_preference",
"ordinal": 14,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
"ordinal": 15,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | ENUM",
"max_size": 28,
"type": "String"
}
},
{
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
"ordinal": 16,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | BINARY | ENUM",
"max_size": 28,
"type": "String"
}
},
{
"name": "user_role_slug",
"ordinal": 17,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 64,
"type": "VarString"
}
},
{
"name": "is_banned",
"ordinal": 18,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_access_studio",
"ordinal": 19,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | MULTIPLE_KEY",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "maybe_feature_flags",
"ordinal": 20,
"type_info": {
"char_set": 224,
"flags": "BINARY",
"max_size": 1020,
"type": "VarString"
}
},
{
"name": "can_use_tts",
"ordinal": 21,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_use_w2l",
"ordinal": 22,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_tts_results",
"ordinal": 23,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_w2l_results",
"ordinal": 24,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_account",
"ordinal": 25,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_upload_tts_models",
"ordinal": 26,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_upload_w2l_templates",
"ordinal": 27,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_tts_models",
"ordinal": 28,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_own_w2l_templates",
"ordinal": 29,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_approve_w2l_templates",
"ordinal": 30,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_profiles",
"ordinal": 31,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_tts_models",
"ordinal": 32,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_edit_other_users_w2l_templates",
"ordinal": 33,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_tts_models",
"ordinal": 34,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_tts_results",
"ordinal": 35,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_w2l_templates",
"ordinal": 36,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_other_users_w2l_results",
"ordinal": 37,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_ban_users",
"ordinal": 38,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
},
{
"name": "can_delete_users",
"ordinal": 39,
"type_info": {
"char_set": 63,
"flags": "",
"max_size": 1,
"type": "Tiny"
}
}
],
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
false,
true,
true,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
],
"parameters": {
"Right": 1
}
},
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": {
"char_set": 63,
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
"max_size": 20,
"type": "LongLong"
}
},
{
"name": "token",
"ordinal": 1,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "user_token",
"ordinal": 2,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 128,
"type": "VarString"
}
},
{
"name": "username",
"ordinal": 3,
"type_info": {
"char_set": 224,
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"max_size": 80,
"type": "VarString"
}
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 2
}
},
"hash": "b22b2a775ddce30c04a7d89f34a606782a695b00e3c78d3891b6c0642d2c6c4c",
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = 'device_approval'\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 0
}
},
"hash": "d68616cd92f80feedf269e04c8d96087f0c44ee12b8cc2768793095e39fd5a35",
"query": "\nUPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at\nWHERE status IN ('pending', 'approved') AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"hash": "d84ac4de934fc5f53e6f202e23329f6db3c29939dfec0bcd29612a8cd9e2f0ce",
"query": "\nUPDATE user_login_challenges SET status = 'redeemed', maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = 'approved' AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 1
}
},
"hash": "db26c830f0b7bb64f8e9bce62eee82f3844c62d8de32f9e76590c27a9c2b3b74",
"query": "\nUPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at\nWHERE id = ? AND status IN ('pending', 'approved') AND expires_at <= NOW()\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 3
}
},
"hash": "e82bcef4f8ac327c3f53be4beb0568f8bc1523f91a46a8d883560872c84d1e23",
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, 'device_approval', NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 7
}
},
"hash": "fb5ceb1ebb2d7f424c590836842948b19102cc583749621e338ccbb23a984b3d",
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = 'pending' AND expires_at > NOW()\n"
}
Generated
+1
View File
@@ -8775,6 +8775,7 @@ dependencies = [
"serde_json",
"server_environment",
"sha1_hash",
"sha2 0.11.0-rc.5",
"shared_env_var_config",
"speculoos",
"sqlx",
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS user_login_challenges;
@@ -0,0 +1,92 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- One approval request for a new cookie-based user session on another device.
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
-- This table does not authorize MCP sessions or API keys.
CREATE TABLE user_login_challenges (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
token VARCHAR(32) NOT NULL,
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
-- The approval token goes in the browser/QR URL and only permits reviewing a
-- request and submitting an authenticated user's explicit approve/decline choice.
approval_token_sha256 BINARY(32) NOT NULL,
-- Only the initiating device receives this token. Required for polling/redeeming.
-- Never include it in the approval URL, QR code, browser page, or application logs.
device_token_sha256 BINARY(32) NOT NULL,
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
-- Only pending may be approved/declined. Only approved may mint a session.
-- Expiry is checked independently of status on EVERY approval/redemption request.
status VARCHAR(16) NOT NULL DEFAULT 'pending',
-- The authenticated web user who approved OR declined. NULL until a decision.
-- On approval this becomes the immutable owner of the session to be minted.
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
-- Set atomically with status=redeemed and session insertion, under a row lock.
-- Numeric reference avoids copying a session credential into this audit table.
-- Intentionally signed to match the existing user_sessions.id column.
-- At most one session per challenge; retries may only return that SAME live session.
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
-- UserLoginChallengeFailureType: user_declined, expired.
-- Only failed rows have a failure type and maybe_failed_at.
-- Transient errors / invalid credentials must not overwrite a valid challenge.
maybe_failure_type VARCHAR(32) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- Server-observed requesting device IP, displayed on the approval screen.
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
ip_address_creation VARCHAR(40) NOT NULL,
-- Browser/mobile IP for approval or denial; may differ from the device IP.
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
-- ip_address_creation is retained on ALL failures, including timeouts.
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
-- The default deliberately fails closed if a writer forgets to set the deadline.
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
-- Decision time for user_declined; expires_at for expired (even if recorded later).
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (approval_token_sha256),
UNIQUE KEY (device_token_sha256),
UNIQUE KEY (maybe_redeemed_user_session_id),
KEY index_status_expires_at (status, expires_at),
KEY index_expires_at (expires_at),
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
KEY index_creation_ip_created_at (ip_address_creation, created_at),
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE user_sessions
DROP COLUMN maybe_creation_type;
@@ -0,0 +1,10 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
-- MCP sessions and API keys are separate tables and do not use this field.
ALTER TABLE user_sessions
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,92 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
-- One approval request for a new cookie-based user session on another device.
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
-- This table does not authorize MCP sessions or API keys.
CREATE TABLE user_login_challenges (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
token VARCHAR(32) NOT NULL,
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
-- The approval token goes in the browser/QR URL and only permits reviewing a
-- request and submitting an authenticated user's explicit approve/decline choice.
approval_token_sha256 BINARY(32) NOT NULL,
-- Only the initiating device receives this token. Required for polling/redeeming.
-- Never include it in the approval URL, QR code, browser page, or application logs.
device_token_sha256 BINARY(32) NOT NULL,
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
-- Only pending may be approved/declined. Only approved may mint a session.
-- Expiry is checked independently of status on EVERY approval/redemption request.
status VARCHAR(16) NOT NULL DEFAULT 'pending',
-- The authenticated web user who approved OR declined. NULL until a decision.
-- On approval this becomes the immutable owner of the session to be minted.
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
-- Set atomically with status=redeemed and session insertion, under a row lock.
-- Numeric reference avoids copying a session credential into this audit table.
-- Intentionally signed to match the existing user_sessions.id column.
-- At most one session per challenge; retries may only return that SAME live session.
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
-- UserLoginChallengeFailureType: user_declined, expired.
-- Only failed rows have a failure type and maybe_failed_at.
-- Transient errors / invalid credentials must not overwrite a valid challenge.
maybe_failure_type VARCHAR(32) DEFAULT NULL,
-- ========== IP ADDRESSES ==========
-- Server-observed requesting device IP, displayed on the approval screen.
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
ip_address_creation VARCHAR(40) NOT NULL,
-- Browser/mobile IP for approval or denial; may differ from the device IP.
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
-- ip_address_creation is retained on ALL failures, including timeouts.
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
-- ========== TIMESTAMPS ==========
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
-- The default deliberately fails closed if a writer forgets to set the deadline.
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
-- Decision time for user_declined; expires_at for expired (even if recorded later).
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
UNIQUE KEY (approval_token_sha256),
UNIQUE KEY (device_token_sha256),
UNIQUE KEY (maybe_redeemed_user_session_id),
KEY index_status_expires_at (status, expires_at),
KEY index_expires_at (expires_at),
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
KEY index_creation_ip_created_at (ip_address_creation, created_at),
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
-- designed to be recoverable.
deleted_at TIMESTAMP NULL,
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
-- MCP sessions and API keys are separate tables and do not use this field.
maybe_creation_type VARCHAR(32) DEFAULT NULL,
-- INDICES --
PRIMARY KEY (id),
UNIQUE KEY (token),
+273
View File
@@ -0,0 +1,273 @@
# Website-approved device login
Implemented across storyteller-web, the browser approval page, and the Tauri
login dialog. Apply the two migrations before deploying the API; deploy the web
approval route before releasing the desktop buttons. No production migrations or
deployments were performed while developing this change.
Call the flow **device approval**, and each attempt a **login challenge**. It works
for a desktop browser link or a phone scanning a QR code, regardless of how the
approving account signed in. It creates an ordinary cookie-based web session.
The design borrows separate device/browser credentials, polling, explicit consent,
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
## Two migrations
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
its terminal outcome audit record together. A separate failure table is not
needed for one immutable outcome per attempt.
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
provenance to cookie sessions. Existing rows and older writers retain NULL.
Both have reverse migrations and matching materialized schemas under
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
back feature code before dropping the column/table; rollback removes bridge audit
records but does not revoke sessions already issued.
`UserWebSessionCreationType` values:
| Rust variant | Stored value | Meaning |
|------------------|-------------------|-------------------------------------------------------------------|
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
| Impersonation | impersonation | Existing consented staff impersonation flow. |
| None | NULL | Unknown provenance, including existing rows and older writers. |
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
ordinary password login is also available in the desktop app. This field does not
replace `maybe_impersonation_user_token` or influence authentication/authorization.
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
Existing password/SSO and impersonation session writers now record provenance.
There is no historical backfill or default that would invent it.
## Credentials and confirmation
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
Reject noncanonical input instead of accepting multiple representations. These
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
| Value | Who receives it | What it permits |
|---------------------|-------------------------------|--------------------------------------------------------|
| token | Internal logs/support | Identifying a row; no authentication. |
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
not be unique because it is never used to locate or authorize a challenge. Manual
short-code entry is outside this initial flow; both entry points carry the full
approval token. The private device token must never be in the URL or QR code.
The website displays the requesting IP, the account being authorized, the matching
code, and explicit **Approve desktop login** / **Decline** actions. An existing
website session skips only the login screen, never consent. If login is needed,
keep the pending approval context in first-party browser state and resume the
confirmation page after password or Google SSO login. Recheck expiry after login.
Do not send the bridge tokens to Google or another identity provider. Allow only
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
The approval page is a read-only GET; link scanners and previews cannot decide
the challenge. Decisions require a valid, non-impersonated user web session plus
CSRF/origin protection. Never accept an API key or MCP credential as consent.
Require the user to have initiated the request on a device in their possession;
the visible code and IP help detect phishing but are not authentication factors.
The approval URL uses HTTPS and carries the browser credential in its fragment.
The page captures and removes that fragment before referral collection or React
providers initialize, skips analytics/referral tracking on direct entry, and sets
a no-referrer policy. API responses send `Cache-Control: no-store` and
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
strings. Shared fonts and the Google sign-in provider still load. Keep request and
response bodies out of gateway logs. Invalid credentials reveal no account or
challenge details. The IP follows the existing trusted-gateway forwarding policy;
the gateway must replace untrusted incoming forwarding headers.
## State and the 20-minute deadline
```mermaid
stateDiagram-v2
[*] --> pending
pending --> approved: authenticated approval
pending --> failed: user_declined
pending --> failed: expired
approved --> failed: expired before redemption
approved --> redeemed: atomically mint one session
redeemed --> [*]
failed --> [*]
```
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
INSERT using the database's clock. The default expiry is immediate so a writer
that omits it fails closed. Neither approval nor polling extends this deadline.
All eligibility checks require `expires_at > NOW()`; equality means expired.
The create query enforces this exact lifetime; these migrations do not add
triggers or database CHECK constraints. Keep database connections in UTC.
The persisted status alone is insufficient to decide validity. A pending or
approved row whose deadline passed is already expired even before a sweeper
marks it `failed`. Approvals and first redemptions must check the deadline again
under the row lock. Polls are normally read-only and do not touch `updated_at`.
| Status | Required accompanying data |
|------------|-------------------------------------------------------------------------------------|
| pending | No decision, failure, redemption, or session reference. |
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
`UserLoginChallengeFailureType` initially has two values:
- `user_declined`: record the deciding user, browser IP in both decision/failure
IP fields, and the decision time in both decision/failure timestamps. Keep the
original desktop IP. Polling with the valid device token reports the decline.
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
scheduled sweep records abandoned requests even when nobody polls again.
Failed challenges are unusable for approval or redemption. Their digests and
audit metadata remain for diagnosing failures; do not physically delete the row
when a user declines. A new attempt creates a new row and fresh tokens. Closing
the desktop dialog stops polling; unused requests expire normally.
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
errors do not terminally fail a valid challenge. Record those requests in the
existing operational/abuse logging with their IP and failure category, without
credentials. A second decision cannot replace the first actor or outcome.
## Atomic redemption and response loss
The website's approval records consent; it never creates or receives the desktop
cookie. The desktop's authenticated poll performs redemption after approval:
1. Begin an InnoDB transaction on the primary and find the challenge by the device
token digest with `SELECT ... FOR UPDATE`.
2. Validate the immutable deadline and state after taking the lock. Handle
failed/pending states without creating a session. Revalidate that the approved
user exists and is permitted to log in under the ordinary session rules.
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
desktop redemption IP, and the ordinary web-session lifetime.
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
challenge in that same transaction. Generate the signed cookie using
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
back too. Commit successfully before returning any credential to the caller.
Both approval/decline and redemption must serialize on the same challenge row;
conditional updates include the expected state and the unexpired deadline, and
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
executor/transaction support, not in the web handler. All three Rust enums in this
change are database enums; separate client-facing status/failure enums include
an unknown-value fallback. Desktop callers fail closed on an unknown state.
One-time means **at most one new session per challenge**. If the first successful
HTTP response is lost, a retry with the same device token may return the same
session until the original 20-minute deadline. For `redeemed`, read the linked
session and verify its owner, creation type, absence of impersonation, expiry,
deletion state, and the user's current eligibility before reconstructing its
cookie. Never mint a replacement or resurrect a deleted session. After the
deadline, return expiry without a cookie; the already-issued session keeps its
normal lifetime, and the audit outcome remains `redeemed`.
The unique nullable session ID index prevents two challenges from referencing one
session and supports retries without duplicating bearer credentials in this
table. The reference is intentionally signed because the legacy `user_sessions.id`
is signed. Like neighboring schemas, references are indexed logical links rather
than cascading foreign keys; removal of a session must not erase the audit row.
If the referenced session is missing, redemption fails closed.
## Implemented endpoints and clients
All endpoints are JSON POSTs under `/v1/login_challenges`:
| Endpoint | Authentication | Effect |
|------------|---------------------------------------------|--------------------------------------------------------------|
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
| poll | Private device token | Reports state or atomically redeems approved consent. |
The API advertises five-second polling. Per-process IP limits are ten creates and
120 other requests per minute, shared across Actix workers, with bounded memory.
Production gateways can additionally enforce aggregate limits across replicas.
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
is intentionally not an automatic purge policy in this change.
The website route is `https://app.getartcraft.com/login/desktop`. The token is kept
in tab-scoped session storage through login, including Google login for accounts
without a subscription. Returning to the page still requires explicit consent.
The desktop displays the QR code, code comparison, countdown, and status. Opening
the website invokes the native system browser. Network errors back off up to 30
seconds; closing the dialog stops polling and ignores late UI results. An already
in-flight HTTP response may still update the native HTTP cookie jar.
Successful polling returns the signed value and the normal `Set-Cookie` header.
The Tauri command synchronizes the HTTP cookie jar with native credentials, scoped
to the configured API origin, and the UI verifies the ordinary session endpoint
before reporting success. No browser session is copied into the desktop.
## Integration tests
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
only its own database on success. Existing `storyteller` and `artcraft_test` data
are not used. A failed test leaves its isolated database for diagnosis. The local
MySQL instance must be running; there is no fallback to a remote database.
From the services repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
cd frontend
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
```
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
From the desktop repository:
```sh
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
cd frontend
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx
```
Coverage:
- Six backend integration tests: passwordless account approval, zero sessions
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
invalid approving sessions, concurrent decisions/redemptions, response-loss
retries, abandoned expiry, expiry during lock contention, revocation, bans,
signed-cookie authentication through the production session guard, and an issued
session remaining valid after the challenge expires.
- Eight browser tests: API/consent interaction, code/IP display, decline, expiry,
invalid requests, safe return URLs, login continuation, and Google sign-in
continuation without subscription gating or bridge credentials in SSO requests.
- Seven desktop UI tests: native browser invocation, QR/code values, polling,
successful credential synchronization and downstream session verification,
decline, timeout, network backoff, unmount, and unknown-state handling.
- Three native tests: HTTP response cookie to native credentials, retry safety,
exclusion of unrelated/expired cookies, and local/production origin isolation.
Frontend tests replace external HTTP and Google/native boundaries; they do not
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
use real MySQL and the production cookie signer/session guard with Redis disabled.
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
sessions; bridge consent/redemption always checks MySQL directly.
Validation also covered both migration directions, nullable compatibility with
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
matching materialized schemas. The focused web TypeScript check and the production
webapp Nx build (including 52 dependency tasks) passed. Broad workspace TypeScript
checks have existing errors. The desktop Nx build is blocked by the existing
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
checks report unavailable dependency outputs. A source-only desktop check reports
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
The full native Rust test target compiled successfully.
@@ -0,0 +1,73 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeRequest {}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeRequest {
pub approval_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct DecideLoginChallengeRequest {
pub approval_token: String,
pub approve: bool,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct PollLoginChallengeRequest {
pub device_token: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct CreateLoginChallengeResponse {
pub success: bool,
pub device_token: String,
pub verification_url: String,
pub confirmation_code: String,
pub expires_at: DateTime<Utc>,
pub poll_interval_seconds: u32,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct ReviewLoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
pub confirmation_code: String,
pub requesting_ip: String,
pub expires_at: DateTime<Utc>,
pub username: String,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct LoginChallengeResponse {
pub success: bool,
pub status: LoginChallengeState,
pub maybe_failure_type: Option<LoginChallengeFailure>,
/// Present only to the device after the redemption transaction commits.
#[serde(skip_serializing_if = "Option::is_none")]
pub maybe_signed_session: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeState {
Pending,
Approved,
Redeemed,
Failed,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum LoginChallengeFailure {
UserDeclined,
Expired,
#[serde(other)]
Unknown,
}
@@ -2,4 +2,5 @@ pub mod change_password;
pub mod edit_email;
pub mod edit_username;
pub mod login;
pub mod session_info;
pub mod login_challenges;
pub mod session_info;
@@ -41,6 +41,7 @@ pub mod unsubscribe_reason;
pub mod uploaded_video_notes;
pub mod uploaded_videos;
pub mod user_impersonation_requests;
pub mod user_login_challenges;
pub mod user_referral_codes;
pub mod user_referrals;
pub mod user_daily_spends;
@@ -52,4 +53,4 @@ pub mod voice_conversion;
pub mod voice_designer;
pub mod wallet_ledger_entries;
pub mod wallets;
pub mod web_referrals;
pub mod web_referrals;
@@ -0,0 +1,155 @@
use chrono::{DateTime, Utc};
use sqlx::{MySqlConnection, MySqlPool};
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
pub struct LoginChallenge {
pub id: u64,
pub confirmation_code: String,
pub status: String,
pub maybe_deciding_user_token: Option<String>,
pub maybe_redeemed_user_session_id: Option<i64>,
pub maybe_failure_type: Option<String>,
pub ip_address_creation: String,
pub expires_at: DateTime<Utc>,
}
pub struct CreateChallengeArgs<'a> {
pub token: &'a UserLoginChallengeToken,
pub approval_hash: &'a [u8],
pub device_hash: &'a [u8],
pub confirmation_code: &'a str,
pub ip_address: &'a str,
}
pub async fn create_challenge(args: CreateChallengeArgs<'_>, conn: &mut MySqlConnection) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO user_login_challenges
(token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)
"#,
args.token.as_str(),
args.approval_hash,
args.device_hash,
args.confirmation_code,
args.ip_address
)
.execute(conn)
.await?;
Ok(())
}
/// Call inside a transaction. The approval token cannot be used in device lookups.
pub async fn lock_by_approval(hash: &[u8], conn: &mut MySqlConnection) -> Result<Option<LoginChallenge>, sqlx::Error> {
sqlx::query_as!(
LoginChallenge,
r#"
SELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,
maybe_failure_type, ip_address_creation, expires_at
FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE
"#,
hash
)
.fetch_optional(conn)
.await
}
pub async fn lock_by_device(hash: &[u8], conn: &mut MySqlConnection) -> Result<Option<LoginChallenge>, sqlx::Error> {
sqlx::query_as!(
LoginChallenge,
r#"
SELECT id, confirmation_code, status, maybe_deciding_user_token, maybe_redeemed_user_session_id,
maybe_failure_type, ip_address_creation, expires_at
FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE
"#,
hash
)
.fetch_optional(conn)
.await
}
/// Run AFTER acquiring the row lock: NOW() on the locking statement can precede
/// time spent waiting for another transaction. Never extend the original deadline.
pub async fn expire_locked_challenge(challenge: &mut LoginChallenge, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
let expired = sqlx::query!(
r#"
SELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?
"#,
challenge.id
)
.fetch_one(&mut *conn)
.await?
.expired
!= 0;
if expired && (challenge.status == "pending" || challenge.status == "approved") {
sqlx::query!(
r#"
UPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at
WHERE id = ? AND status IN ('pending', 'approved') AND expires_at <= NOW()
"#,
challenge.id
)
.execute(conn)
.await?;
challenge.status = "failed".into();
challenge.maybe_failure_type = Some("expired".into());
}
Ok(expired)
}
pub async fn decide_challenge(id: u64, user: &str, ip: &str, approve: bool, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
let status = if approve { "approved" } else { "failed" };
let failure = if approve { None } else { Some("user_declined") };
let failure_ip = if approve { None } else { Some(ip) };
let affected = sqlx::query!(
r#"
UPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,
maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,
maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())
WHERE id = ? AND status = 'pending' AND expires_at > NOW()
"#,
status,
user,
ip,
failure,
failure_ip,
approve,
id
)
.execute(conn)
.await?
.rows_affected();
Ok(affected == 1)
}
pub async fn mark_redeemed(id: u64, session_id: i64, ip: &str, conn: &mut MySqlConnection) -> Result<bool, sqlx::Error> {
let affected = sqlx::query!(
r#"
UPDATE user_login_challenges SET status = 'redeemed', maybe_redeemed_user_session_id = ?,
maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()
WHERE id = ? AND status = 'approved' AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL
"#,
session_id,
ip,
id
)
.execute(conn)
.await?
.rows_affected();
Ok(affected == 1)
}
/// Bounded sweep also audits abandoned attempts that are never polled again.
pub async fn expire_abandoned_challenges(pool: &MySqlPool) -> Result<u64, sqlx::Error> {
Ok(
sqlx::query!(
r#"
UPDATE user_login_challenges SET status = 'failed', maybe_failure_type = 'expired', maybe_failed_at = expires_at
WHERE status IN ('pending', 'approved') AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000
"#
)
.execute(pool)
.await?
.rows_affected(),
)
}
@@ -0,0 +1,2 @@
pub mod challenge_queries;
pub mod session_queries;
@@ -0,0 +1,64 @@
use sqlx::MySqlConnection;
use tokens::tokens::user_sessions::UserSessionToken;
pub struct BridgeSession {
pub id: i64,
pub token: String,
pub user_token: String,
pub username: String,
}
/// Fresh database lookup, not a cached session. Impersonated sessions cannot consent.
pub async fn find_approving_session(token: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
sqlx::query_as!(
BridgeSession,
r#"
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
WHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()
AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE
AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
"#,
token
)
.fetch_optional(conn)
.await
}
/// Returns the one previously minted session, only while it and its owner are live.
pub async fn find_redeemed_session(id: i64, user: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
sqlx::query_as!(
BridgeSession,
r#"
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
WHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = 'device_approval'
AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL
AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
"#,
id,
user
)
.fetch_optional(conn)
.await
}
/// Must share the transaction holding the challenge lock. A failed/expired
/// redemption rolls back this INSERT; no session is minted when merely approving.
pub async fn create_bridge_session(user: &str, ip: &str, conn: &mut MySqlConnection) -> Result<Option<BridgeSession>, sqlx::Error> {
let token = UserSessionToken::generate();
let inserted = sqlx::query!(
r#"
INSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)
SELECT ?, token, ?, 'device_approval', NOW() + INTERVAL 1 YEAR FROM users
WHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL
"#,
token.as_str(),
ip,
user
)
.execute(&mut *conn)
.await?;
if inserted.rows_affected() != 1 {
return Ok(None);
}
find_redeemed_session(inserted.last_insert_id() as i64, user, conn).await
}
@@ -36,9 +36,10 @@ INSERT INTO user_sessions (
user_token,
maybe_impersonation_user_token,
ip_address_creation,
maybe_creation_type,
expires_at
)
VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 'impersonation', ?)
"#,
session_token.as_str(),
args.user_token.as_str(),
@@ -17,9 +17,10 @@ INSERT INTO user_sessions (
token,
user_token,
ip_address_creation,
maybe_creation_type,
expires_at
)
VALUES ( ?, ?, ?, NOW() + interval 1 year )
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
"#,
session_token,
user_token.to_string(),
@@ -19,9 +19,10 @@ INSERT INTO user_sessions (
token,
user_token,
ip_address_creation,
maybe_creation_type,
expires_at
)
VALUES ( ?, ?, ?, NOW() + interval 1 year )
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
"#,
session_token.as_str(),
user_token.as_str(),
@@ -22,9 +22,10 @@ INSERT INTO user_sessions (
token,
user_token,
ip_address_creation,
maybe_creation_type,
expires_at
)
VALUES ( ?, ?, ?, NOW() + interval 1 year )
VALUES ( ?, ?, ?, 'direct_login', NOW() + interval 1 year )
"#,
session_token.as_str(),
user_token.as_str(),
@@ -169,6 +169,7 @@ LEFT OUTER JOIN user_roles
ON users.user_role_slug = user_roles.slug
WHERE user_sessions.token = ?
AND user_sessions.deleted_at IS NULL
AND user_sessions.expires_at > NOW()
AND users.user_deleted_at IS NULL
AND users.mod_deleted_at IS NULL
"#,
@@ -26,6 +26,7 @@ SELECT
FROM user_sessions
WHERE token = ?
AND deleted_at IS NULL
AND expires_at > NOW()
"#,
session_token,
)
@@ -0,0 +1,45 @@
//! Mutators/inspection for disposable bridge integration databases only.
use sqlx::{MySqlPool, Row};
use tokens::tokens::users::UserToken;
pub struct ChallengeAudit {
pub status: String,
pub failure: Option<String>,
pub creation_ip: String,
pub failure_ip: Option<String>,
pub session_id: Option<i64>,
pub lifetime_seconds: i64,
}
pub async fn audit(pool: &MySqlPool, device_hash: &[u8]) -> ChallengeAudit {
let row = sqlx::query("SELECT status, maybe_failure_type, ip_address_creation, maybe_ip_address_failure, maybe_redeemed_user_session_id, TIMESTAMPDIFF(SECOND, created_at, expires_at) AS lifetime FROM user_login_challenges WHERE device_token_sha256 = ?").bind(device_hash).fetch_one(pool).await.expect("challenge audit");
ChallengeAudit { status: row.get("status"), failure: row.get("maybe_failure_type"), creation_ip: row.get("ip_address_creation"), failure_ip: row.get("maybe_ip_address_failure"), session_id: row.get("maybe_redeemed_user_session_id"), lifetime_seconds: row.get("lifetime") }
}
pub async fn bridge_session_count(pool: &MySqlPool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE maybe_creation_type = 'device_approval'").fetch_one(pool).await.expect("bridge session count")
}
pub async fn set_challenge_deadline(pool: &MySqlPool, device_hash: &[u8], seconds_from_now: i32) {
sqlx::query("UPDATE user_login_challenges SET expires_at = TIMESTAMPADD(SECOND, ?, NOW()) WHERE device_token_sha256 = ?").bind(seconds_from_now).bind(device_hash).execute(pool).await.expect("change test deadline");
}
pub async fn expire_session(pool: &MySqlPool, token: &str) {
sqlx::query("UPDATE user_sessions SET expires_at = NOW() - INTERVAL 1 SECOND WHERE token = ?").bind(token).execute(pool).await.expect("expire test session");
}
pub async fn revoke_session(pool: &MySqlPool, token: &str) {
sqlx::query("UPDATE user_sessions SET deleted_at = NOW() WHERE token = ?").bind(token).execute(pool).await.expect("revoke test session");
}
pub async fn set_passwordless(pool: &MySqlPool, user: &UserToken) {
sqlx::query("UPDATE users SET is_without_password = TRUE, email_confirmed_by_google = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("passwordless test account");
}
pub async fn ban_user(pool: &MySqlPool, user: &UserToken) {
sqlx::query("UPDATE users SET is_banned = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("ban test account");
}
pub async fn impersonate_session(pool: &MySqlPool, token: &str, actor: &UserToken) {
sqlx::query("UPDATE user_sessions SET maybe_impersonation_user_token = ? WHERE token = ?").bind(actor.as_str()).bind(token).execute(pool).await.expect("test impersonation");
}
@@ -1,4 +1,5 @@
pub mod mcp_sessions;
pub mod login_challenges;
pub mod media_files;
pub mod users;
pub mod wallets;
@@ -0,0 +1,36 @@
//! Disposable databases on the local MySQL instance only. No application or test
//! database URL is read, so production configuration cannot redirect these tests.
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
use sqlx::MySqlPool;
use tokens::tokens::users::UserToken;
use crate::schema::ensure_schema;
pub struct IsolatedTestDatabase {
pub pool: MySqlPool,
admin_pool: MySqlPool,
name: String,
}
impl IsolatedTestDatabase {
pub async fn create() -> Self {
// Deliberately no URL/env parsing, DNS, TCP (including forwarded local ports),
// or socket overrides. This is the local Homebrew MySQL socket. Only a new
// randomly named database can be created/dropped; existing schemas are unused.
let options = MySqlConnectOptions::new().socket("/tmp/mysql.sock").username("root").ssl_mode(MySqlSslMode::Disabled);
let admin_pool = MySqlPoolOptions::new().max_connections(1).connect_with(options.clone()).await.expect("local MySQL test connection");
let name = format!("artcraft_test_bridge_{}", UserToken::generate().as_str());
assert!(name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
sqlx::query(&format!("CREATE DATABASE `{name}`")).execute(&admin_pool).await.expect("create disposable database");
let pool = MySqlPoolOptions::new().max_connections(8).connect_with(options.database(&name)).await.expect("connect disposable database");
ensure_schema(&pool).await;
Self { pool, admin_pool, name }
}
pub async fn destroy(self) {
self.pool.close().await;
sqlx::query(&format!("DROP DATABASE `{}`", self.name)).execute(&self.admin_pool).await.expect("drop our disposable database");
self.admin_pool.close().await;
}
}
@@ -22,6 +22,7 @@
pub mod fixtures;
pub mod guard;
pub mod isolated;
pub mod pool;
pub mod schema;
pub mod serial;
@@ -24,7 +24,9 @@ pub mod uploaded_video_notes;
pub mod uploaded_videos;
pub mod usages;
pub mod user_bookmarks;
pub mod user_login_challenges;
pub mod user_ratings;
pub mod user_sessions;
pub mod user_spend_events;
pub mod users;
pub mod voice_conversion_models;
@@ -0,0 +1,2 @@
pub mod user_login_challenge_failure_type;
pub mod user_login_challenge_status;
@@ -0,0 +1,89 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::{EnumCount, EnumIter};
use utoipa::ToSchema;
/// Stored in `user_login_challenges.maybe_failure_type` (VARCHAR(32)).
///
/// Terminal outcome for a failed device approval request. Transient transport/database errors and invalid credentials are not challenge outcomes.
///
/// Add new values as needed, but never change stored values without a migration.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum UserLoginChallengeFailureType {
/// An authenticated user explicitly declined the request.
#[serde(rename = "user_declined")]
UserDeclined,
/// The 20-minute deadline passed before the challenge was redeemed.
#[serde(rename = "expired")]
Expired,
}
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeFailureType);
impl_mysql_enum_coders!(UserLoginChallengeFailureType);
impl_mysql_from_row!(UserLoginChallengeFailureType);
impl UserLoginChallengeFailureType {
pub fn to_str(&self) -> &'static str {
match self {
Self::UserDeclined => "user_declined",
Self::Expired => "expired",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"user_declined" => Ok(Self::UserDeclined),
"expired" => Ok(Self::Expired),
_ => Err(format!("invalid UserLoginChallengeFailureType value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([Self::UserDeclined, Self::Expired])
}
}
#[cfg(test)]
mod tests {
use super::UserLoginChallengeFailureType;
use crate::test_helpers::assert_serialization;
use strum::{EnumCount, IntoEnumIterator};
const STORED_VALUES: &[(UserLoginChallengeFailureType, &str)] = &[(UserLoginChallengeFailureType::UserDeclined, "user_declined"), (UserLoginChallengeFailureType::Expired, "expired")];
const MAX_STORED_LENGTH: usize = 32;
#[test]
fn stored_values_and_serialization_are_stable() {
for &(variant, stored) in STORED_VALUES {
assert_serialization(variant, stored);
assert_eq!(variant.to_str(), stored);
assert_eq!(UserLoginChallengeFailureType::from_str(stored).unwrap(), variant);
assert_eq!(format!("{}", variant), stored);
assert_eq!(format!("{:?}", variant), stored);
assert!(!stored.is_empty());
assert!(stored.len() <= MAX_STORED_LENGTH);
}
}
#[test]
fn all_variants_are_covered_and_round_trip() {
let variants = UserLoginChallengeFailureType::all_variants();
assert_eq!(variants.len(), UserLoginChallengeFailureType::COUNT);
assert_eq!(variants.len(), STORED_VALUES.len());
for variant in UserLoginChallengeFailureType::iter() {
assert!(variants.contains(&variant));
assert_eq!(UserLoginChallengeFailureType::from_str(variant.to_str()).unwrap(), variant);
}
}
#[test]
fn unknown_stored_values_are_rejected() {
for value in ["", "unknown", "USER_DECLINED"] {
assert!(UserLoginChallengeFailureType::from_str(value).is_err());
assert!(serde_json::from_str::<UserLoginChallengeFailureType>(&format!("{:?}", value)).is_err());
}
}
}
@@ -0,0 +1,101 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::{EnumCount, EnumIter};
use utoipa::ToSchema;
/// Stored in `user_login_challenges.status` (VARCHAR(16)).
///
/// Persisted state of a device approval request. Always check expires_at independently; a pending or approved row can already be expired.
///
/// Add new values as needed, but never change stored values without a migration.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum UserLoginChallengeStatus {
/// Waiting for an authenticated user's explicit decision.
#[serde(rename = "pending")]
Pending,
/// Approved for the deciding user; no session has been minted yet.
#[serde(rename = "approved")]
Approved,
/// Exactly one session has been minted. Retries must never mint another.
#[serde(rename = "redeemed")]
Redeemed,
/// Terminal failure, described by maybe_failure_type.
#[serde(rename = "failed")]
Failed,
}
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeStatus);
impl_mysql_enum_coders!(UserLoginChallengeStatus);
impl_mysql_from_row!(UserLoginChallengeStatus);
impl UserLoginChallengeStatus {
pub fn to_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Redeemed => "redeemed",
Self::Failed => "failed",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"redeemed" => Ok(Self::Redeemed),
"failed" => Ok(Self::Failed),
_ => Err(format!("invalid UserLoginChallengeStatus value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([Self::Pending, Self::Approved, Self::Redeemed, Self::Failed])
}
}
#[cfg(test)]
mod tests {
use super::UserLoginChallengeStatus;
use crate::test_helpers::assert_serialization;
use strum::{EnumCount, IntoEnumIterator};
const STORED_VALUES: &[(UserLoginChallengeStatus, &str)] = &[(UserLoginChallengeStatus::Pending, "pending"), (UserLoginChallengeStatus::Approved, "approved"), (UserLoginChallengeStatus::Redeemed, "redeemed"), (UserLoginChallengeStatus::Failed, "failed")];
const MAX_STORED_LENGTH: usize = 16;
#[test]
fn stored_values_and_serialization_are_stable() {
for &(variant, stored) in STORED_VALUES {
assert_serialization(variant, stored);
assert_eq!(variant.to_str(), stored);
assert_eq!(UserLoginChallengeStatus::from_str(stored).unwrap(), variant);
assert_eq!(format!("{}", variant), stored);
assert_eq!(format!("{:?}", variant), stored);
assert!(!stored.is_empty());
assert!(stored.len() <= MAX_STORED_LENGTH);
}
}
#[test]
fn all_variants_are_covered_and_round_trip() {
let variants = UserLoginChallengeStatus::all_variants();
assert_eq!(variants.len(), UserLoginChallengeStatus::COUNT);
assert_eq!(variants.len(), STORED_VALUES.len());
for variant in UserLoginChallengeStatus::iter() {
assert!(variants.contains(&variant));
assert_eq!(UserLoginChallengeStatus::from_str(variant.to_str()).unwrap(), variant);
}
}
#[test]
fn unknown_stored_values_are_rejected() {
for value in ["", "unknown", "PENDING"] {
assert!(UserLoginChallengeStatus::from_str(value).is_err());
assert!(serde_json::from_str::<UserLoginChallengeStatus>(&format!("{:?}", value)).is_err());
}
}
}
@@ -0,0 +1 @@
pub mod user_web_session_creation_type;
@@ -0,0 +1,95 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::{EnumCount, EnumIter};
use utoipa::ToSchema;
/// Stored in `user_sessions.maybe_creation_type` (VARCHAR(32)).
///
/// How a cookie-based user session was created; NULL in MySQL means unknown. This is provenance, not an authentication policy or an MCP/API session type.
///
/// Add new values as needed, but never change stored values without a migration.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum UserWebSessionCreationType {
/// A normal password or SSO login/signup, including direct desktop password login.
#[serde(rename = "direct_login")]
DirectLogin,
/// A new session authorized through a user_login_challenges device approval.
#[serde(rename = "device_approval")]
DeviceApproval,
/// An explicitly consented staff impersonation; existing impersonation rules still apply.
#[serde(rename = "impersonation")]
Impersonation,
}
impl_enum_display_and_debug_using_to_str!(UserWebSessionCreationType);
impl_mysql_enum_coders!(UserWebSessionCreationType);
impl_mysql_from_row!(UserWebSessionCreationType);
impl UserWebSessionCreationType {
pub fn to_str(&self) -> &'static str {
match self {
Self::DirectLogin => "direct_login",
Self::DeviceApproval => "device_approval",
Self::Impersonation => "impersonation",
}
}
pub fn from_str(value: &str) -> Result<Self, String> {
match value {
"direct_login" => Ok(Self::DirectLogin),
"device_approval" => Ok(Self::DeviceApproval),
"impersonation" => Ok(Self::Impersonation),
_ => Err(format!("invalid UserWebSessionCreationType value: {:?}", value)),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([Self::DirectLogin, Self::DeviceApproval, Self::Impersonation])
}
}
#[cfg(test)]
mod tests {
use super::UserWebSessionCreationType;
use crate::test_helpers::assert_serialization;
use strum::{EnumCount, IntoEnumIterator};
const STORED_VALUES: &[(UserWebSessionCreationType, &str)] = &[(UserWebSessionCreationType::DirectLogin, "direct_login"), (UserWebSessionCreationType::DeviceApproval, "device_approval"), (UserWebSessionCreationType::Impersonation, "impersonation")];
const MAX_STORED_LENGTH: usize = 32;
#[test]
fn stored_values_and_serialization_are_stable() {
for &(variant, stored) in STORED_VALUES {
assert_serialization(variant, stored);
assert_eq!(variant.to_str(), stored);
assert_eq!(UserWebSessionCreationType::from_str(stored).unwrap(), variant);
assert_eq!(format!("{}", variant), stored);
assert_eq!(format!("{:?}", variant), stored);
assert!(!stored.is_empty());
assert!(stored.len() <= MAX_STORED_LENGTH);
}
}
#[test]
fn all_variants_are_covered_and_round_trip() {
let variants = UserWebSessionCreationType::all_variants();
assert_eq!(variants.len(), UserWebSessionCreationType::COUNT);
assert_eq!(variants.len(), STORED_VALUES.len());
for variant in UserWebSessionCreationType::iter() {
assert!(variants.contains(&variant));
assert_eq!(UserWebSessionCreationType::from_str(variant.to_str()).unwrap(), variant);
}
}
#[test]
fn unknown_stored_values_are_rejected() {
for value in ["", "unknown", "DIRECT_LOGIN"] {
assert!(UserWebSessionCreationType::from_str(value).is_err());
assert!(serde_json::from_str::<UserWebSessionCreationType>(&format!("{:?}", value)).is_err());
}
}
}
@@ -56,6 +56,7 @@ pub(crate) enum TokenPrefix {
UserBookmark,
UserReferralCode,
UserSession,
UserLoginChallenge,
UserSpendEvent,
UserSubscription,
VoiceConversionModel,
@@ -145,6 +146,7 @@ impl PrefixGenerator for TokenPrefix {
Self::UserBookmark => "ub_",
Self::UserReferralCode => "urc_",
Self::UserSession => "session_",
Self::UserLoginChallenge => "login_",
Self::UserSpendEvent => "spend_",
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
Self::VoiceConversionModel => "vcm_",
@@ -40,6 +40,7 @@ pub mod user_bookmarks;
pub mod user_impersonation_requests;
pub mod user_referral_codes;
pub mod user_sessions;
pub mod user_login_challenges;
pub mod user_spend_events;
pub mod user_subscriptions;
pub mod users;
@@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// Non-secret identifier for a login challenge. This never grants access.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct UserLoginChallengeToken(pub String);
impl_string_token!(UserLoginChallengeToken);
impl_mysql_token_from_row!(UserLoginChallengeToken);
impl_crockford_generator!(UserLoginChallengeToken, 32usize, TokenPrefix::UserLoginChallenge, CrockfordLower);
@@ -114,6 +114,7 @@ lexical-sort = "0.3.1"
log.workspace = true
once_cell.workspace = true
rand.workspace = true
sha2.workspace = true
regex.workspace = true
strum_macros = "0.26.4"
time.workspace = true
@@ -274,6 +274,7 @@ use crate::http_server::endpoints::users::edit_username_handler::*;
use crate::http_server::endpoints::users::get_profile_handler::*;
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
use artcraft_api_defs::users::login::{LoginRequest, LoginSuccessResponse, LoginErrorType};
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse, DecideLoginChallengeRequest, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, PollLoginChallengeRequest, ReviewLoginChallengeRequest, ReviewLoginChallengeResponse};
use crate::http_server::endpoints::users::login_handler::LoginErrorResponse;
use crate::http_server::endpoints::users::logout_handler::*;
use crate::http_server::endpoints::users::session_info_handler::*;
@@ -570,6 +571,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::users::get_profile_handler::get_profile_handler,
crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler,
crate::http_server::endpoints::users::login_handler::login_handler,
crate::http_server::endpoints::login_challenges::handlers::create,
crate::http_server::endpoints::login_challenges::handlers::review,
crate::http_server::endpoints::login_challenges::handlers::decide,
crate::http_server::endpoints::login_challenges::handlers::poll,
crate::http_server::endpoints::users::logout_handler::logout_handler,
crate::http_server::endpoints::users::session_info_handler::session_info_handler,
crate::http_server::endpoints::users::session_token_info_handler::session_token_info_handler,
@@ -917,9 +922,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
CreateCommentSuccessResponse,
CreateFeaturedItemRequest,
CreateFeaturedItemSuccessResponse,
CreateLoginChallengeRequest,
CreateLoginChallengeResponse,
CreateUserBookmarkError,
CreateUserBookmarkRequest,
CreateUserBookmarkSuccessResponse,
DecideLoginChallengeRequest,
DeleteCommentError,
DeleteCommentPathInfo,
DeleteCommentRequest,
@@ -1200,6 +1208,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
LogBrowserSessionError,
LogBrowserSessionRequest,
LogBrowserSessionSuccessResponse,
LoginChallengeFailure,
LoginChallengeResponse,
LoginChallengeState,
LoginErrorResponse,
LoginErrorType,
LoginRequest,
@@ -1226,6 +1237,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
NewProjectMultipartForm,
PinnedMediaFile,
PinnedModelWeightForList,
PollLoginChallengeRequest,
ProjectMediaFileInfo,
SessionMediaFileInfo,
PromptInfo,
@@ -1237,6 +1249,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
RenameMediaFileRequest,
RequestDetailsResponse,
ResultDetailsResponse,
ReviewLoginChallengeRequest,
ReviewLoginChallengeResponse,
StaffAuditLogResponse,
SearchFeaturedMediaFileListItem,
SearchFeaturedMediaFilesQueryParams,
@@ -0,0 +1,150 @@
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_web::middleware::DefaultHeaders;
use actix_web::{web, HttpRequest, HttpResponse};
use artcraft_api_defs::users::login_challenges::*;
use mysql_queries::queries::user_login_challenges::challenge_queries::{create_challenge, decide_challenge, expire_locked_challenge, lock_by_approval, lock_by_device, mark_redeemed, CreateChallengeArgs, LoginChallenge};
use mysql_queries::queries::user_login_challenges::session_queries::{create_bridge_session, find_approving_session, find_redeemed_session, BridgeSession};
use sqlx::{MySqlConnection, MySqlPool};
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
use tokens::tokens::user_sessions::UserSessionToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip, require_approval_origin, secret_hash};
const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop";
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll)));
}
#[utoipa::path(post, path = "/v1/login_challenges/create", tag = "Users", request_body = CreateLoginChallengeRequest,
responses((status = 200, body = CreateLoginChallengeResponse), (status = 429, body = CommonWebError)))]
pub async fn create(request: HttpRequest, _body: web::Json<CreateLoginChallengeRequest>, pool: web::Data<MySqlPool>) -> Result<web::Json<CreateLoginChallengeResponse>, CommonWebError> {
let ip = request_ip(&request);
rate_limit(&ip, true)?;
let approval_token = new_secret()?;
let device_token = new_secret()?;
let approval_hash = secret_hash(&approval_token)?;
let device_hash = secret_hash(&device_token)?;
let code = new_confirmation_code()?;
let token = UserLoginChallengeToken::generate();
let mut conn = pool.acquire().await?;
create_challenge(CreateChallengeArgs { token: &token, approval_hash: &approval_hash, device_hash: &device_hash, confirmation_code: &code, ip_address: &ip }, &mut conn).await?;
let challenge = lock_by_device(&device_hash, &mut conn).await?.ok_or(CommonWebError::NotFound)?;
Ok(web::Json(CreateLoginChallengeResponse {
success: true,
device_token,
// A fragment avoids sending the approval credential to the website's HTTP logs.
verification_url: format!("{VERIFICATION_PAGE}#approval_token={approval_token}"),
confirmation_code: code,
expires_at: challenge.expires_at,
poll_interval_seconds: 5,
}))
}
#[utoipa::path(post, path = "/v1/login_challenges/review", tag = "Users", request_body = ReviewLoginChallengeRequest,
responses((status = 200, body = ReviewLoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
pub async fn review(request: HttpRequest, body: web::Json<ReviewLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<ReviewLoginChallengeResponse>, CommonWebError> {
require_approval_origin(&request)?;
rate_limit(&request_ip(&request), false)?;
let hash = secret_hash(&body.approval_token)?;
let mut tx = pool.begin().await?;
let approver = approving_session(&request, &signer, &mut tx).await?;
let mut challenge = lock_by_approval(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
let result = outcome(&challenge, expired)?;
tx.commit().await?;
Ok(web::Json(ReviewLoginChallengeResponse { success: true, status: result.status, maybe_failure_type: result.maybe_failure_type, confirmation_code: challenge.confirmation_code, requesting_ip: challenge.ip_address_creation, expires_at: challenge.expires_at, username: approver.username }))
}
#[utoipa::path(post, path = "/v1/login_challenges/decide", tag = "Users", request_body = DecideLoginChallengeRequest,
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
pub async fn decide(request: HttpRequest, body: web::Json<DecideLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<LoginChallengeResponse>, CommonWebError> {
require_approval_origin(&request)?;
let ip = request_ip(&request);
rate_limit(&ip, false)?;
let hash = secret_hash(&body.approval_token)?;
let mut tx = pool.begin().await?;
let approver = approving_session(&request, &signer, &mut tx).await?;
let mut challenge = lock_by_approval(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
let mut expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
if !expired && challenge.status == "pending" {
if decide_challenge(challenge.id, &approver.user_token, &ip, body.approve, &mut tx).await? {
challenge.status = if body.approve { "approved" } else { "failed" }.into();
challenge.maybe_failure_type = if body.approve { None } else { Some("user_declined".into()) };
} else {
expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
}
}
// No session INSERT occurs here, including on approval. Terminal decisions are immutable.
let result = outcome(&challenge, expired)?;
tx.commit().await?;
Ok(web::Json(result))
}
#[utoipa::path(post, path = "/v1/login_challenges/poll", tag = "Users", request_body = PollLoginChallengeRequest,
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 429, body = CommonWebError)))]
pub async fn poll(request: HttpRequest, body: web::Json<PollLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<HttpResponse, CommonWebError> {
let ip = request_ip(&request);
rate_limit(&ip, false)?;
let hash = secret_hash(&body.device_token)?;
let mut tx = pool.begin().await?;
let mut challenge = lock_by_device(&hash, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
let mut result = outcome(&challenge, expired)?;
if expired || challenge.status == "pending" || challenge.status == "failed" {
tx.commit().await?;
return Ok(HttpResponse::Ok().json(result));
}
let user = challenge.maybe_deciding_user_token.as_deref().ok_or(CommonWebError::NotAuthorized)?;
let session = if challenge.status == "approved" {
let session = create_bridge_session(user, &ip, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?;
if !mark_redeemed(challenge.id, session.id, &ip, &mut tx).await? {
// Includes expiry during redemption. Roll back the INSERT as well.
tx.rollback().await?;
return Ok(HttpResponse::Ok().json(outcome(&challenge, true)?));
}
session
} else if challenge.status == "redeemed" {
let id = challenge.maybe_redeemed_user_session_id.ok_or(CommonWebError::NotAuthorized)?;
find_redeemed_session(id, user, &mut tx).await?.ok_or(CommonWebError::NotAuthorized)?
} else {
return Err(CommonWebError::NotAuthorized);
};
let cookie = signer.create_cookie(&UserSessionToken::new(session.token), &UserToken::new(session.user_token))?;
result.status = LoginChallengeState::Redeemed;
result.maybe_signed_session = Some(cookie.value().to_owned());
// The new session, challenge transition, and signing must ALL succeed first.
tx.commit().await?;
Ok(HttpResponse::Ok().cookie(cookie).json(result))
}
async fn approving_session(request: &HttpRequest, signer: &HttpUserSessionManager, conn: &mut MySqlConnection) -> Result<BridgeSession, CommonWebError> {
let payload = signer.decode_session_payload_from_request(request).map_err(|_| CommonWebError::NotAuthorized)?.ok_or(CommonWebError::NotAuthorized)?;
find_approving_session(&payload.session_token, conn).await?.ok_or(CommonWebError::NotAuthorized)
}
fn outcome(challenge: &LoginChallenge, expired: bool) -> Result<LoginChallengeResponse, CommonWebError> {
// Keep an explicit decline even when its deadline has since elapsed.
let declined = challenge.maybe_failure_type.as_deref() == Some("user_declined");
let (status, failure) = if expired && !declined {
(LoginChallengeState::Failed, Some(LoginChallengeFailure::Expired))
} else {
let status = match challenge.status.as_str() {
"pending" => LoginChallengeState::Pending,
"approved" => LoginChallengeState::Approved,
"redeemed" => LoginChallengeState::Redeemed,
"failed" => LoginChallengeState::Failed,
_ => return Err(CommonWebError::NotAuthorized),
};
let failure = match challenge.maybe_failure_type.as_deref() {
Some("user_declined") => Some(LoginChallengeFailure::UserDeclined),
Some("expired") => Some(LoginChallengeFailure::Expired),
None => None,
_ => return Err(CommonWebError::NotAuthorized),
};
(status, failure)
};
Ok(LoginChallengeResponse { success: true, status, maybe_failure_type: failure, maybe_signed_session: None })
}
@@ -0,0 +1,4 @@
pub mod handlers;
mod security;
#[cfg(test)]
mod tests;
@@ -0,0 +1,86 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use actix_web::HttpRequest;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use http_server_common::request::get_request_ip::get_request_ip;
use rand::{
rngs::{OsRng, StdRng},
Rng, SeedableRng, TryRngCore,
};
use sha2::{Digest, Sha256};
use crate::http_server::common_responses::common_web_error::CommonWebError;
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
const WINDOW: Duration = Duration::from_secs(60);
const MAX_BUCKETS: usize = 10_000;
/// Follow the existing gateway's forwarded-IP policy, but normalize stored IPs
/// and use the socket address for direct IPv6 requests (the legacy helper splits
/// on ':'). Gateway deployments must strip caller-supplied forwarding headers.
pub fn request_ip(request: &HttpRequest) -> String {
get_request_ip(request).parse::<IpAddr>().ok().or_else(|| request.peer_addr().map(|address| address.ip())).map(|ip| ip.to_string()).unwrap_or_else(|| "0.0.0.0".into())
}
pub fn new_secret() -> Result<String, CommonWebError> {
let mut bytes = [0u8; 32];
OsRng.try_fill_bytes(&mut bytes).map_err(CommonWebError::from_error)?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub fn secret_hash(secret: &str) -> Result<[u8; 32], CommonWebError> {
if secret.len() != 43 {
return Err(CommonWebError::NotAuthorized);
}
let bytes = URL_SAFE_NO_PAD.decode(secret).map_err(|_| CommonWebError::NotAuthorized)?;
if bytes.len() != 32 || URL_SAFE_NO_PAD.encode(&bytes) != secret {
return Err(CommonWebError::NotAuthorized);
}
Ok(Sha256::digest(secret.as_bytes()).into())
}
pub fn new_confirmation_code() -> Result<String, CommonWebError> {
let mut rng = StdRng::try_from_os_rng().map_err(CommonWebError::from_error)?;
Ok((0..8).map(|_| CODE_ALPHABET[rng.random_range(0..CODE_ALPHABET.len())] as char).collect())
}
/// Approval is browser-only and requires an exact trusted Origin. JSON POSTs
/// plus Origin validation prevent ambient cookies from granting cross-site consent.
pub fn require_approval_origin(request: &HttpRequest) -> Result<(), CommonWebError> {
let origin = request.headers().get("Origin").and_then(|v| v.to_str().ok()).ok_or(CommonWebError::Forbidden)?;
if APPROVAL_ORIGINS.contains(&origin) {
return Ok(());
}
// Local web development is permitted only when the API is also local.
let host = request.connection_info().host().to_owned();
let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:");
if local_api && ["http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
return Ok(());
}
Err(CommonWebError::Forbidden)
}
/// Per-process abuse cap, shared by all Actix workers. Entries expire and memory
/// is bounded. Deployments can impose stricter aggregate limits at their gateway.
pub fn rate_limit(ip: &str, create: bool) -> Result<(), CommonWebError> {
static BUCKETS: OnceLock<Mutex<HashMap<(String, bool), (Instant, u32)>>> = OnceLock::new();
let mut buckets = BUCKETS.get_or_init(|| Mutex::new(HashMap::new())).lock().map_err(|_| CommonWebError::TooManyRequests)?;
let now = Instant::now();
buckets.retain(|_, (started, _)| now.duration_since(*started) < WINDOW);
let key = (ip.to_owned(), create);
if buckets.len() >= MAX_BUCKETS && !buckets.contains_key(&key) {
return Err(CommonWebError::TooManyRequests);
}
let (_, count) = buckets.entry(key).or_insert((now, 0));
let limit = if create { 10 } else { 120 };
if *count >= limit {
return Err(CommonWebError::TooManyRequests);
}
*count += 1;
Ok(())
}
@@ -0,0 +1,294 @@
//! Real HTTP handlers + signed cookies + disposable local MySQL databases.
//! No application URLs, production DB configuration, Redis, or external providers.
use std::sync::atomic::{AtomicU16, Ordering};
use std::time::Duration;
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
use actix_http::Request;
use actix_web::body::MessageBody;
use actix_web::dev::{Service, ServiceResponse};
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpRequest};
use artcraft_api_defs::users::login_challenges::*;
use mysql_queries::queries::user_login_challenges::challenge_queries::{lock_by_device, expire_abandoned_challenges};
use mysql_testing::fixtures::login_challenges as fixtures;
use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session;
use mysql_testing::fixtures::users::{create_test_user, TestUser};
use mysql_testing::isolated::IsolatedTestDatabase;
use serde::Serialize;
use serde_json::{json, Value};
use sqlx::MySqlPool;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
use super::{handlers, security};
const ORIGIN: &str = "https://app.getartcraft.com";
const BROWSER_IP: &str = "198.51.100.20";
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn approval_mints_once_and_cookie_authenticates_downstream() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
fixtures::set_passwordless(&h.db.pool, &h.user.user_token).await;
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
let approval = approval_token(&created);
let hash = security::secret_hash(&created.device_token).unwrap();
assert!(!created.verification_url.contains(&created.device_token));
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.lifetime_seconds, 1200);
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
let pending = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
assert!(!pending.headers().contains_key("set-cookie"));
assert_eq!(pending.headers().get("cache-control").unwrap(), "no-store");
let pending: LoginChallengeResponse = test::read_body_json(pending).await;
assert_eq!(pending.status, LoginChallengeState::Pending);
assert!(pending.maybe_signed_session.is_none());
assert_eq!(post(&app, &h, "review", json!({"approval_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
// Swapping approval and device credentials cannot expose or redeem a session.
assert_eq!(post(&app, &h, "poll", json!({"device_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(post(&app, &h, "review", json!({"approval_token": created.device_token}), true).await.status(), StatusCode::UNAUTHORIZED);
let reviewed: ReviewLoginChallengeResponse = test::read_body_json(post(&app, &h, "review", json!({"approval_token": approval}), true).await).await;
assert_eq!(reviewed.requesting_ip, h.ip);
assert_eq!(reviewed.confirmation_code, created.confirmation_code);
assert_eq!(reviewed.username, h.user.username);
let approved = post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await;
assert!(!approved.headers().contains_key("set-cookie"));
let approved: LoginChallengeResponse = test::read_body_json(approved).await;
assert_eq!(approved.status, LoginChallengeState::Approved);
assert!(approved.maybe_signed_session.is_none());
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "Yes records consent only");
// Two polls overlap. Both must observe the single committed session.
let (first, retry) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), post(&app, &h, "poll", json!({"device_token": created.device_token}), false));
let cookie = first.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
let first: LoginChallengeResponse = test::read_body_json(first).await;
let retry: LoginChallengeResponse = test::read_body_json(retry).await;
assert_eq!(first.status, LoginChallengeState::Redeemed);
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session, "response loss retries reuse the same session");
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
assert_ne!(cookie.value(), h.cookie, "never copy the website's existing session");
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
assert!(fixtures::audit(&h.db.pool, &hash).await.session_id.is_some());
let response = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie.clone()).to_request()).await;
assert_eq!(response.status(), StatusCode::OK);
let downstream: Value = test::read_body_json(response).await;
assert_eq!(downstream["user_token"], h.user.user_token.as_str());
// The handoff expires in 20 minutes; a successfully issued session keeps its own lifetime.
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
let expired: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
assert!(expired.maybe_signed_session.is_none());
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, "redeemed");
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await.status(), StatusCode::OK);
h.db.destroy().await;
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn decline_expiry_and_invalid_consent_never_mint_sessions() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
let approval = approval_token(&created);
let hash = security::secret_hash(&created.device_token).unwrap();
for origin in [None, Some("https://evil.example"), Some("null")] {
let mut request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": true}));
if let Some(origin) = origin {
request = request.insert_header(("Origin", origin));
}
assert_eq!(test::call_service(&app, request.to_request()).await.status(), StatusCode::FORBIDDEN);
}
let mcp = create_test_mcp_session(&h.db.pool, &h.user.user_token).await.unwrap();
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).insert_header(("Authorization", format!("Bearer {}", mcp.private_session_token.as_str()))).set_json(json!({"approval_token": approval, "approve": true})).to_request();
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED);
let declined: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true).await).await;
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
// A later Yes cannot overwrite No.
let replay: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await).await;
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
let rejected: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
assert_eq!(rejected.status, LoginChallengeState::Failed);
assert!(rejected.maybe_signed_session.is_none());
let audit = fixtures::audit(&h.db.pool, &hash).await;
assert_eq!(audit.creation_ip, h.ip);
assert_eq!(audit.failure_ip.as_deref(), Some(BROWSER_IP));
assert_eq!(audit.failure.as_deref(), Some("user_declined"));
assert!(audit.session_id.is_none());
for approve_first in [false, true] {
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
if approve_first {
assert_eq!(post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await.status(), StatusCode::OK);
}
let hash = security::secret_hash(&created.device_token).unwrap();
fixtures::set_challenge_deadline(&h.db.pool, &hash, 0).await;
let result: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
assert!(result.maybe_signed_session.is_none());
let audit = fixtures::audit(&h.db.pool, &hash).await;
assert_eq!(audit.status, "failed");
assert!(audit.failure_ip.is_none());
}
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
h.db.destroy().await;
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn expired_revoked_and_banned_sessions_fail_closed() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
for revoke in [false, true] {
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
let redeemed: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
let signed = redeemed.maybe_signed_session.unwrap();
let request = test::TestRequest::default().insert_header(("session", signed.as_str())).to_http_request();
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
if revoke {
fixtures::revoke_session(&h.db.pool, &token).await;
} else {
fixtures::expire_session(&h.db.pool, &token).await;
}
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").insert_header(("session", signed)).to_request()).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
}
let count = fixtures::bridge_session_count(&h.db.pool).await;
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
fixtures::ban_user(&h.db.pool, &h.user.user_token).await;
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, count);
h.db.destroy().await;
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn expiry_is_checked_after_waiting_for_the_redemption_lock() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
let hash = security::secret_hash(&created.device_token).unwrap();
fixtures::set_challenge_deadline(&h.db.pool, &hash, 1).await;
let mut lock = h.db.pool.begin().await.unwrap();
lock_by_device(&hash, &mut lock).await.unwrap().unwrap();
let (response, _) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), async {
tokio::time::sleep(Duration::from_millis(1500)).await;
lock.commit().await.unwrap();
});
let result: LoginChallengeResponse = test::read_body_json(response).await;
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
assert!(result.maybe_signed_session.is_none());
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
h.db.destroy().await;
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn competing_decisions_are_immutable_and_abandoned_requests_expire() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
let request = test::TestRequest::post().uri("/v1/login_challenges/create").peer_addr("[2001:db8::42]:1234".parse().unwrap()).set_json(json!({})).to_request();
let created: CreateLoginChallengeResponse = test::read_body_json(test::call_service(&app, request).await).await;
let hash = security::secret_hash(&created.device_token).unwrap();
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.creation_ip, "2001:db8::42");
let approval = approval_token(&created);
let (yes, no) = tokio::join!(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true), post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true));
let yes: LoginChallengeResponse = test::read_body_json(yes).await;
let no: LoginChallengeResponse = test::read_body_json(no).await;
assert_eq!(yes.status, no.status);
assert_eq!(yes.maybe_failure_type, no.maybe_failure_type);
assert!(matches!(yes.status, LoginChallengeState::Approved | LoginChallengeState::Failed));
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
// Sweep both pending and approved challenges even if the desktop never polls.
for approve_first in [false, true] {
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
if approve_first {
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
}
let hash = security::secret_hash(&created.device_token).unwrap();
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
assert_eq!(expire_abandoned_challenges(&h.db.pool).await.unwrap(), 1);
let audit = fixtures::audit(&h.db.pool, &hash).await;
assert_eq!(audit.failure.as_deref(), Some("expired"));
assert!(audit.failure_ip.is_none());
assert!(audit.session_id.is_none());
}
assert_eq!(expire_abandoned_challenges(&h.db.pool).await.unwrap(), 0);
h.db.destroy().await;
}
#[actix_web::test]
#[cfg_attr(feature = "skip_database_tests", ignore)]
async fn impersonated_expired_and_revoked_browser_sessions_cannot_approve() {
let h = Harness::create().await;
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
let approval = approval_token(&created);
for mode in ["impersonated", "expired", "revoked"] {
let user = create_test_user(&h.db.pool).await.unwrap();
match mode {
"impersonated" => fixtures::impersonate_session(&h.db.pool, user.session_token.as_str(), &h.user.user_token).await,
"expired" => fixtures::expire_session(&h.db.pool, user.session_token.as_str()).await,
_ => fixtures::revoke_session(&h.db.pool, user.session_token.as_str()).await,
}
let cookie = h.signer.create_cookie(&user.session_token, &user.user_token).unwrap();
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).cookie(cookie).set_json(json!({"approval_token": approval, "approve": true})).to_request();
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED, "{mode}");
}
let hash = security::secret_hash(&created.device_token).unwrap();
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, "pending");
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
h.db.destroy().await;
}
struct Harness {
db: IsolatedTestDatabase,
user: TestUser,
signer: HttpUserSessionManager,
cookie: String,
ip: String,
}
impl Harness {
async fn create() -> Self {
static NEXT_IP: AtomicU16 = AtomicU16::new(1);
let db = IsolatedTestDatabase::create().await;
let user = create_test_user(&db.pool).await.unwrap();
let signer = HttpUserSessionManager::new("localhost", "bridge_integration_test_secret").unwrap();
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().value().to_owned();
let ip = format!("192.0.2.{}", NEXT_IP.fetch_add(1, Ordering::Relaxed));
Self { db, user, signer, cookie, ip }
}
}
async fn post<S, B>(app: &S, h: &Harness, action: &str, body: impl Serialize, authenticated: bool) -> ServiceResponse<B>
where
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
B: MessageBody,
{
let ip = if action == "review" || action == "decide" { BROWSER_IP } else { &h.ip };
let mut request = test::TestRequest::post().uri(&format!("/v1/login_challenges/{action}")).insert_header(("Origin", ORIGIN)).peer_addr(format!("{ip}:1234").parse().unwrap()).set_json(body);
if authenticated {
request = request.insert_header(("session", h.cookie.as_str()));
}
test::call_service(app, request.to_request()).await
}
fn approval_token(created: &CreateLoginChallengeResponse) -> String {
created.verification_url.split_once("#approval_token=").unwrap().1.to_owned()
}
/// Probe the SAME production authentication helper used by protected endpoints,
/// with the cookie signer and SQL lookups, rather than merely decoding a JWT.
async fn protected(request: HttpRequest, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<Value>, CommonWebError> {
let checker = SessionChecker::new(&signer);
let user = require_user_session(&request, &checker, &**pool).await?;
Ok(web::Json(json!({"user_token": user.user_token, "username": user.username})))
}
@@ -15,6 +15,7 @@ pub mod image_studio;
pub mod inference_job;
pub mod internal;
pub mod mcp_sessions;
pub mod login_challenges;
pub mod media_files;
pub mod misc;
pub mod model_download;
@@ -32,6 +32,7 @@ pub fn add_user_routes<T, B> (app: App<T>) -> App<T>
>,
{
app
.configure(crate::http_server::endpoints::login_challenges::handlers::configure)
.service(
// TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal
web::resource("/create_account")
@@ -55,6 +55,7 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
use crate::startup::setup_metrics::build_metrics;
use crate::state::server_state::ServerState;
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
use crate::threads::expire_login_challenges::expire_login_challenges;
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
@@ -108,6 +109,7 @@ async fn main() -> AnyhowResult<()> {
// ==================== Background threads ==================== //
let tokio_runtime = Runtime::new()?;
tokio_runtime.spawn(expire_login_challenges(server_state.mysql_pool.clone()));
info!("Spawning pager worker thread.");
@@ -0,0 +1,14 @@
use std::time::Duration;
use log::warn;
use mysql_queries::queries::user_login_challenges::challenge_queries::expire_abandoned_challenges;
use sqlx::MySqlPool;
pub async fn expire_login_challenges(pool: MySqlPool) {
loop {
if let Err(error) = expire_abandoned_challenges(&pool).await {
warn!("Login challenge expiration sweep failed: {}", error);
}
tokio::time::sleep(Duration::from_secs(30)).await;
}
}
@@ -1,3 +1,4 @@
pub mod db_health_checker_thread;
pub mod expire_login_challenges;
pub mod poll_ip_banlist_thread;
pub mod poll_model_token_info_thread;
pub mod poll_model_token_info_thread;
+13 -6
View File
@@ -2,17 +2,24 @@
<html lang="en">
<head>
<!-- Google tag (gtag.js) -->
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-VKW89949V0");
if (window.location.pathname === "/login/desktop") {
const policy = document.createElement("meta");
policy.name = "referrer";
policy.content = "no-referrer";
document.head.appendChild(policy);
} else {
const analytics = document.createElement("script");
analytics.async = true;
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
document.head.appendChild(analytics);
gtag("js", new Date());
gtag("config", "G-VKW89949V0");
}
</script>
<meta charset="utf-8" />
@@ -48,6 +48,7 @@ import { SignupCtaModal } from "../components/signup-cta-modal";
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
import { GlobalActionReminder } from "../components/global-action-reminder";
import { useSession } from "../lib/session";
import { DesktopLogin } from "../pages/login/desktop-login";
function ScrollToTop() {
const { pathname } = useLocation();
@@ -144,6 +145,7 @@ export function App() {
<>
<ScrollToTop />
<Routes>
<Route path="/login/desktop" element={<DesktopLogin />} />
{/* Public — no chrome, no auth gate. login/signup share a layout route
so the showcase pane (and its video) stays mounted when toggling
between them. */}
@@ -1,11 +1,12 @@
import { AUTH_FORM_PADDING } from "./auth-form-styles";
import { ReactNode } from "react";
import { Outlet, Navigate, Link } from "react-router-dom";
import { Outlet, Navigate, Link, useSearchParams } from "react-router-dom";
import { LoaderCircleIcon } from "lucide-react";
import { AuthShowcase } from "./auth-showcase";
import { AuthPageFrame } from "./auth-page-frame";
import { useMediaQuery } from "../ui/use-media-query";
import { useSession } from "../../lib/session";
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
/**
* Persistent shell for the auth pages. Rendered as a layout route so the
@@ -15,14 +16,15 @@ import { useSession } from "../../lib/session";
* (AuthHeader) and footer (AuthFooter) into the outlet.
*/
export const AuthLayout = () => {
const [searchParams] = useSearchParams();
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
// mobile never downloads the demo videos.
const showShowcase = useMediaQuery("(min-width: 1024px)");
const { loggedIn, authChecked } = useSession();
// Already signed in? Never show the auth form — go straight home.
// Preserve a safe return path when session refresh completes during login.
if (loggedIn) {
return <Navigate to="/" replace />;
return <Navigate to={safeAuthReturnPath(searchParams.get("from"))} replace />;
}
return (
@@ -0,0 +1,27 @@
export const LOGIN_BRIDGE_PATH = "/login/desktop";
const STORAGE_KEY = "artcraft_pending_desktop_approval";
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
/** Capture the fragment before referral tracking or any third-party code can see it. */
export function captureLoginBridgeContext(): string | null {
if (window.location.pathname !== LOGIN_BRIDGE_PATH) return null;
const fragment = new URLSearchParams(window.location.hash.slice(1));
const supplied = fragment.get("approval_token");
if (supplied !== null) {
// Remove even malformed input from the URL. It must not become attribution data.
window.history.replaceState(window.history.state, "", LOGIN_BRIDGE_PATH);
sessionStorage.removeItem(STORAGE_KEY);
if (TOKEN_PATTERN.test(supplied)) sessionStorage.setItem(STORAGE_KEY, supplied);
}
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored && TOKEN_PATTERN.test(stored) ? stored : null;
}
export function clearLoginBridgeContext(): void {
sessionStorage.removeItem(STORAGE_KEY);
}
export function safeAuthReturnPath(value: string | null): string {
if (!value || !value.startsWith("/") || value.startsWith("//") || /[\\\u0000-\u001f]/.test(value)) return "/";
return value;
}
+6 -2
View File
@@ -8,6 +8,10 @@ import { captureLandingContext, getReferrer } from "@storyteller/common";
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
import { setToastDelegate } from "@storyteller/ui-toaster";
import { toast } from "./components/toast/toast";
import { captureLoginBridgeContext, LOGIN_BRIDGE_PATH } from "./lib/login-bridge-context";
captureLoginBridgeContext();
const isLoginBridge = window.location.pathname === LOGIN_BRIDGE_PATH;
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
@@ -50,10 +54,10 @@ setToastDelegate({
// Persist landing context (referral username, landing URL, referrer) to apex-
// domain cookies so attribution survives the getartcraft.com →
// app.getartcraft.com hop. First visit wins.
captureLandingContext();
if (!isLoginBridge) captureLandingContext();
// Fire-and-forget: log the referral once per browser session
if (!sessionStorage.getItem("referral_logged")) {
if (!isLoginBridge && !sessionStorage.getItem("referral_logged")) {
sessionStorage.setItem("referral_logged", "1");
const referrer = getReferrer();
new UsersApi()
@@ -0,0 +1,113 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { DesktopLogin } from "./desktop-login";
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
const { transport, auth } = vi.hoisted(() => ({ transport: vi.fn(), auth: { loggedIn: true } }));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
vi.mock("../../lib/session", () => ({ useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }) }));
vi.mock("@storyteller/api", async () => ({
...(await import("../../../../../libs/api/src/lib/LoginChallengesApi")),
HttpApiError: (await import("../../../../../libs/api/src/lib/ApiManager")).HttpApiError,
}));
const APPROVAL_TOKEN = "A".repeat(43);
const REVIEW = {
success: true, status: "pending", maybe_failure_type: null,
confirmation_code: "WDJBMJHT", requesting_ip: "2001:db8::42",
username: "google_user", expires_at: new Date(Date.now() + 1_200_000).toISOString(),
};
beforeEach(() => {
auth.loggedIn = true;
transport.mockReset();
sessionStorage.clear();
localStorage.clear();
window.history.replaceState(null, "", `/login/desktop#approval_token=${APPROVAL_TOKEN}`);
transport.mockImplementation(async (url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body));
if (url.endsWith("/review")) return response(REVIEW);
return response({ success: true, status: body.approve ? "approved" : "failed", maybe_failure_type: body.approve ? null : "user_declined" });
});
});
afterEach(cleanup);
describe("website login approval integration", () => {
it("reviews the real API response and requires consent plus matching code before approval", async () => {
renderPage();
expect(await screen.findByText("google_user")).toBeTruthy();
expect(screen.getByText("2001:db8::42")).toBeTruthy();
expect(screen.getByText("WDJB-MJHT")).toBeTruthy();
const approve = screen.getByRole("button", { name: "Approve desktop login" }) as HTMLButtonElement;
expect(approve.disabled).toBe(true);
expect(transport).toHaveBeenCalledTimes(1);
expect(window.location.hash).toBe("");
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.click(approve);
await screen.findByText("Desktop login approved. Return to ArtCraft to finish signing in.");
expect(JSON.parse(transport.mock.calls[1][1].body)).toEqual({ approval_token: APPROVAL_TOKEN, approve: true });
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBeNull();
});
it("records decline without requiring the match checkbox or issuing a session", async () => {
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Decline" }));
await screen.findByText("Desktop login declined. No session was created.");
expect(JSON.parse(transport.mock.calls[1][1].body).approve).toBe(false);
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
});
it("preserves approval through login without putting a credential in the login URL", async () => {
auth.loggedIn = false;
const first = renderPage();
await screen.findByText("/login?from=%2Flogin%2Fdesktop");
expect(transport).not.toHaveBeenCalled();
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN);
first.unmount();
auth.loggedIn = true;
renderPage();
await screen.findByText("google_user");
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ approval_token: APPROVAL_TOKEN });
expect(screen.queryByText("Desktop login approved.")).toBeNull();
});
it("prevents approving an expired request", async () => {
transport.mockResolvedValue(response({ ...REVIEW, expires_at: new Date(Date.now() - 1000).toISOString() }));
renderPage();
await screen.findByRole("alert");
expect(screen.queryByRole("button", { name: "Approve desktop login" })).toBeNull();
expect(transport).toHaveBeenCalledTimes(1);
});
it("shows an invalid challenge without redirecting forever", async () => {
transport.mockResolvedValue(new Response("{}", { status: 401 }));
renderPage();
await screen.findByText(/request or account session is no longer valid/);
await waitFor(() => expect(transport).toHaveBeenCalledTimes(1));
});
it("rejects external or protocol-relative authentication return URLs", () => {
for (const path of ["https://evil.example", "//evil.example", "/\\evil.example", "/\nevil.example"]) {
expect(safeAuthReturnPath(path)).toBe("/");
}
expect(safeAuthReturnPath("/login/desktop")).toBe("/login/desktop");
});
});
function renderPage() {
return render(<MemoryRouter initialEntries={["/login/desktop"]}><Routes>
<Route path="/login/desktop" element={<DesktopLogin />} />
<Route path="/login" element={<LoginLocation />} />
</Routes></MemoryRouter>);
}
function LoginLocation() {
const location = useLocation();
return <p>{location.pathname}{location.search}</p>;
}
function response(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
}
@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { HttpApiError, LoginChallengesApi, type LoginChallengeReview } from "@storyteller/api";
import { useSession } from "../../lib/session";
import { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH } from "../../lib/login-bridge-context";
export function DesktopLogin() {
const { loggedIn, authChecked } = useSession();
const [token] = useState(captureLoginBridgeContext);
const [review, setReview] = useState<LoginChallengeReview | null>(null);
const [matched, setMatched] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [finished, setFinished] = useState(false);
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!token || !loggedIn) return;
let active = true;
new LoginChallengesApi().review(token).then((result) => {
if (active) {
setReview(result);
if (result.status !== "pending") {
setFinished(true);
setMessage(result.maybe_failure_type === "expired" ? "This login request expired. Start again in ArtCraft." : "This login request has already been completed.");
clearLoginBridgeContext();
}
}
}).catch((error) => {
if (!active) return;
setMessage(error instanceof HttpApiError && error.status === 401
? "This login request or account session is no longer valid. Start a new login in ArtCraft."
: "Unable to load this login request. Return to ArtCraft and try again.");
});
return () => { active = false; };
}, [token, loggedIn]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const expired = review ? now >= Date.parse(review.expires_at) : false;
const decide = async (approve: boolean) => {
if (!token || busy || expired || (approve && !matched)) return;
setBusy(true);
setMessage("");
try {
const result = await new LoginChallengesApi().decide(token, approve);
const approved = result.status === "approved" || result.status === "redeemed";
const declined = result.status === "failed" && result.maybe_failure_type === "user_declined";
const timedOut = result.status === "failed" && result.maybe_failure_type === "expired";
if (!result.success || (!approved && !declined && !timedOut)) throw new Error("Unexpected login state");
setFinished(true);
setMessage(approved ? "Desktop login approved. Return to ArtCraft to finish signing in." : declined ? "Desktop login declined. No session was created." : "This login request expired. Start again in ArtCraft.");
clearLoginBridgeContext();
} catch {
setMessage("Unable to confirm your choice. Please try again.");
} finally {
setBusy(false);
}
};
if (!authChecked) return <main className="p-8" role="status">Checking your account…</main>;
if (!token) return <main className="p-8">Open a new login link or scan the QR code from ArtCraft.</main>;
if (!loggedIn) return <Navigate to={`/login?from=${encodeURIComponent(LOGIN_BRIDGE_PATH)}`} replace />;
return <main className="flex min-h-svh items-center justify-center bg-ui-background p-6 text-white">
<section className="w-full max-w-md rounded-2xl border border-white/15 bg-white/5 p-8">
<Link to="/" className="text-sm text-white/60">ArtCraft</Link>
<h1 className="mb-4 mt-6 text-3xl font-semibold">Log in to ArtCraft Desktop?</h1>
{message && <p role="status" className="mb-5">{message}</p>}
{expired && !finished && <p role="alert">This request expired. Start a new login in ArtCraft.</p>}
{!finished && !expired && review && <>
<p>Authorize your desktop as <strong>{review.username}</strong>.</p>
<p className="mt-3 text-sm text-white/70">Requesting IP: <code>{review.requesting_ip}</code></p>
<p className="mt-5">Make sure this code matches the code on your desktop:</p>
<p className="my-5 text-center font-mono text-3xl tracking-widest">{review.confirmation_code.slice(0, 4)}-{review.confirmation_code.slice(4)}</p>
<label className="flex items-start gap-3 text-sm">
<input type="checkbox" checked={matched} onChange={(event) => setMatched(event.target.checked)} />
<span>I started this request on my desktop and the codes match.</span>
</label>
<div className="mt-6 flex gap-3">
<button className="flex-1 rounded-lg bg-white px-4 py-3 font-medium text-black disabled:opacity-40" disabled={!matched || busy} onClick={() => decide(true)}>Approve desktop login</button>
<button className="rounded-lg border border-white/30 px-4 py-3" disabled={busy} onClick={() => decide(false)}>Decline</button>
</div>
</>}
{!review && !message && <p role="status">Loading login request…</p>}
</section>
</main>;
}
@@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import Login from "./login";
import { AuthLayout } from "../../components/auth/auth-layout";
const { transport, refresh, billing, auth } = vi.hoisted(() => ({
transport: vi.fn(), refresh: vi.fn(), billing: vi.fn(), auth: { loggedIn: false },
}));
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
vi.mock("@storyteller/api", async () => await import("../../../../../libs/api/src/lib/UsersApi"));
vi.mock("../../lib/session", () => ({
refreshSession: refresh,
useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }),
}));
vi.mock("../../lib/billing", () => ({ hasActiveSubscription: billing }));
vi.mock("../../components/auth", async () => ({
...(await import("../../components/auth/GoogleLoginButton")),
AuthHeader: () => null,
AuthFooter: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@react-oauth/google", () => ({ GoogleLogin: ({ onSuccess }: any) => <button onClick={() => onSuccess({ credential: "google_test_credential" })}>Google sign in</button> }));
vi.mock("@storyteller/common", () => ({ getLandingUrl: () => undefined, getReferralCode: () => undefined, getReferralUsername: () => undefined, getReferrer: () => undefined }));
vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, ...props }: any) => <button {...props}>{children}</button> }));
vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, ...props }: any) => <input {...props} /> }));
vi.mock("../../components/seo", () => ({ default: () => null }));
vi.mock("../../components/auth/auth-showcase", () => ({ AuthShowcase: () => null }));
vi.mock("../../components/auth/auth-page-frame", () => ({ AuthPageFrame: ({ children }: any) => <div>{children}</div> }));
vi.mock("../../components/ui/use-media-query", () => ({ useMediaQuery: () => false }));
beforeEach(() => {
auth.loggedIn = false;
transport.mockReset();
refresh.mockReset().mockResolvedValue(undefined);
billing.mockReset().mockResolvedValue(false);
transport.mockResolvedValue(new Response(JSON.stringify({ success: true, signed_session: "signed_web_session" }), { status: 200 }));
localStorage.clear();
sessionStorage.clear();
sessionStorage.setItem("artcraft_pending_desktop_approval", "A".repeat(43));
});
afterEach(cleanup);
describe("Google login bridge continuation", () => {
it("returns an unsubscribed Google user to explicit desktop consent without passing the bridge token to SSO", async () => {
renderRoutes(<Login />);
fireEvent.click(screen.getByRole("button", { name: "Google sign in" }));
await screen.findByText("/login/desktop");
expect(refresh).toHaveBeenCalledWith(true);
expect(billing).not.toHaveBeenCalled();
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ google_credential: "google_test_credential" });
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe("A".repeat(43));
});
it("preserves the consent destination when the auth layout sees the refreshed session first", async () => {
auth.loggedIn = true;
renderRoutes(<AuthLayout />);
await screen.findByText("/login/desktop");
expect(transport).not.toHaveBeenCalled();
});
});
function renderRoutes(login: React.ReactNode) {
return render(<MemoryRouter initialEntries={["/login?from=%2Flogin%2Fdesktop"]}><Routes>
<Route path="/login" element={login} />
<Route path="*" element={<Location />} />
</Routes></MemoryRouter>);
}
function Location() {
return <p>{useLocation().pathname}</p>;
}
@@ -14,12 +14,13 @@ import {
import Seo from "../../components/seo";
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
import { LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context";
const Login = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const fromParam = searchParams.get("from");
const redirectTo = fromParam && fromParam.startsWith("/") ? fromParam : "/";
const redirectTo = safeAuthReturnPath(fromParam);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -51,6 +52,11 @@ const Login = () => {
};
const handleGoogleSuccess = async () => {
if (redirectTo === LOGIN_BRIDGE_PATH) {
await refreshSession(true);
navigate(redirectTo);
return;
}
// Refresh the session (so the app sees the new cookie) and check the
// subscription in parallel; users without one are pushed to pricing.
const [, subscribed] = await Promise.all([
+1
View File
@@ -21,6 +21,7 @@ export * from "./lib/PasswordResetApi.js";
export * from "./lib/UserApiKeysApi.js";
export * from "./lib/UserReferralCodesApi.js";
export * from "./lib/UsersApi.js";
export * from "./lib/LoginChallengesApi.js";
export * from "./lib/VideoApi.js";
export * from "./lib/WeightsApi.js";
export * from "./lib/GetCdnOrigin.js";
@@ -0,0 +1,56 @@
import { ApiManager, storeSignedSession } from "./ApiManager.js";
export interface LoginChallenge {
success: boolean;
device_token: string;
verification_url: string;
confirmation_code: string;
expires_at: string;
poll_interval_seconds: number;
}
export interface LoginChallengeResult {
success: boolean;
// Kept open to future values: callers must fail closed on an unknown state.
status: string;
maybe_failure_type: string | null;
maybe_signed_session?: string;
}
export interface LoginChallengeReview extends LoginChallengeResult {
confirmation_code: string;
requesting_ip: string;
expires_at: string;
username: string;
}
export class LoginChallengesApi extends ApiManager {
create(): Promise<LoginChallenge> {
return this.request("create", {});
}
review(approvalToken: string): Promise<LoginChallengeReview> {
return this.request("review", { approval_token: approvalToken });
}
decide(approvalToken: string, approve: boolean): Promise<LoginChallengeResult> {
return this.request("decide", { approval_token: approvalToken, approve });
}
poll(deviceToken: string): Promise<LoginChallengeResult> {
return this.request("poll", { device_token: deviceToken });
}
acceptSession(result: LoginChallengeResult): void {
if (result.status !== "redeemed" || !result.success || !result.maybe_signed_session) {
throw new Error("The desktop login has not completed.");
}
storeSignedSession(result.maybe_signed_session);
}
private request<T>(action: string, body: Record<string, unknown>): Promise<T> {
return this.fetch(`${this.getApiSchemeAndHost()}/v1/login_challenges/${action}`, {
method: "POST", body,
});
}
}