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,275 @@
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.merge!(plugin_schema)
19
+ tables["rateLimit"] = rate_limit_table(options) if rate_limit_option(options, :storage) == "database"
20
+ tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
21
+ end
22
+
23
+ def storage_model_name(options, model)
24
+ table = auth_tables(options).fetch(model.to_s)
25
+ table[:model_name]
26
+ end
27
+
28
+ def storage_field_name(options, model, field)
29
+ table = auth_tables(options).fetch(model.to_s)
30
+ data = table[:fields].fetch(field.to_s)
31
+ data[:field_name] || field.to_s
32
+ end
33
+
34
+ def parse_output(options, model, data)
35
+ return nil unless data
36
+
37
+ table = auth_tables(options).fetch(model.to_s)
38
+ table[:fields].each_with_object({}) do |(field, attributes), result|
39
+ next if attributes[:returned] == false && field != "id"
40
+ next unless data.key?(field)
41
+
42
+ result[field] = data[field]
43
+ end.tap do |result|
44
+ data.each { |key, value| result[key] = value unless result.key?(key) || table[:fields].key?(key) }
45
+ end
46
+ rescue KeyError
47
+ data
48
+ end
49
+
50
+ private_class_method def self.user_table(options, plugin_table)
51
+ table(
52
+ model_name: model_option(options, :user, :model_name) || "users",
53
+ order: 1,
54
+ fields: id_field.merge(
55
+ "name" => field("string", required: true, sortable: true, field_name: mapped_field(options, :user, "name")),
56
+ "email" => field("string", required: true, unique: true, sortable: true, field_name: mapped_field(options, :user, "email")),
57
+ "emailVerified" => field("boolean", required: true, input: false, default_value: false, field_name: mapped_field(options, :user, "emailVerified")),
58
+ "image" => field("string", required: false, field_name: mapped_field(options, :user, "image"))
59
+ ).merge(timestamp_fields),
60
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :user)]
61
+ )
62
+ end
63
+
64
+ private_class_method def self.session_table(options, plugin_table)
65
+ table(
66
+ model_name: model_option(options, :session, :model_name) || "sessions",
67
+ order: 2,
68
+ fields: base_fields.merge(
69
+ "expiresAt" => field("date", required: true, field_name: mapped_field(options, :session, "expiresAt")),
70
+ "token" => field("string", required: true, unique: true, field_name: mapped_field(options, :session, "token")),
71
+ "ipAddress" => field("string", required: false, field_name: mapped_field(options, :session, "ipAddress")),
72
+ "userAgent" => field("string", required: false, field_name: mapped_field(options, :session, "userAgent")),
73
+ "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"})
74
+ ),
75
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :session)]
76
+ )
77
+ end
78
+
79
+ private_class_method def self.account_table(options, plugin_table)
80
+ table(
81
+ model_name: model_option(options, :account, :model_name) || "accounts",
82
+ order: 3,
83
+ fields: base_fields.merge(
84
+ "accountId" => field("string", required: true, field_name: mapped_field(options, :account, "accountId")),
85
+ "providerId" => field("string", required: true, field_name: mapped_field(options, :account, "providerId")),
86
+ "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"}),
87
+ "accessToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "accessToken")),
88
+ "refreshToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "refreshToken")),
89
+ "idToken" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "idToken")),
90
+ "accessTokenExpiresAt" => field("date", required: false, returned: false, field_name: mapped_field(options, :account, "accessTokenExpiresAt")),
91
+ "refreshTokenExpiresAt" => field("date", required: false, returned: false, field_name: mapped_field(options, :account, "refreshTokenExpiresAt")),
92
+ "scope" => field("string", required: false, field_name: mapped_field(options, :account, "scope")),
93
+ "password" => field("string", required: false, returned: false, field_name: mapped_field(options, :account, "password"))
94
+ ),
95
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :account)]
96
+ )
97
+ end
98
+
99
+ private_class_method def self.verification_table(options, plugin_table)
100
+ table(
101
+ model_name: model_option(options, :verification, :model_name) || "verifications",
102
+ order: 4,
103
+ fields: base_fields.merge(
104
+ "identifier" => field("string", required: true, index: true, field_name: mapped_field(options, :verification, "identifier")),
105
+ "value" => field("string", required: true, field_name: mapped_field(options, :verification, "value")),
106
+ "expiresAt" => field("date", required: true, field_name: mapped_field(options, :verification, "expiresAt"))
107
+ ),
108
+ extra_fields: [plugin_table&.fetch(:fields, nil), additional_fields(options, :verification)]
109
+ )
110
+ end
111
+
112
+ private_class_method def self.rate_limit_table(options)
113
+ {
114
+ model_name: rate_limit_option(options, :model_name) || "rate_limits",
115
+ fields: {
116
+ "key" => field("string", required: true, unique: true, field_name: rate_limit_field(options, "key")),
117
+ "count" => field("number", required: true, field_name: rate_limit_field(options, "count")),
118
+ "lastRequest" => field("number", required: true, bigint: true, default_value: -> { current_millis }, field_name: rate_limit_field(options, "lastRequest"))
119
+ }
120
+ }
121
+ end
122
+
123
+ private_class_method def self.base_fields
124
+ id_field.merge(timestamp_fields)
125
+ end
126
+
127
+ private_class_method def self.id_field
128
+ {
129
+ "id" => field("string", required: true)
130
+ }
131
+ end
132
+
133
+ private_class_method def self.timestamp_fields
134
+ {
135
+ "createdAt" => field("date", required: true, default_value: -> { Time.now }, field_name: physical_name("createdAt")),
136
+ "updatedAt" => field("date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }, field_name: physical_name("updatedAt"))
137
+ }
138
+ end
139
+
140
+ private_class_method def self.table(model_name:, fields:, extra_fields:, order:)
141
+ {
142
+ model_name: model_name,
143
+ fields: merge_fields(fields, *extra_fields),
144
+ order: order
145
+ }
146
+ end
147
+
148
+ private_class_method def self.merge_fields(base, *extras)
149
+ extras.compact.each_with_object(base.dup) do |extra, fields|
150
+ normalize_fields(extra).each { |key, value| fields[key] = value }
151
+ end
152
+ end
153
+
154
+ private_class_method def self.field(type, **attributes)
155
+ {type: type}.merge(attributes).compact
156
+ end
157
+
158
+ private_class_method def self.plugin_tables(options)
159
+ plugins_for(options).each_with_object({}) do |plugin, tables|
160
+ schema = fetch_hash(plugin, :schema) || {}
161
+ schema.each do |raw_key, raw_table|
162
+ key = storage_key(raw_key)
163
+ table_data = symbolize_hash(raw_table || {})
164
+ existing = tables[key] || {model_name: table_data[:model_name] || physical_name(key), fields: {}}
165
+ existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_name(key)
166
+ existing[:fields] = existing[:fields].merge(normalize_fields(table_data[:fields] || {}))
167
+ tables[key] = existing
168
+ end
169
+ end
170
+ end
171
+
172
+ private_class_method def self.normalize_fields(fields)
173
+ fields.each_with_object({}) do |(raw_key, raw_value), result|
174
+ key = storage_key(raw_key)
175
+ result[key] = normalize_field(raw_value, key)
176
+ end
177
+ end
178
+
179
+ private_class_method def self.normalize_field(value, key)
180
+ data = symbolize_hash(value || {})
181
+ data[:field_name] ||= physical_name(key)
182
+ data
183
+ end
184
+
185
+ private_class_method def self.mapped_field(options, model, field)
186
+ fields = fetch_hash(model_options(options, model), :fields) || {}
187
+ fetch_mapped_value(fields, field) || physical_name(field)
188
+ end
189
+
190
+ private_class_method def self.rate_limit_field(options, field)
191
+ fields = fetch_hash(rate_limit_options(options), :fields) || {}
192
+ fetch_mapped_value(fields, field) || physical_name(field)
193
+ end
194
+
195
+ private_class_method def self.fetch_mapped_value(hash, field)
196
+ hash[storage_key(field).to_sym] || hash[storage_key(field)] || hash[underscore(field).to_sym] || hash[underscore(field)]
197
+ end
198
+
199
+ private_class_method def self.additional_fields(options, model)
200
+ fetch_hash(model_options(options, model), :additional_fields) || {}
201
+ end
202
+
203
+ private_class_method def self.model_option(options, model, key)
204
+ fetch_hash(model_options(options, model), key)
205
+ end
206
+
207
+ private_class_method def self.session_option(options, key)
208
+ fetch_hash(session_options(options), key)
209
+ end
210
+
211
+ private_class_method def self.rate_limit_option(options, key)
212
+ fetch_hash(rate_limit_options(options), key)
213
+ end
214
+
215
+ private_class_method def self.model_options(options, model)
216
+ options.respond_to?(model) ? options.public_send(model) : fetch_hash(options, model)
217
+ end
218
+
219
+ private_class_method def self.session_options(options)
220
+ options.respond_to?(:session) ? options.session : fetch_hash(options, :session)
221
+ end
222
+
223
+ private_class_method def self.rate_limit_options(options)
224
+ options.respond_to?(:rate_limit) ? options.rate_limit : fetch_hash(options, :rate_limit)
225
+ end
226
+
227
+ private_class_method def self.secondary_storage?(options)
228
+ options.respond_to?(:secondary_storage) ? !!options.secondary_storage : !!fetch_hash(options, :secondary_storage)
229
+ end
230
+
231
+ private_class_method def self.plugins_for(options)
232
+ options.respond_to?(:plugins) ? options.plugins : Array(fetch_hash(options, :plugins))
233
+ end
234
+
235
+ private_class_method def self.fetch_hash(hash, key)
236
+ return nil unless hash.respond_to?(:[])
237
+
238
+ hash[key] || hash[key.to_s] || hash[underscore(key.to_s).to_sym] || hash[underscore(key.to_s)]
239
+ end
240
+
241
+ private_class_method def self.symbolize_hash(value)
242
+ return {} unless value.is_a?(Hash)
243
+
244
+ value.each_with_object({}) do |(key, object), result|
245
+ result[underscore(key.to_s).to_sym] = object.is_a?(Hash) ? symbolize_hash(object) : object
246
+ end
247
+ end
248
+
249
+ private_class_method def self.storage_key(value)
250
+ camelize_lower(value.to_s)
251
+ end
252
+
253
+ private_class_method def self.physical_name(value)
254
+ underscore(value.to_s)
255
+ end
256
+
257
+ private_class_method def self.camelize_lower(value)
258
+ parts = underscore(value).split("_")
259
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
260
+ end
261
+
262
+ private_class_method def self.underscore(value)
263
+ value
264
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
265
+ .tr("-", "_")
266
+ .downcase
267
+ end
268
+
269
+ private_class_method def self.current_millis
270
+ (Time.now.to_f * 1000).to_i
271
+ end
272
+
273
+ public_class_method :storage_key
274
+ end
275
+ 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,55 @@
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
+ {
9
+ id: "apple",
10
+ name: "Apple",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ create_authorization_url: lambda do |data|
14
+ Base.authorization_url(options[:authorization_endpoint] || "https://appleid.apple.com/auth/authorize", {
15
+ client_id: client_id,
16
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
17
+ response_type: "code id_token",
18
+ response_mode: options[:response_mode] || options[:responseMode] || "form_post",
19
+ scope: data[:scopes] || scopes,
20
+ state: data[:state]
21
+ })
22
+ end,
23
+ validate_authorization_code: lambda do |data|
24
+ Base.post_form("https://appleid.apple.com/auth/token", {
25
+ client_id: client_id,
26
+ client_secret: client_secret,
27
+ code: data[:code],
28
+ code_verifier: data[:code_verifier] || data[:codeVerifier],
29
+ grant_type: "authorization_code",
30
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
31
+ })
32
+ end,
33
+ get_user_info: lambda do |tokens|
34
+ profile = Base.decode_jwt_payload(Base.id_token(tokens))
35
+ apple_user = tokens[:user] || tokens["user"] || {}
36
+ name = apple_user.dig(:name, :firstName) || apple_user.dig("name", "firstName")
37
+ last_name = apple_user.dig(:name, :lastName) || apple_user.dig("name", "lastName")
38
+ full_name = [name, last_name].compact.join(" ").strip
39
+ full_name = profile["name"] || " " if full_name.empty?
40
+
41
+ {
42
+ user: {
43
+ id: profile["sub"],
44
+ email: profile["email"],
45
+ name: full_name,
46
+ image: profile["picture"],
47
+ emailVerified: profile["email_verified"] == true || profile["email_verified"] == "true"
48
+ },
49
+ data: profile.merge("name" => full_name)
50
+ }
51
+ end
52
+ }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+
9
+ module BetterAuth
10
+ module SocialProviders
11
+ module Base
12
+ module_function
13
+
14
+ def authorization_url(endpoint, params)
15
+ uri = URI(endpoint)
16
+ query = URI.decode_www_form(uri.query.to_s)
17
+ params.compact.each do |key, value|
18
+ next if value == ""
19
+
20
+ query << [key.to_s, Array(value).join(" ")]
21
+ end
22
+ uri.query = URI.encode_www_form(query)
23
+ uri.to_s
24
+ end
25
+
26
+ def pkce_challenge(verifier)
27
+ digest = OpenSSL::Digest.digest("SHA256", verifier.to_s)
28
+ Base64.urlsafe_encode64(digest, padding: false)
29
+ end
30
+
31
+ def post_form(url, form)
32
+ uri = URI(url)
33
+ response = Net::HTTP.post_form(uri, form.transform_keys(&:to_s))
34
+ JSON.parse(response.body)
35
+ end
36
+
37
+ def get_json(url, headers = {})
38
+ uri = URI(url)
39
+ request = Net::HTTP::Get.new(uri)
40
+ headers.each { |key, value| request[key.to_s] = value.to_s }
41
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
42
+ JSON.parse(response.body)
43
+ end
44
+
45
+ def access_token(tokens)
46
+ tokens[:access_token] || tokens["access_token"] || tokens[:accessToken] || tokens["accessToken"]
47
+ end
48
+
49
+ def id_token(tokens)
50
+ tokens[:id_token] || tokens["id_token"] || tokens[:idToken] || tokens["idToken"]
51
+ end
52
+
53
+ def decode_jwt_payload(token)
54
+ _header, payload, _signature = token.to_s.split(".", 3)
55
+ return {} unless payload
56
+
57
+ JSON.parse(Base64.urlsafe_decode64(padded_base64(payload)))
58
+ rescue JSON::ParserError, ArgumentError
59
+ {}
60
+ end
61
+
62
+ def padded_base64(value)
63
+ value + ("=" * ((4 - value.length % 4) % 4))
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def discord(client_id:, client_secret:, scopes: ["identify", "email"], **options)
8
+ {
9
+ id: "discord",
10
+ name: "Discord",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ create_authorization_url: lambda do |data|
14
+ selected_scopes = data[:scopes] || scopes
15
+ params = {
16
+ client_id: client_id,
17
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
18
+ response_type: "code",
19
+ scope: selected_scopes,
20
+ state: data[:state],
21
+ prompt: options.fetch(:prompt, "none")
22
+ }
23
+ params[:permissions] = options[:permissions] if selected_scopes.include?("bot") && options.key?(:permissions)
24
+ Base.authorization_url("https://discord.com/api/oauth2/authorize", params)
25
+ end,
26
+ validate_authorization_code: lambda do |data|
27
+ Base.post_form("https://discord.com/api/oauth2/token", {
28
+ client_id: client_id,
29
+ client_secret: client_secret,
30
+ code: data[:code],
31
+ grant_type: "authorization_code",
32
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
33
+ })
34
+ end,
35
+ get_user_info: lambda do |tokens|
36
+ profile = Base.get_json("https://discord.com/api/users/@me", "Authorization" => "Bearer #{Base.access_token(tokens)}")
37
+ {
38
+ user: {
39
+ id: profile["id"],
40
+ email: profile["email"],
41
+ name: profile["global_name"] || profile["username"] || "",
42
+ image: discord_avatar_url(profile),
43
+ emailVerified: !!profile["verified"]
44
+ },
45
+ data: profile
46
+ }
47
+ end
48
+ }
49
+ end
50
+
51
+ def discord_avatar_url(profile)
52
+ avatar = profile["avatar"]
53
+ return nil unless avatar
54
+
55
+ format = avatar.start_with?("a_") ? "gif" : "png"
56
+ "https://cdn.discordapp.com/avatars/#{profile["id"]}/#{avatar}.#{format}"
57
+ end
58
+ end
59
+ end