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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ HAVE_I_BEEN_PWNED_ERROR_CODES = {
10
+ "PASSWORD_COMPROMISED" => "The password you entered has been compromised. Please choose a different password."
11
+ }.freeze
12
+
13
+ HAVE_I_BEEN_PWNED_DEFAULT_PATHS = [
14
+ "/sign-up/email",
15
+ "/change-password",
16
+ "/reset-password"
17
+ ].freeze
18
+
19
+ module_function
20
+
21
+ def have_i_been_pwned(options = {})
22
+ config = normalize_hash(options)
23
+ config[:paths] = Array(config[:paths]).empty? ? HAVE_I_BEEN_PWNED_DEFAULT_PATHS : Array(config[:paths])
24
+
25
+ Plugin.new(
26
+ id: "have-i-been-pwned",
27
+ init: ->(context) { have_i_been_pwned_wrap_password_hasher!(context, config) },
28
+ error_codes: HAVE_I_BEEN_PWNED_ERROR_CODES,
29
+ options: config
30
+ )
31
+ end
32
+
33
+ def have_i_been_pwned_wrap_password_hasher!(context, config)
34
+ email_config = context.options.email_and_password
35
+ password_config = email_config[:password] ||= {}
36
+ original_hasher = password_config[:hash]
37
+ algorithm = context.options.password_hasher
38
+ password_config[:hash] = lambda do |password, hash_ctx = nil|
39
+ if config[:enabled] != false && hash_ctx && config[:paths].include?(hash_ctx.path)
40
+ have_i_been_pwned_check_password!(password, config)
41
+ end
42
+
43
+ if original_hasher.respond_to?(:call)
44
+ arity = original_hasher.arity
45
+ return original_hasher.call(password, hash_ctx) if arity != 1 && arity != -1
46
+
47
+ return original_hasher.call(password)
48
+ end
49
+
50
+ Password.hash(password, algorithm: algorithm)
51
+ end
52
+ nil
53
+ end
54
+
55
+ def have_i_been_pwned_check_password!(password, config)
56
+ return if password.to_s.empty?
57
+
58
+ hash = OpenSSL::Digest.hexdigest("SHA1", password.to_s).upcase
59
+ prefix = hash[0, 5]
60
+ suffix = hash[5..]
61
+ data = if config[:range_lookup].respond_to?(:call)
62
+ config[:range_lookup].call(prefix)
63
+ else
64
+ have_i_been_pwned_range_lookup(prefix)
65
+ end
66
+
67
+ found = data.to_s.lines.any? { |line| line.split(":").first.to_s.upcase == suffix }
68
+ return unless found
69
+
70
+ raise APIError.new(
71
+ "BAD_REQUEST",
72
+ message: config[:custom_password_compromised_message] || HAVE_I_BEEN_PWNED_ERROR_CODES["PASSWORD_COMPROMISED"],
73
+ code: "PASSWORD_COMPROMISED"
74
+ )
75
+ rescue APIError
76
+ raise
77
+ rescue
78
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Please try again later.")
79
+ end
80
+
81
+ def have_i_been_pwned_range_lookup(prefix)
82
+ uri = URI.parse("https://api.pwnedpasswords.com/range/#{prefix}")
83
+ request = Net::HTTP::Get.new(uri)
84
+ request["Add-Padding"] = "true"
85
+ request["User-Agent"] = "BetterAuth Password Checker"
86
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
87
+ unless response.is_a?(Net::HTTPSuccess)
88
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
89
+ end
90
+
91
+ response.body.to_s
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,482 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ module JWT
10
+ SUPPORTED_ALGORITHMS = %w[EdDSA RS256 PS256 ES256 ES512].freeze
11
+
12
+ module_function
13
+
14
+ def public_key(jwk)
15
+ data = stringify_jwk(jwk)
16
+ return OpenSSL::PKey.read(data["pem"] || data["publicKey"]) if data["pem"] || data["publicKey"]
17
+
18
+ if data["kty"] == "RSA" && data["n"] && data["e"]
19
+ rsa_from_components(data["n"], data["e"])
20
+ elsif data["kty"] == "OKP" && data["crv"] == "Ed25519" && data["x"]
21
+ OpenSSL::PKey.new_raw_public_key("ED25519", Crypto.base64url_decode(data["x"]))
22
+ else
23
+ raise OpenSSL::PKey::PKeyError, "Unsupported JWK"
24
+ end
25
+ end
26
+
27
+ def rsa_from_components(n, e)
28
+ sequence = OpenSSL::ASN1::Sequence([
29
+ OpenSSL::ASN1::Integer(OpenSSL::BN.new(Crypto.base64url_decode(n).unpack1("H*"), 16)),
30
+ OpenSSL::ASN1::Integer(OpenSSL::BN.new(Crypto.base64url_decode(e).unpack1("H*"), 16))
31
+ ])
32
+ OpenSSL::PKey::RSA.new(sequence.to_der)
33
+ end
34
+
35
+ def stringify_jwk(value)
36
+ value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = object_value } if value.is_a?(Hash)
37
+ end
38
+ end
39
+
40
+ module_function
41
+
42
+ def jwt(options = {})
43
+ config = normalize_hash(options)
44
+ validate_jwt_options!(config)
45
+ jwks_path = config.dig(:jwks, :jwks_path) || "/jwks"
46
+
47
+ Plugin.new(
48
+ id: "jwt",
49
+ endpoints: {
50
+ get_jwks: get_jwks_endpoint(config, jwks_path),
51
+ get_token: get_token_endpoint(config),
52
+ sign_jwt: sign_jwt_endpoint(config),
53
+ verify_jwt: verify_jwt_endpoint(config)
54
+ },
55
+ hooks: {
56
+ after: [
57
+ {
58
+ matcher: ->(ctx) { ctx.path == "/get-session" },
59
+ handler: ->(ctx) { set_jwt_header(ctx, config) }
60
+ }
61
+ ]
62
+ },
63
+ schema: {
64
+ jwks: {
65
+ fields: {
66
+ publicKey: {type: "string", required: true},
67
+ privateKey: {type: "string", required: true},
68
+ createdAt: {type: "date", required: true},
69
+ expiresAt: {type: "date", required: false},
70
+ alg: {type: "string", required: false},
71
+ kty: {type: "string", required: false},
72
+ crv: {type: "string", required: false},
73
+ x: {type: "string", required: false},
74
+ y: {type: "string", required: false},
75
+ pem: {type: "string", required: false},
76
+ n: {type: "string", required: false},
77
+ e: {type: "string", required: false}
78
+ }
79
+ }
80
+ },
81
+ options: config
82
+ )
83
+ end
84
+
85
+ def validate_jwt_options!(config)
86
+ alg = config.dig(:jwks, :key_pair_config, :alg)
87
+ if alg && !JWT::SUPPORTED_ALGORITHMS.include?(alg.to_s)
88
+ raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server. Supported algorithms: #{JWT::SUPPORTED_ALGORITHMS.join(", ")}"
89
+ end
90
+
91
+ if config.dig(:jwt, :sign) && !config.dig(:jwks, :remote_url)
92
+ raise Error, "options.jwks.remoteUrl must be set when using options.jwt.sign"
93
+ end
94
+
95
+ if config.dig(:jwks, :remote_url) && !config.dig(:jwks, :key_pair_config, :alg)
96
+ raise Error, "options.jwks.keyPairConfig.alg must be specified when using the oidc plugin with options.jwks.remoteUrl"
97
+ end
98
+
99
+ path = config.dig(:jwks, :jwks_path)
100
+ if path && (!path.is_a?(String) || path.empty? || !path.start_with?("/") || path.include?(".."))
101
+ raise Error, "options.jwks.jwksPath must be a non-empty string starting with '/' and not contain '.."
102
+ end
103
+ end
104
+
105
+ def get_jwks_endpoint(config, path)
106
+ Endpoint.new(path: path, method: "GET") do |ctx|
107
+ raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)
108
+
109
+ create_jwk(ctx, config) if all_jwks(ctx, config).empty?
110
+ ctx.json({keys: public_jwks(ctx, config).map { |key| public_jwk(key, config) }})
111
+ end
112
+ end
113
+
114
+ def get_token_endpoint(config)
115
+ Endpoint.new(path: "/token", method: "GET") do |ctx|
116
+ session = Session.find_current(ctx)
117
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session
118
+
119
+ ctx.json({token: jwt_token(ctx, session, config)})
120
+ end
121
+ end
122
+
123
+ def sign_jwt_endpoint(config)
124
+ Endpoint.new(path: nil, method: "POST") do |ctx|
125
+ payload = fetch_value(ctx.body, "payload") || {}
126
+ override = normalize_hash(fetch_value(ctx.body, "overrideOptions") || {})
127
+ ctx.json({token: sign_jwt_payload(ctx, stringify_payload(payload), deep_merge(config, override))})
128
+ end
129
+ end
130
+
131
+ def verify_jwt_endpoint(config)
132
+ Endpoint.new(path: nil, method: "POST") do |ctx|
133
+ token = fetch_value(ctx.body, "token")
134
+ issuer = fetch_value(ctx.body, "issuer")
135
+ verify_options = issuer ? deep_merge(config, jwt: {issuer: issuer}) : config
136
+ ctx.json({payload: verify_jwt_token(ctx, token, verify_options)})
137
+ end
138
+ end
139
+
140
+ def set_jwt_header(ctx, config)
141
+ return if config[:disable_setting_jwt_header]
142
+
143
+ session = ctx.context.current_session || ctx.context.new_session
144
+ return unless session && session[:session]
145
+
146
+ token = jwt_token(ctx, session, config)
147
+ exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
148
+ exposed << "set-auth-jwt"
149
+ ctx.set_header("set-auth-jwt", token)
150
+ ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
151
+ nil
152
+ end
153
+
154
+ def jwt_token(ctx, session, config)
155
+ jwt_config = config[:jwt] || {}
156
+ payload = if jwt_config[:define_payload].respond_to?(:call)
157
+ jwt_config[:define_payload].call(session)
158
+ else
159
+ session[:user]
160
+ end
161
+ subject = if jwt_config[:get_subject].respond_to?(:call)
162
+ jwt_config[:get_subject].call(session)
163
+ else
164
+ session[:user]["id"]
165
+ end
166
+ sign_jwt_payload(ctx, stringify_payload(payload).merge("sub" => subject), config)
167
+ end
168
+
169
+ def sign_jwt_payload(ctx, payload, config)
170
+ jwt_config = config[:jwt] || {}
171
+ now = Time.now.to_i
172
+ payload = stringify_payload(payload).dup
173
+ payload["iat"] ||= now
174
+ payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
175
+ payload["iss"] ||= jwt_config[:issuer] || ctx.context.base_url
176
+ payload["aud"] ||= jwt_config[:audience] || ctx.context.base_url
177
+
178
+ return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
179
+
180
+ key = signing_jwk(ctx, config)
181
+ private_key = OpenSSL::PKey.read(jwk_private_key_value(ctx, key, config))
182
+ alg = key["alg"] || "RS256"
183
+ return encode_eddsa_jwt(payload, private_key, key["id"]) if alg == "EdDSA"
184
+
185
+ ::JWT.encode(payload, private_key, alg, kid: key["id"])
186
+ end
187
+
188
+ def verify_jwt_token(ctx, token, config)
189
+ header = ::JWT.decode(token.to_s, nil, false).last
190
+ key = verification_jwks(ctx, config).find { |entry| entry["id"] == header["kid"] || entry["kid"] == header["kid"] }
191
+ return nil unless key
192
+ return verify_eddsa_jwt(ctx, token.to_s, key, config) if (key["alg"] || header["alg"]) == "EdDSA"
193
+
194
+ options = {
195
+ algorithm: key["alg"] || "RS256",
196
+ iss: config.dig(:jwt, :issuer) || ctx.context.base_url,
197
+ verify_iss: true,
198
+ aud: config.dig(:jwt, :audience) || ctx.context.base_url,
199
+ verify_aud: true
200
+ }
201
+ decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
202
+ jwt_payload_valid?(decoded) ? decoded : nil
203
+ rescue ::JWT::DecodeError, OpenSSL::PKey::PKeyError
204
+ nil
205
+ end
206
+
207
+ def latest_jwk(ctx, config)
208
+ all_jwks(ctx, config).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
209
+ end
210
+
211
+ def signing_jwk(ctx, config)
212
+ key = latest_jwk(ctx, config)
213
+ return key if key && !jwk_expired?(key)
214
+
215
+ create_jwk(ctx, config)
216
+ end
217
+
218
+ def public_jwks(ctx, config)
219
+ now = Time.now
220
+ grace_period = config.dig(:jwks, :grace_period) || 60 * 60 * 24 * 30
221
+ all_jwks(ctx, config).select do |key|
222
+ expires_at = normalize_time(key["expiresAt"])
223
+ !expires_at || expires_at + grace_period.to_i > now
224
+ end
225
+ end
226
+
227
+ def all_jwks(ctx, config)
228
+ adapter = config[:adapter]
229
+ if adapter && adapter[:get_jwks].respond_to?(:call)
230
+ return Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
231
+ end
232
+
233
+ ctx.context.adapter.find_many(model: "jwks")
234
+ end
235
+
236
+ def verification_jwks(ctx, config)
237
+ local = all_jwks(ctx, config)
238
+ return local unless config.dig(:jwks, :remote_url)
239
+
240
+ local + remote_jwks(ctx, config)
241
+ end
242
+
243
+ def remote_jwks(ctx, config)
244
+ url = config.dig(:jwks, :remote_url)
245
+ fetcher = config.dig(:jwks, :fetch) || config.dig(:jwks, :fetcher)
246
+ payload = if fetcher.respond_to?(:call)
247
+ fetcher.call(url)
248
+ else
249
+ uri = URI.parse(url.to_s)
250
+ response = Net::HTTP.get_response(uri)
251
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
252
+ end
253
+ keys = fetch_value(payload, "keys")
254
+ Array(keys).map { |entry| normalize_remote_jwk(entry) }
255
+ rescue JSON::ParserError, URI::InvalidURIError, SocketError, SystemCallError
256
+ []
257
+ end
258
+
259
+ def normalize_remote_jwk(entry)
260
+ data = stringify_payload(entry || {})
261
+ data["id"] ||= data["kid"]
262
+ data["publicKey"] ||= data["pem"]
263
+ data
264
+ end
265
+
266
+ def create_jwk(ctx, config)
267
+ adapter = config[:adapter]
268
+ alg = (config.dig(:jwks, :key_pair_config, :alg) || "EdDSA").to_s
269
+ pair = generate_key_pair(alg)
270
+ public_key = public_key_for(pair)
271
+ public_pem = public_key_pem(public_key)
272
+ data = {
273
+ "id" => Crypto.uuid,
274
+ "publicKey" => public_pem,
275
+ "privateKey" => jwk_private_key_for_storage(ctx, private_key_pem(pair), config),
276
+ "createdAt" => Time.now,
277
+ "alg" => alg,
278
+ "pem" => public_pem
279
+ }
280
+ data.merge!(public_key_jwk_fields(public_key, alg))
281
+ data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
282
+
283
+ if adapter && adapter[:create_jwk].respond_to?(:call)
284
+ return stringify_payload(adapter[:create_jwk].call(data, ctx))
285
+ end
286
+
287
+ ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
288
+ end
289
+
290
+ def public_jwk(key, _config)
291
+ data = {
292
+ kid: key["id"],
293
+ kty: key["kty"] || key_type_for_alg(key["alg"] || "RS256"),
294
+ alg: key["alg"] || "EdDSA",
295
+ use: "sig",
296
+ pem: key["pem"] || key["publicKey"]
297
+ }
298
+ data[:n] = key["n"] if key["n"]
299
+ data[:e] = key["e"] if key["e"]
300
+ data[:crv] = key["crv"] if key["crv"]
301
+ data[:x] = key["x"] if key["x"]
302
+ data[:y] = key["y"] if key["y"]
303
+ data
304
+ end
305
+
306
+ def jwk_private_key_for_storage(ctx, private_key, config)
307
+ return private_key if config.dig(:jwks, :disable_private_key_encryption)
308
+
309
+ Crypto.symmetric_encrypt(key: ctx.context.secret, data: private_key)
310
+ end
311
+
312
+ def jwk_private_key_value(ctx, key, _config)
313
+ value = key["privateKey"]
314
+ Crypto.symmetric_decrypt(key: ctx.context.secret, data: value) || value
315
+ end
316
+
317
+ def jwt_payload_valid?(payload)
318
+ return false if payload["sub"].to_s.empty?
319
+
320
+ audience = payload["aud"]
321
+ return false if audience.nil?
322
+ return false if audience.respond_to?(:empty?) && audience.empty?
323
+
324
+ true
325
+ end
326
+
327
+ def generate_key_pair(alg)
328
+ case alg
329
+ when "EdDSA"
330
+ OpenSSL::PKey.generate_key("ED25519")
331
+ when "RS256", "PS256"
332
+ OpenSSL::PKey::RSA.generate(2048)
333
+ when "ES256"
334
+ OpenSSL::PKey::EC.generate("prime256v1")
335
+ when "ES512"
336
+ OpenSSL::PKey::EC.generate("secp521r1")
337
+ else
338
+ raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server"
339
+ end
340
+ end
341
+
342
+ def public_key_for(pair)
343
+ OpenSSL::PKey.read(pair.public_to_pem)
344
+ end
345
+
346
+ def private_key_pem(pair)
347
+ pair.respond_to?(:private_to_pem) ? pair.private_to_pem : pair.to_pem
348
+ end
349
+
350
+ def public_key_pem(pair)
351
+ pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem
352
+ end
353
+
354
+ def public_key_jwk_fields(public_key, alg)
355
+ if public_key.is_a?(OpenSSL::PKey::RSA)
356
+ {
357
+ "kty" => "RSA",
358
+ "n" => base64url_bn(public_key.n),
359
+ "e" => base64url_bn(public_key.e)
360
+ }
361
+ elsif alg == "EdDSA"
362
+ {
363
+ "kty" => "OKP",
364
+ "crv" => "Ed25519",
365
+ "x" => Crypto.base64url_encode(public_key.raw_public_key)
366
+ }
367
+ else
368
+ point = public_key.public_key.to_octet_string(:uncompressed).bytes
369
+ length = (point.length - 1) / 2
370
+ {
371
+ "kty" => "EC",
372
+ "crv" => ec_curve_for_alg(alg),
373
+ "x" => Crypto.base64url_encode(point[1, length].pack("C*")),
374
+ "y" => Crypto.base64url_encode(point[(1 + length), length].pack("C*"))
375
+ }
376
+ end
377
+ end
378
+
379
+ def key_type_for_alg(alg)
380
+ return "OKP" if alg == "EdDSA"
381
+
382
+ alg.to_s.start_with?("ES") ? "EC" : "RSA"
383
+ end
384
+
385
+ def ec_curve_for_alg(alg)
386
+ (alg == "ES512") ? "P-521" : "P-256"
387
+ end
388
+
389
+ def encode_eddsa_jwt(payload, private_key, kid)
390
+ header = {"alg" => "EdDSA", "kid" => kid}
391
+ signing_input = [
392
+ Crypto.base64url_encode(JSON.generate(header)),
393
+ Crypto.base64url_encode(JSON.generate(payload))
394
+ ].join(".")
395
+ signature = private_key.sign(nil, signing_input)
396
+ "#{signing_input}.#{Crypto.base64url_encode(signature)}"
397
+ end
398
+
399
+ def verify_eddsa_jwt(ctx, token, key, config)
400
+ header_segment, payload_segment, signature_segment = token.split(".", 3)
401
+ return nil unless header_segment && payload_segment && signature_segment
402
+
403
+ public_key = JWT.public_key(key)
404
+ signing_input = "#{header_segment}.#{payload_segment}"
405
+ signature = Crypto.base64url_decode(signature_segment)
406
+ return nil unless public_key.verify(nil, signature, signing_input)
407
+
408
+ payload = JSON.parse(Crypto.base64url_decode(payload_segment))
409
+ now = Time.now.to_i
410
+ return nil if payload["exp"] && payload["exp"].to_i <= now
411
+ issuer = config.dig(:jwt, :issuer) || ctx.context.base_url
412
+ audience = config.dig(:jwt, :audience) || ctx.context.base_url
413
+ return nil if issuer && payload["iss"] != issuer
414
+ return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
415
+ return nil unless jwt_payload_valid?(payload)
416
+
417
+ payload
418
+ rescue JSON::ParserError, OpenSSL::PKey::PKeyError, ArgumentError
419
+ nil
420
+ end
421
+
422
+ def jwk_expired?(key)
423
+ expires_at = normalize_time(key["expiresAt"])
424
+ expires_at && expires_at < Time.now
425
+ end
426
+
427
+ def jwt_expiration(value, iat)
428
+ return value.to_i if value.is_a?(Integer)
429
+ return value.to_i if value.is_a?(Time)
430
+
431
+ iat.to_i + parse_duration(value.to_s)
432
+ end
433
+
434
+ def parse_duration(value)
435
+ match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i)
436
+ raise TypeError, "Invalid time string" unless match
437
+
438
+ amount = match[1].to_i
439
+ amount = -amount if value.include?("ago")
440
+ unit = match[2].downcase
441
+ multiplier = case unit
442
+ when "s", "sec", "secs", "second", "seconds" then 1
443
+ when "m", "min", "mins", "minute", "minutes" then 60
444
+ when "h", "hr", "hrs", "hour", "hours" then 3600
445
+ when "d", "day", "days" then 86_400
446
+ when "w", "week", "weeks" then 604_800
447
+ else 31_557_600
448
+ end
449
+ amount * multiplier
450
+ end
451
+
452
+ def base64url_bn(number)
453
+ hex = number.to_s(16)
454
+ hex = "0#{hex}" if hex.length.odd?
455
+ Crypto.base64url_encode([hex].pack("H*"))
456
+ end
457
+
458
+ def deep_merge(base, override)
459
+ normalize_hash(base || {}).merge(normalize_hash(override || {})) do |_key, old_value, new_value|
460
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
461
+ deep_merge(old_value, new_value)
462
+ else
463
+ new_value
464
+ end
465
+ end
466
+ end
467
+
468
+ def stringify_payload(value)
469
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_payload(object_value) } if value.is_a?(Hash)
470
+ return value.map { |entry| stringify_payload(entry) } if value.is_a?(Array)
471
+
472
+ value
473
+ end
474
+
475
+ def normalize_time(value)
476
+ return value if value.is_a?(Time)
477
+ return nil if value.nil?
478
+
479
+ Time.parse(value.to_s)
480
+ end
481
+ end
482
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def last_login_method(options = {})
8
+ config = {
9
+ cookie_name: "better-auth.last_used_login_method",
10
+ max_age: 60 * 60 * 24 * 30
11
+ }.merge(normalize_hash(options))
12
+
13
+ Plugin.new(
14
+ id: "last-login-method",
15
+ schema: last_login_method_schema(config),
16
+ hooks: {
17
+ after: [
18
+ {
19
+ matcher: ->(_ctx) { true },
20
+ handler: ->(ctx) { apply_last_login_method(ctx, config) }
21
+ }
22
+ ]
23
+ },
24
+ options: config
25
+ )
26
+ end
27
+
28
+ def last_login_method_schema(config)
29
+ return {} unless config[:store_in_database]
30
+
31
+ field_name = config.dig(:schema, :user, :last_login_method) || "lastLoginMethod"
32
+ {
33
+ user: {
34
+ fields: {
35
+ lastLoginMethod: {
36
+ type: "string",
37
+ input: false,
38
+ required: false,
39
+ field_name: field_name
40
+ }
41
+ }
42
+ }
43
+ }
44
+ end
45
+
46
+ def apply_last_login_method(ctx, config)
47
+ method = resolve_login_method(ctx, config)
48
+ return unless method
49
+
50
+ set_cookie = ctx.response_headers["set-cookie"].to_s
51
+ return unless set_cookie.include?(ctx.context.auth_cookies[:session_token].name)
52
+
53
+ attributes = ctx.context.auth_cookies[:session_token].attributes.merge(max_age: config[:max_age], http_only: false)
54
+ ctx.set_cookie(config[:cookie_name], method, attributes)
55
+
56
+ if config[:store_in_database] && ctx.context.new_session&.dig(:user, "id")
57
+ updated = ctx.context.internal_adapter.update_user(ctx.context.new_session[:user]["id"], lastLoginMethod: method)
58
+ ctx.context.new_session[:user].merge!(updated) if updated
59
+ end
60
+ nil
61
+ end
62
+
63
+ def resolve_login_method(ctx, config)
64
+ custom = config[:custom_resolve_method]
65
+ resolve_context = ctx
66
+ unless ctx.path
67
+ resolve_context = ctx.dup
68
+ resolve_context.path = ""
69
+ end
70
+ resolved = custom.call(resolve_context) if custom.respond_to?(:call)
71
+ return resolved if resolved
72
+
73
+ path = resolve_context.path.to_s
74
+ case path
75
+ when "/sign-in/email", "/sign-up/email"
76
+ "email"
77
+ when "/callback/:providerId"
78
+ fetch_value(ctx.params, "providerId")
79
+ when "/oauth2/callback/:providerId"
80
+ fetch_value(ctx.params, "providerId")
81
+ else
82
+ return Regexp.last_match(1) if path =~ %r{\A/callback/([^/]+)\z}
83
+ return Regexp.last_match(1) if path =~ %r{\A/oauth2/callback/([^/]+)\z}
84
+ return "siwe" if path.include?("siwe")
85
+ return "passkey" if path.include?("/passkey/verify-authentication")
86
+ return "magic-link" if path.start_with?("/magic-link/verify")
87
+
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end