better_auth 0.8.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 +9 -0
- data/README.md +4 -4
- data/lib/better_auth/adapters/memory.rb +131 -17
- data/lib/better_auth/adapters/sql.rb +139 -57
- data/lib/better_auth/configuration.rb +7 -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/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/version.rb +1 -1
- data/lib/better_auth.rb +3 -0
- metadata +10 -6
|
@@ -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)
|
|
@@ -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
|