better_auth 0.4.0 → 0.5.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
@@ -3,11 +3,34 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.get_session
6
- Endpoint.new(path: "/get-session", method: "GET") do |ctx|
7
- session = current_session(ctx, allow_nil: true)
6
+ Endpoint.new(
7
+ path: "/get-session",
8
+ method: ["GET", "POST"],
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "getSession",
12
+ description: "Get the current session",
13
+ responses: {
14
+ "200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(nullable: true))
15
+ }
16
+ }
17
+ }
18
+ ) do |ctx|
19
+ defer_refresh = ctx.context.session_config[:defer_session_refresh]
20
+ if ctx.method == "POST" && !defer_refresh
21
+ raise APIError.new(
22
+ "METHOD_NOT_ALLOWED",
23
+ code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
24
+ message: BASE_ERROR_CODES["METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED"]
25
+ )
26
+ end
27
+
28
+ session = current_session(ctx, allow_nil: true, disable_refresh: defer_refresh && ctx.method == "GET")
8
29
  next ctx.json(nil) unless session
9
30
 
10
- ctx.json(parsed_session_response(ctx, session))
31
+ response = parsed_session_response(ctx, session)
32
+ response[:needsRefresh] = session_needs_refresh?(ctx, session[:session]) if defer_refresh && ctx.method == "GET"
33
+ ctx.json(response)
11
34
  rescue APIError
12
35
  raise
13
36
  rescue => error
@@ -17,7 +40,22 @@ module BetterAuth
17
40
  end
18
41
 
19
42
  def self.list_sessions
20
- Endpoint.new(path: "/list-sessions", method: "GET") do |ctx|
43
+ Endpoint.new(
44
+ path: "/list-sessions",
45
+ method: "GET",
46
+ metadata: {
47
+ openapi: {
48
+ operationId: "listSessions",
49
+ description: "List active sessions for the current user",
50
+ responses: {
51
+ "200" => OpenAPI.json_response(
52
+ "Active sessions",
53
+ {type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}}
54
+ )
55
+ }
56
+ }
57
+ }
58
+ ) do |ctx|
21
59
  session = current_session(ctx)
22
60
  sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
23
61
  active = sessions
@@ -29,8 +67,22 @@ module BetterAuth
29
67
  end
30
68
 
31
69
  def self.update_session
32
- Endpoint.new(path: "/update-session", method: "POST") do |ctx|
33
- session = current_session(ctx, sensitive: true)
70
+ Endpoint.new(
71
+ path: "/update-session",
72
+ method: "POST",
73
+ metadata: {
74
+ openapi: {
75
+ operationId: "updateSession",
76
+ description: "Update the current session",
77
+ responses: {
78
+ "200" => OpenAPI.json_response("Updated session", OpenAPI.session_response_schema_pair)
79
+ }
80
+ }
81
+ }
82
+ ) do |ctx|
83
+ session = current_session(ctx)
84
+ raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash)
85
+
34
86
  body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
35
87
  raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?
36
88
 
@@ -38,12 +90,32 @@ module BetterAuth
38
90
  updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
39
91
  merged = session[:session].merge(updated || update)
40
92
  Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
41
- ctx.json(parsed_session_response(ctx, {session: merged, user: session[:user]}))
93
+ ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
42
94
  end
43
95
  end
44
96
 
45
97
  def self.revoke_session
46
- Endpoint.new(path: "/revoke-session", method: "POST") do |ctx|
98
+ Endpoint.new(
99
+ path: "/revoke-session",
100
+ method: "POST",
101
+ metadata: {
102
+ openapi: {
103
+ operationId: "revokeSession",
104
+ description: "Revoke a session by token",
105
+ requestBody: OpenAPI.json_request_body(
106
+ OpenAPI.object_schema(
107
+ {
108
+ token: {type: "string", description: "The session token to revoke"}
109
+ },
110
+ required: ["token"]
111
+ )
112
+ ),
113
+ responses: {
114
+ "200" => OpenAPI.json_response("Session revoked", OpenAPI.status_response_schema)
115
+ }
116
+ }
117
+ }
118
+ ) do |ctx|
47
119
  session = current_session(ctx, sensitive: true)
48
120
  body = normalize_hash(ctx.body)
49
121
  token = body["token"].to_s
@@ -58,7 +130,19 @@ module BetterAuth
58
130
  end
59
131
 
60
132
  def self.revoke_sessions
61
- Endpoint.new(path: "/revoke-sessions", method: "POST") do |ctx|
133
+ Endpoint.new(
134
+ path: "/revoke-sessions",
135
+ method: "POST",
136
+ metadata: {
137
+ openapi: {
138
+ operationId: "revokeSessions",
139
+ description: "Revoke all sessions for the current user",
140
+ responses: {
141
+ "200" => OpenAPI.json_response("Sessions revoked", OpenAPI.status_response_schema)
142
+ }
143
+ }
144
+ }
145
+ ) do |ctx|
62
146
  session = current_session(ctx, sensitive: true)
63
147
  ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
64
148
  Cookies.delete_session_cookie(ctx)
@@ -67,7 +151,19 @@ module BetterAuth
67
151
  end
68
152
 
69
153
  def self.revoke_other_sessions
70
- Endpoint.new(path: "/revoke-other-sessions", method: "POST") do |ctx|
154
+ Endpoint.new(
155
+ path: "/revoke-other-sessions",
156
+ method: "POST",
157
+ metadata: {
158
+ openapi: {
159
+ operationId: "revokeOtherSessions",
160
+ description: "Revoke all sessions except the current one",
161
+ responses: {
162
+ "200" => OpenAPI.json_response("Other sessions revoked", OpenAPI.status_response_schema)
163
+ }
164
+ }
165
+ }
166
+ ) do |ctx|
71
167
  session = current_session(ctx, sensitive: true)
72
168
  current_token = session[:session]["token"]
73
169
  sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
@@ -81,11 +177,11 @@ module BetterAuth
81
177
  end
82
178
  end
83
179
 
84
- def self.current_session(ctx, allow_nil: false, sensitive: false)
180
+ def self.current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false)
85
181
  data = Session.find_current(
86
182
  ctx,
87
183
  disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
88
- disable_refresh: truthy_query?(ctx.query, "disableRefresh"),
184
+ disable_refresh: disable_refresh || truthy_query?(ctx.query, "disableRefresh"),
89
185
  sensitive: sensitive
90
186
  )
91
187
  return nil if allow_nil && data.nil?
@@ -93,7 +189,7 @@ module BetterAuth
93
189
  raise APIError.new("UNAUTHORIZED") unless data
94
190
 
95
191
  session = stringify_keys(data[:session] || data["session"])
96
- ensure_fresh_session!(ctx, session) if sensitive
192
+ ensure_fresh_session!(ctx, session) if fresh
97
193
 
98
194
  {
99
195
  session: session,
@@ -101,6 +197,15 @@ module BetterAuth
101
197
  }
102
198
  end
103
199
 
200
+ def self.request_only_session(ctx)
201
+ session = current_session(ctx, allow_nil: true)
202
+ if !session && (ctx.request || !ctx.headers.empty?)
203
+ raise APIError.new("UNAUTHORIZED", code: "UNAUTHORIZED", message: "Unauthorized")
204
+ end
205
+
206
+ session
207
+ end
208
+
104
209
  def self.ensure_fresh_session!(ctx, session)
105
210
  fresh_age = ctx.context.session_config[:fresh_age].to_i
106
211
  return if fresh_age.zero?
@@ -111,6 +216,16 @@ module BetterAuth
111
216
  raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH"))
112
217
  end
113
218
 
219
+ def self.session_needs_refresh?(ctx, session)
220
+ return false if truthy_query?(ctx.query, "disableRefresh") || ctx.context.session_config[:disable_session_refresh]
221
+
222
+ update_age = ctx.context.session_config[:update_age].to_i
223
+ return true if update_age.zero?
224
+
225
+ updated_at = normalize_time(session["updatedAt"])
226
+ updated_at && updated_at + update_age <= Time.now
227
+ end
228
+
114
229
  def self.normalize_time(value)
115
230
  return value if value.is_a?(Time)
116
231
  return nil if value.nil? || value.to_s.empty?
@@ -8,17 +8,39 @@ module BetterAuth
8
8
  Endpoint.new(
9
9
  path: "/sign-in/email",
10
10
  method: "POST",
11
+ body_schema: request_body_schema(required_strings: %w[email password]),
11
12
  metadata: {
12
13
  allowed_media_types: [
13
14
  "application/x-www-form-urlencoded",
14
15
  "application/json"
15
- ]
16
+ ],
17
+ openapi: {
18
+ operationId: "signInEmail",
19
+ description: "Sign in with email and password",
20
+ requestBody: OpenAPI.json_request_body(
21
+ OpenAPI.object_schema(
22
+ {
23
+ email: {type: "string", description: "Email of the user"},
24
+ password: {type: "string", description: "Password of the user"},
25
+ callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
26
+ rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
27
+ },
28
+ required: ["email", "password"]
29
+ )
30
+ ),
31
+ responses: {
32
+ "200" => OpenAPI.json_response(
33
+ "Success - Returns either session details or redirect URL",
34
+ OpenAPI.session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
35
+ )
36
+ }
37
+ }
16
38
  }
17
39
  ) do |ctx|
18
40
  options = ctx.context.options
19
41
  email_config = options.email_and_password
20
42
  if email_config[:enabled] != true
21
- raise APIError.new("BAD_REQUEST", message: "Email and password is not enabled")
43
+ raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_DISABLED"])
22
44
  end
23
45
 
24
46
  body = normalize_hash(ctx.body)
@@ -3,7 +3,19 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.sign_out
6
- Endpoint.new(path: "/sign-out", method: "POST") do |ctx|
6
+ Endpoint.new(
7
+ path: "/sign-out",
8
+ method: "POST",
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "signOut",
12
+ description: "Sign out the current session",
13
+ responses: {
14
+ "200" => OpenAPI.json_response("Successfully signed out", OpenAPI.success_response_schema)
15
+ }
16
+ }
17
+ }
18
+ ) do |ctx|
7
19
  token_cookie = ctx.context.auth_cookies[:session_token]
8
20
  token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
9
21
  ctx.context.internal_adapter.delete_session(token) if token
@@ -11,17 +11,52 @@ module BetterAuth
11
11
  Endpoint.new(
12
12
  path: "/sign-up/email",
13
13
  method: "POST",
14
+ body_schema: request_body_schema(
15
+ required_strings: %w[name email],
16
+ required_nonempty_strings: %w[password]
17
+ ),
14
18
  metadata: {
15
19
  allowed_media_types: [
16
20
  "application/x-www-form-urlencoded",
17
21
  "application/json"
18
- ]
22
+ ],
23
+ openapi: {
24
+ operationId: "signUpWithEmailAndPassword",
25
+ description: "Sign up a user using email and password",
26
+ requestBody: OpenAPI.json_request_body(
27
+ OpenAPI.object_schema(
28
+ {
29
+ name: {type: "string", description: "The name of the user"},
30
+ email: {type: "string", description: "The email of the user"},
31
+ password: {type: "string", description: "The password of the user"},
32
+ image: {type: "string", description: "The profile image URL of the user"},
33
+ callbackURL: {type: "string", description: "The URL to use for email verification callback"},
34
+ rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."}
35
+ },
36
+ required: ["name", "email", "password"]
37
+ ),
38
+ required: false
39
+ ),
40
+ responses: {
41
+ "200" => OpenAPI.json_response(
42
+ "Successfully created user",
43
+ OpenAPI.object_schema(
44
+ {
45
+ token: {type: "string", nullable: true, description: "Authentication token for the session"},
46
+ user: {type: "object", "$ref": "#/components/schemas/User"}
47
+ },
48
+ required: ["user"]
49
+ )
50
+ ),
51
+ "422" => OpenAPI.error_response("Unprocessable Entity. User already exists or failed to create user.")
52
+ }
53
+ }
19
54
  }
20
55
  ) do |ctx|
21
56
  options = ctx.context.options
22
57
  email_config = options.email_and_password
23
58
  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")
59
+ raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_SIGN_UP_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_SIGN_UP_DISABLED"])
25
60
  end
26
61
 
27
62
  body = normalize_hash(ctx.body)
@@ -118,7 +153,8 @@ module BetterAuth
118
153
  "name" => name,
119
154
  "image" => image,
120
155
  "emailVerified" => false
121
- )
156
+ ),
157
+ context: ctx
122
158
  )
123
159
  rescue APIError
124
160
  raise
@@ -151,18 +187,40 @@ module BetterAuth
151
187
  "updatedAt" => now
152
188
  }
153
189
  reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
154
- additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
190
+ additional = synthetic_additional_user_fields(ctx, body.except(*reserved))
155
191
  custom = ctx.context.options.email_and_password[:custom_synthetic_user]
156
192
  return core_fields.merge(additional) unless custom.respond_to?(:call)
157
193
 
158
194
  value = {
159
195
  core_fields: core_fields.except("id"),
196
+ coreFields: core_fields.except("id"),
160
197
  additional_fields: additional,
198
+ additionalFields: additional,
161
199
  id: core_fields["id"]
162
200
  }
163
201
  stringify_synthetic_user(custom.call(value))
164
202
  end
165
203
 
204
+ def self.synthetic_additional_user_fields(ctx, data)
205
+ additional = parse_declared_input(ctx, "user", data, allowed_base: [])
206
+ configured = ctx.context.options.user[:additional_fields] || {}
207
+ configured.each do |field, attributes|
208
+ storage_field = Schema.storage_key(field)
209
+ next if additional.key?(storage_field)
210
+
211
+ field_attributes = normalize_hash(attributes || {})
212
+ next unless field_attributes.key?("defaultValue") || field_attributes.key?("default_value")
213
+
214
+ default = field_attributes.key?("defaultValue") ? field_attributes["defaultValue"] : field_attributes["default_value"]
215
+ additional[storage_field] = resolve_default(default)
216
+ end
217
+ additional
218
+ end
219
+
220
+ def self.resolve_default(value)
221
+ value.respond_to?(:call) ? value.call : value
222
+ end
223
+
166
224
  def self.stringify_synthetic_user(value)
167
225
  return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash)
168
226
 
@@ -7,7 +7,49 @@ require "securerandom"
7
7
  module BetterAuth
8
8
  module Routes
9
9
  def self.sign_in_social
10
- Endpoint.new(path: "/sign-in/social", method: "POST") do |ctx|
10
+ Endpoint.new(
11
+ path: "/sign-in/social",
12
+ method: "POST",
13
+ metadata: {
14
+ openapi: {
15
+ description: "Sign in with a social provider",
16
+ operationId: "socialSignIn",
17
+ requestBody: OpenAPI.json_request_body(
18
+ OpenAPI.object_schema(
19
+ {
20
+ provider: {type: "string"},
21
+ callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"},
22
+ errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"},
23
+ newUserCallbackURL: {type: ["string", "null"]},
24
+ disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"},
25
+ requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"},
26
+ loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"},
27
+ additionalData: {type: ["string", "null"]},
28
+ scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."},
29
+ idToken: {
30
+ type: ["object", "null"],
31
+ properties: {
32
+ token: {type: "string", description: "ID token from the provider"},
33
+ accessToken: {type: ["string", "null"], description: "Access token from the provider"},
34
+ refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"},
35
+ expiresAt: {type: ["number", "null"], description: "Expiry date of the token"},
36
+ nonce: {type: ["string", "null"], description: "Nonce used to generate the token"}
37
+ },
38
+ required: ["token"]
39
+ }
40
+ },
41
+ required: ["provider"]
42
+ )
43
+ ),
44
+ responses: {
45
+ "200" => OpenAPI.json_response(
46
+ "Success - Returns either session details or redirect URL",
47
+ OpenAPI.session_response_schema(description: "Session response when idToken is provided")
48
+ )
49
+ }
50
+ }
51
+ }
52
+ ) do |ctx|
11
53
  body = normalize_hash(ctx.body)
12
54
  provider_id = body["provider"].to_s
13
55
  provider = social_provider(ctx.context, provider_id)
@@ -66,21 +108,38 @@ module BetterAuth
66
108
 
67
109
  def self.callback_oauth
68
110
  Endpoint.new(
69
- path: "/callback/:providerId",
111
+ path: "/callback/:id",
70
112
  method: ["GET", "POST"],
71
- metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
113
+ metadata: {
114
+ allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
115
+ openapi: {
116
+ operationId: "callbackOAuth",
117
+ description: "Handle an OAuth provider callback",
118
+ parameters: [
119
+ {
120
+ name: "id",
121
+ in: "path",
122
+ required: true,
123
+ schema: {type: "string"}
124
+ }
125
+ ],
126
+ responses: {
127
+ "302" => {description: "Redirects to the configured callback URL"}
128
+ }
129
+ }
130
+ }
72
131
  ) do |ctx|
132
+ provider_id = (fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")).to_s
73
133
  if ctx.method == "POST"
74
134
  merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
75
135
  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")}"
136
+ target = "#{ctx.context.base_url}/callback/#{provider_id}"
77
137
  target = "#{target}?#{query}" unless query.empty?
78
138
  raise ctx.redirect(target)
79
139
  end
80
140
 
81
141
  source = ctx.query
82
142
  data = normalize_hash(source)
83
- provider_id = fetch_value(ctx.params, "providerId").to_s
84
143
  provider = social_provider(ctx.context, provider_id)
85
144
  state = data["state"].to_s
86
145
  state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
@@ -132,7 +191,42 @@ module BetterAuth
132
191
  end
133
192
 
134
193
  def self.link_social
135
- Endpoint.new(path: "/link-social", method: "POST") do |ctx|
194
+ Endpoint.new(
195
+ path: "/link-social",
196
+ method: "POST",
197
+ metadata: {
198
+ openapi: {
199
+ operationId: "linkSocialAccount",
200
+ description: "Link a social account to the current user",
201
+ requestBody: OpenAPI.json_request_body(
202
+ OpenAPI.object_schema(
203
+ {
204
+ provider: {type: "string"},
205
+ callbackURL: {type: ["string", "null"]},
206
+ errorCallbackURL: {type: ["string", "null"]},
207
+ disableRedirect: {type: ["boolean", "null"]},
208
+ scopes: {type: ["array", "null"], items: {type: "string"}},
209
+ idToken: {type: ["object", "null"]}
210
+ },
211
+ required: ["provider"]
212
+ )
213
+ ),
214
+ responses: {
215
+ "200" => OpenAPI.json_response(
216
+ "Social account link started or completed",
217
+ OpenAPI.object_schema(
218
+ {
219
+ url: {type: "string"},
220
+ redirect: {type: "boolean"},
221
+ status: {type: ["boolean", "null"]}
222
+ },
223
+ required: ["url", "redirect"]
224
+ )
225
+ )
226
+ }
227
+ }
228
+ }
229
+ ) do |ctx|
136
230
  session = current_session(ctx)
137
231
  body = normalize_hash(ctx.body)
138
232
  provider_id = body["provider"].to_s
@@ -254,7 +348,8 @@ module BetterAuth
254
348
  image: fetch_value(user_info, "image"),
255
349
  emailVerified: !!fetch_value(user_info, "emailVerified")
256
350
  },
257
- account_info.merge("providerId" => provider_id, "accountId" => account_id)
351
+ account_info.merge("providerId" => provider_id, "accountId" => account_id),
352
+ context: ctx
258
353
  )
259
354
  user = created[:user]
260
355
  new_user = true