better_auth 0.1.1 → 0.3.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -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 +441 -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 +211 -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 +142 -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 +694 -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 +995 -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 +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -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 +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -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 +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -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 +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,589 @@
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
+ base["id"] = generated_id if secondary_storage
78
+ data = override_all ? base.merge(override) : override.merge(base)
79
+
80
+ custom = secondary_storage && lambda do |session_data|
81
+ actual_session = apply_schema_create("session", session_data)
82
+ store_session(actual_session)
83
+ adapter.create(model: "session", data: actual_session, force_allow_id: true) if options.session[:store_session_in_database]
84
+ actual_session
85
+ end
86
+ hooks.create(data, "session", custom: custom, context: context)
87
+ end
88
+
89
+ def find_session(token)
90
+ if secondary_storage
91
+ data = parse_storage(secondary_storage.get(token))
92
+ unless data
93
+ return nil unless options.session[:store_session_in_database] && !options.session[:preserve_session_in_database]
94
+ end
95
+
96
+ if data
97
+ return {
98
+ session: normalize_session_dates(data["session"]),
99
+ user: normalize_user_dates(data["user"])
100
+ }
101
+ end
102
+ end
103
+
104
+ found = find_session_with_user(token)
105
+ return nil unless found && found["user"]
106
+
107
+ user = found.delete("user")
108
+ {session: found, user: user}
109
+ end
110
+
111
+ def find_sessions(tokens)
112
+ tokens.filter_map { |token| find_session(token) }
113
+ end
114
+
115
+ def update_session(token, session)
116
+ data = stringify_keys(session)
117
+ if secondary_storage
118
+ return hooks.update(data, [{field: "token", value: token}], "session", custom: lambda { |actual_data|
119
+ stored = update_stored_session(token, actual_data)
120
+ db = adapter.update(model: "session", where: [{field: "token", value: token}], update: actual_data) if options.session[:store_session_in_database]
121
+ db || stored
122
+ })
123
+ end
124
+
125
+ hooks.update(data, [{field: "token", value: token}], "session")
126
+ end
127
+
128
+ def delete_session(token)
129
+ if secondary_storage
130
+ data = parse_storage(secondary_storage.get(token))
131
+ if data && data["session"]
132
+ user_id = data["session"]["userId"]
133
+ entries = active_session_entries(user_id).reject { |entry| entry["token"] == token }
134
+ write_active_sessions(user_id, entries)
135
+ end
136
+ secondary_storage.delete(token)
137
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
138
+ end
139
+
140
+ hooks.delete([{field: "token", value: token}], "session")
141
+ end
142
+
143
+ def delete_sessions(user_id_or_tokens)
144
+ if secondary_storage
145
+ if user_id_or_tokens.is_a?(Array)
146
+ user_id_or_tokens.each { |token| secondary_storage.delete(token) }
147
+ else
148
+ active_session_entries(user_id_or_tokens).each { |entry| secondary_storage.delete(entry["token"]) }
149
+ secondary_storage.delete(active_key(user_id_or_tokens))
150
+ end
151
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
152
+ end
153
+
154
+ field = user_id_or_tokens.is_a?(Array) ? "token" : "userId"
155
+ operator = user_id_or_tokens.is_a?(Array) ? "in" : nil
156
+ hooks.delete_many([{field: field, value: user_id_or_tokens, operator: operator}], "session")
157
+ end
158
+
159
+ def delete_accounts(user_id)
160
+ hooks.delete_many([{field: "userId", value: user_id}], "account")
161
+ end
162
+
163
+ def delete_account(account_id)
164
+ hooks.delete([{field: "id", value: account_id}], "account")
165
+ end
166
+
167
+ def find_oauth_user(email, account_id, provider_id)
168
+ account = find_account_with_user(account_id, provider_id)
169
+ if account
170
+ user = account["user"] || adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
171
+ return nil unless user
172
+
173
+ linked = account.dup
174
+ linked.delete("user")
175
+ return {user: user, linked_account: linked, accounts: [linked]}
176
+ end
177
+
178
+ found_user = adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
179
+ return nil unless found_user
180
+
181
+ {user: found_user, linked_account: nil, accounts: find_accounts(found_user["id"])}
182
+ end
183
+
184
+ def find_user_by_email(email, include_accounts: false)
185
+ user = adapter.find_one(model: "user", where: [{field: "email", value: email.to_s.downcase}])
186
+ return nil unless user
187
+
188
+ {user: user, accounts: include_accounts ? find_accounts(user["id"]) : []}
189
+ end
190
+
191
+ def find_user_by_id(user_id)
192
+ return nil if user_id.to_s.empty?
193
+
194
+ adapter.find_one(model: "user", where: [{field: "id", value: user_id}])
195
+ end
196
+
197
+ def update_user(user_id, data)
198
+ user = hooks.update(stringify_keys(data), [{field: "id", value: user_id}], "user")
199
+ refresh_user_sessions(user) if user
200
+ user
201
+ end
202
+
203
+ def update_user_by_email(email, data)
204
+ user = hooks.update(stringify_keys(data), [{field: "email", value: email.to_s.downcase}], "user")
205
+ refresh_user_sessions(user) if user
206
+ user
207
+ end
208
+
209
+ def update_password(user_id, password)
210
+ hooks.update_many({password: password}, [{field: "userId", value: user_id}, {field: "providerId", value: "credential"}], "account")
211
+ end
212
+
213
+ def find_accounts(user_id)
214
+ adapter.find_many(model: "account", where: [{field: "userId", value: user_id}])
215
+ end
216
+
217
+ def find_account(account_id)
218
+ adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}])
219
+ end
220
+
221
+ def find_account_by_provider_id(account_id, provider_id)
222
+ adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
223
+ end
224
+
225
+ def find_account_by_user_id(user_id)
226
+ find_accounts(user_id)
227
+ end
228
+
229
+ def update_account(id, data)
230
+ hooks.update(stringify_keys(data), [{field: "id", value: id}], "account")
231
+ end
232
+
233
+ def create_verification_value(data)
234
+ payload = timestamps.merge(stringify_keys(data))
235
+ stored_identifier = processed_verification_identifier(payload.fetch("identifier"))
236
+ payload["identifier"] = stored_identifier
237
+
238
+ custom = secondary_storage && lambda do |verification_data|
239
+ actual = apply_schema_create("verification", verification_data)
240
+ actual["id"] ||= generated_id
241
+ store_verification(actual)
242
+ adapter.create(model: "verification", data: actual, force_allow_id: true) if verification_store_in_database?
243
+ actual
244
+ end
245
+
246
+ hooks.create(payload, "verification", custom: custom)
247
+ end
248
+
249
+ def find_verification_value(identifier)
250
+ stored_identifier = processed_verification_identifier(identifier)
251
+ storage_option = verification_storage_option(identifier)
252
+ if secondary_storage
253
+ cached = read_verification(stored_identifier)
254
+ cached ||= read_verification(identifier) if storage_option && storage_option.to_s != "plain"
255
+ return cached if cached
256
+ return nil unless verification_store_in_database?
257
+ end
258
+
259
+ values = adapter.find_many(
260
+ model: "verification",
261
+ where: [{field: "identifier", value: stored_identifier}],
262
+ sort_by: {field: "createdAt", direction: "desc"},
263
+ limit: 1
264
+ )
265
+ if values.empty? && storage_option && storage_option.to_s != "plain"
266
+ values = adapter.find_many(
267
+ model: "verification",
268
+ where: [{field: "identifier", value: identifier}],
269
+ sort_by: {field: "createdAt", direction: "desc"},
270
+ limit: 1
271
+ )
272
+ end
273
+ hooks.delete_many([{field: "expiresAt", value: Time.now, operator: "lt"}], "verification") unless options.verification[:disable_cleanup]
274
+ values.first
275
+ end
276
+
277
+ def delete_verification_value(id)
278
+ if secondary_storage
279
+ stored_identifier = secondary_storage.get(verification_id_key(id))
280
+ if stored_identifier
281
+ secondary_storage.delete(verification_key(stored_identifier))
282
+ secondary_storage.delete(verification_id_key(id))
283
+ return nil unless verification_store_in_database?
284
+ elsif !verification_store_in_database?
285
+ return nil
286
+ end
287
+ end
288
+
289
+ hooks.delete([{field: "id", value: id}], "verification")
290
+ end
291
+
292
+ def delete_verification_by_identifier(identifier)
293
+ stored_identifier = processed_verification_identifier(identifier)
294
+ if secondary_storage
295
+ cached = read_verification(stored_identifier)
296
+ secondary_storage.delete(verification_key(stored_identifier))
297
+ secondary_storage.delete(verification_id_key(cached["id"])) if cached && cached["id"]
298
+ return nil unless verification_store_in_database?
299
+ end
300
+
301
+ hooks.delete([{field: "identifier", value: stored_identifier}], "verification")
302
+ end
303
+
304
+ def update_verification_value(id, data)
305
+ update = stringify_keys(data)
306
+ if secondary_storage
307
+ stored_identifier = secondary_storage.get(verification_id_key(id))
308
+ if stored_identifier
309
+ cached = read_verification(stored_identifier)
310
+ if cached
311
+ updated = cached.merge(update)
312
+ store_verification(updated)
313
+ return updated unless verification_store_in_database?
314
+ end
315
+ elsif !verification_store_in_database?
316
+ return nil
317
+ end
318
+ end
319
+
320
+ hooks.update(update, [{field: "id", value: id}], "verification")
321
+ end
322
+
323
+ private
324
+
325
+ def secondary_storage
326
+ options.secondary_storage
327
+ end
328
+
329
+ def joins_enabled?
330
+ !!options.experimental[:joins]
331
+ end
332
+
333
+ def find_session_with_user(token)
334
+ return adapter.find_one(model: "session", where: [{field: "token", value: token}], join: {user: true}) if joins_enabled?
335
+
336
+ session = adapter.find_one(model: "session", where: [{field: "token", value: token}])
337
+ user = session && adapter.find_one(model: "user", where: [{field: "id", value: session["userId"]}])
338
+ (session && user) ? session.merge("user" => user) : nil
339
+ end
340
+
341
+ def find_account_with_user(account_id, provider_id)
342
+ if joins_enabled?
343
+ return adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}], join: {user: true})
344
+ end
345
+
346
+ account = adapter.find_one(model: "account", where: [{field: "accountId", value: account_id}, {field: "providerId", value: provider_id}])
347
+ user = account && adapter.find_one(model: "user", where: [{field: "id", value: account["userId"]}])
348
+ (account && user) ? account.merge("user" => user) : account
349
+ end
350
+
351
+ def timestamps
352
+ now = Time.now
353
+ {"createdAt" => now, "updatedAt" => now}
354
+ end
355
+
356
+ def generated_id
357
+ generator = options.advanced.dig(:database, :generate_id)
358
+ return generator.call.to_s if generator.respond_to?(:call)
359
+ return SecureRandom.uuid if generator == "uuid"
360
+
361
+ SecureRandom.hex(16)
362
+ end
363
+
364
+ def stringify_keys(data)
365
+ data.each_with_object({}) do |(key, value), result|
366
+ result[Schema.storage_key(key)] = value
367
+ end
368
+ end
369
+
370
+ def apply_schema_create(model, data)
371
+ fields = Schema.auth_tables(options)[model]&.fetch(:fields)
372
+ fields ||= session_additional_fields if model == "session"
373
+ output = stringify_keys(data)
374
+ return output unless fields
375
+
376
+ fields.each do |field, attributes|
377
+ unless output.key?(field)
378
+ if attributes.key?(:default_value)
379
+ output[field] = resolve_default(attributes[:default_value])
380
+ elsif attributes[:required] && field != "id"
381
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required")
382
+ end
383
+ end
384
+ output[field] = coerce_value(output[field], attributes) if output.key?(field)
385
+ end
386
+ output
387
+ end
388
+
389
+ def session_additional_fields
390
+ (options.session[:additional_fields] || {}).each_with_object({}) do |(key, value), result|
391
+ result[Schema.storage_key(key)] = value
392
+ end
393
+ end
394
+
395
+ def resolve_default(default)
396
+ default.respond_to?(:call) ? default.call : default
397
+ end
398
+
399
+ def coerce_value(value, attributes)
400
+ return value if value.nil?
401
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
402
+
403
+ value
404
+ end
405
+
406
+ def store_session(session)
407
+ user = adapter.find_one(model: "user", where: [{field: "id", value: session["userId"]}])
408
+ now_ms = current_millis
409
+ expires_ms = millis(session["expiresAt"])
410
+ entries = active_session_entries(session["userId"])
411
+ .reject { |entry| entry["expiresAt"].to_i <= now_ms || entry["token"] == session["token"] }
412
+ .push({"token" => session["token"], "expiresAt" => expires_ms})
413
+ .sort_by { |entry| entry["expiresAt"] }
414
+ write_active_sessions(session["userId"], entries)
415
+ ttl_seconds = ttl(expires_ms)
416
+ secondary_storage.set(session["token"], JSON.generate({session: session, user: user}), ttl_seconds) if ttl_seconds.positive?
417
+ end
418
+
419
+ def update_stored_session(token, data)
420
+ parsed = parse_storage(secondary_storage.get(token))
421
+ return nil unless parsed && parsed["session"]
422
+
423
+ merged = parsed["session"].merge(data)
424
+ merged["expiresAt"] = normalize_time(merged["expiresAt"])
425
+ merged["createdAt"] = normalize_time(merged["createdAt"])
426
+ merged["updatedAt"] = normalize_time(merged["updatedAt"])
427
+ ttl_seconds = ttl(millis(merged["expiresAt"]))
428
+ if ttl_seconds.positive?
429
+ secondary_storage.set(token, JSON.generate({session: merged, user: parsed["user"]}), ttl_seconds)
430
+ else
431
+ secondary_storage.delete(token)
432
+ end
433
+ entries = active_session_entries(merged["userId"])
434
+ .reject { |entry| entry["token"] == token || entry["expiresAt"].to_i <= current_millis }
435
+ .push({"token" => token, "expiresAt" => millis(merged["expiresAt"])})
436
+ .sort_by { |entry| entry["expiresAt"] }
437
+ write_active_sessions(merged["userId"], entries)
438
+ merged
439
+ end
440
+
441
+ def refresh_user_sessions(user)
442
+ return unless secondary_storage && user
443
+
444
+ active_session_entries(user["id"]).each do |entry|
445
+ parsed = parse_storage(secondary_storage.get(entry["token"]))
446
+ next unless parsed && parsed["session"]
447
+
448
+ secondary_storage.set(entry["token"], JSON.generate({session: parsed["session"], user: user}), ttl(millis(parsed["session"]["expiresAt"])))
449
+ end
450
+ end
451
+
452
+ def active_session_entries(user_id)
453
+ raw = secondary_storage.get(active_key(user_id))
454
+ Array(parse_storage(raw)).map do |entry|
455
+ entry.transform_keys(&:to_s)
456
+ end.uniq { |entry| entry["token"] }
457
+ end
458
+
459
+ def write_active_sessions(user_id, entries)
460
+ future = entries.select { |entry| entry["expiresAt"].to_i > current_millis }.sort_by { |entry| entry["expiresAt"] }
461
+ if future.empty?
462
+ secondary_storage.delete(active_key(user_id))
463
+ else
464
+ ttl_seconds = ttl(future.last["expiresAt"])
465
+ if ttl_seconds.positive?
466
+ secondary_storage.set(active_key(user_id), JSON.generate(future), ttl_seconds)
467
+ else
468
+ secondary_storage.delete(active_key(user_id))
469
+ end
470
+ end
471
+ end
472
+
473
+ def active_key(user_id)
474
+ "active-sessions-#{user_id}"
475
+ end
476
+
477
+ def parse_storage(value)
478
+ return value.transform_keys(&:to_s) if value.is_a?(Hash)
479
+ return value.map { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : entry } if value.is_a?(Array)
480
+ return nil unless value
481
+
482
+ parsed = JSON.parse(value)
483
+ parse_storage(parsed)
484
+ rescue JSON::ParserError
485
+ nil
486
+ end
487
+
488
+ def normalize_session_dates(session)
489
+ return nil unless session
490
+
491
+ session.transform_keys(&:to_s).merge(
492
+ "expiresAt" => normalize_time(session["expiresAt"] || session[:expiresAt]),
493
+ "createdAt" => normalize_time(session["createdAt"] || session[:createdAt]),
494
+ "updatedAt" => normalize_time(session["updatedAt"] || session[:updatedAt])
495
+ )
496
+ end
497
+
498
+ def normalize_user_dates(user)
499
+ return nil unless user
500
+
501
+ user.transform_keys(&:to_s).merge(
502
+ "createdAt" => normalize_time(user["createdAt"] || user[:createdAt]),
503
+ "updatedAt" => normalize_time(user["updatedAt"] || user[:updatedAt])
504
+ )
505
+ end
506
+
507
+ def normalize_verification_dates(verification)
508
+ return nil unless verification
509
+
510
+ verification.transform_keys(&:to_s).merge(
511
+ "expiresAt" => normalize_time(verification["expiresAt"] || verification[:expiresAt]),
512
+ "createdAt" => normalize_time(verification["createdAt"] || verification[:createdAt]),
513
+ "updatedAt" => normalize_time(verification["updatedAt"] || verification[:updatedAt])
514
+ )
515
+ end
516
+
517
+ def store_verification(verification)
518
+ normalized = normalize_verification_dates(verification)
519
+ ttl_seconds = ttl(millis(normalized["expiresAt"]))
520
+ return normalized unless ttl_seconds.positive?
521
+
522
+ secondary_storage.set(verification_key(normalized["identifier"]), JSON.generate(normalized), ttl_seconds)
523
+ secondary_storage.set(verification_id_key(normalized["id"]), normalized["identifier"], ttl_seconds) if normalized["id"]
524
+ normalized
525
+ end
526
+
527
+ def read_verification(identifier)
528
+ normalize_verification_dates(parse_storage(secondary_storage.get(verification_key(identifier))))
529
+ end
530
+
531
+ def verification_key(identifier)
532
+ "verification:#{identifier}"
533
+ end
534
+
535
+ def verification_id_key(id)
536
+ "verification-id:#{id}"
537
+ end
538
+
539
+ def verification_store_in_database?
540
+ !!options.verification[:store_in_database]
541
+ end
542
+
543
+ def processed_verification_identifier(identifier)
544
+ option = verification_storage_option(identifier)
545
+ return identifier.to_s if option.nil? || option.to_s == "plain"
546
+ return Crypto.sha256(identifier.to_s, encoding: :base64url) if option.to_s == "hashed"
547
+ return option[:hash].call(identifier.to_s).to_s if option.is_a?(Hash) && option[:hash].respond_to?(:call)
548
+ return option["hash"].call(identifier.to_s).to_s if option.is_a?(Hash) && option["hash"].respond_to?(:call)
549
+
550
+ identifier.to_s
551
+ end
552
+
553
+ def verification_storage_option(identifier)
554
+ config = options.verification[:store_identifier]
555
+ return nil unless config
556
+
557
+ if config.is_a?(Hash) && (config.key?(:default) || config.key?("default"))
558
+ overrides = config[:overrides] || config["overrides"] || {}
559
+ overrides.each do |prefix, option|
560
+ return option if identifier.to_s.start_with?(prefix.to_s)
561
+ end
562
+ return config[:default] || config["default"]
563
+ end
564
+
565
+ config
566
+ end
567
+
568
+ def normalize_time(value)
569
+ return value if value.is_a?(Time)
570
+ return Time.at(value / 1000.0) if value.is_a?(Integer) && value > 10_000_000_000
571
+ return Time.at(value) if value.is_a?(Integer)
572
+
573
+ Time.parse(value.to_s)
574
+ end
575
+
576
+ def millis(value)
577
+ (normalize_time(value).to_f * 1000).to_i
578
+ end
579
+
580
+ def ttl(expires_ms)
581
+ [(expires_ms - current_millis) / 1000, 0].max.floor
582
+ end
583
+
584
+ def current_millis
585
+ (Time.now.to_f * 1000).to_i
586
+ end
587
+ end
588
+ end
589
+ end