better_auth 0.7.0 → 0.10.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 +10 -1
- data/README.md +6 -6
- data/lib/better_auth/adapters/memory.rb +131 -17
- data/lib/better_auth/adapters/mongodb.rb +3 -3
- data/lib/better_auth/adapters/sql.rb +139 -57
- data/lib/better_auth/auth.rb +68 -1
- data/lib/better_auth/configuration.rb +15 -10
- data/lib/better_auth/context.rb +1 -1
- data/lib/better_auth/cookies.rb +11 -3
- data/lib/better_auth/doctor.rb +97 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/env.rb +38 -0
- data/lib/better_auth/http_client.rb +46 -0
- data/lib/better_auth/migration_plan.rb +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- data/lib/better_auth/plugins/admin.rb +6 -1
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- data/lib/better_auth/plugins/captcha.rb +1 -1
- data/lib/better_auth/plugins/device_authorization.rb +34 -0
- data/lib/better_auth/plugins/dub.rb +8 -0
- data/lib/better_auth/plugins/generic_oauth.rb +34 -7
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/jwt.rb +10 -3
- data/lib/better_auth/plugins/mcp/schema.rb +13 -13
- data/lib/better_auth/plugins/mcp.rb +41 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
- data/lib/better_auth/plugins/oidc_provider.rb +62 -3
- data/lib/better_auth/plugins/one_tap.rb +17 -5
- data/lib/better_auth/plugins/open_api.rb +42 -2
- data/lib/better_auth/plugins/organization.rb +122 -11
- data/lib/better_auth/plugins/phone_number.rb +1 -1
- data/lib/better_auth/plugins/two_factor.rb +21 -0
- data/lib/better_auth/rate_limiter.rb +7 -2
- data/lib/better_auth/routes/account.rb +4 -0
- data/lib/better_auth/routes/email_verification.rb +5 -1
- data/lib/better_auth/routes/password.rb +1 -0
- data/lib/better_auth/routes/social.rb +29 -1
- data/lib/better_auth/routes/user.rb +6 -2
- data/lib/better_auth/schema/sql.rb +104 -15
- data/lib/better_auth/schema.rb +35 -2
- data/lib/better_auth/session.rb +2 -1
- data/lib/better_auth/social_providers/base.rb +4 -9
- data/lib/better_auth/social_providers/facebook.rb +1 -1
- data/lib/better_auth/social_providers/github.rb +2 -0
- data/lib/better_auth/social_providers/line.rb +1 -1
- data/lib/better_auth/social_providers/paypal.rb +1 -1
- data/lib/better_auth/sql_migration.rb +566 -0
- data/lib/better_auth/url_helpers.rb +1 -1
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +4 -0
- metadata +15 -8
|
@@ -441,6 +441,8 @@ module BetterAuth
|
|
|
441
441
|
openapi: {
|
|
442
442
|
operationId: "oauth2EndSession",
|
|
443
443
|
description: "RP-Initiated Logout endpoint",
|
|
444
|
+
parameters: oidc_end_session_schema[:properties].keys.map { |name| OpenAPI.query_parameter(name.to_s, schema: oidc_end_session_schema[:properties][name]) },
|
|
445
|
+
requestBody: OpenAPI.json_request_body(oidc_end_session_schema, required: false),
|
|
444
446
|
responses: {
|
|
445
447
|
"302" => {description: "Redirects after clearing the session cookie"}
|
|
446
448
|
}
|
|
@@ -470,6 +472,7 @@ module BetterAuth
|
|
|
470
472
|
openapi: {
|
|
471
473
|
operationId: operation_id,
|
|
472
474
|
description: description,
|
|
475
|
+
requestBody: oidc_request_body_for(operation_id),
|
|
473
476
|
responses: {
|
|
474
477
|
"200" => OpenAPI.json_response(response_description, response_schema)
|
|
475
478
|
}
|
|
@@ -477,6 +480,62 @@ module BetterAuth
|
|
|
477
480
|
}
|
|
478
481
|
end
|
|
479
482
|
|
|
483
|
+
def oidc_request_body_for(operation_id)
|
|
484
|
+
schema = case operation_id
|
|
485
|
+
when "registerOAuthApplication", "updateOAuthApplication"
|
|
486
|
+
oidc_client_registration_schema
|
|
487
|
+
when "rotateOAuthApplicationSecret"
|
|
488
|
+
OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
|
|
489
|
+
when "oauth2Consent"
|
|
490
|
+
OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}}, required: ["consent_code"])
|
|
491
|
+
when "oauth2Token"
|
|
492
|
+
OpenAPI.object_schema(
|
|
493
|
+
{
|
|
494
|
+
grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
|
|
495
|
+
code: {type: "string"},
|
|
496
|
+
redirect_uri: {type: "string", format: "uri"},
|
|
497
|
+
code_verifier: {type: "string"},
|
|
498
|
+
client_id: {type: "string"},
|
|
499
|
+
client_secret: {type: "string"},
|
|
500
|
+
refresh_token: {type: "string"},
|
|
501
|
+
scope: {type: "string"}
|
|
502
|
+
},
|
|
503
|
+
required: ["grant_type"]
|
|
504
|
+
)
|
|
505
|
+
when "oauth2Introspect", "oauth2Revoke"
|
|
506
|
+
OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
|
|
507
|
+
end
|
|
508
|
+
schema ? OpenAPI.json_request_body(schema) : nil
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def oidc_client_registration_schema
|
|
512
|
+
OpenAPI.object_schema(
|
|
513
|
+
{
|
|
514
|
+
redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
|
|
515
|
+
post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
|
|
516
|
+
client_name: {type: "string"},
|
|
517
|
+
client_uri: {type: "string", format: "uri"},
|
|
518
|
+
logo_uri: {type: "string", format: "uri"},
|
|
519
|
+
grant_types: {type: "array", items: {type: "string"}},
|
|
520
|
+
response_types: {type: "array", items: {type: "string"}},
|
|
521
|
+
scope: {type: "string"},
|
|
522
|
+
scopes: {type: "array", items: {type: "string"}},
|
|
523
|
+
metadata: {type: "object", additionalProperties: true}
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def oidc_end_session_schema
|
|
529
|
+
OpenAPI.object_schema(
|
|
530
|
+
{
|
|
531
|
+
id_token_hint: {type: "string"},
|
|
532
|
+
client_id: {type: "string"},
|
|
533
|
+
post_logout_redirect_uri: {type: "string", format: "uri"},
|
|
534
|
+
state: {type: "string"}
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
end
|
|
538
|
+
|
|
480
539
|
def oidc_client_schema
|
|
481
540
|
OpenAPI.object_schema(
|
|
482
541
|
{
|
|
@@ -541,7 +600,7 @@ module BetterAuth
|
|
|
541
600
|
def oidc_provider_schema
|
|
542
601
|
{
|
|
543
602
|
oauthApplication: {
|
|
544
|
-
|
|
603
|
+
model_name: "oauth_applications",
|
|
545
604
|
fields: {
|
|
546
605
|
name: {type: "string"},
|
|
547
606
|
icon: {type: "string", required: false},
|
|
@@ -565,7 +624,7 @@ module BetterAuth
|
|
|
565
624
|
}
|
|
566
625
|
},
|
|
567
626
|
oauthAccessToken: {
|
|
568
|
-
|
|
627
|
+
model_name: "oauth_access_tokens",
|
|
569
628
|
fields: {
|
|
570
629
|
accessToken: {type: "string", unique: true, required: false},
|
|
571
630
|
token: {type: "string", unique: true, required: false},
|
|
@@ -583,7 +642,7 @@ module BetterAuth
|
|
|
583
642
|
}
|
|
584
643
|
},
|
|
585
644
|
oauthConsent: {
|
|
586
|
-
|
|
645
|
+
model_name: "oauth_consents",
|
|
587
646
|
fields: {
|
|
588
647
|
clientId: {type: "string", required: true},
|
|
589
648
|
userId: {type: "string", required: true},
|
|
@@ -32,6 +32,14 @@ module BetterAuth
|
|
|
32
32
|
operationId: "oneTapCallback",
|
|
33
33
|
summary: "One tap callback",
|
|
34
34
|
description: "Use this endpoint to authenticate with Google One Tap",
|
|
35
|
+
requestBody: OpenAPI.json_request_body(
|
|
36
|
+
OpenAPI.object_schema(
|
|
37
|
+
{
|
|
38
|
+
id_token: {type: "string", description: "Google One Tap ID token"}
|
|
39
|
+
},
|
|
40
|
+
required: ["id_token"]
|
|
41
|
+
)
|
|
42
|
+
),
|
|
35
43
|
responses: {
|
|
36
44
|
"200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair)
|
|
37
45
|
}
|
|
@@ -105,16 +113,20 @@ module BetterAuth
|
|
|
105
113
|
options[:aud] = audience
|
|
106
114
|
options[:verify_aud] = true
|
|
107
115
|
end
|
|
108
|
-
payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
|
|
116
|
+
payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
|
|
109
117
|
payload
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
def one_tap_google_jwks
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)
|
|
121
|
+
cached = @one_tap_google_jwks_cache
|
|
122
|
+
return cached[:jwks] if cached && cached[:expires_at] > Time.now
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs")
|
|
125
|
+
raise "Unable to fetch Google JWKS" unless payload
|
|
126
|
+
|
|
127
|
+
jwks = ::JWT::JWK::Set.new(payload)
|
|
128
|
+
@one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300}
|
|
129
|
+
jwks
|
|
118
130
|
end
|
|
119
131
|
|
|
120
132
|
def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
|
|
@@ -128,6 +128,21 @@ module BetterAuth
|
|
|
128
128
|
}
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
+
def default_request_body
|
|
132
|
+
{
|
|
133
|
+
required: true,
|
|
134
|
+
content: {
|
|
135
|
+
"application/json" => {
|
|
136
|
+
schema: {
|
|
137
|
+
type: "object",
|
|
138
|
+
properties: {},
|
|
139
|
+
additionalProperties: true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
131
146
|
def responses(responses = nil)
|
|
132
147
|
{"200" => success_response}.merge(default_error_responses).merge(responses || {})
|
|
133
148
|
end
|
|
@@ -142,6 +157,17 @@ module BetterAuth
|
|
|
142
157
|
)
|
|
143
158
|
end
|
|
144
159
|
|
|
160
|
+
def default_success_response
|
|
161
|
+
json_response(
|
|
162
|
+
"Success",
|
|
163
|
+
{
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {},
|
|
166
|
+
additionalProperties: true
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
145
171
|
def default_error_responses
|
|
146
172
|
{
|
|
147
173
|
"400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
|
|
@@ -168,10 +194,16 @@ module BetterAuth
|
|
|
168
194
|
|
|
169
195
|
def default_metadata(path, methods)
|
|
170
196
|
method = Array(methods).reject { |value| value.to_s == "*" }.first.to_s.upcase
|
|
171
|
-
{
|
|
197
|
+
metadata = {
|
|
172
198
|
operationId: operation_id(path, method),
|
|
173
|
-
description: "#{method}
|
|
199
|
+
description: "Execute the #{operation_id(path, method)} endpoint.",
|
|
200
|
+
parameters: default_path_parameters(path),
|
|
201
|
+
responses: {
|
|
202
|
+
"200" => default_success_response
|
|
203
|
+
}
|
|
174
204
|
}
|
|
205
|
+
metadata[:requestBody] = default_request_body if %w[POST PUT PATCH].include?(method)
|
|
206
|
+
metadata
|
|
175
207
|
end
|
|
176
208
|
|
|
177
209
|
def operation_id(path, method)
|
|
@@ -183,6 +215,14 @@ module BetterAuth
|
|
|
183
215
|
|
|
184
216
|
"#{method.to_s.downcase}#{base}"
|
|
185
217
|
end
|
|
218
|
+
|
|
219
|
+
def default_path_parameters(path)
|
|
220
|
+
path.to_s.split("/").filter_map do |part|
|
|
221
|
+
next unless part.start_with?(":")
|
|
222
|
+
|
|
223
|
+
path_parameter(part.delete_prefix(":"))
|
|
224
|
+
end
|
|
225
|
+
end
|
|
186
226
|
end
|
|
187
227
|
|
|
188
228
|
module Plugins
|
|
@@ -527,14 +527,14 @@ module BetterAuth
|
|
|
527
527
|
end
|
|
528
528
|
end
|
|
529
529
|
|
|
530
|
-
def organization_list_members_endpoint(
|
|
530
|
+
def organization_list_members_endpoint(config)
|
|
531
531
|
Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx|
|
|
532
532
|
session = Routes.current_session(ctx)
|
|
533
533
|
query = normalize_hash(ctx.query)
|
|
534
534
|
organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
|
|
535
535
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
|
|
536
536
|
require_member!(ctx, session[:user]["id"], organization_id)
|
|
537
|
-
ctx.json(list_members_for(ctx, organization_id, query))
|
|
537
|
+
ctx.json(list_members_for(ctx, organization_id, query, config, session[:user]))
|
|
538
538
|
end
|
|
539
539
|
end
|
|
540
540
|
|
|
@@ -768,6 +768,7 @@ module BetterAuth
|
|
|
768
768
|
end
|
|
769
769
|
|
|
770
770
|
def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil)
|
|
771
|
+
request ||= organization_request_schema(operation_id)
|
|
771
772
|
openapi = {
|
|
772
773
|
operationId: operation_id,
|
|
773
774
|
description: description,
|
|
@@ -781,6 +782,68 @@ module BetterAuth
|
|
|
781
782
|
{openapi: openapi}
|
|
782
783
|
end
|
|
783
784
|
|
|
785
|
+
def organization_request_schema(operation_id)
|
|
786
|
+
string = {type: "string"}
|
|
787
|
+
boolean = {type: "boolean"}
|
|
788
|
+
array = ->(items = string) { {type: "array", items: items} }
|
|
789
|
+
object = {type: "object", additionalProperties: true}
|
|
790
|
+
{
|
|
791
|
+
"createOrganization" => {
|
|
792
|
+
name: string,
|
|
793
|
+
slug: string,
|
|
794
|
+
logo: string,
|
|
795
|
+
metadata: object,
|
|
796
|
+
userId: string,
|
|
797
|
+
user_id: string,
|
|
798
|
+
keepCurrentActiveOrganization: boolean,
|
|
799
|
+
keep_current_active_organization: boolean
|
|
800
|
+
},
|
|
801
|
+
"checkOrganizationSlug" => {slug: string},
|
|
802
|
+
"updateOrganization" => {
|
|
803
|
+
organizationId: string,
|
|
804
|
+
organization_id: string,
|
|
805
|
+
organizationSlug: string,
|
|
806
|
+
organization_slug: string,
|
|
807
|
+
data: object,
|
|
808
|
+
name: string,
|
|
809
|
+
slug: string,
|
|
810
|
+
logo: string,
|
|
811
|
+
metadata: object
|
|
812
|
+
},
|
|
813
|
+
"deleteOrganization" => {organizationId: string, organization_id: string, organizationSlug: string, organization_slug: string},
|
|
814
|
+
"setActiveOrganization" => {organizationId: string, organization_id: string, organizationSlug: string, organization_slug: string},
|
|
815
|
+
"createOrganizationInvitation" => {
|
|
816
|
+
organizationId: string,
|
|
817
|
+
organization_id: string,
|
|
818
|
+
organizationSlug: string,
|
|
819
|
+
organization_slug: string,
|
|
820
|
+
email: {type: "string", format: "email"},
|
|
821
|
+
role: {oneOf: [string, array.call]},
|
|
822
|
+
teamId: string,
|
|
823
|
+
team_id: string,
|
|
824
|
+
teamIds: array.call,
|
|
825
|
+
team_ids: array.call
|
|
826
|
+
},
|
|
827
|
+
"acceptOrganizationInvitation" => {invitationId: string, invitation_id: string, id: string},
|
|
828
|
+
"rejectOrganizationInvitation" => {invitationId: string, invitation_id: string},
|
|
829
|
+
"cancelOrganizationInvitation" => {invitationId: string, invitation_id: string},
|
|
830
|
+
"addOrganizationMember" => {organizationId: string, organization_id: string, userId: string, user_id: string, role: {oneOf: [string, array.call]}},
|
|
831
|
+
"removeOrganizationMember" => {memberId: string, member_id: string, userId: string, user_id: string, organizationId: string, organization_id: string},
|
|
832
|
+
"updateOrganizationMemberRole" => {memberId: string, member_id: string, userId: string, user_id: string, organizationId: string, organization_id: string, role: {oneOf: [string, array.call]}},
|
|
833
|
+
"leaveOrganization" => {organizationId: string, organization_id: string},
|
|
834
|
+
"hasOrganizationPermission" => {organizationId: string, organization_id: string, permission: object, permissions: object},
|
|
835
|
+
"createOrganizationTeam" => {organizationId: string, organization_id: string, name: string},
|
|
836
|
+
"updateOrganizationTeam" => {teamId: string, team_id: string, name: string},
|
|
837
|
+
"removeOrganizationTeam" => {teamId: string, team_id: string},
|
|
838
|
+
"setActiveOrganizationTeam" => {teamId: string, team_id: string},
|
|
839
|
+
"addTeamMember" => {teamId: string, team_id: string, userId: string, user_id: string},
|
|
840
|
+
"removeTeamMember" => {teamId: string, team_id: string, userId: string, user_id: string},
|
|
841
|
+
"createOrganizationRole" => {organizationId: string, organization_id: string, role: string, roleName: string, role_name: string, permission: object, permissions: object},
|
|
842
|
+
"updateOrganizationRole" => {organizationId: string, organization_id: string, roleId: string, role_id: string, role: string, roleName: string, role_name: string, permission: object, permissions: object, data: object},
|
|
843
|
+
"deleteOrganizationRole" => {organizationId: string, organization_id: string, roleId: string, role_id: string, role: string, roleName: string, role_name: string}
|
|
844
|
+
}[operation_id]
|
|
845
|
+
end
|
|
846
|
+
|
|
784
847
|
def organization_ref_schema(name)
|
|
785
848
|
{
|
|
786
849
|
type: "object",
|
|
@@ -969,7 +1032,7 @@ module BetterAuth
|
|
|
969
1032
|
ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}])
|
|
970
1033
|
end
|
|
971
1034
|
|
|
972
|
-
def list_members_for(ctx, organization_id, query = {})
|
|
1035
|
+
def list_members_for(ctx, organization_id, query = {}, config = nil, user = nil)
|
|
973
1036
|
where = [{field: "organizationId", value: organization_id}]
|
|
974
1037
|
if query[:filter_field]
|
|
975
1038
|
where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]}
|
|
@@ -977,19 +1040,48 @@ module BetterAuth
|
|
|
977
1040
|
filter = normalize_hash(query[:filter])
|
|
978
1041
|
where << {field: filter[:field], value: filter[:value], operator: filter[:operator]}
|
|
979
1042
|
end
|
|
1043
|
+
limit = member_list_limit(ctx, organization_id, query, config, user)
|
|
980
1044
|
members = ctx.context.adapter.find_many(
|
|
981
1045
|
model: "member",
|
|
982
1046
|
where: where,
|
|
983
|
-
limit:
|
|
1047
|
+
limit: limit,
|
|
984
1048
|
offset: query[:offset],
|
|
985
1049
|
sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
|
|
986
1050
|
)
|
|
1051
|
+
users_by_id = member_users_by_id(ctx, members)
|
|
987
1052
|
{
|
|
988
|
-
members: members.map { |entry| member_wire(ctx, entry) },
|
|
1053
|
+
members: members.map { |entry| member_wire(ctx, entry, users_by_id: users_by_id) },
|
|
989
1054
|
total: ctx.context.adapter.count(model: "member", where: where)
|
|
990
1055
|
}
|
|
991
1056
|
end
|
|
992
1057
|
|
|
1058
|
+
def member_list_limit(ctx, organization_id, query, config, user)
|
|
1059
|
+
configured = config && config[:membership_limit]
|
|
1060
|
+
configured = 100 if configured.nil?
|
|
1061
|
+
default = numeric_member_limit(configured)
|
|
1062
|
+
default = 100 unless default.positive?
|
|
1063
|
+
requested = query[:limit].to_i if query.key?(:limit) && !query[:limit].to_s.empty?
|
|
1064
|
+
return default unless requested&.positive?
|
|
1065
|
+
|
|
1066
|
+
[requested, default].min
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
def numeric_member_limit(value)
|
|
1070
|
+
return value.to_i if value.is_a?(Numeric)
|
|
1071
|
+
return value.to_i if value.to_s.match?(/\A\d+\z/)
|
|
1072
|
+
|
|
1073
|
+
100
|
|
1074
|
+
end
|
|
1075
|
+
|
|
1076
|
+
def member_users_by_id(ctx, members)
|
|
1077
|
+
user_ids = members.map { |member| member["userId"] }.compact.uniq
|
|
1078
|
+
return {} if user_ids.empty?
|
|
1079
|
+
|
|
1080
|
+
ctx.context.adapter.find_many(model: "user", where: [{field: "id", operator: "in", value: user_ids}]).each_with_object({}) do |user, result|
|
|
1081
|
+
result[user["id"]] = user
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
993
1085
|
def ensure_team_member_capacity!(ctx, config, team_ids)
|
|
994
1086
|
max_members = config.dig(:teams, :maximum_members_per_team)
|
|
995
1087
|
return unless max_members && team_ids.any?
|
|
@@ -1002,9 +1094,9 @@ module BetterAuth
|
|
|
1002
1094
|
end
|
|
1003
1095
|
end
|
|
1004
1096
|
|
|
1005
|
-
def member_wire(ctx, member)
|
|
1097
|
+
def member_wire(ctx, member, users_by_id: nil)
|
|
1006
1098
|
data = Schema.parse_output(ctx.context.options, "member", member)
|
|
1007
|
-
user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
|
|
1099
|
+
user = users_by_id ? users_by_id[member["userId"]] : ctx.context.internal_adapter.find_user_by_id(member["userId"])
|
|
1008
1100
|
data["user"] = user.slice("id", "name", "email", "image") if user
|
|
1009
1101
|
data
|
|
1010
1102
|
end
|
|
@@ -1034,8 +1126,11 @@ module BetterAuth
|
|
|
1034
1126
|
def ensure_not_last_owner!(ctx, member)
|
|
1035
1127
|
return unless member["role"].to_s.split(",").include?("owner")
|
|
1036
1128
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1129
|
+
owner_count = 0
|
|
1130
|
+
organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "organizationId", value: member["organizationId"]}]) do |entry|
|
|
1131
|
+
owner_count += 1 if entry["role"].to_s.split(",").include?("owner")
|
|
1132
|
+
end
|
|
1133
|
+
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owner_count <= 1
|
|
1039
1134
|
end
|
|
1040
1135
|
|
|
1041
1136
|
def create_default_team(ctx, config, organization, session)
|
|
@@ -1053,8 +1148,24 @@ module BetterAuth
|
|
|
1053
1148
|
end
|
|
1054
1149
|
|
|
1055
1150
|
def organization_created_count(ctx, user_id)
|
|
1056
|
-
|
|
1057
|
-
|
|
1151
|
+
count = 0
|
|
1152
|
+
organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "userId", value: user_id}]) do |member|
|
|
1153
|
+
count += 1 if member["role"].to_s.split(",").include?("owner")
|
|
1154
|
+
end
|
|
1155
|
+
count
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def organization_each_adapter_record(adapter, model, where:, page_size: 100)
|
|
1159
|
+
offset = 0
|
|
1160
|
+
loop do
|
|
1161
|
+
records = adapter.find_many(model: model, where: where, limit: page_size, offset: offset)
|
|
1162
|
+
break if records.empty?
|
|
1163
|
+
|
|
1164
|
+
records.each { |record| yield record }
|
|
1165
|
+
break if records.length < page_size
|
|
1166
|
+
|
|
1167
|
+
offset += records.length
|
|
1168
|
+
end
|
|
1058
1169
|
end
|
|
1059
1170
|
|
|
1060
1171
|
def run_org_hook(config, key, data, ctx)
|
|
@@ -300,6 +300,7 @@ module BetterAuth
|
|
|
300
300
|
openapi: {
|
|
301
301
|
operationId: operation_id,
|
|
302
302
|
description: description,
|
|
303
|
+
requestBody: two_factor_request_body(operation_id),
|
|
303
304
|
responses: {
|
|
304
305
|
"200" => OpenAPI.json_response("Success", response_schema)
|
|
305
306
|
}
|
|
@@ -307,6 +308,26 @@ module BetterAuth
|
|
|
307
308
|
}
|
|
308
309
|
end
|
|
309
310
|
|
|
311
|
+
def two_factor_request_body(operation_id)
|
|
312
|
+
schema = case operation_id
|
|
313
|
+
when "enableTwoFactor"
|
|
314
|
+
OpenAPI.object_schema({password: {type: "string"}, issuer: {type: "string"}})
|
|
315
|
+
when "disableTwoFactor", "getTOTPURI", "generateBackupCodes"
|
|
316
|
+
OpenAPI.object_schema({password: {type: "string"}})
|
|
317
|
+
when "generateTOTP"
|
|
318
|
+
OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"])
|
|
319
|
+
when "verifyTOTP", "verifyTwoFactorOTP"
|
|
320
|
+
OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}, trust_device: {type: "boolean"}}, required: ["code"])
|
|
321
|
+
when "verifyBackupCode"
|
|
322
|
+
OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, disable_session: {type: "boolean"}, trustDevice: {type: "boolean"}, trust_device: {type: "boolean"}}, required: ["code"])
|
|
323
|
+
when "sendTwoFactorOTP"
|
|
324
|
+
OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
|
|
325
|
+
else
|
|
326
|
+
{type: "object", properties: {}}
|
|
327
|
+
end
|
|
328
|
+
OpenAPI.json_request_body(schema)
|
|
329
|
+
end
|
|
330
|
+
|
|
310
331
|
def two_factor_enable_response_schema
|
|
311
332
|
OpenAPI.object_schema(
|
|
312
333
|
{
|
|
@@ -121,9 +121,14 @@ module BetterAuth
|
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
def default_special_rule(path)
|
|
124
|
-
return
|
|
124
|
+
return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
|
|
125
|
+
return {window: 60, max: 3} if path == "/request-password-reset" ||
|
|
126
|
+
path == "/send-verification-email" ||
|
|
127
|
+
path.start_with?("/forget-password") ||
|
|
128
|
+
path == "/email-otp/send-verification-otp" ||
|
|
129
|
+
path == "/email-otp/request-password-reset"
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
nil
|
|
127
132
|
end
|
|
128
133
|
|
|
129
134
|
def matching_custom_rule(config, path)
|
|
@@ -118,6 +118,8 @@ module BetterAuth
|
|
|
118
118
|
) do |ctx|
|
|
119
119
|
session = current_session(ctx, allow_nil: true)
|
|
120
120
|
body = normalize_hash(ctx.body)
|
|
121
|
+
raise APIError.new("UNAUTHORIZED") if ctx.request && !session
|
|
122
|
+
|
|
121
123
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
122
124
|
raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
|
|
123
125
|
|
|
@@ -174,6 +176,8 @@ module BetterAuth
|
|
|
174
176
|
) do |ctx|
|
|
175
177
|
session = current_session(ctx, allow_nil: true)
|
|
176
178
|
body = normalize_hash(ctx.body)
|
|
179
|
+
raise APIError.new("UNAUTHORIZED") if ctx.request && !session
|
|
180
|
+
|
|
177
181
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
178
182
|
raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
|
|
179
183
|
|
|
@@ -184,7 +184,11 @@ module BetterAuth
|
|
|
184
184
|
|
|
185
185
|
def self.set_verified_session_cookie(ctx, user)
|
|
186
186
|
session = current_session(ctx, allow_nil: true)
|
|
187
|
-
session_data = session
|
|
187
|
+
session_data = if session && session[:user]["id"] == user["id"]
|
|
188
|
+
session[:session]
|
|
189
|
+
else
|
|
190
|
+
ctx.context.internal_adapter.create_session(user["id"])
|
|
191
|
+
end
|
|
188
192
|
Cookies.set_session_cookie(ctx, {session: session_data, user: user})
|
|
189
193
|
end
|
|
190
194
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
4
|
require "json"
|
|
5
|
+
require "net/http"
|
|
5
6
|
require "securerandom"
|
|
6
7
|
|
|
7
8
|
module BetterAuth
|
|
@@ -92,6 +93,7 @@ module BetterAuth
|
|
|
92
93
|
ctx.context.secret,
|
|
93
94
|
expires_in: 600
|
|
94
95
|
)
|
|
96
|
+
store_oauth_state_cookie(ctx, state)
|
|
95
97
|
url = call_provider(provider, :create_authorization_url, {
|
|
96
98
|
state: state,
|
|
97
99
|
codeVerifier: code_verifier,
|
|
@@ -148,6 +150,7 @@ module BetterAuth
|
|
|
148
150
|
raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
|
|
149
151
|
raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
|
|
150
152
|
raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
|
|
153
|
+
raise ctx.redirect(oauth_error_url(error_url, "state_mismatch")) unless valid_oauth_state_cookie?(ctx, state)
|
|
151
154
|
raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
|
|
152
155
|
|
|
153
156
|
tokens = call_provider(provider, :validate_authorization_code, {
|
|
@@ -161,7 +164,11 @@ module BetterAuth
|
|
|
161
164
|
|
|
162
165
|
token_data = token_hash(tokens)
|
|
163
166
|
token_data["user"] = parse_json_hash(data["user"]) if data["user"]
|
|
164
|
-
user_info =
|
|
167
|
+
user_info = begin
|
|
168
|
+
call_provider(provider, :get_user_info, token_data)
|
|
169
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
165
172
|
user = user_info[:user] || user_info["user"] if user_info
|
|
166
173
|
raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
|
|
167
174
|
raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
|
|
@@ -269,6 +276,7 @@ module BetterAuth
|
|
|
269
276
|
}
|
|
270
277
|
}.merge(safe_additional_state(body))
|
|
271
278
|
state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
|
|
279
|
+
store_oauth_state_cookie(ctx, state)
|
|
272
280
|
url = call_provider(provider, :create_authorization_url, {
|
|
273
281
|
state: state,
|
|
274
282
|
codeVerifier: code_verifier,
|
|
@@ -285,6 +293,10 @@ module BetterAuth
|
|
|
285
293
|
|
|
286
294
|
def self.social_user_from_id_token!(ctx, provider, id_token)
|
|
287
295
|
token = fetch_value(id_token, "token").to_s
|
|
296
|
+
unless provider_callable(provider, :verify_id_token)
|
|
297
|
+
raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["ID_TOKEN_NOT_SUPPORTED"])
|
|
298
|
+
end
|
|
299
|
+
|
|
288
300
|
valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
|
|
289
301
|
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
|
|
290
302
|
|
|
@@ -360,6 +372,22 @@ module BetterAuth
|
|
|
360
372
|
{session: session, user: user, new_user: new_user}
|
|
361
373
|
end
|
|
362
374
|
|
|
375
|
+
def self.store_oauth_state_cookie(ctx, state)
|
|
376
|
+
return unless ctx.request
|
|
377
|
+
|
|
378
|
+
cookie = ctx.context.create_auth_cookie("state", max_age: 600)
|
|
379
|
+
ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self.valid_oauth_state_cookie?(ctx, state)
|
|
383
|
+
return true unless ctx.request
|
|
384
|
+
|
|
385
|
+
cookie = ctx.context.create_auth_cookie("state", max_age: 600)
|
|
386
|
+
stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
|
|
387
|
+
Cookies.expire_cookie(ctx, cookie)
|
|
388
|
+
stored == state
|
|
389
|
+
end
|
|
390
|
+
|
|
363
391
|
def self.oauth_error_url(base_url, error, description = nil)
|
|
364
392
|
uri = URI.parse(base_url.to_s)
|
|
365
393
|
query = URI.decode_www_form(uri.query.to_s)
|
|
@@ -80,7 +80,9 @@ module BetterAuth
|
|
|
80
80
|
current_password = body["currentPassword"] || body["current_password"]
|
|
81
81
|
validate_password_length!(new_password, ctx.context.options.email_and_password)
|
|
82
82
|
account = credential_account(ctx, session[:user]["id"])
|
|
83
|
-
|
|
83
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
|
|
84
|
+
|
|
85
|
+
unless verify_password_value(ctx, current_password.to_s, account["password"])
|
|
84
86
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
|
|
85
87
|
end
|
|
86
88
|
|
|
@@ -176,7 +178,9 @@ module BetterAuth
|
|
|
176
178
|
sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification)
|
|
177
179
|
if body["password"]
|
|
178
180
|
account = credential_account(ctx, session[:user]["id"])
|
|
179
|
-
|
|
181
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
|
|
182
|
+
|
|
183
|
+
unless verify_password_value(ctx, body["password"], account["password"])
|
|
180
184
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
|
|
181
185
|
end
|
|
182
186
|
end
|