better_auth 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -5
- data/lib/better_auth/adapters/sql.rb +96 -18
- data/lib/better_auth/api.rb +113 -13
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +5 -5
- data/lib/better_auth/endpoint.rb +87 -3
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +246 -15
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +53 -7
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization.rb +135 -36
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +65 -23
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +20 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +204 -38
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +125 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +24 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +62 -4
- data/lib/better_auth/routes/social.rb +102 -7
- data/lib/better_auth/routes/user.rb +222 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +1 -1
- data/lib/better_auth/url_helpers.rb +12 -1
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +4 -0
- metadata +15 -1
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
module BetterAuth
|
|
4
4
|
module Routes
|
|
5
5
|
def self.list_accounts
|
|
6
|
-
Endpoint.new(
|
|
6
|
+
Endpoint.new(
|
|
7
|
+
path: "/list-accounts",
|
|
8
|
+
method: "GET",
|
|
9
|
+
metadata: {
|
|
10
|
+
openapi: {
|
|
11
|
+
operationId: "listUserAccounts",
|
|
12
|
+
description: "List linked accounts for the current user",
|
|
13
|
+
responses: {
|
|
14
|
+
"200" => OpenAPI.json_response(
|
|
15
|
+
"Linked accounts",
|
|
16
|
+
{type: "array", items: {type: "object", "$ref": "#/components/schemas/Account"}}
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
) do |ctx|
|
|
7
22
|
session = current_session(ctx)
|
|
8
23
|
accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account|
|
|
9
24
|
parsed = Schema.parse_output(ctx.context.options, "account", account)
|
|
@@ -15,8 +30,33 @@ module BetterAuth
|
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
def self.unlink_account
|
|
18
|
-
Endpoint.new(
|
|
19
|
-
|
|
33
|
+
Endpoint.new(
|
|
34
|
+
path: "/unlink-account",
|
|
35
|
+
method: "POST",
|
|
36
|
+
body_schema: request_body_schema(
|
|
37
|
+
required_strings: %w[providerId],
|
|
38
|
+
optional_strings: %w[accountId]
|
|
39
|
+
),
|
|
40
|
+
metadata: {
|
|
41
|
+
openapi: {
|
|
42
|
+
operationId: "unlinkAccount",
|
|
43
|
+
description: "Unlink an account from the current user",
|
|
44
|
+
requestBody: OpenAPI.json_request_body(
|
|
45
|
+
OpenAPI.object_schema(
|
|
46
|
+
{
|
|
47
|
+
providerId: {type: "string"},
|
|
48
|
+
accountId: {type: ["string", "null"]}
|
|
49
|
+
},
|
|
50
|
+
required: ["providerId"]
|
|
51
|
+
)
|
|
52
|
+
),
|
|
53
|
+
responses: {
|
|
54
|
+
"200" => OpenAPI.json_response("Account unlinked", OpenAPI.status_response_schema)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
) do |ctx|
|
|
59
|
+
session = current_session(ctx, sensitive: true, fresh: true)
|
|
20
60
|
body = normalize_hash(ctx.body)
|
|
21
61
|
accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"])
|
|
22
62
|
if accounts.length == 1 && !ctx.context.options.account.dig(:account_linking, :allow_unlinking_all)
|
|
@@ -24,6 +64,8 @@ module BetterAuth
|
|
|
24
64
|
end
|
|
25
65
|
|
|
26
66
|
provider_id = body["providerId"] || body["provider_id"]
|
|
67
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
68
|
+
|
|
27
69
|
account_id = body["accountId"] || body["account_id"]
|
|
28
70
|
account = accounts.find do |entry|
|
|
29
71
|
entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id)
|
|
@@ -36,44 +78,108 @@ module BetterAuth
|
|
|
36
78
|
end
|
|
37
79
|
|
|
38
80
|
def self.get_access_token
|
|
39
|
-
Endpoint.new(
|
|
81
|
+
Endpoint.new(
|
|
82
|
+
path: "/get-access-token",
|
|
83
|
+
method: "POST",
|
|
84
|
+
body_schema: request_body_schema(
|
|
85
|
+
required_strings: %w[providerId],
|
|
86
|
+
optional_strings: %w[accountId userId]
|
|
87
|
+
),
|
|
88
|
+
metadata: {
|
|
89
|
+
openapi: {
|
|
90
|
+
operationId: "getAccessToken",
|
|
91
|
+
description: "Get an access token for a linked provider account",
|
|
92
|
+
requestBody: OpenAPI.json_request_body(
|
|
93
|
+
OpenAPI.object_schema(
|
|
94
|
+
{
|
|
95
|
+
providerId: {type: "string"},
|
|
96
|
+
accountId: {type: ["string", "null"]},
|
|
97
|
+
userId: {type: ["string", "null"]}
|
|
98
|
+
},
|
|
99
|
+
required: ["providerId"]
|
|
100
|
+
)
|
|
101
|
+
),
|
|
102
|
+
responses: {
|
|
103
|
+
"200" => OpenAPI.json_response(
|
|
104
|
+
"Provider access token",
|
|
105
|
+
OpenAPI.object_schema(
|
|
106
|
+
{
|
|
107
|
+
accessToken: {type: ["string", "null"]},
|
|
108
|
+
accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
109
|
+
scopes: {type: "array", items: {type: "string"}},
|
|
110
|
+
idToken: {type: ["string", "null"]}
|
|
111
|
+
},
|
|
112
|
+
required: ["scopes"]
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
) do |ctx|
|
|
40
119
|
session = current_session(ctx, allow_nil: true)
|
|
41
120
|
body = normalize_hash(ctx.body)
|
|
42
121
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
43
122
|
raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
|
|
44
123
|
|
|
45
124
|
provider_id = body["providerId"] || body["provider_id"]
|
|
46
|
-
|
|
47
|
-
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
|
|
125
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
48
126
|
|
|
49
127
|
account_id = body["accountId"] || body["account_id"]
|
|
50
|
-
|
|
51
|
-
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
|
|
52
|
-
|
|
53
|
-
if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
|
|
54
|
-
tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
|
|
55
|
-
updated = update_account_tokens(ctx, account, tokens)
|
|
56
|
-
account = account.merge(token_hash(tokens))
|
|
57
|
-
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
ctx.json({
|
|
61
|
-
accessToken: oauth_token_value(ctx, account["accessToken"]),
|
|
62
|
-
accessTokenExpiresAt: account["accessTokenExpiresAt"],
|
|
63
|
-
scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
|
|
64
|
-
idToken: account["idToken"]
|
|
65
|
-
})
|
|
128
|
+
ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: account_id))
|
|
66
129
|
end
|
|
67
130
|
end
|
|
68
131
|
|
|
69
132
|
def self.refresh_token
|
|
70
|
-
Endpoint.new(
|
|
133
|
+
Endpoint.new(
|
|
134
|
+
path: "/refresh-token",
|
|
135
|
+
method: "POST",
|
|
136
|
+
body_schema: request_body_schema(
|
|
137
|
+
required_strings: %w[providerId],
|
|
138
|
+
optional_strings: %w[accountId userId]
|
|
139
|
+
),
|
|
140
|
+
metadata: {
|
|
141
|
+
openapi: {
|
|
142
|
+
operationId: "refreshToken",
|
|
143
|
+
description: "Refresh an OAuth provider access token",
|
|
144
|
+
requestBody: OpenAPI.json_request_body(
|
|
145
|
+
OpenAPI.object_schema(
|
|
146
|
+
{
|
|
147
|
+
providerId: {type: "string"},
|
|
148
|
+
accountId: {type: ["string", "null"]},
|
|
149
|
+
userId: {type: ["string", "null"]}
|
|
150
|
+
},
|
|
151
|
+
required: ["providerId"]
|
|
152
|
+
)
|
|
153
|
+
),
|
|
154
|
+
responses: {
|
|
155
|
+
"200" => OpenAPI.json_response(
|
|
156
|
+
"Refreshed provider tokens",
|
|
157
|
+
OpenAPI.object_schema(
|
|
158
|
+
{
|
|
159
|
+
accessToken: {type: ["string", "null"]},
|
|
160
|
+
refreshToken: {type: ["string", "null"]},
|
|
161
|
+
accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
162
|
+
refreshTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
|
|
163
|
+
scope: {type: ["string", "null"]},
|
|
164
|
+
idToken: {type: ["string", "null"]},
|
|
165
|
+
providerId: {type: "string"},
|
|
166
|
+
accountId: {type: "string"}
|
|
167
|
+
},
|
|
168
|
+
required: ["providerId", "accountId"]
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
) do |ctx|
|
|
71
175
|
session = current_session(ctx, allow_nil: true)
|
|
72
176
|
body = normalize_hash(ctx.body)
|
|
73
177
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
74
178
|
raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
|
|
75
179
|
|
|
76
180
|
provider_id = body["providerId"] || body["provider_id"]
|
|
181
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
|
|
182
|
+
|
|
77
183
|
provider = social_provider(ctx.context, provider_id)
|
|
78
184
|
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider
|
|
79
185
|
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token)
|
|
@@ -84,10 +190,15 @@ module BetterAuth
|
|
|
84
190
|
refresh_token = oauth_token_value(ctx, account["refreshToken"])
|
|
85
191
|
raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty?
|
|
86
192
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
begin
|
|
194
|
+
tokens = call_provider(provider, :refresh_access_token, refresh_token)
|
|
195
|
+
updated = update_account_tokens(ctx, account, tokens)
|
|
196
|
+
values = token_hash(tokens)
|
|
197
|
+
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
198
|
+
rescue => error
|
|
199
|
+
log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.message}")
|
|
200
|
+
raise APIError.new("BAD_REQUEST", code: "FAILED_TO_REFRESH_ACCESS_TOKEN", message: "Failed to refresh access token")
|
|
201
|
+
end
|
|
91
202
|
ctx.json({
|
|
92
203
|
accessToken: values["accessToken"],
|
|
93
204
|
refreshToken: values["refreshToken"] || refresh_token,
|
|
@@ -102,31 +213,85 @@ module BetterAuth
|
|
|
102
213
|
end
|
|
103
214
|
|
|
104
215
|
def self.account_info
|
|
105
|
-
Endpoint.new(
|
|
216
|
+
Endpoint.new(
|
|
217
|
+
path: "/account-info",
|
|
218
|
+
method: "GET",
|
|
219
|
+
metadata: {
|
|
220
|
+
openapi: {
|
|
221
|
+
operationId: "accountInfo",
|
|
222
|
+
description: "Get user info from a linked provider account",
|
|
223
|
+
parameters: [
|
|
224
|
+
{
|
|
225
|
+
name: "accountId",
|
|
226
|
+
in: "query",
|
|
227
|
+
required: false,
|
|
228
|
+
schema: {type: "string"}
|
|
229
|
+
}
|
|
230
|
+
],
|
|
231
|
+
responses: {
|
|
232
|
+
"200" => OpenAPI.json_response("Provider user info", {type: "object"})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
) do |ctx|
|
|
106
237
|
session = current_session(ctx)
|
|
107
238
|
account_id = fetch_value(ctx.query, "accountId")
|
|
108
239
|
account = if account_id
|
|
109
240
|
ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
|
|
110
241
|
entry["id"] == account_id || entry["accountId"] == account_id
|
|
111
242
|
end
|
|
243
|
+
else
|
|
244
|
+
account_cookie(ctx, nil, nil, session[:user]["id"])
|
|
112
245
|
end
|
|
113
246
|
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
|
|
114
247
|
|
|
115
248
|
provider = social_provider(ctx.context, account["providerId"])
|
|
116
249
|
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
|
|
117
|
-
raise APIError.new("BAD_REQUEST", message: "Access token not found") if account["accessToken"].to_s.empty?
|
|
118
250
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
251
|
+
tokens = access_token_response(
|
|
252
|
+
ctx,
|
|
253
|
+
user_id: session[:user]["id"],
|
|
254
|
+
provider_id: account["providerId"],
|
|
255
|
+
account_id: account["accountId"],
|
|
256
|
+
provider: provider
|
|
257
|
+
)
|
|
258
|
+
raise APIError.new("BAD_REQUEST", message: "Access token not found") if tokens[:accessToken].to_s.empty?
|
|
259
|
+
|
|
260
|
+
info = call_provider(provider, :get_user_info, tokens.merge(access_token: tokens[:accessToken]))
|
|
125
261
|
ctx.json(info)
|
|
126
262
|
end
|
|
127
263
|
end
|
|
128
264
|
|
|
265
|
+
def self.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil)
|
|
266
|
+
provider ||= social_provider(ctx.context, provider_id)
|
|
267
|
+
raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
|
|
268
|
+
|
|
269
|
+
account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
|
|
270
|
+
raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
|
|
271
|
+
|
|
272
|
+
if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
|
|
273
|
+
begin
|
|
274
|
+
tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
|
|
275
|
+
updated = update_account_tokens(ctx, account, tokens)
|
|
276
|
+
account = account.merge(token_hash(tokens))
|
|
277
|
+
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
278
|
+
rescue => error
|
|
279
|
+
log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.message}")
|
|
280
|
+
raise APIError.new("BAD_REQUEST", code: "FAILED_TO_GET_ACCESS_TOKEN", message: "Failed to get a valid access token")
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
accessToken: oauth_token_value(ctx, account["accessToken"]),
|
|
286
|
+
accessTokenExpiresAt: account["accessTokenExpiresAt"],
|
|
287
|
+
scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
|
|
288
|
+
idToken: account["idToken"]
|
|
289
|
+
}
|
|
290
|
+
end
|
|
291
|
+
|
|
129
292
|
def self.social_provider(context, provider_id)
|
|
293
|
+
return nil if provider_id.to_s.empty?
|
|
294
|
+
|
|
130
295
|
provider = context.social_providers[provider_id.to_sym] || context.social_providers[provider_id.to_s]
|
|
131
296
|
return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id")
|
|
132
297
|
|
|
@@ -143,7 +308,8 @@ module BetterAuth
|
|
|
143
308
|
return nil unless ctx.context.options.account[:store_account_cookie]
|
|
144
309
|
|
|
145
310
|
account = Cookies.get_account_cookie(ctx)
|
|
146
|
-
return nil unless account
|
|
311
|
+
return nil unless account
|
|
312
|
+
return nil if provider_id && account["providerId"] != provider_id
|
|
147
313
|
return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id
|
|
148
314
|
return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id
|
|
149
315
|
|
|
@@ -199,14 +365,14 @@ module BetterAuth
|
|
|
199
365
|
return token if token.to_s.empty?
|
|
200
366
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
201
367
|
|
|
202
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
368
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
|
|
203
369
|
end
|
|
204
370
|
|
|
205
371
|
def self.oauth_token_value(ctx, token)
|
|
206
372
|
return token if token.to_s.empty?
|
|
207
373
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
208
374
|
|
|
209
|
-
Crypto.symmetric_decrypt(key: ctx.context.
|
|
375
|
+
Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: token) || token
|
|
210
376
|
end
|
|
211
377
|
|
|
212
378
|
def self.provider_callable(provider, key)
|
|
@@ -5,7 +5,28 @@ require "uri"
|
|
|
5
5
|
module BetterAuth
|
|
6
6
|
module Routes
|
|
7
7
|
def self.send_verification_email
|
|
8
|
-
Endpoint.new(
|
|
8
|
+
Endpoint.new(
|
|
9
|
+
path: "/send-verification-email",
|
|
10
|
+
method: "POST",
|
|
11
|
+
metadata: {
|
|
12
|
+
openapi: {
|
|
13
|
+
operationId: "sendVerificationEmail",
|
|
14
|
+
description: "Send an email verification link",
|
|
15
|
+
requestBody: OpenAPI.json_request_body(
|
|
16
|
+
OpenAPI.object_schema(
|
|
17
|
+
{
|
|
18
|
+
email: {type: "string", description: "The email address to verify"},
|
|
19
|
+
callbackURL: {type: ["string", "null"], description: "The URL to redirect to after verification"}
|
|
20
|
+
},
|
|
21
|
+
required: ["email"]
|
|
22
|
+
)
|
|
23
|
+
),
|
|
24
|
+
responses: {
|
|
25
|
+
"200" => OpenAPI.json_response("Verification email sent", OpenAPI.status_response_schema)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
) do |ctx|
|
|
9
30
|
sender = ctx.context.options.email_verification[:send_verification_email]
|
|
10
31
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
|
|
11
32
|
|
|
@@ -32,7 +53,42 @@ module BetterAuth
|
|
|
32
53
|
end
|
|
33
54
|
|
|
34
55
|
def self.verify_email
|
|
35
|
-
Endpoint.new(
|
|
56
|
+
Endpoint.new(
|
|
57
|
+
path: "/verify-email",
|
|
58
|
+
method: "GET",
|
|
59
|
+
metadata: {
|
|
60
|
+
openapi: {
|
|
61
|
+
operationId: "verifyEmail",
|
|
62
|
+
description: "Verify an email address by token",
|
|
63
|
+
parameters: [
|
|
64
|
+
{
|
|
65
|
+
name: "token",
|
|
66
|
+
in: "query",
|
|
67
|
+
required: true,
|
|
68
|
+
schema: {type: "string"}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "callbackURL",
|
|
72
|
+
in: "query",
|
|
73
|
+
required: false,
|
|
74
|
+
schema: {type: "string"}
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
responses: {
|
|
78
|
+
"200" => OpenAPI.json_response(
|
|
79
|
+
"Email verified",
|
|
80
|
+
OpenAPI.object_schema(
|
|
81
|
+
{
|
|
82
|
+
status: {type: "boolean"},
|
|
83
|
+
user: {type: ["object", "null"], "$ref": "#/components/schemas/User"}
|
|
84
|
+
},
|
|
85
|
+
required: ["status"]
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
) do |ctx|
|
|
36
92
|
token = fetch_value(ctx.query, "token").to_s
|
|
37
93
|
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
38
94
|
validate_callback_url!(ctx.context, callback_url)
|
|
@@ -44,11 +100,27 @@ module BetterAuth
|
|
|
44
100
|
|
|
45
101
|
user = user_data[:user]
|
|
46
102
|
if update_to
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
103
|
+
session = current_session(ctx, allow_nil: true)
|
|
104
|
+
return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email
|
|
105
|
+
|
|
106
|
+
request_type = payload["requestType"] || payload["request_type"]
|
|
107
|
+
case request_type
|
|
108
|
+
when "change-email-confirmation"
|
|
109
|
+
send_change_email_verification_payload(ctx, user, update_to, callback_url)
|
|
110
|
+
next redirect_or_json(ctx, callback_url, {status: true})
|
|
111
|
+
when "change-email-verification"
|
|
112
|
+
updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
|
|
113
|
+
updated_user = updated || user.merge("email" => update_to, "emailVerified" => true)
|
|
114
|
+
call_option(ctx.context.options.email_verification[:after_email_verification], updated_user, ctx.request)
|
|
115
|
+
set_verified_session_cookie(ctx, updated_user)
|
|
116
|
+
next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
|
|
117
|
+
else
|
|
118
|
+
updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
|
|
119
|
+
updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
|
|
120
|
+
send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
|
|
121
|
+
set_verified_session_cookie(ctx, updated_user)
|
|
122
|
+
next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
|
|
123
|
+
end
|
|
52
124
|
end
|
|
53
125
|
|
|
54
126
|
if user["emailVerified"]
|
|
@@ -71,6 +143,16 @@ module BetterAuth
|
|
|
71
143
|
ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
|
|
72
144
|
end
|
|
73
145
|
|
|
146
|
+
def self.send_change_email_verification_payload(ctx, user, update_to, callback_url)
|
|
147
|
+
sender = ctx.context.options.email_verification[:send_verification_email]
|
|
148
|
+
return unless sender.respond_to?(:call)
|
|
149
|
+
|
|
150
|
+
token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"})
|
|
151
|
+
callback = URI.encode_www_form_component(callback_url || "/")
|
|
152
|
+
url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
|
|
153
|
+
sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request)
|
|
154
|
+
end
|
|
155
|
+
|
|
74
156
|
def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
|
|
75
157
|
payload = {"email" => email.to_s.downcase}.merge(extra)
|
|
76
158
|
payload["updateTo"] = update_to if update_to
|
|
@@ -78,18 +160,20 @@ module BetterAuth
|
|
|
78
160
|
end
|
|
79
161
|
|
|
80
162
|
def self.verify_email_token(ctx, token, callback_url)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
redirect_or_error(ctx, callback_url, "
|
|
163
|
+
decoded, = JWT.decode(token.to_s, ctx.context.secret.to_s, true, algorithm: "HS256")
|
|
164
|
+
decoded
|
|
165
|
+
rescue JWT::ExpiredSignature
|
|
166
|
+
redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_EXPIRED"], code: "TOKEN_EXPIRED")
|
|
167
|
+
rescue JWT::DecodeError
|
|
168
|
+
redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN")
|
|
85
169
|
end
|
|
86
170
|
|
|
87
|
-
def self.redirect_or_error(ctx, callback_url, error)
|
|
171
|
+
def self.redirect_or_error(ctx, callback_url, error, code: nil)
|
|
88
172
|
if callback_url
|
|
89
173
|
separator = callback_url.include?("?") ? "&" : "?"
|
|
90
|
-
raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
|
|
174
|
+
raise ctx.redirect("#{callback_url}#{separator}error=#{code || error}")
|
|
91
175
|
end
|
|
92
|
-
raise APIError.new("UNAUTHORIZED", message: error)
|
|
176
|
+
raise APIError.new("UNAUTHORIZED", code: code, message: error)
|
|
93
177
|
end
|
|
94
178
|
|
|
95
179
|
def self.redirect_or_json(ctx, callback_url, data)
|
|
@@ -8,14 +8,46 @@ module BetterAuth
|
|
|
8
8
|
PASSWORD_RESET_MESSAGE = "If this email exists in our system, check your email for the reset link"
|
|
9
9
|
|
|
10
10
|
def self.request_password_reset
|
|
11
|
-
Endpoint.new(
|
|
11
|
+
Endpoint.new(
|
|
12
|
+
path: "/request-password-reset",
|
|
13
|
+
method: "POST",
|
|
14
|
+
body_schema: request_body_schema(email_strings: %w[email]),
|
|
15
|
+
metadata: {
|
|
16
|
+
openapi: {
|
|
17
|
+
operationId: "requestPasswordReset",
|
|
18
|
+
description: "Request a password reset link",
|
|
19
|
+
requestBody: OpenAPI.json_request_body(
|
|
20
|
+
OpenAPI.object_schema(
|
|
21
|
+
{
|
|
22
|
+
email: {type: "string", description: "The email address of the user"},
|
|
23
|
+
redirectTo: {type: ["string", "null"], description: "The URL to redirect to after reset"}
|
|
24
|
+
},
|
|
25
|
+
required: ["email"]
|
|
26
|
+
)
|
|
27
|
+
),
|
|
28
|
+
responses: {
|
|
29
|
+
"200" => OpenAPI.json_response(
|
|
30
|
+
"Password reset request processed",
|
|
31
|
+
OpenAPI.status_response_schema(
|
|
32
|
+
{
|
|
33
|
+
message: {type: "string"}
|
|
34
|
+
},
|
|
35
|
+
required: ["status", "message"]
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
) do |ctx|
|
|
12
42
|
sender = ctx.context.options.email_and_password[:send_reset_password]
|
|
13
|
-
|
|
43
|
+
unless sender.respond_to?(:call)
|
|
44
|
+
raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"])
|
|
45
|
+
end
|
|
14
46
|
|
|
15
47
|
body = normalize_hash(ctx.body)
|
|
16
48
|
email = body["email"].to_s.downcase
|
|
17
49
|
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
18
|
-
|
|
50
|
+
validate_redirect_url!(ctx.context, redirect_to)
|
|
19
51
|
found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
|
|
20
52
|
unless found
|
|
21
53
|
SecureRandom.hex(12)
|
|
@@ -33,15 +65,46 @@ module BetterAuth
|
|
|
33
65
|
|
|
34
66
|
callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
|
|
35
67
|
url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
|
|
36
|
-
|
|
68
|
+
begin
|
|
69
|
+
sender.call({user: found[:user], url: url, token: token}, ctx.request)
|
|
70
|
+
rescue => error
|
|
71
|
+
log(ctx.context, :error, "RESET_PASSWORD_EMAIL_ERROR #{error.message}")
|
|
72
|
+
end
|
|
37
73
|
ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
|
|
38
74
|
end
|
|
39
75
|
end
|
|
40
76
|
|
|
41
77
|
def self.request_password_reset_callback
|
|
42
|
-
Endpoint.new(
|
|
78
|
+
Endpoint.new(
|
|
79
|
+
path: "/reset-password/:token",
|
|
80
|
+
method: "GET",
|
|
81
|
+
query_schema: request_query_schema(required_strings: %w[callbackURL]),
|
|
82
|
+
metadata: {
|
|
83
|
+
openapi: {
|
|
84
|
+
operationId: "requestPasswordResetCallback",
|
|
85
|
+
description: "Validate a password reset token and redirect to the callback URL",
|
|
86
|
+
parameters: [
|
|
87
|
+
{
|
|
88
|
+
name: "token",
|
|
89
|
+
in: "path",
|
|
90
|
+
required: true,
|
|
91
|
+
schema: {type: "string"}
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "callbackURL",
|
|
95
|
+
in: "query",
|
|
96
|
+
required: true,
|
|
97
|
+
schema: {type: "string"}
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
responses: {
|
|
101
|
+
"302" => {description: "Redirects to callback URL with token or error"}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
) do |ctx|
|
|
43
106
|
token = ctx.params[:token].to_s
|
|
44
|
-
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
107
|
+
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
45
108
|
validate_callback_url!(ctx.context, callback_url)
|
|
46
109
|
verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
|
|
47
110
|
|
|
@@ -54,7 +117,33 @@ module BetterAuth
|
|
|
54
117
|
end
|
|
55
118
|
|
|
56
119
|
def self.reset_password
|
|
57
|
-
Endpoint.new(
|
|
120
|
+
Endpoint.new(
|
|
121
|
+
path: "/reset-password",
|
|
122
|
+
method: "POST",
|
|
123
|
+
body_schema: request_body_schema(
|
|
124
|
+
required_strings: %w[newPassword],
|
|
125
|
+
optional_strings: %w[token]
|
|
126
|
+
),
|
|
127
|
+
query_schema: request_query_schema(optional_strings: %w[token]),
|
|
128
|
+
metadata: {
|
|
129
|
+
openapi: {
|
|
130
|
+
operationId: "resetPassword",
|
|
131
|
+
description: "Reset a password using a reset token",
|
|
132
|
+
requestBody: OpenAPI.json_request_body(
|
|
133
|
+
OpenAPI.object_schema(
|
|
134
|
+
{
|
|
135
|
+
token: {type: "string", description: "The password reset token"},
|
|
136
|
+
newPassword: {type: "string", description: "The new password to set"}
|
|
137
|
+
},
|
|
138
|
+
required: ["token", "newPassword"]
|
|
139
|
+
)
|
|
140
|
+
),
|
|
141
|
+
responses: {
|
|
142
|
+
"200" => OpenAPI.json_response("Password reset successfully", OpenAPI.status_response_schema)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
) do |ctx|
|
|
58
147
|
body = normalize_hash(ctx.body)
|
|
59
148
|
token = body["token"] || fetch_value(ctx.query, "token")
|
|
60
149
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
|
|
@@ -86,7 +175,27 @@ module BetterAuth
|
|
|
86
175
|
end
|
|
87
176
|
|
|
88
177
|
def self.verify_password
|
|
89
|
-
Endpoint.new(
|
|
178
|
+
Endpoint.new(
|
|
179
|
+
path: "/verify-password",
|
|
180
|
+
method: "POST",
|
|
181
|
+
metadata: {
|
|
182
|
+
openapi: {
|
|
183
|
+
operationId: "verifyPassword",
|
|
184
|
+
description: "Verify the current user's password",
|
|
185
|
+
requestBody: OpenAPI.json_request_body(
|
|
186
|
+
OpenAPI.object_schema(
|
|
187
|
+
{
|
|
188
|
+
password: {type: "string", description: "The password to verify"}
|
|
189
|
+
},
|
|
190
|
+
required: ["password"]
|
|
191
|
+
)
|
|
192
|
+
),
|
|
193
|
+
responses: {
|
|
194
|
+
"200" => OpenAPI.json_response("Password verified", OpenAPI.status_response_schema)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
) do |ctx|
|
|
90
199
|
session = current_session(ctx, sensitive: true)
|
|
91
200
|
password = normalize_hash(ctx.body)["password"].to_s
|
|
92
201
|
account = credential_account(ctx, session[:user]["id"])
|
|
@@ -180,5 +289,13 @@ module BetterAuth
|
|
|
180
289
|
|
|
181
290
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
|
|
182
291
|
end
|
|
292
|
+
|
|
293
|
+
def self.validate_redirect_url!(context, redirect_url)
|
|
294
|
+
validate_callback_url!(context, redirect_url)
|
|
295
|
+
rescue APIError => error
|
|
296
|
+
raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
|
|
297
|
+
|
|
298
|
+
raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["INVALID_REDIRECT_URL"])
|
|
299
|
+
end
|
|
183
300
|
end
|
|
184
301
|
end
|