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
|
@@ -38,6 +38,14 @@ module BetterAuth
|
|
|
38
38
|
openapi: {
|
|
39
39
|
operationId: "dubLink",
|
|
40
40
|
description: "Link a Dub OAuth account",
|
|
41
|
+
requestBody: OpenAPI.json_request_body(
|
|
42
|
+
OpenAPI.object_schema(
|
|
43
|
+
{
|
|
44
|
+
callbackURL: {type: "string", description: "The URL to redirect to after linking"},
|
|
45
|
+
callback_url: {type: "string", description: "The URL to redirect to after linking"}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
),
|
|
41
49
|
responses: {
|
|
42
50
|
"200" => OpenAPI.json_response(
|
|
43
51
|
"Authorization URL generated successfully for linking a Dub account",
|
|
@@ -220,6 +220,7 @@ module BetterAuth
|
|
|
220
220
|
openapi: {
|
|
221
221
|
operationId: "signInOAuth2",
|
|
222
222
|
description: "Sign in with OAuth2",
|
|
223
|
+
requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
|
|
223
224
|
responses: {
|
|
224
225
|
"200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
|
|
225
226
|
}
|
|
@@ -242,6 +243,7 @@ module BetterAuth
|
|
|
242
243
|
openapi: {
|
|
243
244
|
operationId: "linkOAuth2",
|
|
244
245
|
description: "Link an OAuth2 account to the current user session",
|
|
246
|
+
requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
|
|
245
247
|
responses: {
|
|
246
248
|
"200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
|
|
247
249
|
}
|
|
@@ -352,6 +354,28 @@ module BetterAuth
|
|
|
352
354
|
)
|
|
353
355
|
end
|
|
354
356
|
|
|
357
|
+
def generic_oauth_start_body_schema
|
|
358
|
+
OpenAPI.object_schema(
|
|
359
|
+
{
|
|
360
|
+
providerId: {type: "string", description: "OAuth provider ID"},
|
|
361
|
+
provider_id: {type: "string", description: "OAuth provider ID"},
|
|
362
|
+
callbackURL: {type: "string", description: "URL to redirect to after success"},
|
|
363
|
+
callback_url: {type: "string", description: "URL to redirect to after success"},
|
|
364
|
+
errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
|
|
365
|
+
error_callback_url: {type: "string", description: "URL to redirect to after error"},
|
|
366
|
+
newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
|
|
367
|
+
new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
|
|
368
|
+
requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
|
|
369
|
+
request_sign_up: {type: "boolean", description: "Whether this request is a sign-up flow"},
|
|
370
|
+
scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
|
|
371
|
+
disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
|
|
372
|
+
disable_redirect: {type: "boolean", description: "Return the URL instead of redirecting"},
|
|
373
|
+
additionalData: {type: "object", additionalProperties: true},
|
|
374
|
+
additional_data: {type: "object", additionalProperties: true}
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
|
|
355
379
|
def generic_oauth_authorization_url(ctx, provider, body, link:)
|
|
356
380
|
authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
|
|
357
381
|
token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
|
|
@@ -487,14 +511,17 @@ module BetterAuth
|
|
|
487
511
|
if verification
|
|
488
512
|
cookie = ctx.context.create_auth_cookie("state")
|
|
489
513
|
cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
|
|
490
|
-
if
|
|
514
|
+
if ctx.request && cookie_state != state
|
|
515
|
+
Cookies.expire_cookie(ctx, cookie)
|
|
516
|
+
raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
|
|
517
|
+
elsif !ctx.request && cookie_state && cookie_state != state
|
|
491
518
|
Cookies.expire_cookie(ctx, cookie)
|
|
492
519
|
raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
|
|
493
520
|
end
|
|
494
521
|
|
|
495
522
|
parsed = JSON.parse(verification.fetch("value"))
|
|
496
523
|
ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
|
|
497
|
-
Cookies.expire_cookie(ctx, cookie) if cookie_state
|
|
524
|
+
Cookies.expire_cookie(ctx, cookie) if ctx.request || cookie_state
|
|
498
525
|
return parsed
|
|
499
526
|
end
|
|
500
527
|
end
|
|
@@ -521,7 +548,7 @@ module BetterAuth
|
|
|
521
548
|
uri = URI(user_info_url)
|
|
522
549
|
request = Net::HTTP::Get.new(uri)
|
|
523
550
|
request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
|
|
524
|
-
response =
|
|
551
|
+
response = HTTPClient.request(uri, request)
|
|
525
552
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
526
553
|
|
|
527
554
|
generic_oauth_normalize_user_info(JSON.parse(response.body))
|
|
@@ -615,7 +642,7 @@ module BetterAuth
|
|
|
615
642
|
normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
|
|
616
643
|
request[key.to_s.tr("_", "-")] = value.to_s
|
|
617
644
|
end
|
|
618
|
-
response =
|
|
645
|
+
response = HTTPClient.request(uri, request)
|
|
619
646
|
provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
|
|
620
647
|
rescue
|
|
621
648
|
{}
|
|
@@ -649,7 +676,7 @@ module BetterAuth
|
|
|
649
676
|
form_data[key] = value unless form_data.key?(key)
|
|
650
677
|
end
|
|
651
678
|
request.set_form_data(form_data)
|
|
652
|
-
response =
|
|
679
|
+
response = HTTPClient.request(uri, request)
|
|
653
680
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
654
681
|
|
|
655
682
|
generic_oauth_normalize_tokens(JSON.parse(response.body))
|
|
@@ -714,7 +741,7 @@ module BetterAuth
|
|
|
714
741
|
uri = URI(url)
|
|
715
742
|
request = Net::HTTP::Get.new(uri)
|
|
716
743
|
normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
|
|
717
|
-
response =
|
|
744
|
+
response = HTTPClient.request(uri, request)
|
|
718
745
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
719
746
|
|
|
720
747
|
JSON.parse(response.body)
|
|
@@ -790,7 +817,7 @@ module BetterAuth
|
|
|
790
817
|
token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
|
|
791
818
|
normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
|
|
792
819
|
request.set_form_data(form_data.compact)
|
|
793
|
-
response =
|
|
820
|
+
response = HTTPClient.request(uri, request)
|
|
794
821
|
raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
|
|
795
822
|
|
|
796
823
|
generic_oauth_normalize_tokens(JSON.parse(response.body))
|
|
@@ -83,7 +83,7 @@ module BetterAuth
|
|
|
83
83
|
request = Net::HTTP::Get.new(uri)
|
|
84
84
|
request["Add-Padding"] = "true"
|
|
85
85
|
request["User-Agent"] = "BetterAuth Password Checker"
|
|
86
|
-
response =
|
|
86
|
+
response = HTTPClient.request(uri, request)
|
|
87
87
|
unless response.is_a?(Net::HTTPSuccess)
|
|
88
88
|
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
|
|
89
89
|
end
|
|
@@ -63,6 +63,7 @@ module BetterAuth
|
|
|
63
63
|
schema: {
|
|
64
64
|
jwks: {
|
|
65
65
|
fields: {
|
|
66
|
+
id: {type: "string", required: true},
|
|
66
67
|
publicKey: {type: "string", required: true},
|
|
67
68
|
privateKey: {type: "string", required: true},
|
|
68
69
|
createdAt: {type: "date", required: true},
|
|
@@ -279,9 +280,15 @@ module BetterAuth
|
|
|
279
280
|
payload = if fetcher.respond_to?(:call)
|
|
280
281
|
fetcher.call(url)
|
|
281
282
|
else
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
cached = @jwt_remote_jwks_cache ||= {}
|
|
284
|
+
entry = cached[url.to_s]
|
|
285
|
+
if entry && entry[:expires_at] > Time.now
|
|
286
|
+
entry[:payload]
|
|
287
|
+
else
|
|
288
|
+
fetched = HTTPClient.get_json(url)
|
|
289
|
+
cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
|
|
290
|
+
fetched
|
|
291
|
+
end
|
|
285
292
|
end
|
|
286
293
|
keys = fetch_value(payload, "keys")
|
|
287
294
|
Array(keys).map { |entry| normalize_remote_jwk(entry) }
|
|
@@ -8,7 +8,7 @@ module BetterAuth
|
|
|
8
8
|
def schema
|
|
9
9
|
{
|
|
10
10
|
oauthClient: {
|
|
11
|
-
|
|
11
|
+
model_name: "oauth_clients",
|
|
12
12
|
fields: {
|
|
13
13
|
clientId: {type: "string", unique: true, required: true},
|
|
14
14
|
clientSecret: {type: "string", required: false},
|
|
@@ -17,7 +17,7 @@ module BetterAuth
|
|
|
17
17
|
enableEndSession: {type: "boolean", required: false},
|
|
18
18
|
clientSecretExpiresAt: {type: "number", required: false},
|
|
19
19
|
scopes: {type: "string[]", required: false},
|
|
20
|
-
userId: {type: "string", required: false},
|
|
20
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
21
21
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
22
22
|
updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
|
|
23
23
|
name: {type: "string", required: false},
|
|
@@ -45,9 +45,9 @@ module BetterAuth
|
|
|
45
45
|
oauthRefreshToken: {
|
|
46
46
|
fields: {
|
|
47
47
|
token: {type: "string", required: true},
|
|
48
|
-
clientId: {type: "string", required: true},
|
|
49
|
-
sessionId: {type: "string", required: false},
|
|
50
|
-
userId: {type: "string", required: false},
|
|
48
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
49
|
+
sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
|
|
50
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
51
51
|
referenceId: {type: "string", required: false},
|
|
52
52
|
authTime: {type: "date", required: false},
|
|
53
53
|
expiresAt: {type: "date", required: false},
|
|
@@ -57,27 +57,27 @@ module BetterAuth
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
oauthAccessToken: {
|
|
60
|
-
|
|
60
|
+
model_name: "oauth_access_tokens",
|
|
61
61
|
fields: {
|
|
62
62
|
token: {type: "string", unique: true, required: true},
|
|
63
63
|
expiresAt: {type: "date", required: true},
|
|
64
|
-
clientId: {type: "string", required: true},
|
|
65
|
-
userId: {type: "string", required: false},
|
|
66
|
-
sessionId: {type: "string", required: false},
|
|
64
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
65
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
66
|
+
sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
|
|
67
67
|
scopes: {type: "string[]", required: true},
|
|
68
68
|
revoked: {type: "date", required: false},
|
|
69
69
|
referenceId: {type: "string", required: false},
|
|
70
70
|
authTime: {type: "date", required: false},
|
|
71
|
-
refreshId: {type: "string", required: false},
|
|
71
|
+
refreshId: {type: "string", required: false, references: {model: "oauthRefreshToken", field: "id"}},
|
|
72
72
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
73
73
|
updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
oauthConsent: {
|
|
77
|
-
|
|
77
|
+
model_name: "oauth_consents",
|
|
78
78
|
fields: {
|
|
79
|
-
clientId: {type: "string", required: true},
|
|
80
|
-
userId: {type: "string", required: false},
|
|
79
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
80
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
81
81
|
referenceId: {type: "string", required: false},
|
|
82
82
|
scopes: {type: "string[]", required: true},
|
|
83
83
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
@@ -144,6 +144,7 @@ module BetterAuth
|
|
|
144
144
|
openapi: {
|
|
145
145
|
operationId: operation_id,
|
|
146
146
|
description: description,
|
|
147
|
+
requestBody: mcp_request_body_for(operation_id),
|
|
147
148
|
responses: {
|
|
148
149
|
"200" => OpenAPI.json_response(response_description, response_schema)
|
|
149
150
|
}
|
|
@@ -151,6 +152,46 @@ module BetterAuth
|
|
|
151
152
|
}
|
|
152
153
|
end
|
|
153
154
|
|
|
155
|
+
def mcp_request_body_for(operation_id)
|
|
156
|
+
schema = case operation_id
|
|
157
|
+
when "registerMcpClient", "legacyRegisterMcpClient"
|
|
158
|
+
OpenAPI.object_schema(
|
|
159
|
+
{
|
|
160
|
+
redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
|
|
161
|
+
client_name: {type: "string"},
|
|
162
|
+
client_uri: {type: "string", format: "uri"},
|
|
163
|
+
logo_uri: {type: "string", format: "uri"},
|
|
164
|
+
grant_types: {type: "array", items: {type: "string"}},
|
|
165
|
+
response_types: {type: "array", items: {type: "string"}},
|
|
166
|
+
scope: {type: "string"},
|
|
167
|
+
contacts: {type: "array", items: {type: "string"}},
|
|
168
|
+
metadata: {type: "object", additionalProperties: true}
|
|
169
|
+
},
|
|
170
|
+
required: ["redirect_uris"]
|
|
171
|
+
)
|
|
172
|
+
when "mcpOAuthConsent"
|
|
173
|
+
OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}, scope: {type: "string"}, scopes: {type: "array", items: {type: "string"}}}, required: ["consent_code"])
|
|
174
|
+
when "mcpOAuthToken", "legacyMcpOAuthToken"
|
|
175
|
+
OpenAPI.object_schema(
|
|
176
|
+
{
|
|
177
|
+
grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
|
|
178
|
+
code: {type: "string"},
|
|
179
|
+
redirect_uri: {type: "string", format: "uri"},
|
|
180
|
+
code_verifier: {type: "string"},
|
|
181
|
+
client_id: {type: "string"},
|
|
182
|
+
client_secret: {type: "string"},
|
|
183
|
+
refresh_token: {type: "string"},
|
|
184
|
+
scope: {type: "string"},
|
|
185
|
+
resource: {oneOf: [{type: "string"}, {type: "array", items: {type: "string"}}]}
|
|
186
|
+
},
|
|
187
|
+
required: ["grant_type"]
|
|
188
|
+
)
|
|
189
|
+
when "mcpOAuthIntrospect", "mcpOAuthRevoke"
|
|
190
|
+
OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
|
|
191
|
+
end
|
|
192
|
+
schema ? OpenAPI.json_request_body(schema) : nil
|
|
193
|
+
end
|
|
194
|
+
|
|
154
195
|
def mcp_client_schema
|
|
155
196
|
OpenAPI.object_schema(
|
|
156
197
|
{
|
|
@@ -29,6 +29,12 @@ module BetterAuth
|
|
|
29
29
|
parse_scopes(value).join(" ")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def request_body!(value)
|
|
33
|
+
return stringify_keys(value || {}) if value.nil? || value.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
raise APIError.new("BAD_REQUEST", message: "request body must be an object")
|
|
36
|
+
end
|
|
37
|
+
|
|
32
38
|
def issuer(ctx)
|
|
33
39
|
ctx.context.options.base_url.to_s.empty? ? origin_for(ctx.context.base_url) : ctx.context.options.base_url
|
|
34
40
|
end
|
|
@@ -100,7 +106,7 @@ module BetterAuth
|
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain", unauthenticated: false, default_scopes: nil, allowed_scopes: nil, prefix: {}, dynamic_registration: false, admin: false, pairwise_secret: nil, strip_client_metadata: false, reference_id: nil)
|
|
103
|
-
body =
|
|
109
|
+
body = request_body!(body || {})
|
|
104
110
|
requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
|
|
105
111
|
validate_client_metadata_enums!(requested_auth_method, body)
|
|
106
112
|
validate_admin_only_fields!(body, admin: admin)
|
|
@@ -116,6 +122,7 @@ module BetterAuth
|
|
|
116
122
|
grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
|
|
117
123
|
response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
|
|
118
124
|
validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
|
|
125
|
+
validate_redirect_scheme_for_client!(auth_method, body, redirects)
|
|
119
126
|
validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
120
127
|
|
|
121
128
|
scopes = parse_scopes(body["scope"] || body["scopes"])
|
|
@@ -237,6 +244,20 @@ module BetterAuth
|
|
|
237
244
|
end
|
|
238
245
|
end
|
|
239
246
|
|
|
247
|
+
def validate_redirect_scheme_for_client!(auth_method, body, redirects)
|
|
248
|
+
return if auth_method == "none" && body["type"] != "web"
|
|
249
|
+
|
|
250
|
+
redirects.each do |value|
|
|
251
|
+
uri = URI.parse(value.to_s)
|
|
252
|
+
next if uri.scheme == "https"
|
|
253
|
+
next if uri.scheme == "http" && loopback_host?(uri.hostname || uri.host)
|
|
254
|
+
|
|
255
|
+
raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
|
|
256
|
+
end
|
|
257
|
+
rescue URI::InvalidURIError
|
|
258
|
+
raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
|
|
259
|
+
end
|
|
260
|
+
|
|
240
261
|
def validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
241
262
|
subject_type = body["subject_type"] || body["subjectType"]
|
|
242
263
|
return unless subject_type == "pairwise"
|
|
@@ -266,7 +287,11 @@ module BetterAuth
|
|
|
266
287
|
end
|
|
267
288
|
|
|
268
289
|
def client_metadata(body, strip_unknown: false)
|
|
269
|
-
|
|
290
|
+
raw_metadata = body["metadata"]
|
|
291
|
+
unless raw_metadata.nil? || raw_metadata.is_a?(Hash)
|
|
292
|
+
raise APIError.new("BAD_REQUEST", message: "metadata must be an object")
|
|
293
|
+
end
|
|
294
|
+
metadata = stringify_keys(raw_metadata || {})
|
|
270
295
|
metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
|
|
271
296
|
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
272
297
|
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
@@ -313,15 +338,23 @@ module BetterAuth
|
|
|
313
338
|
ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id.to_s}])
|
|
314
339
|
end
|
|
315
340
|
|
|
316
|
-
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
|
|
317
|
-
body =
|
|
341
|
+
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {}, require_confidential: false)
|
|
342
|
+
body = request_body!(ctx.body || {})
|
|
318
343
|
client_id = body["client_id"]
|
|
319
344
|
client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
|
|
320
345
|
|
|
321
346
|
authorization = ctx.headers["authorization"]
|
|
322
|
-
|
|
323
|
-
|
|
347
|
+
auth_method_used = client_secret.to_s.empty? ? nil : "client_secret_post"
|
|
348
|
+
if authorization.to_s.start_with?("Basic ")
|
|
349
|
+
decoded = Base64.strict_decode64(authorization.delete_prefix("Basic "))
|
|
350
|
+
unless decoded.include?(":")
|
|
351
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
352
|
+
end
|
|
324
353
|
client_id, client_secret = decoded.split(":", 2)
|
|
354
|
+
if client_id.to_s.empty? || client_secret.to_s.empty?
|
|
355
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
356
|
+
end
|
|
357
|
+
auth_method_used = "client_secret_basic"
|
|
325
358
|
end
|
|
326
359
|
|
|
327
360
|
client = find_client(ctx, model, client_id)
|
|
@@ -332,20 +365,34 @@ module BetterAuth
|
|
|
332
365
|
|
|
333
366
|
method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
|
|
334
367
|
if method == "none"
|
|
368
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") if require_confidential
|
|
335
369
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
|
|
336
370
|
return client
|
|
337
371
|
end
|
|
372
|
+
expected_method = (method == "client_secret_post") ? "client_secret_post" : "client_secret_basic"
|
|
373
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless auth_method_used == expected_method
|
|
374
|
+
if client_secret_expired?(client_data["clientSecretExpiresAt"])
|
|
375
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
376
|
+
end
|
|
338
377
|
if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
|
|
339
378
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
340
379
|
end
|
|
341
380
|
|
|
342
|
-
client
|
|
381
|
+
client.merge("__providedClientSecret" => client_secret)
|
|
343
382
|
rescue ArgumentError
|
|
344
|
-
raise APIError.new("
|
|
383
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
345
384
|
end
|
|
346
385
|
|
|
347
|
-
def
|
|
348
|
-
|
|
386
|
+
def client_secret_expired?(value)
|
|
387
|
+
return false if value.nil? || value.to_i == 0
|
|
388
|
+
|
|
389
|
+
seconds = timestamp_seconds(value)
|
|
390
|
+
seconds && seconds <= Time.now.to_i
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil, expires_in: 600, store_tokens: "hashed")
|
|
394
|
+
stored_code = get_stored_token(store_tokens, code, "authorization_code")
|
|
395
|
+
store[:codes][stored_code] = {
|
|
349
396
|
client_id: client_id,
|
|
350
397
|
redirect_uri: redirect_uri,
|
|
351
398
|
session: session,
|
|
@@ -359,8 +406,9 @@ module BetterAuth
|
|
|
359
406
|
}
|
|
360
407
|
end
|
|
361
408
|
|
|
362
|
-
def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil)
|
|
363
|
-
|
|
409
|
+
def consume_code!(store, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
|
|
410
|
+
stored_code = get_stored_token(store_tokens, code.to_s, "authorization_code")
|
|
411
|
+
data = store[:codes].delete(stored_code) || store[:codes].delete(code.to_s)
|
|
364
412
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
365
413
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
|
|
366
414
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
|
|
@@ -396,6 +444,7 @@ module BetterAuth
|
|
|
396
444
|
|
|
397
445
|
def pkce_required?(client, scopes)
|
|
398
446
|
data = stringify_keys(client)
|
|
447
|
+
return true if data["public"] || data["tokenEndpointAuthMethod"] == "none" || ["native", "user-agent-based"].include?(data["type"])
|
|
399
448
|
return true if parse_scopes(scopes).include?("offline_access")
|
|
400
449
|
require_pkce = client_require_pkce(data)
|
|
401
450
|
return require_pkce unless require_pkce.nil?
|
|
@@ -403,7 +452,7 @@ module BetterAuth
|
|
|
403
452
|
true
|
|
404
453
|
end
|
|
405
454
|
|
|
406
|
-
def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
|
|
455
|
+
def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed")
|
|
407
456
|
data = stringify_keys(session || {})
|
|
408
457
|
user = stringify_keys(data["user"] || data[:user] || {})
|
|
409
458
|
session_data = stringify_keys(data["session"] || data[:session] || {})
|
|
@@ -424,7 +473,7 @@ module BetterAuth
|
|
|
424
473
|
refresh_record = nil
|
|
425
474
|
if refresh_token_value
|
|
426
475
|
refresh_record = {
|
|
427
|
-
"token" => refresh_token_value,
|
|
476
|
+
"token" => store_token_value(store_tokens, refresh_token_value, "refresh_token"),
|
|
428
477
|
"clientId" => client_data["clientId"],
|
|
429
478
|
"sessionId" => session_data["id"],
|
|
430
479
|
"userId" => user["id"],
|
|
@@ -440,13 +489,13 @@ module BetterAuth
|
|
|
440
489
|
"issuedAt" => Time.now
|
|
441
490
|
}
|
|
442
491
|
created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
|
|
443
|
-
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
492
|
+
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "token" => refresh_token_value, "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
444
493
|
store[:refresh_tokens][refresh_token_value] = refresh_record
|
|
445
494
|
store[:refresh_tokens][refresh_token] = refresh_record
|
|
446
495
|
end
|
|
447
496
|
unless jwt_access_token && audience
|
|
448
497
|
record = {
|
|
449
|
-
"token" => access_token_value,
|
|
498
|
+
"token" => store_token_value(store_tokens, access_token_value, "access_token"),
|
|
450
499
|
"expiresAt" => expires_at,
|
|
451
500
|
"clientId" => client_data["clientId"],
|
|
452
501
|
"userId" => user["id"],
|
|
@@ -464,7 +513,7 @@ module BetterAuth
|
|
|
464
513
|
created_access = ctx.context.adapter.create(model: model, data: record)
|
|
465
514
|
created = stringify_keys(created_access || {})
|
|
466
515
|
record = record.merge("id" => created["id"]) if created["id"]
|
|
467
|
-
stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
|
|
516
|
+
stored_record = record.merge("token" => access_token_value, "user" => user, "session" => session_data, "client" => client_data)
|
|
468
517
|
store[:tokens][access_token_value] = stored_record
|
|
469
518
|
store[:tokens][access_token] = stored_record
|
|
470
519
|
end
|
|
@@ -478,7 +527,8 @@ module BetterAuth
|
|
|
478
527
|
}
|
|
479
528
|
response[:audience] = audience if audience
|
|
480
529
|
response[:refresh_token] = refresh_token if refresh_token
|
|
481
|
-
|
|
530
|
+
id_token_client_data = client_data.merge("clientSecret" => client_data["__providedClientSecret"] || client_data["clientSecret"])
|
|
531
|
+
response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: id_token_client_data, filter_claims_by_scope: filter_id_token_claims_by_scope, expires_in: id_token_expires_in, use_jwt_plugin: use_jwt_plugin) if parse_scopes(scope).include?("openid")
|
|
482
532
|
if custom_token_response_fields.respond_to?(:call)
|
|
483
533
|
extra = custom_token_response_fields.call({grant_type: grant_type, user: user.empty? ? nil : user, scopes: parse_scopes(scope), metadata: stringify_keys(client_data["metadata"] || {})})
|
|
484
534
|
response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
|
|
@@ -486,7 +536,7 @@ module BetterAuth
|
|
|
486
536
|
response
|
|
487
537
|
end
|
|
488
538
|
|
|
489
|
-
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
|
|
539
|
+
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed")
|
|
490
540
|
refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
|
|
491
541
|
data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
|
|
492
542
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
@@ -532,7 +582,8 @@ module BetterAuth
|
|
|
532
582
|
id_token_expires_in: id_token_expires_in,
|
|
533
583
|
auth_time: data["authTime"],
|
|
534
584
|
reference_id: data["referenceId"],
|
|
535
|
-
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
|
|
585
|
+
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope,
|
|
586
|
+
store_tokens: store_tokens
|
|
536
587
|
)
|
|
537
588
|
end
|
|
538
589
|
|
|
@@ -757,6 +808,11 @@ module BetterAuth
|
|
|
757
808
|
end
|
|
758
809
|
|
|
759
810
|
def id_token_hs256_key(ctx, client_id, client_secret = nil)
|
|
811
|
+
oauth_provider = ctx&.context&.options&.plugins&.find { |plugin| plugin.id == "oauth-provider" }
|
|
812
|
+
if oauth_provider&.options&.fetch(:store_client_secret, nil).to_s == "hashed"
|
|
813
|
+
label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
|
|
814
|
+
return OpenSSL::HMAC.hexdigest("SHA256", ctx.context.secret.to_s, "oidc.id_token.#{label}")
|
|
815
|
+
end
|
|
760
816
|
return client_secret.to_s unless client_secret.to_s.empty?
|
|
761
817
|
|
|
762
818
|
label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
|
|
@@ -857,10 +913,29 @@ module BetterAuth
|
|
|
857
913
|
secret
|
|
858
914
|
end
|
|
859
915
|
|
|
916
|
+
def store_token_value(storage_method, token, type)
|
|
917
|
+
case storage_method
|
|
918
|
+
when "hashed", :hashed
|
|
919
|
+
Crypto.sha256(token.to_s, encoding: :base64url)
|
|
920
|
+
else
|
|
921
|
+
mode = normalize_secret_storage_mode(storage_method)
|
|
922
|
+
return mode[:hash].call(token.to_s, type) if mode.is_a?(Hash) && mode[:hash].respond_to?(:call)
|
|
923
|
+
|
|
924
|
+
raise Error, "storeToken: unsupported storageMethod type '#{storage_method}'"
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def get_stored_token(storage_method, token, type)
|
|
929
|
+
store_token_value(storage_method, token, type)
|
|
930
|
+
end
|
|
931
|
+
|
|
860
932
|
def verify_client_secret(ctx, stored_secret, provided_secret, mode)
|
|
861
933
|
mode = normalize_secret_storage_mode(mode)
|
|
862
934
|
return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
|
|
863
|
-
|
|
935
|
+
if mode == "encrypted"
|
|
936
|
+
decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret)
|
|
937
|
+
return Crypto.constant_time_compare(decrypted.to_s, provided_secret.to_s)
|
|
938
|
+
end
|
|
864
939
|
|
|
865
940
|
if mode.is_a?(Hash)
|
|
866
941
|
return Crypto.constant_time_compare(mode[:hash].call(provided_secret).to_s, stored_secret.to_s) if mode[:hash].respond_to?(:call)
|
|
@@ -868,6 +943,8 @@ module BetterAuth
|
|
|
868
943
|
end
|
|
869
944
|
|
|
870
945
|
Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
|
|
946
|
+
rescue Error, ArgumentError
|
|
947
|
+
false
|
|
871
948
|
end
|
|
872
949
|
|
|
873
950
|
def normalize_secret_storage_mode(mode)
|