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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2ba3cc27f9b1ebc6cb35de7a95a10351c30f5523de796c82b7c90773d663381
4
- data.tar.gz: 81b03e1d625d242adc6255806b7651cebee65f59179110aae7df85b7beab426f
3
+ metadata.gz: a5e95c51ce68259fcccbbfabe1bd9ffa5383ce4c098161f1c2d54da76137fa0a
4
+ data.tar.gz: 7c81ade6292ced9b98c1c411c320e4ad6e05734ea891145f75a9205d6e4fdea0
5
5
  SHA512:
6
- metadata.gz: 61a29dc835cd5758628b358153888cc78eb721795cd286e55210c920766129826d08b5bbdf2fe9a049df56b3d6a9d4bd8f33c82b067bde77c812c87738af94e2
7
- data.tar.gz: 18d543129a8577e9974ce0887db187e837ff2b9175cce97efd33a6afba615f1d34272fb193a0008694c63c998b12740ed6c2490ecb765ad9b24f131be389a250
6
+ metadata.gz: 4b7f5c635ce16aa7be7f3ea42c5fa03304b7c682821c8a82e55c81754ba330a88aba350f0a6ed59b171333766ba6a973c4a2d15cf3d46debeae42ddd59b602c5
7
+ data.tar.gz: 524ce3bfb3f023bcf057154fde0e88e0555753016d1297d67d40f2db6de6cf5516b0fcb8b513fd260cd284b38722eeffe9a02784d3d08a58cd5811cce23a1d39
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.1] - 2026-03-22
11
+
12
+ ### Fixed
13
+
14
+ - Fixed gemspec files list to use `Dir.glob` instead of `git ls-files` for better CI compatibility
15
+
10
16
  ### Added
11
17
 
12
18
  - Initial project setup
data/README.md CHANGED
@@ -60,11 +60,94 @@ gem install better_auth
60
60
  ```ruby
61
61
  require 'better_auth'
62
62
 
63
- # Configure Better Auth
64
- BetterAuth.configure do |config|
65
- config.secret_key = ENV['BETTER_AUTH_SECRET']
66
- config.database_url = ENV['DATABASE_URL']
67
- end
63
+ auth = BetterAuth.auth(
64
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
65
+ database: :memory
66
+ )
67
+ ```
68
+
69
+ ### Password Hashing
70
+
71
+ Better Auth Ruby uses upstream-compatible `scrypt` password hashes by default through Ruby's `OpenSSL::KDF.scrypt`, so no extra password-hashing gem is required for the default setup.
72
+
73
+ ```ruby
74
+ auth = BetterAuth.auth(
75
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
76
+ password_hasher: :scrypt # default
77
+ )
78
+ ```
79
+
80
+ Applications that prefer Ruby's familiar BCrypt ecosystem can opt in by adding `gem "bcrypt"` and configuring:
81
+
82
+ ```ruby
83
+ auth = BetterAuth.auth(
84
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
85
+ password_hasher: :bcrypt
86
+ )
87
+ ```
88
+
89
+ Custom Better Auth-style password callbacks are still supported through `email_and_password[:password][:hash]` and `[:verify]`.
90
+
91
+ ### Database Adapters
92
+
93
+ The core gem ships framework-agnostic adapters for memory, PostgreSQL, MySQL, SQLite, MongoDB, and MSSQL. Driver gems are loaded only when their adapter is instantiated.
94
+
95
+ ```ruby
96
+ auth = BetterAuth.auth(
97
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
98
+ database: BetterAuth::Adapters::SQLite.new(path: "storage/auth.sqlite3")
99
+ )
100
+ ```
101
+
102
+ ```ruby
103
+ auth = BetterAuth.auth(
104
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
105
+ database: BetterAuth::Adapters::MongoDB.new(
106
+ database: mongo_client.database,
107
+ client: mongo_client,
108
+ transaction: false
109
+ )
110
+ )
111
+ ```
112
+
113
+ ```ruby
114
+ auth = BetterAuth.auth(
115
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
116
+ database: BetterAuth::Adapters::MSSQL.new(url: ENV.fetch("DATABASE_URL"))
117
+ )
118
+ ```
119
+
120
+ ### Social Providers
121
+
122
+ ```ruby
123
+ require "better_auth"
124
+
125
+ auth = BetterAuth.auth(
126
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
127
+ social_providers: {
128
+ google: BetterAuth::SocialProviders.google(
129
+ client_id: ENV.fetch("GOOGLE_CLIENT_ID"),
130
+ client_secret: ENV.fetch("GOOGLE_CLIENT_SECRET")
131
+ ),
132
+ github: BetterAuth::SocialProviders.github(
133
+ client_id: ENV.fetch("GITHUB_CLIENT_ID"),
134
+ client_secret: ENV.fetch("GITHUB_CLIENT_SECRET")
135
+ )
136
+ }
137
+ )
138
+ ```
139
+
140
+ ### JavaScript Client
141
+
142
+ Ruby Better Auth exposes the same HTTP route surface. Frontend apps should use the upstream Better Auth JavaScript client and point it at the Ruby server:
143
+
144
+ ```ts
145
+ import { createAuthClient } from "better-auth/client";
146
+
147
+ export const authClient = createAuthClient({
148
+ baseURL: "http://localhost:3000",
149
+ basePath: "/api/auth",
150
+ });
68
151
  ```
69
152
 
70
153
  ### Rails Integration
@@ -97,6 +180,8 @@ end
97
180
 
98
181
  ## Development
99
182
 
183
+ Full documentation is being adapted in the root [`docs/`](/Users/sebastiansala/projects/better-auth/docs/README.md) app. Start with the Ruby-first installation, basic usage, Rack, Rails, PostgreSQL, and MySQL pages there; pages with a Ruby port warning still contain upstream TypeScript examples for reference.
184
+
100
185
  ### Quick Start
101
186
 
102
187
  ```bash
@@ -132,7 +217,7 @@ make test-coverage # Tests with coverage
132
217
  make ci # Full CI (lint + test)
133
218
 
134
219
  # Databases for testing
135
- make db-up # Start PostgreSQL, MySQL, Redis
220
+ make db-up # Start PostgreSQL, MySQL, MongoDB, MSSQL, Redis
136
221
  make db-down # Stop containers
137
222
  ```
138
223
 
@@ -183,10 +268,10 @@ git push origin feat/new-feature
183
268
 
184
269
  **Automatic Release (GitHub Actions):**
185
270
 
186
- Release is triggered on `push` to `main` when `lib/better_auth/version.rb` changes.
271
+ Release is triggered by package-prefixed tags so each gem can ship independently.
187
272
 
188
273
  ```bash
189
- # STEP 1: Update version in lib/better_auth/version.rb
274
+ # STEP 1: Update the target package version file
190
275
  # Example: VERSION = "0.1.1"
191
276
 
192
277
  # STEP 2: Commit and push to main
@@ -194,19 +279,24 @@ git add lib/better_auth/version.rb
194
279
  git commit -m "chore: bump version to 0.1.1"
195
280
  git push origin main
196
281
 
197
- # STEP 3: GitHub Actions automatically:
282
+ # STEP 3: Create the tag for the gem you want to publish
283
+ git tag better_auth-v0.1.1
284
+ git push origin better_auth-v0.1.1
285
+
286
+ # STEP 4: GitHub Actions automatically:
198
287
  # - Runs tests
199
288
  # - Builds the gem
200
289
  # - Publishes to RubyGems (if version is new)
201
- # - Creates and pushes git tag (v0.1.1)
202
290
  # - Creates GitHub Release
203
291
  ```
204
292
 
293
+ Use `better_auth-vX.Y.Z` for the core gem, `better_auth-rails-vX.Y.Z` for Rails, `better_auth-sinatra-vX.Y.Z` for Sinatra, and `better_auth-hanami-vX.Y.Z` for Hanami.
294
+
205
295
  **Required GitHub Configuration:**
206
296
 
207
- 1. Go to Settings Secrets and variables Actions
208
- 2. Add `RUBYGEMS_API_KEY` with your RubyGems API key
209
- 3. The workflow `.github/workflows/release.yml` does the rest
297
+ 1. In RubyGems, configure Trusted Publishing for each gem that should publish from CI.
298
+ 2. Use this repository and workflow file: `.github/workflows/release.yml`.
299
+ 3. The workflow exchanges GitHub's OIDC token for short-lived RubyGems credentials when the matching package tag is pushed.
210
300
 
211
301
  **Dry-run options:**
212
302
 
@@ -231,8 +321,8 @@ gem build better_auth.gemspec
231
321
  gem push better_auth-*.gem
232
322
 
233
323
  # 4. Create and push the tag
234
- git tag -a v0.1.1 -m "Release v0.1.1"
235
- git push origin --tags
324
+ git tag -a better_auth-v0.1.1 -m "Release better_auth v0.1.1"
325
+ git push origin better_auth-v0.1.1
236
326
  ```
237
327
 
238
328
  ### Project Structure
@@ -262,6 +352,6 @@ The gem is available as open source under the terms of the [MIT License](https:/
262
352
 
263
353
  ## Security
264
354
 
265
- If you discover a security vulnerability within Better Auth Ruby, please send an e-mail to [security@better-auth.com](mailto:security@better-auth.com).
355
+ If you discover a security vulnerability within Better Auth Ruby, please send an e-mail to [security@openparcel.dev](mailto:security@openparcel.dev).
266
356
 
267
357
  All reports will be promptly addressed, and you'll be credited accordingly.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Adapters
5
+ class Base
6
+ attr_reader :options
7
+
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def create(**)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def find_one(**)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def find_many(**)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def update(**)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def update_many(**)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def delete(**)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def delete_many(**)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def count(**)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def transaction
45
+ yield self
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,439 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module BetterAuth
8
+ module Adapters
9
+ class InternalAdapter
10
+ attr_reader :adapter, :options, :hooks
11
+
12
+ def initialize(adapter, options)
13
+ @adapter = adapter
14
+ @options = options
15
+ @hooks = DatabaseHooks.new(adapter, options)
16
+ end
17
+
18
+ def create_oauth_user(user, account)
19
+ adapter.transaction do
20
+ created_user = create_user(user)
21
+ created_account = create_account(stringify_keys(account).merge("userId" => created_user["id"]))
22
+ {user: created_user, account: created_account}
23
+ end
24
+ end
25
+
26
+ def create_user(user)
27
+ data = timestamps.merge(stringify_keys(user))
28
+ data["email"] = data["email"].to_s.downcase if data["email"]
29
+ hooks.create(data, "user")
30
+ end
31
+
32
+ def create_account(account)
33
+ hooks.create(timestamps.merge(stringify_keys(account)), "account")
34
+ end
35
+
36
+ def link_account(account)
37
+ create_account(account)
38
+ end
39
+
40
+ def list_sessions(user_id)
41
+ if secondary_storage
42
+ active_session_entries(user_id).filter_map do |entry|
43
+ data = parse_storage(secondary_storage.get(entry.fetch("token")))
44
+ next unless data && data["session"]
45
+
46
+ normalize_session_dates(data["session"])
47
+ end
48
+ else
49
+ adapter.find_many(model: "session", where: [{field: "userId", value: user_id}])
50
+ end
51
+ end
52
+
53
+ def list_users(limit: nil, offset: nil, sort_by: nil, where: nil)
54
+ adapter.find_many(model: "user", where: where || [], limit: limit, offset: offset, sort_by: sort_by)
55
+ end
56
+
57
+ def count_total_users(where: nil)
58
+ adapter.count(model: "user", where: where || [])
59
+ end
60
+
61
+ def delete_user(user_id)
62
+ delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
63
+ hooks.delete_many([{field: "userId", value: user_id}], "account")
64
+ hooks.delete([{field: "id", value: user_id}], "user")
65
+ end
66
+
67
+ def create_session(user_id, dont_remember_me = false, override = nil, override_all = false, context = nil)
68
+ override = stringify_keys(override || {})
69
+ token = override.delete("token") || SecureRandom.hex(16)
70
+ base = {
71
+ "ipAddress" => "",
72
+ "userAgent" => "",
73
+ "expiresAt" => Time.now + (dont_remember_me ? 86_400 : options.session[:expires_in].to_i),
74
+ "userId" => user_id,
75
+ "token" => token
76
+ }.merge(timestamps)
77
+ data = override_all ? base.merge(override) : override.merge(base)
78
+
79
+ custom = secondary_storage && lambda do |session_data|
80
+ actual_session = apply_schema_create("session", session_data)
81
+ store_session(actual_session)
82
+ actual_session
83
+ end
84
+ execute_main = !secondary_storage || options.session[:store_session_in_database]
85
+ created = hooks.create(data, "session", custom: custom, context: context)
86
+ adapter.create(model: "session", data: data, force_allow_id: true) if secondary_storage && execute_main
87
+ created
88
+ end
89
+
90
+ def find_session(token)
91
+ if secondary_storage
92
+ data = parse_storage(secondary_storage.get(token))
93
+ return nil unless data
94
+
95
+ return {
96
+ session: normalize_session_dates(data["session"]),
97
+ user: normalize_user_dates(data["user"])
98
+ }
99
+ end
100
+
101
+ found = find_session_with_user(token)
102
+ return nil unless found && found["user"]
103
+
104
+ user = found.delete("user")
105
+ {session: found, user: user}
106
+ end
107
+
108
+ def find_sessions(tokens)
109
+ tokens.filter_map { |token| find_session(token) }
110
+ end
111
+
112
+ def update_session(token, session)
113
+ data = stringify_keys(session)
114
+ if secondary_storage
115
+ return hooks.update(data, [{field: "token", value: token}], "session", custom: lambda { |actual_data|
116
+ update_stored_session(token, actual_data)
117
+ })
118
+ end
119
+
120
+ hooks.update(data, [{field: "token", value: token}], "session")
121
+ end
122
+
123
+ def delete_session(token)
124
+ if secondary_storage
125
+ data = parse_storage(secondary_storage.get(token))
126
+ if data && data["session"]
127
+ user_id = data["session"]["userId"]
128
+ entries = active_session_entries(user_id).reject { |entry| entry["token"] == token }
129
+ write_active_sessions(user_id, entries)
130
+ end
131
+ secondary_storage.delete(token)
132
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
133
+ end
134
+
135
+ hooks.delete([{field: "token", value: token}], "session")
136
+ end
137
+
138
+ def delete_sessions(user_id_or_tokens)
139
+ if secondary_storage
140
+ if user_id_or_tokens.is_a?(Array)
141
+ user_id_or_tokens.each { |token| secondary_storage.delete(token) }
142
+ else
143
+ active_session_entries(user_id_or_tokens).each { |entry| secondary_storage.delete(entry["token"]) }
144
+ secondary_storage.delete(active_key(user_id_or_tokens))
145
+ end
146
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
147
+ end
148
+
149
+ field = user_id_or_tokens.is_a?(Array) ? "token" : "userId"
150
+ operator = user_id_or_tokens.is_a?(Array) ? "in" : nil
151
+ hooks.delete_many([{field: field, value: user_id_or_tokens, operator: operator}], "session")
152
+ end
153
+
154
+ def delete_accounts(user_id)
155
+ hooks.delete_many([{field: "userId", value: user_id}], "account")
156
+ end
157
+
158
+ def delete_account(account_id)
159
+ hooks.delete([{field: "id", value: account_id}], "account")
160
+ end
161
+
162
+ def find_oauth_user(email, account_id, provider_id)
163
+ account = find_account_with_user(account_id, provider_id)
164
+ if account
165
+ user = account["user"] || adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
166
+ return nil unless user
167
+
168
+ linked = account.dup
169
+ linked.delete("user")
170
+ return {user: user, linked_account: linked, accounts: [linked]}
171
+ end
172
+
173
+ found_user = adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
174
+ return nil unless found_user
175
+
176
+ {user: found_user, linked_account: nil, accounts: find_accounts(found_user["id"])}
177
+ end
178
+
179
+ def find_user_by_email(email, include_accounts: false)
180
+ user = adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
181
+ return nil unless user
182
+
183
+ {user: user, accounts: include_accounts ? find_accounts(user["id"]) : []}
184
+ end
185
+
186
+ def find_user_by_id(user_id)
187
+ return nil if user_id.to_s.empty?
188
+
189
+ adapter.find_one(model: "user", where: [{field: "id", value: user_id}])
190
+ end
191
+
192
+ def update_user(user_id, data)
193
+ user = hooks.update(stringify_keys(data), [{field: "id", value: user_id}], "user")
194
+ refresh_user_sessions(user) if user
195
+ user
196
+ end
197
+
198
+ def update_user_by_email(email, data)
199
+ user = hooks.update(stringify_keys(data), [{field: "email", value: email.to_s.downcase}], "user")
200
+ refresh_user_sessions(user) if user
201
+ user
202
+ end
203
+
204
+ def update_password(user_id, password)
205
+ hooks.update_many({password: password}, [{field: "userId", value: user_id}, {field: "providerId", value: "credential"}], "account")
206
+ end
207
+
208
+ def find_accounts(user_id)
209
+ adapter.find_many(model: "account", where: [{field: "userId", value: user_id}])
210
+ end
211
+
212
+ def find_account(account_id)
213
+ adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}])
214
+ end
215
+
216
+ def find_account_by_provider_id(account_id, provider_id)
217
+ adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
218
+ end
219
+
220
+ def find_account_by_user_id(user_id)
221
+ find_accounts(user_id)
222
+ end
223
+
224
+ def update_account(id, data)
225
+ hooks.update(stringify_keys(data), [{field: "id", value: id}], "account")
226
+ end
227
+
228
+ def create_verification_value(data)
229
+ hooks.create(timestamps.merge(stringify_keys(data)), "verification")
230
+ end
231
+
232
+ def find_verification_value(identifier)
233
+ values = adapter.find_many(
234
+ model: "verification",
235
+ where: [{field: "identifier", value: identifier}],
236
+ sort_by: {field: "createdAt", direction: "desc"},
237
+ limit: 1
238
+ )
239
+ hooks.delete_many([{field: "expiresAt", value: Time.now, operator: "lt"}], "verification") unless options.verification[:disable_cleanup]
240
+ values.first
241
+ end
242
+
243
+ def delete_verification_value(id)
244
+ hooks.delete([{field: "id", value: id}], "verification")
245
+ end
246
+
247
+ def delete_verification_by_identifier(identifier)
248
+ hooks.delete([{field: "identifier", value: identifier}], "verification")
249
+ end
250
+
251
+ def update_verification_value(id, data)
252
+ hooks.update(stringify_keys(data), [{field: "id", value: id}], "verification")
253
+ end
254
+
255
+ private
256
+
257
+ def secondary_storage
258
+ options.secondary_storage
259
+ end
260
+
261
+ def joins_enabled?
262
+ !!options.experimental[:joins]
263
+ end
264
+
265
+ def find_session_with_user(token)
266
+ return adapter.find_one(model: "session", where: [{field: "token", value: token}], join: {user: true}) if joins_enabled?
267
+
268
+ session = adapter.find_one(model: "session", where: [{field: "token", value: token}])
269
+ user = session && adapter.find_one(model: "user", where: [{field: "id", value: session["userId"]}])
270
+ (session && user) ? session.merge("user" => user) : nil
271
+ end
272
+
273
+ def find_account_with_user(account_id, provider_id)
274
+ if joins_enabled?
275
+ return adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}], join: {user: true})
276
+ end
277
+
278
+ account = adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
279
+ user = account && adapter.find_one(model: "user", where: [{field: "id", value: account["userId"]}])
280
+ (account && user) ? account.merge("user" => user) : account
281
+ end
282
+
283
+ def timestamps
284
+ now = Time.now
285
+ {"createdAt" => now, "updatedAt" => now}
286
+ end
287
+
288
+ def stringify_keys(data)
289
+ data.each_with_object({}) do |(key, value), result|
290
+ result[Schema.storage_key(key)] = value
291
+ end
292
+ end
293
+
294
+ def apply_schema_create(model, data)
295
+ fields = Schema.auth_tables(options)[model]&.fetch(:fields)
296
+ fields ||= session_additional_fields if model == "session"
297
+ output = stringify_keys(data)
298
+ fields.each do |field, attributes|
299
+ unless output.key?(field)
300
+ if attributes.key?(:default_value)
301
+ output[field] = resolve_default(attributes[:default_value])
302
+ elsif attributes[:required] && field != "id"
303
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required")
304
+ end
305
+ end
306
+ output[field] = coerce_value(output[field], attributes) if output.key?(field)
307
+ end
308
+ output
309
+ end
310
+
311
+ def session_additional_fields
312
+ (options.session[:additional_fields] || {}).each_with_object({}) do |(key, value), result|
313
+ result[Schema.storage_key(key)] = value
314
+ end
315
+ end
316
+
317
+ def resolve_default(default)
318
+ default.respond_to?(:call) ? default.call : default
319
+ end
320
+
321
+ def coerce_value(value, attributes)
322
+ return value if value.nil?
323
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
324
+
325
+ value
326
+ end
327
+
328
+ def store_session(session)
329
+ user = adapter.find_one(model: "user", where: [{field: "id", value: session["userId"]}])
330
+ now_ms = current_millis
331
+ expires_ms = millis(session["expiresAt"])
332
+ entries = active_session_entries(session["userId"])
333
+ .reject { |entry| entry["expiresAt"].to_i <= now_ms || entry["token"] == session["token"] }
334
+ .push({"token" => session["token"], "expiresAt" => expires_ms})
335
+ .sort_by { |entry| entry["expiresAt"] }
336
+ write_active_sessions(session["userId"], entries)
337
+ secondary_storage.set(session["token"], JSON.generate({session: session, user: user}), ttl(expires_ms))
338
+ end
339
+
340
+ def update_stored_session(token, data)
341
+ parsed = parse_storage(secondary_storage.get(token))
342
+ return nil unless parsed && parsed["session"]
343
+
344
+ merged = parsed["session"].merge(data)
345
+ merged["expiresAt"] = normalize_time(merged["expiresAt"])
346
+ merged["createdAt"] = normalize_time(merged["createdAt"])
347
+ merged["updatedAt"] = normalize_time(merged["updatedAt"])
348
+ secondary_storage.set(token, JSON.generate({session: merged, user: parsed["user"]}), ttl(millis(merged["expiresAt"])))
349
+ entries = active_session_entries(merged["userId"])
350
+ .reject { |entry| entry["token"] == token || entry["expiresAt"].to_i <= current_millis }
351
+ .push({"token" => token, "expiresAt" => millis(merged["expiresAt"])})
352
+ .sort_by { |entry| entry["expiresAt"] }
353
+ write_active_sessions(merged["userId"], entries)
354
+ merged
355
+ end
356
+
357
+ def refresh_user_sessions(user)
358
+ return unless secondary_storage && user
359
+
360
+ active_session_entries(user["id"]).each do |entry|
361
+ parsed = parse_storage(secondary_storage.get(entry["token"]))
362
+ next unless parsed && parsed["session"]
363
+
364
+ secondary_storage.set(entry["token"], JSON.generate({session: parsed["session"], user: user}), ttl(millis(parsed["session"]["expiresAt"])))
365
+ end
366
+ end
367
+
368
+ def active_session_entries(user_id)
369
+ raw = secondary_storage.get(active_key(user_id))
370
+ Array(parse_storage(raw)).map do |entry|
371
+ entry.transform_keys(&:to_s)
372
+ end
373
+ end
374
+
375
+ def write_active_sessions(user_id, entries)
376
+ future = entries.select { |entry| entry["expiresAt"].to_i > current_millis }.sort_by { |entry| entry["expiresAt"] }
377
+ if future.empty?
378
+ secondary_storage.delete(active_key(user_id))
379
+ else
380
+ secondary_storage.set(active_key(user_id), JSON.generate(future), ttl(future.last["expiresAt"]))
381
+ end
382
+ end
383
+
384
+ def active_key(user_id)
385
+ "active-sessions-#{user_id}"
386
+ end
387
+
388
+ def parse_storage(value)
389
+ return value.transform_keys(&:to_s) if value.is_a?(Hash)
390
+ return value.map { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : entry } if value.is_a?(Array)
391
+ return nil unless value
392
+
393
+ parsed = JSON.parse(value)
394
+ parse_storage(parsed)
395
+ rescue JSON::ParserError
396
+ nil
397
+ end
398
+
399
+ def normalize_session_dates(session)
400
+ return nil unless session
401
+
402
+ session.transform_keys(&:to_s).merge(
403
+ "expiresAt" => normalize_time(session["expiresAt"] || session[:expiresAt]),
404
+ "createdAt" => normalize_time(session["createdAt"] || session[:createdAt]),
405
+ "updatedAt" => normalize_time(session["updatedAt"] || session[:updatedAt])
406
+ )
407
+ end
408
+
409
+ def normalize_user_dates(user)
410
+ return nil unless user
411
+
412
+ user.transform_keys(&:to_s).merge(
413
+ "createdAt" => normalize_time(user["createdAt"] || user[:createdAt]),
414
+ "updatedAt" => normalize_time(user["updatedAt"] || user[:updatedAt])
415
+ )
416
+ end
417
+
418
+ def normalize_time(value)
419
+ return value if value.is_a?(Time)
420
+ return Time.at(value / 1000.0) if value.is_a?(Integer) && value > 10_000_000_000
421
+ return Time.at(value) if value.is_a?(Integer)
422
+
423
+ Time.parse(value.to_s)
424
+ end
425
+
426
+ def millis(value)
427
+ (normalize_time(value).to_f * 1000).to_i
428
+ end
429
+
430
+ def ttl(expires_ms)
431
+ [(expires_ms - current_millis) / 1000, 0].max.floor
432
+ end
433
+
434
+ def current_millis
435
+ (Time.now.to_f * 1000).to_i
436
+ end
437
+ end
438
+ end
439
+ end