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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require_relative "error"
7
+
8
+ module BetterAuth
9
+ module Password
10
+ PREFIX = "bcrypt_sha256$"
11
+ BCRYPT_PREFIXES = ["$2a$", "$2b$", "$2x$", "$2y$"].freeze
12
+ SCRYPT = {
13
+ N: 16_384,
14
+ r: 16,
15
+ p: 1,
16
+ length: 64
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def hash(password, hasher: nil, algorithm: :scrypt)
22
+ return hasher.call(password) if hasher.respond_to?(:call)
23
+
24
+ case (hasher || algorithm || :scrypt).to_sym
25
+ when :scrypt
26
+ hash_scrypt(password)
27
+ when :bcrypt
28
+ hash_bcrypt(password)
29
+ else
30
+ raise Error, "Unsupported password hasher: #{hasher || algorithm}. Supported hashers are :scrypt and :bcrypt."
31
+ end
32
+ end
33
+
34
+ def verify(password:, hash:, verifier: nil, algorithm: :scrypt)
35
+ return call_verifier(verifier, password, hash) if verifier.respond_to?(:call)
36
+
37
+ digest = hash.to_s
38
+ if digest.start_with?(PREFIX)
39
+ return verify_bcrypt(password_input(password), digest.delete_prefix(PREFIX))
40
+ end
41
+
42
+ return verify_bcrypt(password.to_s, digest) if bcrypt_hash?(digest)
43
+ return verify_scrypt(password, digest) if scrypt_hash?(digest)
44
+
45
+ false
46
+ end
47
+
48
+ def password_input(password)
49
+ Digest::SHA256.hexdigest(password.to_s)
50
+ end
51
+
52
+ def hash_scrypt(password)
53
+ salt = SecureRandom.random_bytes(16).unpack1("H*")
54
+ key = scrypt_key(password, salt)
55
+ "#{salt}:#{key.unpack1("H*")}"
56
+ end
57
+
58
+ def verify_scrypt(password, digest)
59
+ salt, key = digest.to_s.split(":", 2)
60
+ return false unless salt && key
61
+
62
+ expected = scrypt_key(password, salt).unpack1("H*")
63
+ return false unless expected.bytesize == key.bytesize
64
+
65
+ OpenSSL.fixed_length_secure_compare(expected, key.downcase)
66
+ rescue OpenSSL::KDF::KDFError, ArgumentError
67
+ false
68
+ end
69
+
70
+ def scrypt_key(password, salt)
71
+ OpenSSL::KDF.scrypt(
72
+ password.to_s.unicode_normalize(:nfkc),
73
+ salt: salt,
74
+ N: SCRYPT.fetch(:N),
75
+ r: SCRYPT.fetch(:r),
76
+ p: SCRYPT.fetch(:p),
77
+ length: SCRYPT.fetch(:length)
78
+ )
79
+ end
80
+
81
+ def hash_bcrypt(password)
82
+ klass = require_bcrypt!
83
+ "#{PREFIX}#{klass.create(password_input(password))}"
84
+ end
85
+
86
+ def verify_bcrypt(password, digest)
87
+ klass = require_bcrypt!
88
+ klass.new(digest) == password.to_s
89
+ rescue BCrypt::Errors::InvalidHash
90
+ false
91
+ end
92
+
93
+ def bcrypt_hash?(digest)
94
+ BCRYPT_PREFIXES.any? { |prefix| digest.start_with?(prefix) }
95
+ end
96
+
97
+ def scrypt_hash?(digest)
98
+ /\A[0-9a-fA-F]{32}:[0-9a-fA-F]{128}\z/.match?(digest.to_s)
99
+ end
100
+
101
+ def call_verifier(verifier, password, digest)
102
+ if verifier.arity == 1
103
+ verifier.call(password: password, hash: digest)
104
+ else
105
+ verifier.call(password, digest)
106
+ end
107
+ end
108
+
109
+ def bcrypt_password_class
110
+ require "bcrypt"
111
+ BCrypt::Password
112
+ rescue LoadError
113
+ nil
114
+ end
115
+
116
+ def require_bcrypt!
117
+ bcrypt_password_class || raise(Error, "The :bcrypt password hasher requires the optional bcrypt gem. Add `gem \"bcrypt\"` to your Gemfile.")
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class Plugin
5
+ FIELDS = [
6
+ :id,
7
+ :init,
8
+ :endpoints,
9
+ :middlewares,
10
+ :hooks,
11
+ :schema,
12
+ :migrations,
13
+ :options,
14
+ :rate_limit,
15
+ :error_codes,
16
+ :on_request,
17
+ :on_response,
18
+ :adapter
19
+ ].freeze
20
+
21
+ attr_reader(*FIELDS)
22
+
23
+ def self.coerce(value)
24
+ return value if value.is_a?(self)
25
+
26
+ new(value || {})
27
+ end
28
+
29
+ def initialize(data = {}, **keywords)
30
+ data = data.to_h if data.respond_to?(:to_h) && !data.is_a?(Hash)
31
+ raw = normalize_hash((data || {}).merge(keywords))
32
+
33
+ @id = raw[:id].to_s
34
+ @init = raw[:init]
35
+ @endpoints = normalize_endpoint_keys(raw[:endpoints] || {})
36
+ @middlewares = normalize_middlewares(raw[:middlewares] || [])
37
+ @hooks = normalize_hooks(raw[:hooks] || {})
38
+ @schema = raw[:schema] || {}
39
+ @migrations = raw[:migrations] || {}
40
+ @options = raw[:options] || {}
41
+ @rate_limit = Array(raw[:rate_limit])
42
+ @error_codes = normalize_error_codes(raw)
43
+ @on_request = raw[:on_request]
44
+ @on_response = raw[:on_response]
45
+ @adapter = raw[:adapter]
46
+ end
47
+
48
+ def [](key)
49
+ to_h[normalize_key(key)]
50
+ end
51
+
52
+ def fetch(key, *default, &block)
53
+ normalized = normalize_key(key)
54
+ return to_h.fetch(normalized, *default, &block) if default.any? || block
55
+
56
+ to_h.fetch(normalized)
57
+ end
58
+
59
+ def dig(*keys)
60
+ keys.reduce(to_h) do |value, key|
61
+ return nil unless value.respond_to?(:[])
62
+
63
+ value[normalize_key(key)] || value[key]
64
+ end
65
+ end
66
+
67
+ def merge_options!(defaults)
68
+ @options = deep_merge(@options, normalize_hash(defaults || {}))
69
+ end
70
+
71
+ def to_h
72
+ FIELDS.each_with_object({}) do |field, result|
73
+ result[field] = public_send(field)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def normalize_endpoint_keys(value)
80
+ normalize_hash(value).each_with_object({}) do |(key, endpoint), result|
81
+ result[normalize_key(key)] = endpoint
82
+ end
83
+ end
84
+
85
+ def normalize_middlewares(value)
86
+ Array(value).map { |middleware| normalize_hash(middleware) }
87
+ end
88
+
89
+ def normalize_hooks(value)
90
+ data = normalize_hash(value)
91
+ {
92
+ before: Array(data[:before]).map { |hook| normalize_hash(hook) },
93
+ after: Array(data[:after]).map { |hook| normalize_hash(hook) }
94
+ }
95
+ end
96
+
97
+ def normalize_error_codes(raw)
98
+ codes = raw[:error_codes] || raw[:ERROR_CODES] || raw[:$ERROR_CODES]
99
+ normalize_hash(codes || {}).transform_keys { |key| key.to_s.upcase }
100
+ end
101
+
102
+ def normalize_hash(value)
103
+ return {} unless value.is_a?(Hash)
104
+
105
+ value.each_with_object({}) do |(key, object), result|
106
+ result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
107
+ end
108
+ end
109
+
110
+ def normalize_key(key)
111
+ key.to_s
112
+ .delete_prefix("$")
113
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
114
+ .tr("-", "_")
115
+ .downcase
116
+ .to_sym
117
+ end
118
+
119
+ def deep_merge(base, override)
120
+ base.merge(override) do |_key, old_value, new_value|
121
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
122
+ deep_merge(old_value, new_value)
123
+ else
124
+ new_value
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class PluginContext
5
+ attr_reader :context, :plugin
6
+
7
+ def initialize(context, plugin = nil)
8
+ @context = context
9
+ @plugin = plugin
10
+ end
11
+
12
+ def apply!(attributes)
13
+ context.apply_plugin_context!(attributes || {})
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class PluginRegistry
5
+ attr_reader :context, :plugins
6
+
7
+ def initialize(context)
8
+ @context = context
9
+ @plugins = context.options.plugins
10
+ end
11
+
12
+ def run_init!
13
+ plugins.each do |plugin|
14
+ next unless plugin.init
15
+
16
+ result = plugin.init.call(context)
17
+ next unless result.is_a?(Hash)
18
+
19
+ apply_options(plugin, result[:options] || result["options"])
20
+ PluginContext.new(context, plugin).apply!(result[:context] || result["context"])
21
+ end
22
+
23
+ context.refresh_from_options!
24
+ context.set_internal_adapter(Adapters::InternalAdapter.new(context.adapter, context.options))
25
+ end
26
+
27
+ def endpoints
28
+ plugins.each_with_object({}) do |plugin, result|
29
+ result.merge!(plugin.endpoints)
30
+ end
31
+ end
32
+
33
+ def error_codes(base)
34
+ plugins.each_with_object(base.dup) do |plugin, codes|
35
+ plugin.error_codes.each do |key, value|
36
+ codes[key.to_s.upcase] = value
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def apply_options(plugin, options)
44
+ return unless options.is_a?(Hash)
45
+
46
+ normalized = normalize_hash(options)
47
+ plugin.merge_options!(normalized)
48
+ context.options.merge_defaults!(normalized)
49
+ end
50
+
51
+ def normalize_hash(value)
52
+ return {} unless value.is_a?(Hash)
53
+
54
+ value.each_with_object({}) do |(key, object), result|
55
+ result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
56
+ end
57
+ end
58
+
59
+ def normalize_key(key)
60
+ key.to_s
61
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
62
+ .tr("-", "_")
63
+ .downcase
64
+ .to_sym
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ class Role
6
+ attr_reader :statements
7
+
8
+ def initialize(statements)
9
+ @statements = stringify_statements(statements)
10
+ end
11
+
12
+ def authorize(request, connector = "AND")
13
+ success = false
14
+ stringify_request(request).each do |resource, requested_actions|
15
+ allowed_actions = statements[resource]
16
+ unless allowed_actions
17
+ return {success: false, error: "You are not allowed to access resource: #{resource}"}
18
+ end
19
+
20
+ success = if requested_actions.is_a?(Array)
21
+ requested_actions.all? { |action| allowed_actions.include?(action.to_s) }
22
+ elsif requested_actions.is_a?(Hash)
23
+ unless requested_actions.key?("actions") || requested_actions.key?(:actions)
24
+ raise Error, "Invalid access control request"
25
+ end
26
+
27
+ raw_actions = requested_actions["actions"] || requested_actions[:actions]
28
+ raise Error, "Invalid access control request" if raw_actions.nil?
29
+
30
+ actions = Array(raw_actions).map(&:to_s)
31
+ action_connector = (requested_actions["connector"] || requested_actions[:connector] || "AND").to_s.upcase
32
+ if action_connector == "OR"
33
+ actions.any? { |action| allowed_actions.include?(action) }
34
+ else
35
+ actions.all? { |action| allowed_actions.include?(action) }
36
+ end
37
+ else
38
+ raise Error, "Invalid access control request"
39
+ end
40
+
41
+ return {success: true} if success && connector.to_s.upcase == "OR"
42
+ return {success: false, error: "unauthorized to access resource \"#{resource}\""} if !success && connector.to_s.upcase == "AND"
43
+ end
44
+
45
+ success ? {success: true} : {success: false, error: "Not authorized"}
46
+ end
47
+
48
+ private
49
+
50
+ def stringify_statements(value)
51
+ (value || {}).each_with_object({}) do |(resource, actions), result|
52
+ result[resource.to_s] = Array(actions).map(&:to_s)
53
+ end
54
+ end
55
+
56
+ def stringify_request(value)
57
+ (value || {}).each_with_object({}) do |(resource, actions), result|
58
+ result[resource.to_s] = actions
59
+ end
60
+ end
61
+ end
62
+
63
+ class AccessControl
64
+ attr_reader :statements
65
+
66
+ def initialize(statements)
67
+ @statements = (statements || {}).each_with_object({}) do |(resource, actions), result|
68
+ result[resource.to_s] = Array(actions).map(&:to_s)
69
+ end
70
+ end
71
+
72
+ def new_role(statements)
73
+ Role.new(statements)
74
+ end
75
+
76
+ alias_method :newRole, :new_role
77
+ end
78
+
79
+ module_function
80
+
81
+ def create_access_control(statements)
82
+ AccessControl.new(statements)
83
+ end
84
+
85
+ singleton_class.alias_method :createAccessControl, :create_access_control
86
+ end
87
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def additional_fields(schema = {})
8
+ config = normalize_hash(schema)
9
+ user_fields = storage_fields(config[:user] || {})
10
+ session_fields = storage_fields(config[:session] || {})
11
+
12
+ Plugin.new(
13
+ id: "additional-fields",
14
+ schema: {
15
+ user: {fields: user_fields},
16
+ session: {fields: session_fields}
17
+ },
18
+ init: lambda do |_context|
19
+ {
20
+ options: {
21
+ user: {additional_fields: user_fields},
22
+ session: {additional_fields: session_fields}
23
+ }
24
+ }
25
+ end
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module AdminSchema
6
+ module_function
7
+
8
+ def build(custom = nil)
9
+ schema = {
10
+ user: {
11
+ fields: {
12
+ role: {type: "string", required: false, input: false},
13
+ banned: {type: "boolean", required: false, input: false, default_value: false},
14
+ banReason: {type: "string", required: false, input: false},
15
+ banExpires: {type: "date", required: false, input: false}
16
+ }
17
+ },
18
+ session: {
19
+ fields: {
20
+ impersonatedBy: {type: "string", required: false}
21
+ }
22
+ }
23
+ }
24
+ OrganizationSchema.merge_custom_schema(schema, custom)
25
+ end
26
+ end
27
+ end
28
+ end