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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +106 -16
- data/lib/better_auth/adapters/base.rb +49 -0
- data/lib/better_auth/adapters/internal_adapter.rb +439 -0
- data/lib/better_auth/adapters/memory.rb +232 -0
- data/lib/better_auth/adapters/mongodb.rb +369 -0
- data/lib/better_auth/adapters/mssql.rb +42 -0
- data/lib/better_auth/adapters/mysql.rb +33 -0
- data/lib/better_auth/adapters/postgres.rb +17 -0
- data/lib/better_auth/adapters/sql.rb +425 -0
- data/lib/better_auth/adapters/sqlite.rb +20 -0
- data/lib/better_auth/api.rb +226 -0
- data/lib/better_auth/api_error.rb +53 -0
- data/lib/better_auth/auth.rb +42 -0
- data/lib/better_auth/configuration.rb +399 -0
- data/lib/better_auth/context.rb +210 -0
- data/lib/better_auth/cookies.rb +278 -0
- data/lib/better_auth/core.rb +37 -1
- data/lib/better_auth/crypto/jwe.rb +76 -0
- data/lib/better_auth/crypto.rb +191 -0
- data/lib/better_auth/database_hooks.rb +114 -0
- data/lib/better_auth/endpoint.rb +326 -0
- data/lib/better_auth/error.rb +52 -0
- data/lib/better_auth/middleware/origin_check.rb +128 -0
- data/lib/better_auth/password.rb +120 -0
- data/lib/better_auth/plugin.rb +129 -0
- data/lib/better_auth/plugin_context.rb +16 -0
- data/lib/better_auth/plugin_registry.rb +67 -0
- data/lib/better_auth/plugins/access.rb +87 -0
- data/lib/better_auth/plugins/additional_fields.rb +29 -0
- data/lib/better_auth/plugins/admin/schema.rb +28 -0
- data/lib/better_auth/plugins/admin.rb +518 -0
- data/lib/better_auth/plugins/anonymous.rb +198 -0
- data/lib/better_auth/plugins/api_key.rb +16 -0
- data/lib/better_auth/plugins/bearer.rb +128 -0
- data/lib/better_auth/plugins/captcha.rb +159 -0
- data/lib/better_auth/plugins/custom_session.rb +84 -0
- data/lib/better_auth/plugins/device_authorization.rb +302 -0
- data/lib/better_auth/plugins/email_otp.rb +536 -0
- data/lib/better_auth/plugins/expo.rb +88 -0
- data/lib/better_auth/plugins/generic_oauth.rb +780 -0
- data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
- data/lib/better_auth/plugins/jwt.rb +482 -0
- data/lib/better_auth/plugins/last_login_method.rb +92 -0
- data/lib/better_auth/plugins/magic_link.rb +181 -0
- data/lib/better_auth/plugins/mcp.rb +342 -0
- data/lib/better_auth/plugins/multi_session.rb +173 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
- data/lib/better_auth/plugins/oauth_provider.rb +16 -0
- data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
- data/lib/better_auth/plugins/oidc_provider.rb +597 -0
- data/lib/better_auth/plugins/one_tap.rb +154 -0
- data/lib/better_auth/plugins/one_time_token.rb +106 -0
- data/lib/better_auth/plugins/open_api.rb +489 -0
- data/lib/better_auth/plugins/organization/schema.rb +106 -0
- data/lib/better_auth/plugins/organization.rb +990 -0
- data/lib/better_auth/plugins/passkey.rb +16 -0
- data/lib/better_auth/plugins/phone_number.rb +321 -0
- data/lib/better_auth/plugins/scim.rb +16 -0
- data/lib/better_auth/plugins/siwe.rb +242 -0
- data/lib/better_auth/plugins/sso.rb +16 -0
- data/lib/better_auth/plugins/stripe.rb +16 -0
- data/lib/better_auth/plugins/two_factor.rb +514 -0
- data/lib/better_auth/plugins/username.rb +278 -0
- data/lib/better_auth/plugins.rb +46 -0
- data/lib/better_auth/rate_limiter.rb +215 -0
- data/lib/better_auth/request_ip.rb +70 -0
- data/lib/better_auth/router.rb +365 -0
- data/lib/better_auth/routes/account.rb +211 -0
- data/lib/better_auth/routes/email_verification.rb +108 -0
- data/lib/better_auth/routes/error.rb +102 -0
- data/lib/better_auth/routes/ok.rb +15 -0
- data/lib/better_auth/routes/password.rb +164 -0
- data/lib/better_auth/routes/session.rb +137 -0
- data/lib/better_auth/routes/sign_in.rb +90 -0
- data/lib/better_auth/routes/sign_out.rb +15 -0
- data/lib/better_auth/routes/sign_up.rb +145 -0
- data/lib/better_auth/routes/social.rb +188 -0
- data/lib/better_auth/routes/user.rb +193 -0
- data/lib/better_auth/schema/sql.rb +191 -0
- data/lib/better_auth/schema.rb +275 -0
- data/lib/better_auth/session.rb +122 -0
- data/lib/better_auth/session_store.rb +91 -0
- data/lib/better_auth/social_providers/apple.rb +55 -0
- data/lib/better_auth/social_providers/base.rb +67 -0
- data/lib/better_auth/social_providers/discord.rb +59 -0
- data/lib/better_auth/social_providers/github.rb +59 -0
- data/lib/better_auth/social_providers/gitlab.rb +54 -0
- data/lib/better_auth/social_providers/google.rb +65 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
- data/lib/better_auth/social_providers.rb +9 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +87 -2
- metadata +218 -21
- data/.ruby-version +0 -1
- data/.standard.yml +0 -12
- data/.vscode/settings.json +0 -22
- data/AGENTS.md +0 -50
- data/CLAUDE.md +0 -1
- data/CODE_OF_CONDUCT.md +0 -173
- data/CONTRIBUTING.md +0 -187
- data/Gemfile +0 -12
- data/Makefile +0 -207
- data/Rakefile +0 -25
- data/SECURITY.md +0 -28
- 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
|