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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Cookies
9
+ SECURE_COOKIE_PREFIX = "__Secure-"
10
+ HOST_COOKIE_PREFIX = "__Host-"
11
+
12
+ Cookie = Struct.new(:name, :attributes, keyword_init: true) do
13
+ alias_method :options, :attributes
14
+ end
15
+
16
+ module_function
17
+
18
+ def get_cookies(options)
19
+ {
20
+ session_token: create_cookie(options, "session_token", max_age: options.session[:expires_in] || 60 * 60 * 24 * 7),
21
+ session_data: create_cookie(options, "session_data", max_age: options.session.dig(:cookie_cache, :max_age) || 60 * 5),
22
+ account_data: create_cookie(options, "account_data", max_age: options.session.dig(:cookie_cache, :max_age) || 60 * 5),
23
+ dont_remember: create_cookie(options, "dont_remember")
24
+ }
25
+ end
26
+
27
+ def create_cookie(options, cookie_name, override_attributes = {})
28
+ advanced = options.advanced || {}
29
+ secure = if advanced.key?(:use_secure_cookies)
30
+ advanced[:use_secure_cookies]
31
+ elsif options.base_url.to_s.start_with?("https://")
32
+ true
33
+ else
34
+ production_environment?
35
+ end
36
+ cross_subdomain = advanced.dig(:cross_subdomain_cookies, :enabled)
37
+ domain = if cross_subdomain
38
+ advanced.dig(:cross_subdomain_cookies, :domain) || begin
39
+ uri = URI.parse(options.base_url.to_s)
40
+ uri.host unless uri.host.to_s.empty?
41
+ end
42
+ end
43
+ raise Error, "base_url is required when cross_subdomain_cookies are enabled" if cross_subdomain && domain.to_s.empty?
44
+
45
+ custom = advanced.dig(:cookies, cookie_name.to_sym) || {}
46
+ prefix = advanced[:cookie_prefix] || "better-auth"
47
+ name = custom[:name] || "#{prefix}.#{cookie_name}"
48
+ attributes = {
49
+ secure: !!secure,
50
+ same_site: "lax",
51
+ path: "/",
52
+ http_only: true
53
+ }
54
+ attributes[:domain] = domain if domain
55
+ attributes = attributes
56
+ .merge(advanced[:default_cookie_attributes] || {})
57
+ .merge(override_attributes || {})
58
+ .merge(custom[:attributes] || {})
59
+ .compact
60
+
61
+ cookie_prefix = secure ? SECURE_COOKIE_PREFIX : ""
62
+ Cookie.new(name: "#{cookie_prefix}#{name}", attributes: attributes)
63
+ end
64
+
65
+ def parse_cookies(cookie_header)
66
+ cookie_header.to_s.split(/;\s*/).each_with_object({}) do |pair, result|
67
+ name, value = pair.split("=", 2)
68
+ next if name.to_s.empty? || value.nil?
69
+
70
+ result[name.strip] = value.strip
71
+ end
72
+ end
73
+
74
+ def strip_secure_cookie_prefix(name)
75
+ name.to_s.delete_prefix(SECURE_COOKIE_PREFIX).delete_prefix(HOST_COOKIE_PREFIX)
76
+ end
77
+
78
+ def get_session_cookie(request_or_cookie_header, config = {})
79
+ cookie_header = header_value(request_or_cookie_header)
80
+ return nil if cookie_header.to_s.empty?
81
+
82
+ parsed = parse_cookies(cookie_header)
83
+ cookie_name = config[:cookie_name] || "session_token"
84
+ cookie_prefix = config[:cookie_prefix] || "better-auth"
85
+ candidates = [
86
+ "#{cookie_prefix}.#{cookie_name}",
87
+ "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}",
88
+ "#{cookie_prefix}-#{cookie_name}",
89
+ "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}-#{cookie_name}"
90
+ ]
91
+ candidates.lazy.filter_map { |candidate| parsed[candidate] }.first
92
+ end
93
+
94
+ def set_session_cookie(ctx, session, dont_remember_me = false, overrides = {})
95
+ token_cookie = ctx.context.auth_cookies[:session_token]
96
+ max_age = dont_remember_me ? nil : ctx.context.session_config[:expires_in]
97
+ ctx.set_signed_cookie(token_cookie.name, session.fetch(:session).fetch("token"), ctx.context.secret, token_cookie.attributes.merge(max_age: max_age).merge(overrides || {}))
98
+
99
+ if dont_remember_me
100
+ dont_remember_cookie = ctx.context.auth_cookies[:dont_remember]
101
+ ctx.set_signed_cookie(dont_remember_cookie.name, "true", ctx.context.secret, dont_remember_cookie.attributes)
102
+ end
103
+
104
+ set_cookie_cache(ctx, session, dont_remember_me)
105
+ ctx.context.set_new_session(session) if ctx.context.respond_to?(:set_new_session)
106
+ end
107
+
108
+ def set_cookie_cache(ctx, session, dont_remember_me)
109
+ config = ctx.context.session_config[:cookie_cache] || {}
110
+ return unless config[:enabled]
111
+
112
+ cookie = ctx.context.auth_cookies[:session_data]
113
+ max_age = dont_remember_me ? nil : cookie.attributes[:max_age]
114
+ data = filtered_cache_data(ctx, session)
115
+ value = encode_cookie_cache(data, ctx.context.secret, strategy: config[:strategy] || "compact", max_age: max_age || 60 * 5)
116
+ attributes = cookie.attributes.merge(max_age: max_age)
117
+ store = SessionStore.new(cookie.name, attributes, ctx)
118
+
119
+ if value.length > SessionStore::CHUNK_SIZE
120
+ store.set_cookies(store.chunk(value, attributes))
121
+ else
122
+ store.set_cookies(store.clean) if store.chunks?
123
+ ctx.set_cookie(cookie.name, value, attributes)
124
+ end
125
+ end
126
+
127
+ def set_account_cookie(ctx, account_data)
128
+ return unless ctx.context.options.account[:store_account_cookie]
129
+
130
+ cookie = ctx.context.auth_cookies[:account_data]
131
+ attributes = cookie.attributes.merge(max_age: cookie.attributes[:max_age] || 60 * 5)
132
+ value = Crypto.symmetric_encode_jwt(stringify_keys(account_data), ctx.context.secret, "better-auth-account", expires_in: attributes[:max_age])
133
+ store = SessionStore.new(cookie.name, attributes, ctx)
134
+
135
+ if value.length > SessionStore::CHUNK_SIZE
136
+ store.set_cookies(store.chunk(value, attributes))
137
+ else
138
+ store.set_cookies(store.clean) if store.chunks?
139
+ ctx.set_cookie(cookie.name, value, attributes)
140
+ end
141
+ end
142
+
143
+ def get_account_cookie(ctx)
144
+ cookie = ctx.context.auth_cookies[:account_data]
145
+ value = SessionStore.get_chunked_cookie(ctx, cookie.name)
146
+ return nil unless value
147
+
148
+ Crypto.symmetric_decode_jwt(value, ctx.context.secret, "better-auth-account")
149
+ end
150
+
151
+ def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil)
152
+ cookie_header = header_value(request_or_cookie_header)
153
+ return nil if cookie_header.to_s.empty?
154
+
155
+ parsed = parse_cookies(cookie_header)
156
+ name = if is_secure.nil?
157
+ production_environment? ? "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}" : "#{cookie_prefix}.#{cookie_name}"
158
+ else
159
+ secure_prefix = is_secure ? SECURE_COOKIE_PREFIX : ""
160
+ "#{secure_prefix}#{cookie_prefix}.#{cookie_name}"
161
+ end
162
+ raw = parsed[name] || chunked_value(parsed, name)
163
+ return nil unless raw
164
+
165
+ payload = decode_cookie_cache(raw, secret, strategy: strategy)
166
+ return nil unless payload && payload["session"] && payload["user"]
167
+
168
+ expected_version = cookie_cache_version(version, payload["session"], payload["user"])
169
+ return nil if version && (payload["version"] || "1") != expected_version
170
+
171
+ payload
172
+ end
173
+
174
+ def expire_cookie(ctx, cookie)
175
+ ctx.set_cookie(cookie.name, "", cookie.attributes.merge(max_age: 0))
176
+ end
177
+
178
+ def delete_session_cookie(ctx, skip_dont_remember_me: false)
179
+ expire_cookie(ctx, ctx.context.auth_cookies[:session_token])
180
+ expire_cookie(ctx, ctx.context.auth_cookies[:session_data])
181
+ expire_cookie(ctx, ctx.context.auth_cookies[:account_data]) if ctx.context.options.account[:store_account_cookie]
182
+
183
+ store = SessionStore.new(ctx.context.auth_cookies[:session_data].name, ctx.context.auth_cookies[:session_data].attributes, ctx)
184
+ store.set_cookies(store.clean)
185
+ expire_cookie(ctx, ctx.context.auth_cookies[:dont_remember]) unless skip_dont_remember_me
186
+ end
187
+
188
+ def dont_remember?(ctx)
189
+ cookie = ctx.context.auth_cookies[:dont_remember]
190
+ ctx.get_signed_cookie(cookie.name, ctx.context.secret) == "true"
191
+ end
192
+
193
+ def encode_cookie_cache(data, secret, strategy:, max_age:)
194
+ case strategy.to_s
195
+ when "jwt"
196
+ Crypto.sign_jwt(data, secret, expires_in: max_age)
197
+ when "jwe"
198
+ Crypto.symmetric_encode_jwt(data, secret, "better-auth-session", expires_in: max_age)
199
+ else
200
+ expires_at = current_millis + (max_age.to_i * 1000)
201
+ signed = data.merge("expiresAt" => expires_at)
202
+ signature = Crypto.hmac_signature(JSON.generate(signed), secret, encoding: :base64url)
203
+ Crypto.base64url_encode(JSON.generate({"session" => data, "expiresAt" => expires_at, "signature" => signature}))
204
+ end
205
+ end
206
+
207
+ def decode_cookie_cache(value, secret, strategy:)
208
+ case strategy.to_s
209
+ when "jwt"
210
+ Crypto.verify_jwt(value, secret)
211
+ when "jwe"
212
+ Crypto.symmetric_decode_jwt(value, secret, "better-auth-session")
213
+ else
214
+ payload = JSON.parse(Crypto.base64url_decode(value))
215
+ return nil if payload["expiresAt"].to_i <= current_millis
216
+
217
+ signed = payload.fetch("session").merge("expiresAt" => payload.fetch("expiresAt"))
218
+ valid = Crypto.verify_hmac_signature(JSON.generate(signed), payload["signature"], secret, encoding: :base64url)
219
+ valid ? payload["session"] : nil
220
+ end
221
+ rescue JSON::ParserError, KeyError, ArgumentError, JWT::DecodeError
222
+ nil
223
+ end
224
+
225
+ def filtered_cache_data(ctx, session)
226
+ {
227
+ "session" => stringify_keys(Schema.parse_output(ctx.context.options, "session", stringify_keys(session.fetch(:session)))),
228
+ "user" => stringify_keys(Schema.parse_output(ctx.context.options, "user", stringify_keys(session.fetch(:user)))),
229
+ "updatedAt" => current_millis,
230
+ "version" => cookie_cache_version(
231
+ ctx.context.session_config.dig(:cookie_cache, :version),
232
+ session.fetch(:session),
233
+ session.fetch(:user)
234
+ )
235
+ }
236
+ end
237
+
238
+ def chunked_value(cookies, name)
239
+ chunks = cookies.each_with_object([]) do |(cookie_name, value), result|
240
+ next unless cookie_name.start_with?("#{name}.")
241
+
242
+ result << [SessionStore.chunk_index(cookie_name), value]
243
+ end
244
+ return nil if chunks.empty?
245
+
246
+ chunks.sort_by(&:first).map(&:last).join
247
+ end
248
+
249
+ def header_value(request_or_cookie_header)
250
+ return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
251
+ return request_or_cookie_header.get_header("HTTP_COOKIE") if request_or_cookie_header.respond_to?(:get_header)
252
+
253
+ request_or_cookie_header.to_s
254
+ end
255
+
256
+ def cookie_cache_version(config, session, user)
257
+ return "1" unless config
258
+ return config.to_s unless config.respond_to?(:call)
259
+
260
+ config.call(session, user).to_s
261
+ end
262
+
263
+ def current_millis
264
+ (Time.now.to_f * 1000).to_i
265
+ end
266
+
267
+ def stringify_keys(value)
268
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
269
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
270
+
271
+ value
272
+ end
273
+
274
+ def production_environment?
275
+ ENV["RACK_ENV"] == "production" || ENV["RAILS_ENV"] == "production" || ENV["APP_ENV"] == "production"
276
+ end
277
+ end
278
+ end
@@ -1,7 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "request_ip"
4
+
3
5
  module BetterAuth
4
6
  module Core
5
- # Core authentication logic goes here
7
+ def self.base_endpoints
8
+ {
9
+ ok: Routes.ok,
10
+ error: Routes.error,
11
+ sign_up_email: Routes.sign_up_email,
12
+ sign_in_email: Routes.sign_in_email,
13
+ sign_in_social: Routes.sign_in_social,
14
+ callback_oauth: Routes.callback_oauth,
15
+ sign_out: Routes.sign_out,
16
+ get_session: Routes.get_session,
17
+ list_sessions: Routes.list_sessions,
18
+ update_session: Routes.update_session,
19
+ revoke_session: Routes.revoke_session,
20
+ revoke_sessions: Routes.revoke_sessions,
21
+ revoke_other_sessions: Routes.revoke_other_sessions,
22
+ request_password_reset: Routes.request_password_reset,
23
+ request_password_reset_callback: Routes.request_password_reset_callback,
24
+ reset_password: Routes.reset_password,
25
+ verify_password: Routes.verify_password,
26
+ send_verification_email: Routes.send_verification_email,
27
+ verify_email: Routes.verify_email,
28
+ update_user: Routes.update_user,
29
+ change_email: Routes.change_email,
30
+ change_password: Routes.change_password,
31
+ set_password: Routes.set_password,
32
+ delete_user: Routes.delete_user,
33
+ delete_user_callback: Routes.delete_user_callback,
34
+ list_accounts: Routes.list_accounts,
35
+ link_social: Routes.link_social,
36
+ unlink_account: Routes.unlink_account,
37
+ get_access_token: Routes.get_access_token,
38
+ refresh_token: Routes.refresh_token,
39
+ account_info: Routes.account_info
40
+ }
41
+ end
6
42
  end
7
43
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ previous_verbose = $VERBOSE
9
+ $VERBOSE = nil
10
+ require "jwe"
11
+ $VERBOSE = previous_verbose
12
+
13
+ module BetterAuth
14
+ module Crypto
15
+ module JWE
16
+ ALG = "dir"
17
+ ENC = "A256CBC-HS512"
18
+ INFO = "BetterAuth.js Generated Encryption Key"
19
+ CLOCK_TOLERANCE = 15
20
+
21
+ module_function
22
+
23
+ def encode(payload, secret, salt, expires_in: 3600)
24
+ claims = Crypto.stringify_keys(payload).merge(
25
+ "iat" => Time.now.to_i,
26
+ "exp" => Time.now.to_i + expires_in.to_i,
27
+ "jti" => SecureRandom.uuid
28
+ )
29
+ key = encryption_key(secret, salt)
30
+ ::JWE.encrypt(JSON.generate(claims), key, alg: ALG, enc: ENC, kid: thumbprint(key))
31
+ end
32
+
33
+ def decode(token, secret, salt)
34
+ return nil if token.to_s.empty?
35
+
36
+ header = protected_header(token)
37
+ return nil unless valid_header?(header)
38
+ return nil unless header["kid"].nil? || header["kid"] == thumbprint(encryption_key(secret, salt))
39
+
40
+ payload = JSON.parse(::JWE.decrypt(token.to_s, encryption_key(secret, salt)))
41
+ return nil if expired?(payload)
42
+
43
+ payload
44
+ rescue JSON::ParserError, ArgumentError, ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK
45
+ nil
46
+ end
47
+
48
+ def encryption_key(secret, salt)
49
+ OpenSSL::KDF.hkdf(secret.to_s, salt: salt.to_s, info: INFO, length: 64, hash: "SHA256")
50
+ end
51
+
52
+ def thumbprint(key)
53
+ jwk = {
54
+ "k" => Base64.urlsafe_encode64(key, padding: false),
55
+ "kty" => "oct"
56
+ }
57
+ Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", JSON.generate(jwk)))
58
+ end
59
+
60
+ def protected_header(token)
61
+ first_segment = token.to_s.split(".", 2).first
62
+ JSON.parse(Crypto.base64url_decode(first_segment))
63
+ rescue JSON::ParserError, ArgumentError
64
+ {}
65
+ end
66
+
67
+ def valid_header?(header)
68
+ header["alg"] == ALG && header["enc"] == ENC
69
+ end
70
+
71
+ def expired?(payload)
72
+ payload["exp"] && payload["exp"].to_i < Time.now.to_i - CLOCK_TOLERANCE
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "jwt"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require_relative "crypto/jwe"
9
+
10
+ module BetterAuth
11
+ module Crypto
12
+ URL_SAFE_ALPHABET = [*"a".."z", *"A".."Z", *"0".."9", "-", "_"].freeze
13
+ MASK_64 = (1 << 64) - 1
14
+ KECCAK_ROUND_CONSTANTS = [
15
+ 0x0000000000000001, 0x0000000000008082, 0x800000000000808a, 0x8000000080008000,
16
+ 0x000000000000808b, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
17
+ 0x000000000000008a, 0x0000000000000088, 0x0000000080008009, 0x000000008000000a,
18
+ 0x000000008000808b, 0x800000000000008b, 0x8000000000008089, 0x8000000000008003,
19
+ 0x8000000000008002, 0x8000000000000080, 0x000000000000800a, 0x800000008000000a,
20
+ 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008
21
+ ].freeze
22
+ KECCAK_ROTATION_OFFSETS = [
23
+ [0, 36, 3, 41, 18],
24
+ [1, 44, 10, 45, 2],
25
+ [62, 6, 43, 15, 61],
26
+ [28, 55, 25, 21, 56],
27
+ [27, 20, 39, 8, 14]
28
+ ].freeze
29
+
30
+ module_function
31
+
32
+ def random_string(length = 32)
33
+ Array.new(length) { URL_SAFE_ALPHABET[SecureRandom.random_number(URL_SAFE_ALPHABET.length)] }.join
34
+ end
35
+
36
+ def uuid
37
+ SecureRandom.uuid
38
+ end
39
+
40
+ def sha256(value, encoding: :hex)
41
+ digest = OpenSSL::Digest.digest("SHA256", value.to_s)
42
+ (encoding == :base64url) ? base64url_encode(digest) : digest.unpack1("H*")
43
+ end
44
+
45
+ def keccak256(value, encoding: :hex)
46
+ digest = keccak256_bytes(value.to_s.b)
47
+ (encoding == :bytes) ? digest : digest.unpack1("H*")
48
+ end
49
+
50
+ def to_checksum_address(address)
51
+ normalized = address.to_s.downcase.delete_prefix("0x")
52
+ hash = keccak256(normalized)
53
+
54
+ "0x" + normalized.chars.each_with_index.map do |char, index|
55
+ (hash[index].to_i(16) >= 8) ? char.upcase : char
56
+ end.join
57
+ end
58
+
59
+ def hmac_signature(value, secret, encoding: :base64)
60
+ digest = OpenSSL::HMAC.digest("SHA256", secret.to_s, value.to_s)
61
+ (encoding == :base64url) ? base64url_encode(digest) : Base64.strict_encode64(digest)
62
+ end
63
+
64
+ def verify_hmac_signature(value, signature, secret, encoding: :base64)
65
+ expected = hmac_signature(value, secret, encoding: encoding)
66
+ constant_time_compare(expected, signature.to_s)
67
+ end
68
+
69
+ def constant_time_compare(left, right)
70
+ return false unless left.bytesize == right.bytesize
71
+
72
+ OpenSSL.fixed_length_secure_compare(left, right)
73
+ end
74
+
75
+ def symmetric_encrypt(key:, data:)
76
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
77
+ cipher.encrypt
78
+ cipher.key = OpenSSL::Digest.digest("SHA256", key.to_s)
79
+ iv = SecureRandom.random_bytes(12)
80
+ cipher.iv = iv
81
+ ciphertext = cipher.update(data.to_s) + cipher.final
82
+ payload = {
83
+ "iv" => base64url_encode(iv),
84
+ "data" => base64url_encode(ciphertext),
85
+ "tag" => base64url_encode(cipher.auth_tag)
86
+ }
87
+ base64url_encode(JSON.generate(payload))
88
+ end
89
+
90
+ def symmetric_decrypt(key:, data:)
91
+ payload = JSON.parse(base64url_decode(data.to_s))
92
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
93
+ cipher.decrypt
94
+ cipher.key = OpenSSL::Digest.digest("SHA256", key.to_s)
95
+ cipher.iv = base64url_decode(payload.fetch("iv"))
96
+ cipher.auth_tag = base64url_decode(payload.fetch("tag"))
97
+ cipher.update(base64url_decode(payload.fetch("data"))) + cipher.final
98
+ rescue JSON::ParserError, KeyError, OpenSSL::Cipher::CipherError, ArgumentError
99
+ nil
100
+ end
101
+
102
+ def sign_jwt(payload, secret, expires_in: 3600)
103
+ claims = stringify_keys(payload).merge(
104
+ "iat" => Time.now.to_i,
105
+ "exp" => Time.now.to_i + expires_in.to_i
106
+ )
107
+ JWT.encode(claims, secret.to_s, "HS256")
108
+ end
109
+
110
+ def verify_jwt(token, secret)
111
+ decoded, = JWT.decode(token.to_s, secret.to_s, true, algorithm: "HS256")
112
+ decoded
113
+ rescue JWT::DecodeError
114
+ nil
115
+ end
116
+
117
+ def symmetric_encode_jwt(payload, secret, salt, expires_in: 3600)
118
+ JWE.encode(payload, secret, salt, expires_in: expires_in)
119
+ end
120
+
121
+ def symmetric_decode_jwt(token, secret, salt)
122
+ JWE.decode(token, secret, salt)
123
+ end
124
+
125
+ def base64url_encode(value)
126
+ Base64.urlsafe_encode64(value.to_s, padding: false)
127
+ end
128
+
129
+ def base64url_decode(value)
130
+ Base64.urlsafe_decode64(value.to_s)
131
+ end
132
+
133
+ def stringify_keys(value)
134
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
135
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
136
+
137
+ value
138
+ end
139
+
140
+ def keccak256_bytes(input)
141
+ rate = 136
142
+ state = Array.new(25, 0)
143
+ padded = input.bytes
144
+ padded << 0x01
145
+ padded << 0 while (padded.length % rate) != rate - 1
146
+ padded << 0x80
147
+
148
+ padded.each_slice(rate) do |block|
149
+ block.each_with_index do |byte, index|
150
+ state[index / 8] ^= byte << (8 * (index % 8))
151
+ end
152
+ keccak_permute!(state)
153
+ end
154
+
155
+ state.pack("Q<*").byteslice(0, 32)
156
+ end
157
+
158
+ def keccak_permute!(state)
159
+ KECCAK_ROUND_CONSTANTS.each do |round_constant|
160
+ columns = Array.new(5) { |x| state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20] }
161
+ deltas = Array.new(5) { |x| columns[(x - 1) % 5] ^ rotate_left_64(columns[(x + 1) % 5], 1) }
162
+ 5.times do |x|
163
+ 5.times { |y| state[x + (5 * y)] = (state[x + (5 * y)] ^ deltas[x]) & MASK_64 }
164
+ end
165
+
166
+ rotated = Array.new(25, 0)
167
+ 5.times do |x|
168
+ 5.times do |y|
169
+ rotated[y + (5 * ((2 * x + 3 * y) % 5))] =
170
+ rotate_left_64(state[x + (5 * y)], KECCAK_ROTATION_OFFSETS[x][y])
171
+ end
172
+ end
173
+
174
+ 5.times do |y|
175
+ 5.times do |x|
176
+ state[x + (5 * y)] =
177
+ (rotated[x + (5 * y)] ^ ((~rotated[((x + 1) % 5) + (5 * y)]) & rotated[((x + 2) % 5) + (5 * y)])) & MASK_64
178
+ end
179
+ end
180
+ state[0] = (state[0] ^ round_constant) & MASK_64
181
+ end
182
+ end
183
+
184
+ def rotate_left_64(value, shift)
185
+ shift %= 64
186
+ return value & MASK_64 if shift.zero?
187
+
188
+ ((value << shift) | (value >> (64 - shift))) & MASK_64
189
+ end
190
+ end
191
+ end