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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Schema
5
+ module_function
6
+
7
+ def auth_tables(options)
8
+ plugin_schema = plugin_tables(options)
9
+
10
+ tables = {
11
+ "user" => user_table(options, plugin_schema.delete("user")),
12
+ "session" => session_table(options, plugin_schema.delete("session")),
13
+ "account" => account_table(options, plugin_schema.delete("account")),
14
+ "verification" => verification_table(options, plugin_schema.delete("verification"))
15
+ }
16
+
17
+ tables.delete("session") if secondary_storage?(options) && !session_option(options, :store_session_in_database)
18
+ tables.delete("verification") if secondary_storage?(options) && !verification_option(options, :store_in_database)
19
+ tables.merge!(plugin_schema)
20
+ tables["rateLimit"] = rate_limit_table(options) if rate_limit_option(options, :storage) == "database"
21
+ tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
22
+ end
23
+
24
+ def storage_model_name(options, model)
25
+ table = auth_tables(options).fetch(model.to_s)
26
+ table[:model_name]
27
+ end
28
+
29
+ def storage_field_name(options, model, field)
30
+ table = auth_tables(options).fetch(model.to_s)
31
+ data = table[:fields].fetch(field.to_s)
32
+ data[:field_name] || field.to_s
33
+ end
34
+
35
+ def parse_output(options, model, data)
36
+ return nil unless data
37
+
38
+ table = auth_tables(options).fetch(model.to_s)
39
+ table[:fields].each_with_object({}) do |(field, attributes), result|
40
+ next if attributes[:returned] == false && field != "id"
41
+ next unless data.key?(field)
42
+
43
+ result[field] = data[field]
44
+ end.tap do |result|
45
+ data.each { |key, value| result[key] = value unless result.key?(key) || table[:fields].key?(key) }
46
+ end
47
+ rescue KeyError
48
+ data
49
+ end
50
+
51
+ private_class_method def self.user_table(options, plugin_table)
52
+ table(
53
+ model_name: model_option(options, :user, :model_name) || "users",
54
+ order: 1,
55
+ fields: id_field.merge(
56
+ "name" => field("string", required: true, sortable: true, field_name: mapped_field(options, :user, "name")),
57
+ "email" => field("string", required: true, unique: true, sortable: true, field_name: mapped_field(options, :user, "email")),
58
+ "emailVerified" => field("boolean", required: true, input: false, default_value: false, field_name: mapped_field(options, :user, "emailVerified")),
59
+ "image" => field("string", required: false, field_name: mapped_field(options, :user, "image"))
60
+ ).merge(timestamp_fields),
61
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :user)]
62
+ )
63
+ end
64
+
65
+ private_class_method def self.session_table(options, plugin_table)
66
+ table(
67
+ model_name: model_option(options, :session, :model_name) || "sessions",
68
+ order: 2,
69
+ fields: base_fields.merge(
70
+ "expiresAt" => field("date", required: true, field_name: mapped_field(options, :session, "expiresAt")),
71
+ "token" => field("string", required: true, unique: true, field_name: mapped_field(options, :session, "token")),
72
+ "ipAddress" => field("string", required: false, field_name: mapped_field(options, :session, "ipAddress")),
73
+ "userAgent" => field("string", required: false, field_name: mapped_field(options, :session, "userAgent")),
74
+ "userId" => field("string", required: true, index: true, field_name: mapped_field(options, :session, "userId"), references: {model: model_option(options, :user, :model_name) || "users", field: "id", on_delete: "cascade"})
75
+ ),
76
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :session)]
77
+ )
78
+ end
79
+
80
+ private_class_method def self.account_table(options, plugin_table)
81
+ table(
82
+ model_name: model_option(options, :account, :model_name) || "accounts",
83
+ order: 3,
84
+ fields: base_fields.merge(
85
+ "accountId" => field("string", required: true, field_name: mapped_field(options, :account, "accountId")),
86
+ "providerId" => field("string", required: true, field_name: mapped_field(options, :account, "providerId")),
87
+ "userId" => field("string", required: true, index: true, field_name: mapped_field(options, :account, "userId"), references: {model: model_option(options, :user, :model_name) || "users", field: "id", on_delete: "cascade"}),
88
+ "accessToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "accessToken")),
89
+ "refreshToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "refreshToken")),
90
+ "idToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "idToken")),
91
+ "accessTokenExpiresAt" => field("date", required: false, returned: false, field_name: mapped_field(options, :account, "accessTokenExpiresAt")),
92
+ "refreshTokenExpiresAt" => field("date", required: false, returned: false, field_name: mapped_field(options, :account, "refreshTokenExpiresAt")),
93
+ "scope" => field("string", required: false, field_name: mapped_field(options, :account, "scope")),
94
+ "password" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "password"))
95
+ ),
96
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :account)]
97
+ )
98
+ end
99
+
100
+ private_class_method def self.verification_table(options, plugin_table)
101
+ table(
102
+ model_name: model_option(options, :verification, :model_name) || "verifications",
103
+ order: 4,
104
+ fields: base_fields.merge(
105
+ "identifier" => field("string", required: true, index: true, field_name: mapped_field(options, :verification, "identifier")),
106
+ "value" => field("string", required: true, field_name: mapped_field(options, :verification, "value")),
107
+ "expiresAt" => field("date", required: true, field_name: mapped_field(options, :verification, "expiresAt"))
108
+ ),
109
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :verification)]
110
+ )
111
+ end
112
+
113
+ private_class_method def self.rate_limit_table(options)
114
+ {
115
+ model_name: rate_limit_option(options, :model_name) || "rate_limits",
116
+ fields: {
117
+ "key" => field("string", required: true, unique: true, field_name: rate_limit_field(options, "key")),
118
+ "count" => field("number", required: true, field_name: rate_limit_field(options, "count")),
119
+ "lastRequest" => field("number", required: true, bigint: true, default_value: -> { current_millis }, field_name: rate_limit_field(options, "lastRequest"))
120
+ }
121
+ }
122
+ end
123
+
124
+ private_class_method def self.base_fields
125
+ id_field.merge(timestamp_fields)
126
+ end
127
+
128
+ private_class_method def self.id_field
129
+ {
130
+ "id" => field("string", required: true)
131
+ }
132
+ end
133
+
134
+ private_class_method def self.timestamp_fields
135
+ {
136
+ "createdAt" => field("date", required: true, default_value: -> { Time.now }, field_name: physical_name("createdAt")),
137
+ "updatedAt" => field("date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }, field_name: physical_name("updatedAt"))
138
+ }
139
+ end
140
+
141
+ private_class_method def self.table(model_name:, fields:, extra_fields:, order:)
142
+ {
143
+ model_name: model_name,
144
+ fields: merge_fields(fields, *extra_fields),
145
+ order: order
146
+ }
147
+ end
148
+
149
+ private_class_method def self.merge_fields(base, *extras)
150
+ extras.compact.each_with_object(base.dup) do |extra, fields|
151
+ normalize_fields(extra).each { |key, value| fields[key] = value }
152
+ end
153
+ end
154
+
155
+ private_class_method def self.field(type, **attributes)
156
+ {type: type}.merge(attributes).compact
157
+ end
158
+
159
+ private_class_method def self.plugin_tables(options)
160
+ plugins_for(options).each_with_object({}) do |plugin, tables|
161
+ schema = fetch_hash(plugin, :schema) || {}
162
+ schema.each do |raw_key, raw_table|
163
+ key = storage_key(raw_key)
164
+ table_data = symbolize_hash(raw_table || {})
165
+ existing = tables[key] || {model_name: table_data[:model_name] || physical_name(key), fields: {}}
166
+ existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_name(key)
167
+ existing[:fields] = existing[:fields].merge(normalize_fields(table_data[:fields] || {}))
168
+ tables[key] = existing
169
+ end
170
+ end
171
+ end
172
+
173
+ private_class_method def self.normalize_fields(fields)
174
+ fields.each_with_object({}) do |(raw_key, raw_value), result|
175
+ key = storage_key(raw_key)
176
+ result[key] = normalize_field(raw_value, key)
177
+ end
178
+ end
179
+
180
+ private_class_method def self.normalize_field(value, key)
181
+ data = symbolize_hash(value || {})
182
+ data[:field_name] ||= physical_name(key)
183
+ data[:references] = normalize_reference(data[:references]) if data[:references]
184
+ data
185
+ end
186
+
187
+ private_class_method def self.normalize_reference(value)
188
+ reference = symbolize_hash(value || {})
189
+ reference[:on_delete] ||= "cascade"
190
+ reference
191
+ end
192
+
193
+ private_class_method def self.mapped_field(options, model, field)
194
+ fields = fetch_hash(model_options(options, model), :fields) || {}
195
+ fetch_mapped_value(fields, field) || physical_name(field)
196
+ end
197
+
198
+ private_class_method def self.rate_limit_field(options, field)
199
+ fields = fetch_hash(rate_limit_options(options), :fields) || {}
200
+ fetch_mapped_value(fields, field) || physical_name(field)
201
+ end
202
+
203
+ private_class_method def self.fetch_mapped_value(hash, field)
204
+ hash[storage_key(field).to_sym] || hash[storage_key(field)] || hash[underscore(field).to_sym] || hash[underscore(field)]
205
+ end
206
+
207
+ private_class_method def self.additional_fields(options, model)
208
+ fetch_hash(model_options(options, model), :additional_fields) || {}
209
+ end
210
+
211
+ private_class_method def self.model_option(options, model, key)
212
+ fetch_hash(model_options(options, model), key)
213
+ end
214
+
215
+ private_class_method def self.session_option(options, key)
216
+ fetch_hash(session_options(options), key)
217
+ end
218
+
219
+ private_class_method def self.rate_limit_option(options, key)
220
+ fetch_hash(rate_limit_options(options), key)
221
+ end
222
+
223
+ private_class_method def self.verification_option(options, key)
224
+ fetch_hash(verification_options(options), key)
225
+ end
226
+
227
+ private_class_method def self.model_options(options, model)
228
+ options.respond_to?(model) ? options.public_send(model) : fetch_hash(options, model)
229
+ end
230
+
231
+ private_class_method def self.session_options(options)
232
+ options.respond_to?(:session) ? options.session : fetch_hash(options, :session)
233
+ end
234
+
235
+ private_class_method def self.rate_limit_options(options)
236
+ options.respond_to?(:rate_limit) ? options.rate_limit : fetch_hash(options, :rate_limit)
237
+ end
238
+
239
+ private_class_method def self.verification_options(options)
240
+ options.respond_to?(:verification) ? options.verification : fetch_hash(options, :verification)
241
+ end
242
+
243
+ private_class_method def self.secondary_storage?(options)
244
+ options.respond_to?(:secondary_storage) ? !!options.secondary_storage : !!fetch_hash(options, :secondary_storage)
245
+ end
246
+
247
+ private_class_method def self.plugins_for(options)
248
+ options.respond_to?(:plugins) ? options.plugins : Array(fetch_hash(options, :plugins))
249
+ end
250
+
251
+ private_class_method def self.fetch_hash(hash, key)
252
+ return nil unless hash.respond_to?(:[])
253
+
254
+ hash[key] || hash[key.to_s] || hash[underscore(key.to_s).to_sym] || hash[underscore(key.to_s)]
255
+ end
256
+
257
+ private_class_method def self.symbolize_hash(value)
258
+ return {} unless value.is_a?(Hash)
259
+
260
+ value.each_with_object({}) do |(key, object), result|
261
+ result[underscore(key.to_s).to_sym] = object.is_a?(Hash) ? symbolize_hash(object) : object
262
+ end
263
+ end
264
+
265
+ private_class_method def self.storage_key(value)
266
+ camelize_lower(value.to_s)
267
+ end
268
+
269
+ private_class_method def self.physical_name(value)
270
+ underscore(value.to_s)
271
+ end
272
+
273
+ private_class_method def self.camelize_lower(value)
274
+ parts = underscore(value).split("_")
275
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
276
+ end
277
+
278
+ private_class_method def self.underscore(value)
279
+ value
280
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
281
+ .tr("-", "_")
282
+ .downcase
283
+ end
284
+
285
+ private_class_method def self.current_millis
286
+ (Time.now.to_f * 1000).to_i
287
+ end
288
+
289
+ public_class_method :storage_key
290
+ end
291
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module BetterAuth
6
+ module Session
7
+ module_function
8
+
9
+ def find_current(ctx, disable_cookie_cache: false, disable_refresh: false, sensitive: false)
10
+ if ctx.context.current_session
11
+ return ctx.context.current_session
12
+ end
13
+
14
+ token_cookie = ctx.context.auth_cookies[:session_token]
15
+ token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
16
+ return nil unless token
17
+
18
+ cached = cached_session(ctx, token, disable_cookie_cache: disable_cookie_cache, sensitive: sensitive)
19
+ if cached
20
+ ctx.context.set_current_session(cached) if ctx.context.respond_to?(:set_current_session)
21
+ return cached
22
+ end
23
+
24
+ found = ctx.context.internal_adapter.find_session(token)
25
+ return missing_session(ctx) unless found
26
+
27
+ session = stringify_keys(found[:session] || found["session"])
28
+ user = stringify_keys(found[:user] || found["user"])
29
+ return missing_session(ctx) if expired?(session)
30
+
31
+ result = {session: session, user: user}
32
+ result = refresh_session(ctx, result) if should_refresh?(ctx, session, disable_refresh)
33
+ Cookies.set_cookie_cache(ctx, result, false)
34
+ ctx.context.set_current_session(result) if ctx.context.respond_to?(:set_current_session)
35
+ result
36
+ end
37
+
38
+ def cached_session(ctx, token, disable_cookie_cache:, sensitive:)
39
+ config = ctx.context.session_config[:cookie_cache] || {}
40
+ return nil if disable_cookie_cache || sensitive || !config[:enabled]
41
+
42
+ payload = Cookies.get_cookie_cache(
43
+ ctx,
44
+ secret: ctx.context.secret,
45
+ strategy: config[:strategy] || "compact",
46
+ version: config[:version],
47
+ cookie_prefix: ctx.context.options.advanced[:cookie_prefix] || "better-auth",
48
+ is_secure: ctx.context.auth_cookies[:session_data].name.start_with?(Cookies::SECURE_COOKIE_PREFIX)
49
+ )
50
+ return nil unless payload
51
+ return nil if payload["session"]["token"] && payload["session"]["token"] != token
52
+
53
+ result = {session: payload["session"], user: payload["user"]}
54
+ Cookies.set_cookie_cache(ctx, result, false) if should_refresh_cookie_cache?(config, payload)
55
+ result
56
+ end
57
+
58
+ def missing_session(ctx)
59
+ Cookies.delete_session_cookie(ctx)
60
+ nil
61
+ end
62
+
63
+ def expired?(session)
64
+ expires_at = normalize_time(session["expiresAt"])
65
+ expires_at && expires_at <= Time.now
66
+ end
67
+
68
+ def should_refresh?(ctx, session, disable_refresh)
69
+ return false if disable_refresh
70
+
71
+ update_age = ctx.context.session_config[:update_age].to_i
72
+ return true if update_age.zero?
73
+
74
+ updated_at = normalize_time(session["updatedAt"])
75
+ updated_at && updated_at + update_age <= Time.now
76
+ end
77
+
78
+ def refresh_session(ctx, result)
79
+ now = Time.now
80
+ expires_at = now + ctx.context.session_config[:expires_in].to_i
81
+ updated = ctx.context.internal_adapter.update_session(
82
+ result[:session]["token"],
83
+ "expiresAt" => expires_at,
84
+ "updatedAt" => now
85
+ )
86
+ session = stringify_keys(updated || result[:session]).merge("expiresAt" => expires_at, "updatedAt" => now)
87
+ refreshed = {session: session, user: result[:user]}
88
+ Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
89
+ refreshed
90
+ end
91
+
92
+ def should_refresh_cookie_cache?(config, payload)
93
+ refresh_cache = config[:refresh_cache]
94
+ return false if refresh_cache == false || refresh_cache.nil?
95
+
96
+ max_age = (config[:max_age] || 60 * 5).to_i
97
+ update_age = if refresh_cache.is_a?(Hash)
98
+ (refresh_cache[:update_age] || refresh_cache["updateAge"] || refresh_cache["update_age"]).to_i
99
+ else
100
+ (max_age * 0.8).to_i
101
+ end
102
+ updated_at = payload["updatedAt"].to_i
103
+ updated_at.positive? && updated_at + (update_age * 1000) <= (Time.now.to_f * 1000).to_i
104
+ end
105
+
106
+ def normalize_time(value)
107
+ return value if value.is_a?(Time)
108
+ return Time.at(value / 1000.0) if value.is_a?(Integer) && value > 10_000_000_000
109
+ return Time.at(value) if value.is_a?(Integer)
110
+ return nil if value.nil?
111
+
112
+ Time.parse(value.to_s)
113
+ end
114
+
115
+ def stringify_keys(value)
116
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
117
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
118
+
119
+ value
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class SessionStore
5
+ ALLOWED_COOKIE_SIZE = 4096
6
+ ESTIMATED_EMPTY_COOKIE_SIZE = 200
7
+ CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE
8
+
9
+ CookieValue = Struct.new(:name, :value, :attributes, keyword_init: true)
10
+
11
+ attr_reader :cookie_name, :cookie_options, :context, :chunks
12
+
13
+ def initialize(cookie_name, cookie_options, context)
14
+ @cookie_name = cookie_name
15
+ @cookie_options = cookie_options || {}
16
+ @context = context
17
+ @chunks = read_existing_chunks
18
+ end
19
+
20
+ def value
21
+ self.class.join_chunks(chunks)
22
+ end
23
+
24
+ def chunks?
25
+ !chunks.empty?
26
+ end
27
+
28
+ def chunk(value, options = {})
29
+ cleaned = clean
30
+ new_chunks = build_chunks(value.to_s, cookie_options.merge(options || {}))
31
+ cleaned + new_chunks
32
+ end
33
+
34
+ def clean
35
+ existing = chunks.keys.map do |name|
36
+ CookieValue.new(name: name, value: "", attributes: cookie_options.merge(max_age: 0))
37
+ end
38
+ chunks.clear
39
+ existing
40
+ end
41
+
42
+ def set_cookies(cookies)
43
+ cookies.each do |cookie|
44
+ context.set_cookie(cookie.name, cookie.value, cookie.attributes)
45
+ end
46
+ end
47
+
48
+ def self.get_chunked_cookie(context, cookie_name)
49
+ direct = context.get_cookie(cookie_name)
50
+ return direct if direct && !direct.empty?
51
+
52
+ chunks = context.cookies.each_with_object({}) do |(name, value), result|
53
+ result[name] = value if name.start_with?("#{cookie_name}.")
54
+ end
55
+ return nil if chunks.empty?
56
+
57
+ join_chunks(chunks)
58
+ end
59
+
60
+ def self.join_chunks(chunks)
61
+ chunks.keys.sort_by { |name| chunk_index(name) }.map { |name| chunks[name] }.join
62
+ end
63
+
64
+ def self.chunk_index(cookie_name)
65
+ Integer(cookie_name.split(".").last)
66
+ rescue ArgumentError, TypeError
67
+ 0
68
+ end
69
+
70
+ private
71
+
72
+ def read_existing_chunks
73
+ context.cookies.each_with_object({}) do |(name, value), result|
74
+ result[name] = value if name == cookie_name || name.start_with?("#{cookie_name}.")
75
+ end
76
+ end
77
+
78
+ def build_chunks(value, attributes)
79
+ if value.length <= CHUNK_SIZE
80
+ chunks[cookie_name] = value
81
+ return [CookieValue.new(name: cookie_name, value: value, attributes: attributes)]
82
+ end
83
+
84
+ value.chars.each_slice(CHUNK_SIZE).map(&:join).each_with_index.map do |part, index|
85
+ name = "#{cookie_name}.#{index}"
86
+ chunks[name] = part
87
+ CookieValue.new(name: name, value: part, attributes: attributes)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def apple(client_id:, client_secret:, scopes: ["email", "name"], **options)
8
+ normalized = Base.normalize_options(options)
9
+ primary_client_id = Base.primary_client_id(client_id)
10
+ {
11
+ id: "apple",
12
+ name: "Apple",
13
+ client_id: client_id,
14
+ client_secret: client_secret,
15
+ create_authorization_url: lambda do |data|
16
+ Base.authorization_url(options[:authorization_endpoint] || "https://appleid.apple.com/auth/authorize", {
17
+ client_id: primary_client_id,
18
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
19
+ response_type: "code id_token",
20
+ response_mode: options[:response_mode] || options[:responseMode] || "form_post",
21
+ scope: Base.selected_scopes(scopes, normalized, data),
22
+ state: data[:state]
23
+ })
24
+ end,
25
+ validate_authorization_code: lambda do |data|
26
+ Base.post_form("https://appleid.apple.com/auth/token", {
27
+ client_id: primary_client_id,
28
+ client_secret: client_secret,
29
+ code: data[:code],
30
+ code_verifier: data[:code_verifier] || data[:codeVerifier],
31
+ grant_type: "authorization_code",
32
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
33
+ })
34
+ end,
35
+ verify_id_token: normalized[:verify_id_token] || lambda do |token, nonce = nil|
36
+ return false if normalized[:disable_id_token_sign_in]
37
+
38
+ audiences = Array(normalized[:audience] || normalized[:app_bundle_identifier] || normalized[:appBundleIdentifier] || client_id)
39
+ return false if audiences.empty?
40
+
41
+ profile = Base.verify_jwt_with_jwks(
42
+ token,
43
+ jwks: normalized[:jwks],
44
+ jwks_endpoint: normalized[:jwks_endpoint] || "https://appleid.apple.com/auth/keys",
45
+ algorithms: ["RS256"],
46
+ issuers: "https://appleid.apple.com",
47
+ audience: audiences,
48
+ nonce: nonce
49
+ )
50
+ !!profile&.fetch("sub", nil)
51
+ end,
52
+ get_user_info: lambda do |tokens|
53
+ custom = normalized[:get_user_info]
54
+ next custom.call(tokens) if custom
55
+
56
+ profile = Base.decode_jwt_payload(Base.id_token(tokens))
57
+ apple_user = tokens[:user] || tokens["user"] || {}
58
+ name = apple_user.dig(:name, :firstName) ||
59
+ apple_user.dig(:name, :first_name) ||
60
+ apple_user.dig("name", "firstName") ||
61
+ apple_user.dig("name", "first_name")
62
+ last_name = apple_user.dig(:name, :lastName) ||
63
+ apple_user.dig(:name, :last_name) ||
64
+ apple_user.dig("name", "lastName") ||
65
+ apple_user.dig("name", "last_name")
66
+ full_name = [name, last_name].compact.join(" ").strip
67
+ full_name = profile["name"] || "" if full_name.empty?
68
+
69
+ user = Base.apply_profile_mapping(
70
+ {
71
+ id: profile["sub"],
72
+ email: profile["email"],
73
+ name: full_name,
74
+ image: profile["picture"],
75
+ emailVerified: profile["email_verified"] == true || profile["email_verified"] == "true"
76
+ },
77
+ profile.merge("name" => full_name),
78
+ normalized
79
+ )
80
+ {
81
+ user: user,
82
+ data: profile.merge("name" => full_name)
83
+ }
84
+ end,
85
+ refresh_access_token: options[:refresh_access_token] || options[:refreshAccessToken] || lambda do |refresh_token|
86
+ Base.refresh_access_token("https://appleid.apple.com/auth/token", refresh_token, client_id: primary_client_id, client_secret: client_secret)
87
+ end
88
+ }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def atlassian(client_id:, client_secret:, scopes: ["read:jira-user", "offline_access"], **options)
8
+ Base.oauth_provider(
9
+ id: "atlassian",
10
+ name: "Atlassian",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ authorization_endpoint: "https://auth.atlassian.com/authorize",
14
+ token_endpoint: "https://auth.atlassian.com/oauth/token",
15
+ user_info_endpoint: "https://api.atlassian.com/me",
16
+ scopes: scopes,
17
+ pkce: true,
18
+ auth_params: {audience: "api.atlassian.com"},
19
+ profile_map: ->(profile) {
20
+ {
21
+ id: profile["account_id"],
22
+ name: profile["name"],
23
+ email: profile["email"],
24
+ image: profile["picture"],
25
+ emailVerified: false
26
+ }
27
+ },
28
+ **options
29
+ )
30
+ end
31
+ end
32
+ end