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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ module_function
9
+
10
+ def one_tap(options = {})
11
+ config = normalize_hash(options)
12
+
13
+ Plugin.new(
14
+ id: "one-tap",
15
+ endpoints: {
16
+ one_tap_callback: one_tap_callback_endpoint(config)
17
+ },
18
+ options: config
19
+ )
20
+ end
21
+
22
+ def one_tap_callback_endpoint(config)
23
+ Endpoint.new(
24
+ path: "/one-tap/callback",
25
+ method: "POST",
26
+ body_schema: ->(body) {
27
+ data = normalize_hash(body)
28
+ data[:id_token].to_s.empty? ? false : data
29
+ },
30
+ metadata: {
31
+ openapi: {
32
+ summary: "One tap callback",
33
+ description: "Use this endpoint to authenticate with Google One Tap"
34
+ }
35
+ }
36
+ ) do |ctx|
37
+ body = normalize_hash(ctx.body)
38
+ id_token = body[:id_token].to_s
39
+ payload = one_tap_verify_id_token(ctx, config, id_token)
40
+ email = fetch_value(payload, "email").to_s.downcase
41
+
42
+ if email.empty?
43
+ next ctx.json({error: "Email not available in token"})
44
+ end
45
+
46
+ user = ctx.context.internal_adapter.find_user_by_email(email)
47
+ if user
48
+ one_tap_link_account_unless_present!(ctx, config, user, payload, id_token)
49
+ session_data = one_tap_create_session(ctx, user[:user])
50
+ else
51
+ raise APIError.new("BAD_GATEWAY", message: "User not found") if config[:disable_signup]
52
+
53
+ created = ctx.context.internal_adapter.create_oauth_user(
54
+ {
55
+ email: email,
56
+ emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")),
57
+ name: fetch_value(payload, "name").to_s,
58
+ image: fetch_value(payload, "picture")
59
+ },
60
+ {
61
+ providerId: "google",
62
+ accountId: fetch_value(payload, "sub").to_s,
63
+ idToken: id_token
64
+ }
65
+ )
66
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") unless created
67
+
68
+ session_data = one_tap_create_session(ctx, created[:user])
69
+ end
70
+
71
+ Cookies.set_session_cookie(ctx, session_data)
72
+ ctx.json({
73
+ token: session_data[:session]["token"],
74
+ user: Schema.parse_output(ctx.context.options, "user", session_data[:user])
75
+ })
76
+ end
77
+ end
78
+
79
+ def one_tap_verify_id_token(ctx, config, id_token)
80
+ verifier = config[:verify_id_token]
81
+ audience = config[:client_id] || ctx.context.options.social_providers.dig(:google, :client_id)
82
+ payload = if verifier.respond_to?(:call)
83
+ verifier.call(id_token, ctx, audience: audience)
84
+ else
85
+ one_tap_verify_google_id_token(id_token, audience)
86
+ end
87
+ one_tap_stringify_payload(payload)
88
+ rescue
89
+ raise APIError.new("BAD_REQUEST", message: "invalid id token")
90
+ end
91
+
92
+ def one_tap_verify_google_id_token(id_token, audience)
93
+ jwks = one_tap_google_jwks
94
+ options = {
95
+ algorithms: ["RS256"],
96
+ iss: ["https://accounts.google.com", "accounts.google.com"],
97
+ verify_iss: true
98
+ }
99
+ if audience
100
+ options[:aud] = audience
101
+ options[:verify_aud] = true
102
+ end
103
+ payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
104
+ payload
105
+ end
106
+
107
+ def one_tap_google_jwks
108
+ uri = URI("https://www.googleapis.com/oauth2/v3/certs")
109
+ response = Net::HTTP.get_response(uri)
110
+ raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)
111
+
112
+ JWT::JWK::Set.new(JSON.parse(response.body))
113
+ end
114
+
115
+ def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
116
+ sub = fetch_value(payload, "sub").to_s
117
+ account = ctx.context.internal_adapter.find_account(sub)
118
+ return if account
119
+
120
+ account_linking = ctx.context.options.account[:account_linking] || {}
121
+ trusted = Array(account_linking[:trusted_providers]).map(&:to_s).include?("google")
122
+ enabled = account_linking.fetch(:enabled, true)
123
+ should_link_account = enabled != false && (trusted || one_tap_boolean_value(fetch_value(payload, "email_verified")))
124
+ unless should_link_account
125
+ raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match")
126
+ end
127
+
128
+ ctx.context.internal_adapter.link_account(
129
+ userId: user[:user]["id"],
130
+ providerId: "google",
131
+ accountId: sub,
132
+ scope: "openid,profile,email",
133
+ idToken: id_token
134
+ )
135
+ end
136
+
137
+ def one_tap_create_session(ctx, user)
138
+ session = ctx.context.internal_adapter.create_session(user["id"])
139
+ {session: session, user: user}
140
+ end
141
+
142
+ def one_tap_stringify_payload(payload)
143
+ raise "Invalid payload" unless payload.is_a?(Hash)
144
+
145
+ payload.each_with_object({}) do |(key, value), result|
146
+ result[key.to_s] = value
147
+ end
148
+ end
149
+
150
+ def one_tap_boolean_value(value)
151
+ value == true || value.to_s.downcase == "true"
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def one_time_token(options = {})
8
+ config = {
9
+ expires_in: 3,
10
+ store_token: "plain"
11
+ }.merge(normalize_hash(options))
12
+
13
+ Plugin.new(
14
+ id: "one-time-token",
15
+ endpoints: {
16
+ generate_one_time_token: generate_one_time_token_endpoint(config),
17
+ verify_one_time_token: verify_one_time_token_endpoint(config)
18
+ },
19
+ hooks: {
20
+ after: [
21
+ {
22
+ matcher: ->(_ctx) { true },
23
+ handler: ->(ctx) { one_time_token_after_response(ctx, config) }
24
+ }
25
+ ]
26
+ },
27
+ options: config
28
+ )
29
+ end
30
+
31
+ def generate_one_time_token_endpoint(config)
32
+ Endpoint.new(path: "/one-time-token/generate", method: "GET") do |ctx|
33
+ if config[:disable_client_request] && ctx.request
34
+ raise APIError.new("BAD_REQUEST", message: "Client requests are disabled")
35
+ end
36
+
37
+ session = Routes.current_session(ctx)
38
+ token = one_time_token_create(ctx, config, session)
39
+ ctx.json({token: token})
40
+ end
41
+ end
42
+
43
+ def verify_one_time_token_endpoint(config)
44
+ Endpoint.new(path: "/one-time-token/verify", method: "POST") do |ctx|
45
+ body = normalize_hash(ctx.body)
46
+ token = body[:token].to_s
47
+ stored_token = one_time_token_stored_value(config, token)
48
+ verification = ctx.context.internal_adapter.find_verification_value("one-time-token:#{stored_token}")
49
+ raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification
50
+
51
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
52
+ raise APIError.new("BAD_REQUEST", message: "Token expired") if Routes.expired_time?(verification["expiresAt"])
53
+
54
+ session = ctx.context.internal_adapter.find_session(verification["value"])
55
+ raise APIError.new("BAD_REQUEST", message: "Session not found") unless session
56
+ raise APIError.new("BAD_REQUEST", message: "Session expired") if Routes.expired_time?(session[:session]["expiresAt"])
57
+
58
+ Cookies.set_session_cookie(ctx, session) unless config[:disable_set_session_cookie]
59
+ ctx.json(session)
60
+ end
61
+ end
62
+
63
+ def one_time_token_after_response(ctx, config)
64
+ return unless config[:set_ott_header_on_new_session]
65
+
66
+ session = ctx.context.new_session
67
+ return unless session && session[:session] && session[:user]
68
+
69
+ token = one_time_token_create(ctx, config, session)
70
+ existing = ctx.response_headers["access-control-expose-headers"].to_s
71
+ exposed = existing.split(",").map(&:strip).reject(&:empty?)
72
+ exposed << "set-ott"
73
+ ctx.set_header("set-ott", token)
74
+ ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
75
+ nil
76
+ end
77
+
78
+ def one_time_token_create(ctx, config, session)
79
+ generator = config[:generate_token]
80
+ token = if generator.respond_to?(:call)
81
+ generator.call(session, ctx)
82
+ else
83
+ Crypto.random_string(32)
84
+ end.to_s
85
+ stored_token = one_time_token_stored_value(config, token)
86
+ ctx.context.internal_adapter.create_verification_value(
87
+ identifier: "one-time-token:#{stored_token}",
88
+ value: session[:session]["token"],
89
+ expiresAt: Time.now + config[:expires_in].to_i * 60
90
+ )
91
+ token
92
+ end
93
+
94
+ def one_time_token_stored_value(config, token)
95
+ storage = config[:store_token]
96
+ return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed"
97
+
98
+ if storage.is_a?(Hash) && storage[:type].to_s.tr("_", "-") == "custom-hasher"
99
+ hasher = storage[:hash]
100
+ return hasher.call(token) if hasher.respond_to?(:call)
101
+ end
102
+
103
+ token
104
+ end
105
+ end
106
+ end