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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "securerandom"
5
+
6
+ module BetterAuth
7
+ module Routes
8
+ EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
9
+
10
+ def self.sign_up_email
11
+ Endpoint.new(
12
+ path: "/sign-up/email",
13
+ method: "POST",
14
+ metadata: {
15
+ allowed_media_types: [
16
+ "application/x-www-form-urlencoded",
17
+ "application/json"
18
+ ]
19
+ }
20
+ ) do |ctx|
21
+ options = ctx.context.options
22
+ email_config = options.email_and_password
23
+ if email_config[:enabled] != true || email_config[:disable_sign_up]
24
+ raise APIError.new("BAD_REQUEST", message: "Email and password sign up is not enabled")
25
+ end
26
+
27
+ body = normalize_hash(ctx.body)
28
+ name = body["name"].to_s
29
+ email = body["email"].to_s
30
+ password = body["password"]
31
+ image = body["image"]
32
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
33
+ remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
34
+
35
+ validate_sign_up_input!(email, password, email_config)
36
+
37
+ ctx.context.adapter.transaction do
38
+ existing = ctx.context.internal_adapter.find_user_by_email(email)
39
+ if existing
40
+ if email_config[:require_email_verification]
41
+ hash_password(ctx, password)
42
+ call_existing_sign_up_callback(ctx, email_config, existing)
43
+ synthetic_user = synthetic_sign_up_user(ctx, body, email, name, image)
44
+ next ctx.json({token: nil, user: Schema.parse_output(options, "user", synthetic_user)})
45
+ end
46
+
47
+ raise APIError.new(
48
+ "UNPROCESSABLE_ENTITY",
49
+ message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]
50
+ )
51
+ end
52
+
53
+ hashed_password = hash_password(ctx, password)
54
+ created_user = create_sign_up_user(ctx, body, email, name, image)
55
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless created_user
56
+
57
+ ctx.context.internal_adapter.link_account(
58
+ userId: created_user["id"],
59
+ providerId: "credential",
60
+ accountId: created_user["id"],
61
+ password: hashed_password
62
+ )
63
+
64
+ send_sign_up_verification_email(ctx, created_user, callback_url)
65
+
66
+ if email_config[:auto_sign_in] == false || email_config[:require_email_verification]
67
+ next ctx.json({token: nil, user: Schema.parse_output(options, "user", created_user)})
68
+ end
69
+
70
+ dont_remember_me = remember_me == false || remember_me.to_s == "false"
71
+ session = ctx.context.internal_adapter.create_session(
72
+ created_user["id"],
73
+ dont_remember_me,
74
+ session_overrides(ctx),
75
+ true,
76
+ ctx
77
+ )
78
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
79
+
80
+ Cookies.set_session_cookie(ctx, {session: session, user: created_user}, dont_remember_me)
81
+ ctx.json({token: session["token"], user: Schema.parse_output(options, "user", created_user)})
82
+ end
83
+ end
84
+ end
85
+
86
+ def self.validate_sign_up_input!(email, password, email_config)
87
+ unless EMAIL_PATTERN.match?(email)
88
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
89
+ end
90
+
91
+ unless password.is_a?(String) && !password.empty?
92
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
93
+ end
94
+
95
+ if password.length < email_config[:min_password_length].to_i
96
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"])
97
+ end
98
+
99
+ if password.length > email_config[:max_password_length].to_i
100
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"])
101
+ end
102
+ end
103
+
104
+ def self.create_sign_up_user(ctx, body, email, name, image)
105
+ reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
106
+ additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
107
+ ctx.context.internal_adapter.create_user(
108
+ additional.merge(
109
+ "email" => email.downcase,
110
+ "name" => name,
111
+ "image" => image,
112
+ "emailVerified" => false
113
+ )
114
+ )
115
+ rescue APIError
116
+ raise
117
+ rescue
118
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"])
119
+ end
120
+
121
+ def self.call_existing_sign_up_callback(ctx, email_config, existing)
122
+ callback = email_config[:on_existing_user_sign_up]
123
+ return unless callback.respond_to?(:call)
124
+
125
+ user = existing[:user] || existing["user"] || existing
126
+ data = {user: user}
127
+ if callback.arity == 1
128
+ callback.call(data)
129
+ else
130
+ callback.call(data, ctx.request)
131
+ end
132
+ end
133
+
134
+ def self.synthetic_sign_up_user(ctx, body, email, name, image)
135
+ now = Time.now
136
+ core_fields = {
137
+ "id" => SecureRandom.hex(16),
138
+ "name" => name,
139
+ "email" => email.to_s.downcase,
140
+ "emailVerified" => false,
141
+ "image" => image,
142
+ "createdAt" => now,
143
+ "updatedAt" => now
144
+ }
145
+ reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
146
+ additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
147
+ custom = ctx.context.options.email_and_password[:custom_synthetic_user]
148
+ return core_fields.merge(additional) unless custom.respond_to?(:call)
149
+
150
+ value = {
151
+ core_fields: core_fields.except("id"),
152
+ additional_fields: additional,
153
+ id: core_fields["id"]
154
+ }
155
+ stringify_synthetic_user(custom.call(value))
156
+ end
157
+
158
+ def self.stringify_synthetic_user(value)
159
+ return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash)
160
+
161
+ {}
162
+ end
163
+
164
+ def self.send_sign_up_verification_email(ctx, user, callback_url)
165
+ verification = ctx.context.options.email_verification
166
+ password_config = ctx.context.options.email_and_password
167
+ send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
168
+ return unless send_on_sign_up
169
+
170
+ sender = verification[:send_verification_email]
171
+ return unless sender.respond_to?(:call)
172
+
173
+ token = Crypto.sign_jwt(
174
+ {"email" => user["email"].to_s.downcase},
175
+ ctx.context.secret,
176
+ expires_in: verification[:expires_in] || 3600
177
+ )
178
+ callback = URI.encode_www_form_component(callback_url || "/")
179
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
180
+ sender.call({user: user, url: url, token: token}, ctx.request)
181
+ end
182
+
183
+ def self.session_overrides(ctx)
184
+ {
185
+ ipAddress: RequestIP.client_ip(ctx, ctx.context.options).to_s,
186
+ userAgent: ctx.headers["user-agent"].to_s
187
+ }
188
+ end
189
+
190
+ def self.normalize_hash(value)
191
+ value.each_with_object({}) do |(key, object_value), result|
192
+ result[Schema.storage_key(key)] = object_value
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module BetterAuth
8
+ module Routes
9
+ def self.sign_in_social
10
+ Endpoint.new(path: "/sign-in/social", method: "POST") do |ctx|
11
+ body = normalize_hash(ctx.body)
12
+ provider_id = body["provider"].to_s
13
+ provider = social_provider(ctx.context, provider_id)
14
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
15
+ validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
16
+ validate_social_callback_url!(ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL")
17
+ validate_social_callback_url!(ctx.context, body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"], "INVALID_NEW_USER_CALLBACK_URL")
18
+
19
+ id_token = fetch_value(body, "idToken")
20
+ if id_token
21
+ data = social_user_from_id_token!(ctx, provider, id_token)
22
+ session_data = persist_social_user(
23
+ ctx,
24
+ provider_id,
25
+ data[:user],
26
+ token_hash_for_storage(ctx, data[:account]),
27
+ callback_url: body["callbackURL"],
28
+ disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !body["requestSignUp"])
29
+ )
30
+ raise APIError.new("UNAUTHORIZED", message: session_data[:error], code: "OAUTH_LINK_ERROR") if session_data[:error]
31
+
32
+ Cookies.set_session_cookie(ctx, session_data)
33
+ next ctx.json({
34
+ redirect: false,
35
+ token: session_data[:session]["token"],
36
+ url: nil,
37
+ user: Schema.parse_output(ctx.context.options, "user", session_data[:user])
38
+ })
39
+ end
40
+
41
+ code_verifier = SecureRandom.hex(16)
42
+ state = Crypto.sign_jwt(
43
+ {
44
+ "callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/",
45
+ "errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"],
46
+ "newUserCallbackURL" => body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"],
47
+ "requestSignUp" => body["requestSignUp"] || body["request_sign_up"],
48
+ "codeVerifier" => code_verifier
49
+ }.merge(safe_additional_state(body)),
50
+ ctx.context.secret,
51
+ expires_in: 600
52
+ )
53
+ url = call_provider(provider, :create_authorization_url, {
54
+ state: state,
55
+ codeVerifier: code_verifier,
56
+ code_verifier: code_verifier,
57
+ redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
58
+ redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
59
+ scopes: body["scopes"],
60
+ loginHint: body["loginHint"] || body["login_hint"]
61
+ })
62
+ ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"]
63
+ ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])})
64
+ end
65
+ end
66
+
67
+ def self.callback_oauth
68
+ Endpoint.new(
69
+ path: "/callback/:providerId",
70
+ method: ["GET", "POST"],
71
+ metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
72
+ ) do |ctx|
73
+ if ctx.method == "POST"
74
+ merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
75
+ query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? })
76
+ target = "#{ctx.context.base_url}/callback/#{fetch_value(ctx.params, "providerId")}"
77
+ target = "#{target}?#{query}" unless query.empty?
78
+ raise ctx.redirect(target)
79
+ end
80
+
81
+ source = ctx.query
82
+ data = normalize_hash(source)
83
+ provider_id = fetch_value(ctx.params, "providerId").to_s
84
+ provider = social_provider(ctx.context, provider_id)
85
+ state = data["state"].to_s
86
+ state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
87
+ error_url = state_data ? (state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error") : "#{ctx.context.base_url}/error"
88
+
89
+ raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
90
+ raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
91
+ raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
92
+ raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
93
+
94
+ tokens = call_provider(provider, :validate_authorization_code, {
95
+ code: data["code"],
96
+ codeVerifier: state_data["codeVerifier"],
97
+ code_verifier: state_data["codeVerifier"],
98
+ redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
99
+ redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}"
100
+ })
101
+ raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens
102
+
103
+ token_data = token_hash(tokens)
104
+ token_data["user"] = parse_json_hash(data["user"]) if data["user"]
105
+ user_info = call_provider(provider, :get_user_info, token_data)
106
+ user = user_info[:user] || user_info["user"] if user_info
107
+ raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
108
+ raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
109
+
110
+ link = state_data["link"] || state_data[:link]
111
+ if link
112
+ linked = link_social_account_from_callback(ctx, provider_id, user, tokens, link)
113
+ raise ctx.redirect(oauth_error_url(error_url, linked[:error])) if linked[:error]
114
+
115
+ raise ctx.redirect(state_data["callbackURL"] || "/")
116
+ end
117
+
118
+ account_info = token_hash_for_storage(ctx, tokens).merge("accountId" => fetch_value(user, "id").to_s)
119
+ session_data = persist_social_user(
120
+ ctx,
121
+ provider_id,
122
+ user,
123
+ account_info,
124
+ callback_url: state_data["callbackURL"],
125
+ disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !state_data["requestSignUp"])
126
+ )
127
+ raise ctx.redirect(oauth_error_url(error_url, session_data[:error].tr(" ", "_"))) if session_data[:error]
128
+ Cookies.set_session_cookie(ctx, session_data)
129
+ callback_url = session_data[:new_user] ? (state_data["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/")
130
+ raise ctx.redirect(callback_url)
131
+ end
132
+ end
133
+
134
+ def self.link_social
135
+ Endpoint.new(path: "/link-social", method: "POST") do |ctx|
136
+ session = current_session(ctx)
137
+ body = normalize_hash(ctx.body)
138
+ provider_id = body["provider"].to_s
139
+ provider = social_provider(ctx.context, provider_id)
140
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
141
+ validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
142
+ validate_social_callback_url!(ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL")
143
+
144
+ id_token = fetch_value(body, "idToken")
145
+ if id_token
146
+ data = social_user_from_id_token!(ctx, provider, id_token)
147
+ email = fetch_value(data[:user], "email").to_s.downcase
148
+ unless linkable_provider?(ctx, provider_id, data[:user])
149
+ raise APIError.new("UNAUTHORIZED", message: "Account not linked - untrusted provider")
150
+ end
151
+ unless email == session[:user]["email"].to_s.downcase || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
152
+ raise APIError.new("UNAUTHORIZED", message: "Account not linked - different emails not allowed")
153
+ end
154
+
155
+ account_id = fetch_value(data[:user], "id").to_s
156
+ existing = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |account|
157
+ account["providerId"] == provider_id && account["accountId"] == account_id
158
+ end
159
+ unless existing
160
+ ctx.context.internal_adapter.create_account(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
161
+ end
162
+ update_verified_email_on_link(ctx, session[:user]["id"], session[:user]["email"], data[:user])
163
+ next ctx.json({url: "", status: true, redirect: false})
164
+ end
165
+
166
+ code_verifier = SecureRandom.hex(16)
167
+ state_data = {
168
+ "callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || ctx.context.base_url,
169
+ "errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"],
170
+ "requestSignUp" => body["requestSignUp"] || body["request_sign_up"],
171
+ "codeVerifier" => code_verifier,
172
+ "link" => {
173
+ "userId" => session[:user]["id"],
174
+ "email" => session[:user]["email"]
175
+ }
176
+ }.merge(safe_additional_state(body))
177
+ state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
178
+ url = call_provider(provider, :create_authorization_url, {
179
+ state: state,
180
+ codeVerifier: code_verifier,
181
+ code_verifier: code_verifier,
182
+ redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
183
+ redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
184
+ scopes: body["scopes"],
185
+ loginHint: body["loginHint"] || body["login_hint"]
186
+ })
187
+ ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"]
188
+ ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])})
189
+ end
190
+ end
191
+
192
+ def self.social_user_from_id_token!(ctx, provider, id_token)
193
+ token = fetch_value(id_token, "token").to_s
194
+ valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
195
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
196
+
197
+ token_user = parse_json_hash(fetch_value(id_token, "user"))
198
+ user_info = call_provider(provider, :get_user_info, {
199
+ "idToken" => token,
200
+ "id_token" => token,
201
+ "accessToken" => fetch_value(id_token, "accessToken"),
202
+ "access_token" => fetch_value(id_token, "accessToken"),
203
+ "refreshToken" => fetch_value(id_token, "refreshToken"),
204
+ "refresh_token" => fetch_value(id_token, "refreshToken"),
205
+ "user" => token_user
206
+ })
207
+ user = user_info[:user] || user_info["user"] if user_info
208
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user
209
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["USER_EMAIL_NOT_FOUND"]) if fetch_value(user, "email").to_s.empty?
210
+
211
+ {
212
+ user: user,
213
+ account: {
214
+ "providerId" => fetch_value(provider, "id").to_s,
215
+ "accountId" => fetch_value(user, "id").to_s,
216
+ "accessToken" => fetch_value(id_token, "accessToken"),
217
+ "refreshToken" => fetch_value(id_token, "refreshToken"),
218
+ "idToken" => token,
219
+ "user" => token_user
220
+ }
221
+ }
222
+ end
223
+
224
+ def self.persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false)
225
+ email = fetch_value(user_info, "email").to_s.downcase
226
+ account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s
227
+ existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
228
+
229
+ if existing && existing[:linked_account]
230
+ user = existing[:user]
231
+ new_user = false
232
+ elsif existing
233
+ unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
234
+ return {error: "account not linked"}
235
+ end
236
+ user = existing[:user]
237
+ ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
238
+ new_user = false
239
+ else
240
+ return {error: "signup disabled"} if disable_sign_up
241
+
242
+ created = ctx.context.internal_adapter.create_oauth_user(
243
+ {
244
+ email: email,
245
+ name: fetch_value(user_info, "name").to_s,
246
+ image: fetch_value(user_info, "image"),
247
+ emailVerified: !!fetch_value(user_info, "emailVerified")
248
+ },
249
+ account_info.merge("providerId" => provider_id, "accountId" => account_id)
250
+ )
251
+ user = created[:user]
252
+ new_user = true
253
+ end
254
+
255
+ session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
256
+ {session: session, user: user, new_user: new_user}
257
+ end
258
+
259
+ def self.oauth_error_url(base_url, error, description = nil)
260
+ uri = URI.parse(base_url.to_s)
261
+ query = URI.decode_www_form(uri.query.to_s)
262
+ query << ["error", error.to_s]
263
+ query << ["error_description", description.to_s] if description
264
+ uri.query = URI.encode_www_form(query)
265
+ uri.to_s
266
+ end
267
+
268
+ def self.provider_disable_implicit_sign_up?(provider)
269
+ !!(fetch_value(provider, "disableImplicitSignUp") || fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
270
+ end
271
+
272
+ def self.provider_disable_sign_up?(provider)
273
+ !!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
274
+ end
275
+
276
+ def self.linkable_provider?(ctx, provider_id, user_info, implicit: false)
277
+ linking = ctx.context.options.account[:account_linking] || {}
278
+ return false if linking[:enabled] == false
279
+ return false if implicit && linking[:disable_implicit_linking] == true
280
+
281
+ trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
282
+ trusted || !!fetch_value(user_info, "emailVerified")
283
+ end
284
+
285
+ def self.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link)
286
+ return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, user_info)
287
+
288
+ email = fetch_value(user_info, "email").to_s.downcase
289
+ link_email = fetch_value(link, "email").to_s.downcase
290
+ unless email == link_email || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
291
+ return {error: "email_doesn't_match"}
292
+ end
293
+
294
+ account_id = fetch_value(user_info, "id").to_s
295
+ user_id = fetch_value(link, "userId").to_s
296
+ account_info = token_hash_for_storage(ctx, tokens).merge(
297
+ "providerId" => provider_id,
298
+ "accountId" => account_id,
299
+ "userId" => user_id
300
+ )
301
+ existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id)
302
+ if existing
303
+ return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id
304
+
305
+ ctx.context.internal_adapter.update_account(existing["id"], account_info)
306
+ else
307
+ ctx.context.internal_adapter.create_account(account_info)
308
+ end
309
+
310
+ if ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
311
+ ctx.context.internal_adapter.update_user(user_id, {
312
+ name: fetch_value(user_info, "name"),
313
+ image: fetch_value(user_info, "image")
314
+ }.compact)
315
+ end
316
+ update_verified_email_on_link(ctx, user_id, link_email, user_info)
317
+
318
+ {status: true}
319
+ end
320
+
321
+ def self.safe_additional_state(body)
322
+ additional = body["additionalData"] || body["additional_data"]
323
+ return {} unless additional.is_a?(Hash)
324
+
325
+ reserved = %w[callbackURL callbackUrl callback_url errorCallbackURL errorCallbackUrl error_callback_url errorURL error_url newUserCallbackURL newUserCallbackUrl new_user_callback_url newUserURL new_user_url requestSignUp request_sign_up codeVerifier code_verifier link expiresAt expires_at]
326
+ normalize_hash(additional).reject { |key, _value| reserved.include?(key.to_s) }
327
+ end
328
+
329
+ def self.validate_social_callback_url!(context, callback_url, error_code)
330
+ validate_callback_url!(context, callback_url)
331
+ rescue APIError => error
332
+ return if oauth_proxy_callback_url?(context, callback_url)
333
+ raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
334
+
335
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES[error_code])
336
+ end
337
+
338
+ def self.oauth_proxy_callback_url?(context, callback_url)
339
+ uri = URI.parse(callback_url.to_s)
340
+ proxy_path = "#{context.options.base_path}/oauth-proxy-callback"
341
+ return false unless uri.path == proxy_path
342
+
343
+ nested = URI.decode_www_form(uri.query.to_s).assoc("callbackURL")&.last
344
+ validate_callback_url!(context, nested)
345
+ true
346
+ rescue APIError, URI::InvalidURIError
347
+ false
348
+ end
349
+
350
+ def self.update_verified_email_on_link(ctx, user_id, current_email, social_user)
351
+ return unless fetch_value(social_user, "emailVerified")
352
+ return unless fetch_value(social_user, "email").to_s.downcase == current_email.to_s.downcase
353
+
354
+ ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true})
355
+ end
356
+
357
+ def self.parse_json_hash(value)
358
+ return value if value.is_a?(Hash)
359
+ return {} if value.nil? || value.to_s.empty?
360
+
361
+ parsed = JSON.parse(value.to_s)
362
+ parsed.is_a?(Hash) ? parsed : {}
363
+ rescue JSON::ParserError
364
+ {}
365
+ end
366
+ end
367
+ end