better_auth 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +425 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +210 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +129 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +990 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "securerandom"
5
+
6
+ module BetterAuth
7
+ module Routes
8
+ def self.sign_in_social
9
+ Endpoint.new(path: "/sign-in/social", method: "POST") do |ctx|
10
+ body = normalize_hash(ctx.body)
11
+ provider_id = body["provider"].to_s
12
+ provider = social_provider(ctx.context, provider_id)
13
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
14
+
15
+ id_token = fetch_value(body, "idToken")
16
+ if id_token
17
+ data = social_user_from_id_token!(ctx, provider, id_token)
18
+ session_data = persist_social_user(ctx, provider_id, data[:user], data[:account])
19
+ Cookies.set_session_cookie(ctx, session_data)
20
+ next ctx.json({
21
+ redirect: false,
22
+ token: session_data[:session]["token"],
23
+ url: nil,
24
+ user: Schema.parse_output(ctx.context.options, "user", session_data[:user])
25
+ })
26
+ end
27
+
28
+ state = Crypto.sign_jwt(
29
+ {
30
+ "callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/",
31
+ "errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"],
32
+ "newUserCallbackURL" => body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"],
33
+ "requestSignUp" => body["requestSignUp"] || body["request_sign_up"]
34
+ },
35
+ ctx.context.secret,
36
+ expires_in: 600
37
+ )
38
+ code_verifier = SecureRandom.hex(16)
39
+ url = call_provider(provider, :create_authorization_url, {
40
+ state: state,
41
+ codeVerifier: code_verifier,
42
+ code_verifier: code_verifier,
43
+ redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
44
+ redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
45
+ scopes: body["scopes"],
46
+ loginHint: body["loginHint"] || body["login_hint"]
47
+ })
48
+ ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"]
49
+ ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])})
50
+ end
51
+ end
52
+
53
+ def self.callback_oauth
54
+ Endpoint.new(
55
+ path: "/callback/:providerId",
56
+ method: ["GET", "POST"],
57
+ metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
58
+ ) do |ctx|
59
+ source = (ctx.method == "POST") ? ctx.body.merge(ctx.query) : ctx.query
60
+ data = normalize_hash(source)
61
+ provider_id = fetch_value(ctx.params, "providerId").to_s
62
+ provider = social_provider(ctx.context, provider_id)
63
+ state = data["state"].to_s
64
+ state_data = Crypto.verify_jwt(state, ctx.context.secret) || {}
65
+ error_url = state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
66
+
67
+ raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
68
+ raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
69
+ raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) if state.empty?
70
+ raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
71
+
72
+ tokens = call_provider(provider, :validate_authorization_code, {
73
+ code: data["code"],
74
+ codeVerifier: state_data["codeVerifier"],
75
+ code_verifier: state_data["codeVerifier"],
76
+ redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
77
+ redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}"
78
+ })
79
+ raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens
80
+
81
+ user_info = call_provider(provider, :get_user_info, token_hash(tokens))
82
+ user = user_info[:user] || user_info["user"] if user_info
83
+ raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
84
+ raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
85
+
86
+ session_data = persist_social_user(ctx, provider_id, user, token_hash(tokens).merge("accountId" => fetch_value(user, "id").to_s))
87
+ Cookies.set_session_cookie(ctx, session_data)
88
+ callback_url = state_data["callbackURL"] || "/"
89
+ raise ctx.redirect(callback_url)
90
+ end
91
+ end
92
+
93
+ def self.link_social
94
+ Endpoint.new(path: "/link-social", method: "POST") do |ctx|
95
+ session = current_session(ctx)
96
+ body = normalize_hash(ctx.body)
97
+ provider_id = body["provider"].to_s
98
+ provider = social_provider(ctx.context, provider_id)
99
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
100
+
101
+ id_token = fetch_value(body, "idToken")
102
+ if id_token
103
+ data = social_user_from_id_token!(ctx, provider, id_token)
104
+ email = fetch_value(data[:user], "email").to_s.downcase
105
+ unless email == session[:user]["email"].to_s.downcase || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
106
+ raise APIError.new("UNAUTHORIZED", message: "Account not linked - different emails not allowed")
107
+ end
108
+
109
+ account_id = fetch_value(data[:user], "id").to_s
110
+ existing = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |account|
111
+ account["providerId"] == provider_id && account["accountId"] == account_id
112
+ end
113
+ unless existing
114
+ ctx.context.internal_adapter.create_account(data[:account].merge("userId" => session[:user]["id"]))
115
+ end
116
+ next ctx.json({url: "", status: true, redirect: false})
117
+ end
118
+
119
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"])
120
+ end
121
+ end
122
+
123
+ def self.social_user_from_id_token!(ctx, provider, id_token)
124
+ token = fetch_value(id_token, "token").to_s
125
+ valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
126
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
127
+
128
+ user_info = call_provider(provider, :get_user_info, {
129
+ idToken: token,
130
+ id_token: token,
131
+ accessToken: fetch_value(id_token, "accessToken"),
132
+ access_token: fetch_value(id_token, "accessToken"),
133
+ refreshToken: fetch_value(id_token, "refreshToken"),
134
+ refresh_token: fetch_value(id_token, "refreshToken")
135
+ })
136
+ user = user_info[:user] || user_info["user"] if user_info
137
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user
138
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["USER_EMAIL_NOT_FOUND"]) if fetch_value(user, "email").to_s.empty?
139
+
140
+ {
141
+ user: user,
142
+ account: {
143
+ "providerId" => fetch_value(provider, "id").to_s,
144
+ "accountId" => fetch_value(user, "id").to_s,
145
+ "accessToken" => fetch_value(id_token, "accessToken"),
146
+ "refreshToken" => fetch_value(id_token, "refreshToken"),
147
+ "idToken" => token
148
+ }
149
+ }
150
+ end
151
+
152
+ def self.persist_social_user(ctx, provider_id, user_info, account_info)
153
+ email = fetch_value(user_info, "email").to_s.downcase
154
+ account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s
155
+ existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
156
+
157
+ if existing && existing[:linked_account]
158
+ user = existing[:user]
159
+ elsif existing
160
+ user = existing[:user]
161
+ ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
162
+ else
163
+ created = ctx.context.internal_adapter.create_oauth_user(
164
+ {
165
+ email: email,
166
+ name: fetch_value(user_info, "name").to_s,
167
+ image: fetch_value(user_info, "image"),
168
+ emailVerified: !!fetch_value(user_info, "emailVerified")
169
+ },
170
+ account_info.merge("providerId" => provider_id, "accountId" => account_id)
171
+ )
172
+ user = created[:user]
173
+ end
174
+
175
+ session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
176
+ {session: session, user: user}
177
+ end
178
+
179
+ def self.oauth_error_url(base_url, error, description = nil)
180
+ uri = URI.parse(base_url.to_s)
181
+ query = URI.decode_www_form(uri.query.to_s)
182
+ query << ["error", error.to_s]
183
+ query << ["error_description", description.to_s] if description
184
+ uri.query = URI.encode_www_form(query)
185
+ uri.to_s
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.update_user
6
+ Endpoint.new(path: "/update-user", method: "POST") do |ctx|
7
+ session = current_session(ctx)
8
+ body = normalize_hash(ctx.body)
9
+ raise APIError.new("BAD_REQUEST", message: "Body must be an object") unless body.is_a?(Hash)
10
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_CAN_NOT_BE_UPDATED"]) if body.key?("email")
11
+ update = parse_declared_input(ctx, "user", body, allowed_base: ["name", "image"])
12
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if update.empty?
13
+
14
+ updated = ctx.context.internal_adapter.update_user(session[:user]["id"], update)
15
+ Cookies.set_session_cookie(ctx, {session: session[:session], user: updated}, Cookies.dont_remember?(ctx))
16
+ ctx.json({status: true})
17
+ end
18
+ end
19
+
20
+ def self.change_password
21
+ Endpoint.new(path: "/change-password", method: "POST") do |ctx|
22
+ session = current_session(ctx, sensitive: true)
23
+ body = normalize_hash(ctx.body)
24
+ new_password = body["newPassword"] || body["new_password"]
25
+ current_password = body["currentPassword"] || body["current_password"]
26
+ validate_password_length!(new_password, ctx.context.options.email_and_password)
27
+ account = credential_account(ctx, session[:user]["id"])
28
+ unless account && account["password"] && verify_password_value(ctx, current_password.to_s, account["password"])
29
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
30
+ end
31
+
32
+ ctx.context.internal_adapter.update_account(account["id"], password: hash_password(ctx, new_password))
33
+ token = nil
34
+ if body["revokeOtherSessions"] || body["revoke_other_sessions"]
35
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
36
+ new_session = ctx.context.internal_adapter.create_session(session[:user]["id"])
37
+ Cookies.set_session_cookie(ctx, {session: new_session, user: session[:user]})
38
+ token = new_session["token"]
39
+ end
40
+ ctx.json({token: token, user: Schema.parse_output(ctx.context.options, "user", session[:user])})
41
+ end
42
+ end
43
+
44
+ def self.set_password
45
+ Endpoint.new(path: "/set-password", method: "POST") do |ctx|
46
+ session = current_session(ctx, sensitive: true)
47
+ body = normalize_hash(ctx.body)
48
+ new_password = body["newPassword"] || body["new_password"]
49
+ validate_password_length!(new_password, ctx.context.options.email_and_password)
50
+ account = credential_account(ctx, session[:user]["id"])
51
+ raise APIError.new("BAD_REQUEST", message: "user already has a password") if account && account["password"]
52
+
53
+ ctx.context.internal_adapter.link_account(
54
+ userId: session[:user]["id"],
55
+ providerId: "credential",
56
+ accountId: session[:user]["id"],
57
+ password: hash_password(ctx, new_password)
58
+ )
59
+ ctx.json({status: true})
60
+ end
61
+ end
62
+
63
+ def self.delete_user
64
+ Endpoint.new(path: "/delete-user", method: "POST") do |ctx|
65
+ enabled = ctx.context.options.user.dig(:delete_user, :enabled)
66
+ raise APIError.new("NOT_FOUND") unless enabled
67
+
68
+ session = current_session(ctx, sensitive: true)
69
+ body = normalize_hash(ctx.body)
70
+ if body["password"]
71
+ account = credential_account(ctx, session[:user]["id"])
72
+ unless account && account["password"] && verify_password_value(ctx, body["password"], account["password"])
73
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
74
+ end
75
+ end
76
+
77
+ if body["token"]
78
+ delete_user_by_token!(ctx, session, body["token"])
79
+ elsif (sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification))
80
+ token = SecureRandom.hex(16)
81
+ ctx.context.internal_adapter.create_verification_value(
82
+ identifier: "delete-account-#{token}",
83
+ value: session[:user]["id"],
84
+ expiresAt: Time.now + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
85
+ )
86
+ sender.call({user: session[:user], token: token}, ctx.request)
87
+ next ctx.json({success: true, message: "Verification email sent"})
88
+ end
89
+
90
+ delete_current_user!(ctx, session)
91
+ ctx.json({success: true, message: "User deleted"})
92
+ end
93
+ end
94
+
95
+ def self.delete_user_callback
96
+ Endpoint.new(path: "/delete-user/callback", method: "GET") do |ctx|
97
+ enabled = ctx.context.options.user.dig(:delete_user, :enabled)
98
+ raise APIError.new("NOT_FOUND") unless enabled
99
+ session = current_session(ctx)
100
+ token = fetch_value(ctx.query, "token")
101
+ delete_user_by_token!(ctx, session, token)
102
+ delete_current_user!(ctx, session)
103
+ callback_url = fetch_value(ctx.query, "callbackURL")
104
+ raise ctx.redirect(callback_url) if callback_url
105
+
106
+ ctx.json({success: true, message: "User deleted"})
107
+ end
108
+ end
109
+
110
+ def self.change_email
111
+ Endpoint.new(path: "/change-email", method: "POST") do |ctx|
112
+ enabled = ctx.context.options.user.dig(:change_email, :enabled)
113
+ raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
114
+ session = current_session(ctx, sensitive: true)
115
+ body = normalize_hash(ctx.body)
116
+ new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
117
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
118
+ raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
119
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]) if ctx.context.internal_adapter.find_user_by_email(new_email)
120
+
121
+ if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
122
+ updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
123
+ Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
124
+ next ctx.json({status: true})
125
+ end
126
+
127
+ sender = ctx.context.options.email_verification[:send_verification_email]
128
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
129
+
130
+ token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
131
+ sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
132
+ ctx.json({status: true})
133
+ end
134
+ end
135
+
136
+ def self.delete_user_by_token!(ctx, session, token)
137
+ verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
138
+ unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
139
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"])
140
+ end
141
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
142
+ end
143
+
144
+ def self.delete_current_user!(ctx, session)
145
+ config = ctx.context.options.user[:delete_user] || {}
146
+ call_option(config[:before_delete], session[:user], ctx.request)
147
+ ctx.context.internal_adapter.delete_user(session[:user]["id"])
148
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
149
+ Cookies.delete_session_cookie(ctx)
150
+ call_option(config[:after_delete], session[:user], ctx.request)
151
+ end
152
+
153
+ def self.parse_declared_input(ctx, model, data, allowed_base: [])
154
+ input = normalize_hash(data || {})
155
+ table = Schema.auth_tables(ctx.context.options)[model.to_s]
156
+ fields = table ? table.fetch(:fields) : {}
157
+ additional = ctx.context.options.public_send(model.to_sym)[:additional_fields] || {}
158
+ fields = fields.merge(additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }) if model.to_s == "session"
159
+ declared_fields = fields.keys - core_model_fields(model)
160
+ allowed = (Array(allowed_base).map { |field| Schema.storage_key(field) } + declared_fields).uniq
161
+
162
+ input.each_with_object({}) do |(field, value), result|
163
+ next unless fields.key?(field)
164
+ next unless allowed.include?(field)
165
+
166
+ attributes = fields.fetch(field)
167
+ if attributes[:input] == false
168
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
169
+ end
170
+
171
+ result[field] = coerce_input_value(value, attributes)
172
+ end
173
+ end
174
+
175
+ def self.coerce_input_value(value, attributes)
176
+ return value if value.nil?
177
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
178
+
179
+ value
180
+ end
181
+
182
+ def self.core_model_fields(model)
183
+ case model.to_s
184
+ when "user"
185
+ %w[id name email emailVerified image createdAt updatedAt]
186
+ when "session"
187
+ %w[id expiresAt token ipAddress userAgent userId createdAt updatedAt]
188
+ else
189
+ %w[id createdAt updatedAt]
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Schema
5
+ module SQL
6
+ module_function
7
+
8
+ def create_statements(options, dialect:)
9
+ dialect = dialect.to_sym
10
+ tables = Schema.auth_tables(options)
11
+ statements = tables.map { |logical_name, table| create_table_statement(logical_name, table, dialect, tables) }
12
+ statements.concat(tables.flat_map { |_logical_name, table| index_statements(table, dialect) })
13
+ end
14
+
15
+ def create_table_statement(logical_name, table, dialect, tables = nil)
16
+ table_name = table.fetch(:model_name)
17
+ columns = table.fetch(:fields).map do |logical_field, attributes|
18
+ column_definition(table_name, logical_field, attributes, dialect)
19
+ end
20
+ constraints = table.fetch(:fields).flat_map do |logical_field, attributes|
21
+ field_constraints(table_name, logical_field, attributes, dialect, tables)
22
+ end
23
+ body = (columns + constraints).join(",\n ")
24
+
25
+ case dialect
26
+ when :postgres, :sqlite
27
+ %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n);)
28
+ when :mysql
29
+ %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
30
+ when :mssql
31
+ %(IF OBJECT_ID(N'#{quote(table_name, dialect)}', N'U') IS NULL\nCREATE TABLE #{quote(table_name, dialect)} (\n #{body}\n);)
32
+ else
33
+ raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
34
+ end
35
+ end
36
+
37
+ def column_definition(table_name, logical_field, attributes, dialect)
38
+ column = quote(attributes[:field_name] || physical_name(logical_field), dialect)
39
+ parts = [column, sql_type(logical_field, attributes, dialect)]
40
+ parts << "PRIMARY KEY" if logical_field == "id"
41
+ if attributes[:required]
42
+ parts << "NOT NULL"
43
+ elsif dialect == :mssql
44
+ parts << "NULL"
45
+ end
46
+ default = default_sql(attributes, dialect)
47
+ parts << "DEFAULT #{default}" if default
48
+ parts.join(" ")
49
+ end
50
+
51
+ def field_constraints(table_name, logical_field, attributes, dialect, tables = nil)
52
+ constraints = []
53
+ column = attributes[:field_name] || physical_name(logical_field)
54
+
55
+ if attributes[:unique] && logical_field != "id"
56
+ constraints << unique_constraint(table_name, column, dialect)
57
+ end
58
+
59
+ reference = attributes[:references]
60
+ if reference
61
+ constraints << foreign_key_constraint(table_name, column, reference, dialect, tables)
62
+ end
63
+
64
+ constraints
65
+ end
66
+
67
+ def index_statements(table, dialect)
68
+ table_name = table.fetch(:model_name)
69
+ table.fetch(:fields).filter_map do |logical_field, attributes|
70
+ next unless attributes[:index]
71
+
72
+ column = attributes[:field_name] || Schema.physical_name(logical_field)
73
+ name = "index_#{table_name}_on_#{column}"
74
+ case dialect
75
+ when :postgres, :sqlite
76
+ %(CREATE INDEX IF NOT EXISTS #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
77
+ when :mysql
78
+ %(CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
79
+ when :mssql
80
+ %(IF NOT EXISTS (SELECT name FROM sys.indexes WHERE name = '#{name.gsub("'", "''")}' AND object_id = OBJECT_ID(N'#{quote(table_name, dialect)}')) CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
81
+ end
82
+ end
83
+ end
84
+
85
+ def sql_type(logical_field, attributes, dialect)
86
+ case attributes[:type]
87
+ when "boolean"
88
+ case dialect
89
+ when :mysql
90
+ "tinyint(1)"
91
+ when :sqlite
92
+ "integer"
93
+ when :mssql
94
+ "smallint"
95
+ else
96
+ "boolean"
97
+ end
98
+ when "date"
99
+ case dialect
100
+ when :mysql
101
+ "datetime(6)"
102
+ when :sqlite
103
+ "date"
104
+ when :mssql
105
+ "datetime2(3)"
106
+ else
107
+ "timestamptz"
108
+ end
109
+ when "number"
110
+ attributes[:bigint] ? "bigint" : "integer"
111
+ else
112
+ if dialect == :mysql
113
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references]
114
+ indexed ? "varchar(191)" : "text"
115
+ elsif dialect == :mssql
116
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
117
+ indexed ? "varchar(255)" : "varchar(8000)"
118
+ else
119
+ "text"
120
+ end
121
+ end
122
+ end
123
+
124
+ def default_sql(attributes, dialect)
125
+ default = attributes[:default_value]
126
+ return unless default == false || default == true || default.is_a?(Numeric) || default.is_a?(String) || default.respond_to?(:call)
127
+
128
+ if attributes[:type] == "date" && default.respond_to?(:call)
129
+ return (dialect == :mysql) ? "CURRENT_TIMESTAMP(6)" : "CURRENT_TIMESTAMP"
130
+ end
131
+
132
+ case default
133
+ when true
134
+ (dialect == :mysql || dialect == :sqlite || dialect == :mssql) ? "1" : "true"
135
+ when false
136
+ (dialect == :mysql || dialect == :sqlite || dialect == :mssql) ? "0" : "false"
137
+ when Numeric
138
+ default.to_s
139
+ when String
140
+ "'#{default.gsub("'", "''")}'"
141
+ end
142
+ end
143
+
144
+ def unique_constraint(table_name, column, dialect)
145
+ case dialect
146
+ when :postgres, :sqlite
147
+ %(UNIQUE (#{quote(column, dialect)}))
148
+ when :mysql
149
+ %(UNIQUE KEY #{quote("uniq_#{table_name}_#{column}", dialect)} (#{quote(column, dialect)}))
150
+ when :mssql
151
+ %(CONSTRAINT #{quote("uniq_#{table_name}_#{column}", dialect)} UNIQUE (#{quote(column, dialect)}))
152
+ end
153
+ end
154
+
155
+ def foreign_key_constraint(table_name, column, reference, dialect, tables = nil)
156
+ target_model = tables&.fetch(reference.fetch(:model).to_s, nil)&.fetch(:model_name) || reference.fetch(:model)
157
+ target_field = reference.fetch(:field)
158
+ on_delete = reference[:on_delete] ? " ON DELETE #{reference[:on_delete].to_s.upcase}" : ""
159
+
160
+ case dialect
161
+ when :postgres, :sqlite
162
+ %(FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
163
+ when :mysql
164
+ %(CONSTRAINT #{quote("fk_#{table_name}_#{column}", dialect)} FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
165
+ when :mssql
166
+ %(CONSTRAINT #{quote("fk_#{table_name}_#{column}", dialect)} FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
167
+ end
168
+ end
169
+
170
+ def quote(identifier, dialect)
171
+ case dialect
172
+ when :postgres, :sqlite
173
+ %("#{identifier.to_s.gsub("\"", "\"\"")}")
174
+ when :mysql
175
+ "`#{identifier.to_s.gsub("`", "``")}`"
176
+ when :mssql
177
+ "[#{identifier.to_s.gsub("]", "]]")}]"
178
+ else
179
+ raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
180
+ end
181
+ end
182
+
183
+ def physical_name(value)
184
+ value.to_s
185
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
186
+ .tr("-", "_")
187
+ .downcase
188
+ end
189
+ end
190
+ end
191
+ end