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
@@ -1,6 +1,165 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BetterAuth
4
+ module OpenAPI
5
+ module_function
6
+
7
+ def object_schema(properties, required: [])
8
+ {
9
+ type: "object",
10
+ properties: properties,
11
+ required: required
12
+ }
13
+ end
14
+
15
+ def json_request_body(schema, required: true)
16
+ request = {
17
+ content: {
18
+ "application/json" => {
19
+ schema: schema
20
+ }
21
+ }
22
+ }
23
+ request[:required] = true if required
24
+ request
25
+ end
26
+
27
+ def json_response(description, schema)
28
+ {
29
+ description: description,
30
+ content: {
31
+ "application/json" => {
32
+ schema: schema
33
+ }
34
+ }
35
+ }
36
+ end
37
+
38
+ def session_response_schema(description:, nullable_url: false)
39
+ object_schema(
40
+ {
41
+ redirect: {type: "boolean", enum: [false]},
42
+ token: {type: "string", description: "Session token"},
43
+ url: nullable_url ? {type: "string", nullable: true} : {type: "string"},
44
+ user: {type: "object", "$ref": "#/components/schemas/User"}
45
+ },
46
+ required: ["redirect", "token", "user"]
47
+ ).merge(description: description)
48
+ end
49
+
50
+ def user_response_schema
51
+ object_schema(
52
+ {
53
+ id: {type: "string", description: "The unique identifier of the user"},
54
+ email: {type: "string", format: "email", description: "The email address of the user"},
55
+ name: {type: "string", description: "The name of the user"},
56
+ image: {type: "string", format: "uri", nullable: true, description: "The profile image URL of the user"},
57
+ emailVerified: {type: "boolean", description: "Whether the email has been verified"},
58
+ createdAt: {type: "string", format: "date-time", description: "When the user was created"},
59
+ updatedAt: {type: "string", format: "date-time", description: "When the user was last updated"}
60
+ },
61
+ required: ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"]
62
+ )
63
+ end
64
+
65
+ def session_response_schema_pair
66
+ object_schema(
67
+ {
68
+ session: {type: "object", "$ref": "#/components/schemas/Session"},
69
+ user: {type: "object", "$ref": "#/components/schemas/User"}
70
+ },
71
+ required: ["session", "user"]
72
+ )
73
+ end
74
+
75
+ def status_response_schema(extra_properties = {}, required: ["status"])
76
+ object_schema(
77
+ {
78
+ status: {type: "boolean"}
79
+ }.merge(extra_properties),
80
+ required: required
81
+ )
82
+ end
83
+
84
+ def success_response_schema
85
+ object_schema(
86
+ {
87
+ success: {type: "boolean"}
88
+ },
89
+ required: ["success"]
90
+ )
91
+ end
92
+
93
+ def empty_request_body
94
+ {
95
+ content: {
96
+ "application/json" => {
97
+ schema: {
98
+ type: "object",
99
+ properties: {}
100
+ }
101
+ }
102
+ }
103
+ }
104
+ end
105
+
106
+ def responses(responses = nil)
107
+ {"200" => success_response}.merge(default_error_responses).merge(responses || {})
108
+ end
109
+
110
+ def success_response
111
+ json_response(
112
+ "Success",
113
+ {
114
+ type: "object",
115
+ properties: {}
116
+ }
117
+ )
118
+ end
119
+
120
+ def default_error_responses
121
+ {
122
+ "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
123
+ "401" => error_response("Unauthorized. Due to missing or invalid authentication.", required: true),
124
+ "403" => error_response("Forbidden. You do not have permission to access this resource or to perform this action."),
125
+ "404" => error_response("Not Found. The requested resource was not found."),
126
+ "429" => error_response("Too Many Requests. You have exceeded the rate limit. Try again later."),
127
+ "500" => error_response("Internal Server Error. This is a problem with the server that you cannot fix.")
128
+ }
129
+ end
130
+
131
+ def error_response(description, required: false)
132
+ schema = {
133
+ type: "object",
134
+ properties: {
135
+ message: {
136
+ type: "string"
137
+ }
138
+ }
139
+ }
140
+ schema[:required] = ["message"] if required
141
+ json_response(description, schema)
142
+ end
143
+
144
+ def default_metadata(path, methods)
145
+ method = Array(methods).reject { |value| value.to_s == "*" }.first.to_s.upcase
146
+ {
147
+ operationId: operation_id(path, method),
148
+ description: "#{method} #{path}"
149
+ }
150
+ end
151
+
152
+ def operation_id(path, method)
153
+ parts = path.to_s.split("/").reject(&:empty?).map do |part|
154
+ part.delete_prefix(":").gsub(/[^a-zA-Z0-9]+/, " ").split.map(&:capitalize).join
155
+ end
156
+ base = parts.join
157
+ return method.downcase if base.empty?
158
+
159
+ "#{method.to_s.downcase}#{base}"
160
+ end
161
+ end
162
+
4
163
  module Plugins
5
164
  module_function
6
165
 
@@ -105,338 +264,24 @@ module BetterAuth
105
264
  metadata = endpoint.metadata[:openapi] || {}
106
265
  operation = {
107
266
  tags: Array(metadata[:tags] || [tag]),
108
- description: metadata[:description] || route_description(endpoint.path, method),
109
- operationId: metadata.key?(:operationId) ? metadata[:operationId] : route_operation_id(endpoint.path, method),
267
+ description: metadata[:description],
268
+ operationId: metadata[:operationId],
110
269
  security: [
111
270
  {
112
271
  bearerAuth: []
113
272
  }
114
273
  ],
115
274
  parameters: metadata[:parameters] || [],
116
- responses: open_api_responses(metadata[:responses] || route_responses(endpoint.path, method))
275
+ responses: OpenAPI.responses(metadata[:responses])
117
276
  }
118
277
 
119
278
  if %w[POST PATCH PUT].include?(method)
120
- operation[:requestBody] = metadata[:requestBody] || route_request_body(endpoint.path, method) || empty_request_body
279
+ operation[:requestBody] = metadata[:requestBody] || OpenAPI.empty_request_body
121
280
  end
122
281
 
123
282
  operation
124
283
  end
125
284
 
126
- def route_description(path, method)
127
- route_open_api_metadata(path, method)[:description]
128
- end
129
-
130
- def route_operation_id(path, method)
131
- route_open_api_metadata(path, method)[:operationId]
132
- end
133
-
134
- def route_request_body(path, method)
135
- route_open_api_metadata(path, method)[:requestBody]
136
- end
137
-
138
- def route_responses(path, method)
139
- route_open_api_metadata(path, method)[:responses]
140
- end
141
-
142
- def route_open_api_metadata(path, method)
143
- case [path, method.to_s.upcase]
144
- when ["/change-email", "POST"]
145
- {
146
- operationId: "changeEmail",
147
- requestBody: {
148
- required: true,
149
- content: {
150
- "application/json" => {
151
- schema: object_schema(
152
- {
153
- callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"},
154
- newEmail: {type: "string", description: "The new email address to set must be a valid email address"}
155
- },
156
- required: ["newEmail"]
157
- )
158
- }
159
- }
160
- },
161
- responses: {
162
- "200" => {
163
- description: "Email change request processed successfully",
164
- content: {
165
- "application/json" => {
166
- schema: object_schema(
167
- {
168
- message: {
169
- type: "string",
170
- nullable: true,
171
- enum: ["Email updated", "Verification email sent"],
172
- description: "Status message of the email change process"
173
- },
174
- status: {type: "boolean", description: "Indicates if the request was successful"},
175
- user: {type: "object", "$ref": "#/components/schemas/User"}
176
- },
177
- required: ["status"]
178
- )
179
- }
180
- }
181
- },
182
- "422" => error_response("Unprocessable Entity. Email already exists")
183
- }
184
- }
185
- when ["/change-password", "POST"]
186
- {
187
- description: "Change the password of the user",
188
- operationId: "changePassword",
189
- requestBody: {
190
- required: true,
191
- content: {
192
- "application/json" => {
193
- schema: object_schema(
194
- {
195
- newPassword: {type: "string", description: "The new password to set"},
196
- currentPassword: {type: "string", description: "The current password is required"},
197
- revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"}
198
- },
199
- required: ["newPassword", "currentPassword"]
200
- )
201
- }
202
- }
203
- },
204
- responses: {
205
- "200" => {
206
- description: "Password successfully changed",
207
- content: {
208
- "application/json" => {
209
- schema: object_schema(
210
- {
211
- token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"},
212
- user: open_api_user_response_schema
213
- },
214
- required: ["user"]
215
- )
216
- }
217
- }
218
- }
219
- }
220
- }
221
- when ["/sign-in/email", "POST"]
222
- {
223
- description: "Sign in with email and password",
224
- operationId: "signInEmail",
225
- requestBody: {
226
- required: true,
227
- content: {
228
- "application/json" => {
229
- schema: object_schema(
230
- {
231
- email: {type: "string", description: "Email of the user"},
232
- password: {type: "string", description: "Password of the user"},
233
- callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
234
- rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
235
- },
236
- required: ["email", "password"]
237
- )
238
- }
239
- }
240
- },
241
- responses: {
242
- "200" => {
243
- description: "Success - Returns either session details or redirect URL",
244
- content: {
245
- "application/json" => {
246
- schema: session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
247
- }
248
- }
249
- }
250
- }
251
- }
252
- when ["/sign-in/social", "POST"]
253
- {
254
- description: "Sign in with a social provider",
255
- operationId: "socialSignIn",
256
- requestBody: {
257
- required: true,
258
- content: {
259
- "application/json" => {
260
- schema: object_schema(
261
- {
262
- provider: {type: "string"},
263
- callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"},
264
- errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"},
265
- newUserCallbackURL: {type: ["string", "null"]},
266
- disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"},
267
- requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"},
268
- loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"},
269
- additionalData: {type: ["string", "null"]},
270
- scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."},
271
- idToken: {
272
- type: ["object", "null"],
273
- properties: {
274
- token: {type: "string", description: "ID token from the provider"},
275
- accessToken: {type: ["string", "null"], description: "Access token from the provider"},
276
- refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"},
277
- expiresAt: {type: ["number", "null"], description: "Expiry date of the token"},
278
- nonce: {type: ["string", "null"], description: "Nonce used to generate the token"}
279
- },
280
- required: ["token"]
281
- }
282
- },
283
- required: ["provider"]
284
- )
285
- }
286
- }
287
- },
288
- responses: {
289
- "200" => {
290
- description: "Success - Returns either session details or redirect URL",
291
- content: {
292
- "application/json" => {
293
- schema: session_response_schema(description: "Session response when idToken is provided")
294
- }
295
- }
296
- }
297
- }
298
- }
299
- when ["/sign-up/email", "POST"]
300
- {
301
- description: "Sign up a user using email and password",
302
- operationId: "signUpWithEmailAndPassword",
303
- requestBody: {
304
- content: {
305
- "application/json" => {
306
- schema: object_schema(
307
- {
308
- name: {type: "string", description: "The name of the user"},
309
- email: {type: "string", description: "The email of the user"},
310
- password: {type: "string", description: "The password of the user"},
311
- image: {type: "string", description: "The profile image URL of the user"},
312
- callbackURL: {type: "string", description: "The URL to use for email verification callback"},
313
- rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."}
314
- },
315
- required: ["name", "email", "password"]
316
- )
317
- }
318
- }
319
- },
320
- responses: {
321
- "200" => {
322
- description: "Successfully created user",
323
- content: {
324
- "application/json" => {
325
- schema: object_schema(
326
- {
327
- token: {type: "string", nullable: true, description: "Authentication token for the session"},
328
- user: {type: "object", "$ref": "#/components/schemas/User"}
329
- },
330
- required: ["user"]
331
- )
332
- }
333
- }
334
- },
335
- "422" => error_response("Unprocessable Entity. User already exists or failed to create user.")
336
- }
337
- }
338
- else
339
- {}
340
- end
341
- end
342
-
343
- def object_schema(properties, required: [])
344
- {
345
- type: "object",
346
- properties: properties,
347
- required: required
348
- }
349
- end
350
-
351
- def session_response_schema(description:, nullable_url: false)
352
- object_schema(
353
- {
354
- redirect: {type: "boolean", enum: [false]},
355
- token: {type: "string", description: "Session token"},
356
- url: nullable_url ? {type: "string", nullable: true} : {type: "string"},
357
- user: {type: "object", "$ref": "#/components/schemas/User"}
358
- },
359
- required: ["redirect", "token", "user"]
360
- ).merge(description: description)
361
- end
362
-
363
- def open_api_user_response_schema
364
- object_schema(
365
- {
366
- id: {type: "string", description: "The unique identifier of the user"},
367
- email: {type: "string", format: "email", description: "The email address of the user"},
368
- name: {type: "string", description: "The name of the user"},
369
- image: {type: "string", format: "uri", nullable: true, description: "The profile image URL of the user"},
370
- emailVerified: {type: "boolean", description: "Whether the email has been verified"},
371
- createdAt: {type: "string", format: "date-time", description: "When the user was created"},
372
- updatedAt: {type: "string", format: "date-time", description: "When the user was last updated"}
373
- },
374
- required: ["id", "email", "name", "emailVerified", "createdAt", "updatedAt"]
375
- )
376
- end
377
-
378
- def empty_request_body
379
- {
380
- content: {
381
- "application/json" => {
382
- schema: {
383
- type: "object",
384
- properties: {}
385
- }
386
- }
387
- }
388
- }
389
- end
390
-
391
- def open_api_responses(responses = nil)
392
- {"200" => success_response}.merge(default_error_responses).merge(responses || {})
393
- end
394
-
395
- def success_response
396
- {
397
- description: "Success",
398
- content: {
399
- "application/json" => {
400
- schema: {
401
- type: "object",
402
- properties: {}
403
- }
404
- }
405
- }
406
- }
407
- end
408
-
409
- def default_error_responses
410
- {
411
- "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
412
- "401" => error_response("Unauthorized. Due to missing or invalid authentication.", required: true),
413
- "403" => error_response("Forbidden. You do not have permission to access this resource or to perform this action."),
414
- "404" => error_response("Not Found. The requested resource was not found."),
415
- "429" => error_response("Too Many Requests. You have exceeded the rate limit. Try again later."),
416
- "500" => error_response("Internal Server Error. This is a problem with the server that you cannot fix.")
417
- }
418
- end
419
-
420
- def error_response(description, required: false)
421
- schema = {
422
- type: "object",
423
- properties: {
424
- message: {
425
- type: "string"
426
- }
427
- }
428
- }
429
- schema[:required] = ["message"] if required
430
- {
431
- description: description,
432
- content: {
433
- "application/json" => {
434
- schema: schema
435
- }
436
- }
437
- }
438
- end
439
-
440
285
  def open_api_components(options)
441
286
  Schema.auth_tables(options).each_with_object({}) do |(model, table), schemas|
442
287
  name = model.to_s.split(/[_-]/).map(&:capitalize).join