better_auth 0.3.0 → 0.4.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 +15 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -2
- data/lib/better_auth/adapters/memory.rb +57 -11
- data/lib/better_auth/adapters/sql.rb +27 -2
- data/lib/better_auth/api.rb +6 -1
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/database_hooks.rb +3 -3
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +5 -2
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugins/email_otp.rb +16 -5
- data/lib/better_auth/plugins/generic_oauth.rb +14 -28
- data/lib/better_auth/plugins/oauth_protocol.rb +171 -28
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +51 -20
- data/lib/better_auth/plugins/two_factor.rb +53 -18
- data/lib/better_auth/rate_limiter.rb +18 -0
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/routes/account.rb +16 -4
- data/lib/better_auth/routes/password.rb +2 -1
- data/lib/better_auth/routes/sign_in.rb +2 -0
- data/lib/better_auth/routes/sign_up.rb +8 -0
- data/lib/better_auth/routes/social.rb +30 -0
- data/lib/better_auth/routes/user.rb +9 -3
- data/lib/better_auth/session.rb +12 -1
- data/lib/better_auth/url_helpers.rb +195 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +8 -0
- metadata +9 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "jwt"
|
|
7
|
+
|
|
8
|
+
module BetterAuth
|
|
9
|
+
module OAuth2
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate_token(token, jwks:, audience: nil, issuer: nil)
|
|
13
|
+
header = JWT.decode(token, nil, false).last
|
|
14
|
+
kid = header["kid"]
|
|
15
|
+
raise APIError.new("UNAUTHORIZED", message: "Missing jwt kid") if kid.to_s.empty?
|
|
16
|
+
|
|
17
|
+
key_data = Array(jwks["keys"] || jwks[:keys]).find { |key| (key["kid"] || key[:kid]).to_s == kid.to_s }
|
|
18
|
+
raise APIError.new("UNAUTHORIZED", message: "kid doesn't match any key") unless key_data
|
|
19
|
+
|
|
20
|
+
public_key = JWT::JWK.import(stringify_keys(key_data)).public_key
|
|
21
|
+
algorithm = header["alg"] || key_data["alg"] || key_data[:alg]
|
|
22
|
+
options = {algorithm: algorithm}
|
|
23
|
+
options[:aud] = audience if audience
|
|
24
|
+
options[:verify_aud] = true if audience
|
|
25
|
+
options[:iss] = issuer if issuer
|
|
26
|
+
options[:verify_iss] = true if issuer
|
|
27
|
+
JWT.decode(token, public_key, true, **options).first
|
|
28
|
+
rescue JWT::DecodeError => error
|
|
29
|
+
raise APIError.new("UNAUTHORIZED", message: error.message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def refresh_access_token(refresh_token:, token_endpoint:, options:, authentication: nil, extra_params: nil, resource: nil, fetcher: nil)
|
|
33
|
+
request = create_refresh_access_token_request(
|
|
34
|
+
refresh_token: refresh_token,
|
|
35
|
+
options: options,
|
|
36
|
+
authentication: authentication,
|
|
37
|
+
extra_params: extra_params,
|
|
38
|
+
resource: resource
|
|
39
|
+
)
|
|
40
|
+
data = fetcher ? fetcher.call(token_endpoint, request) : post_form(token_endpoint, request)
|
|
41
|
+
now = Time.now
|
|
42
|
+
tokens = {
|
|
43
|
+
access_token: data["access_token"] || data[:access_token],
|
|
44
|
+
refresh_token: data["refresh_token"] || data[:refresh_token],
|
|
45
|
+
token_type: data["token_type"] || data[:token_type],
|
|
46
|
+
scopes: (data["scope"] || data[:scope])&.split(" "),
|
|
47
|
+
id_token: data["id_token"] || data[:id_token]
|
|
48
|
+
}.compact
|
|
49
|
+
|
|
50
|
+
expires_in = data["expires_in"] || data[:expires_in]
|
|
51
|
+
tokens[:access_token_expires_at] = now + expires_in.to_i if expires_in
|
|
52
|
+
|
|
53
|
+
refresh_expires_in = data["refresh_token_expires_in"] || data[:refresh_token_expires_in]
|
|
54
|
+
tokens[:refresh_token_expires_at] = now + refresh_expires_in.to_i if refresh_expires_in
|
|
55
|
+
tokens
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_refresh_access_token_request(refresh_token:, options:, authentication: nil, extra_params: nil, resource: nil)
|
|
59
|
+
body = {
|
|
60
|
+
"grant_type" => "refresh_token",
|
|
61
|
+
"refresh_token" => refresh_token
|
|
62
|
+
}
|
|
63
|
+
headers = {
|
|
64
|
+
"content-type" => "application/x-www-form-urlencoded",
|
|
65
|
+
"accept" => "application/json"
|
|
66
|
+
}
|
|
67
|
+
client_id = Array(options[:client_id] || options["client_id"] || options[:clientId] || options["clientId"]).first
|
|
68
|
+
client_secret = options[:client_secret] || options["client_secret"] || options[:clientSecret] || options["clientSecret"]
|
|
69
|
+
|
|
70
|
+
if authentication.to_s == "basic"
|
|
71
|
+
headers["authorization"] = "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"
|
|
72
|
+
else
|
|
73
|
+
body["client_id"] = client_id if client_id
|
|
74
|
+
body["client_secret"] = client_secret if client_secret
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Array(resource).each { |entry| (body["resource"] ||= []) << entry } if resource
|
|
78
|
+
extra_params&.each { |key, value| body[key.to_s] = value }
|
|
79
|
+
{body: body, headers: headers}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def post_form(token_endpoint, request)
|
|
83
|
+
uri = URI.parse(token_endpoint)
|
|
84
|
+
response = Net::HTTP.post(uri, URI.encode_www_form(request[:body]), request[:headers])
|
|
85
|
+
JSON.parse(response.body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stringify_keys(hash)
|
|
89
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
90
|
+
result[key.to_s] = value.is_a?(Hash) ? stringify_keys(value) : value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -193,9 +193,9 @@ module BetterAuth
|
|
|
193
193
|
user = if found
|
|
194
194
|
found[:user]
|
|
195
195
|
else
|
|
196
|
-
raise APIError.new("BAD_REQUEST", message:
|
|
196
|
+
raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) if config[:disable_sign_up]
|
|
197
197
|
|
|
198
|
-
ctx.context.internal_adapter.create_user(email_otp_sign_up_user_data(body, email))
|
|
198
|
+
ctx.context.internal_adapter.create_user(email_otp_sign_up_user_data(ctx, body, email))
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
unless user["emailVerified"]
|
|
@@ -419,10 +419,21 @@ module BetterAuth
|
|
|
419
419
|
Array.new(config[:otp_length].to_i) { SecureRandom.random_number(10).to_s }.join
|
|
420
420
|
end
|
|
421
421
|
|
|
422
|
-
def email_otp_sign_up_user_data(body, email)
|
|
422
|
+
def email_otp_sign_up_user_data(ctx, body, email)
|
|
423
423
|
reserved = %i[email otp name image callback_url callbackURL callbackUrl]
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
user_fields = Schema.auth_tables(ctx.context.options).fetch("user").fetch(:fields)
|
|
425
|
+
core_fields = %w[id name email emailVerified image createdAt updatedAt]
|
|
426
|
+
additional = body.each_with_object({}) do |(key, value), result|
|
|
427
|
+
next if reserved.include?(key.to_sym)
|
|
428
|
+
|
|
429
|
+
field = Schema.storage_key(key)
|
|
430
|
+
attributes = user_fields[field]
|
|
431
|
+
next unless attributes
|
|
432
|
+
next if core_fields.include?(field)
|
|
433
|
+
next if attributes[:input] == false
|
|
434
|
+
|
|
435
|
+
result[field] = value
|
|
436
|
+
end
|
|
426
437
|
additional.merge(
|
|
427
438
|
"email" => email,
|
|
428
439
|
"emailVerified" => true,
|
|
@@ -52,19 +52,7 @@ module BetterAuth
|
|
|
52
52
|
data,
|
|
53
53
|
provider_id: "auth0",
|
|
54
54
|
discovery_url: "https://#{domain}/.well-known/openid-configuration",
|
|
55
|
-
scopes: ["openid", "profile", "email"]
|
|
56
|
-
get_user_info: ->(tokens) {
|
|
57
|
-
profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
|
|
58
|
-
return nil unless profile
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
id: fetch_value(profile, "sub"),
|
|
62
|
-
name: fetch_value(profile, "name") || fetch_value(profile, "nickname"),
|
|
63
|
-
email: fetch_value(profile, "email"),
|
|
64
|
-
image: fetch_value(profile, "picture"),
|
|
65
|
-
emailVerified: fetch_value(profile, "email_verified") || false
|
|
66
|
-
}
|
|
67
|
-
}
|
|
55
|
+
scopes: ["openid", "profile", "email"]
|
|
68
56
|
)
|
|
69
57
|
end
|
|
70
58
|
|
|
@@ -688,24 +676,12 @@ module BetterAuth
|
|
|
688
676
|
nil
|
|
689
677
|
end
|
|
690
678
|
|
|
691
|
-
def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url,
|
|
679
|
+
def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, _user_info_url)
|
|
692
680
|
generic_oauth_provider_config(
|
|
693
681
|
options,
|
|
694
682
|
provider_id: provider_id,
|
|
695
683
|
discovery_url: discovery_url,
|
|
696
|
-
scopes: ["openid", "profile", "email"]
|
|
697
|
-
get_user_info: ->(tokens) {
|
|
698
|
-
profile = generic_oauth_fetch_json(user_info_url, authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
|
|
699
|
-
return nil unless profile
|
|
700
|
-
|
|
701
|
-
{
|
|
702
|
-
id: fetch_value(profile, "sub"),
|
|
703
|
-
name: fetch_value(profile, "name") || fetch_value(profile, "preferred_username"),
|
|
704
|
-
email: fetch_value(profile, "email"),
|
|
705
|
-
image: fetch_value(profile, "picture"),
|
|
706
|
-
emailVerified: fetch_value(profile, "email_verified") || false
|
|
707
|
-
}
|
|
708
|
-
}
|
|
684
|
+
scopes: ["openid", "profile", "email"]
|
|
709
685
|
)
|
|
710
686
|
end
|
|
711
687
|
|
|
@@ -730,12 +706,22 @@ module BetterAuth
|
|
|
730
706
|
result[provider_id.to_sym] = {
|
|
731
707
|
id: provider_id,
|
|
732
708
|
name: provider_id,
|
|
733
|
-
get_user_info: ->(tokens) {
|
|
709
|
+
get_user_info: ->(tokens) { generic_oauth_provider_user_info(provider, tokens) },
|
|
734
710
|
refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }
|
|
735
711
|
}
|
|
736
712
|
end
|
|
737
713
|
end
|
|
738
714
|
|
|
715
|
+
def generic_oauth_provider_user_info(provider, tokens)
|
|
716
|
+
user_info = generic_oauth_user_info(provider, tokens)
|
|
717
|
+
return nil unless user_info
|
|
718
|
+
|
|
719
|
+
{
|
|
720
|
+
user: generic_oauth_map_user(provider, user_info),
|
|
721
|
+
data: user_info
|
|
722
|
+
}
|
|
723
|
+
end
|
|
724
|
+
|
|
739
725
|
def generic_oauth_refresh_access_token(ctx, provider, refresh_token)
|
|
740
726
|
token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
|
|
741
727
|
raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?
|
|
@@ -63,13 +63,15 @@ module BetterAuth
|
|
|
63
63
|
def loopback_redirect_match?(redirects, redirect_uri)
|
|
64
64
|
requested = URI.parse(redirect_uri.to_s)
|
|
65
65
|
return false unless ["http", "https"].include?(requested.scheme)
|
|
66
|
-
|
|
66
|
+
requested_host = requested.hostname || requested.host
|
|
67
|
+
return false unless loopback_host?(requested_host)
|
|
67
68
|
|
|
68
69
|
redirects.any? do |allowed|
|
|
69
70
|
allowed_uri = URI.parse(allowed.to_s)
|
|
71
|
+
allowed_host = allowed_uri.hostname || allowed_uri.host
|
|
70
72
|
allowed_uri.scheme == requested.scheme &&
|
|
71
|
-
loopback_host?(
|
|
72
|
-
|
|
73
|
+
loopback_host?(allowed_host) &&
|
|
74
|
+
allowed_host == requested_host &&
|
|
73
75
|
allowed_uri.path == requested.path &&
|
|
74
76
|
allowed_uri.query == requested.query
|
|
75
77
|
rescue URI::InvalidURIError
|
|
@@ -97,7 +99,7 @@ module BetterAuth
|
|
|
97
99
|
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
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)
|
|
102
|
+
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)
|
|
101
103
|
body = stringify_keys(body || {})
|
|
102
104
|
requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
|
|
103
105
|
validate_client_metadata_enums!(requested_auth_method, body)
|
|
@@ -108,10 +110,13 @@ module BetterAuth
|
|
|
108
110
|
client_secret = public_client ? nil : Crypto.random_string(32)
|
|
109
111
|
redirects = Array(body["redirect_uris"]).map(&:to_s)
|
|
110
112
|
raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
|
|
113
|
+
redirects.each { |uri| validate_safe_url!(uri, field: "redirect_uris") }
|
|
114
|
+
Array(body["post_logout_redirect_uris"]).map(&:to_s).each { |uri| validate_safe_url!(uri, field: "post_logout_redirect_uris") }
|
|
111
115
|
|
|
112
116
|
grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
|
|
113
117
|
response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
|
|
114
118
|
validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
|
|
119
|
+
validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
115
120
|
|
|
116
121
|
scopes = parse_scopes(body["scope"] || body["scopes"])
|
|
117
122
|
scopes = parse_scopes(default_scopes) if scopes.empty? && default_scopes
|
|
@@ -120,7 +125,7 @@ module BetterAuth
|
|
|
120
125
|
raise APIError.new("BAD_REQUEST", message: "invalid_scope")
|
|
121
126
|
end
|
|
122
127
|
|
|
123
|
-
metadata =
|
|
128
|
+
metadata = client_metadata(body, strip_unknown: strip_client_metadata)
|
|
124
129
|
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
125
130
|
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
126
131
|
metadata["software_statement"] = body["software_statement"] if body["software_statement"]
|
|
@@ -163,7 +168,8 @@ module BetterAuth
|
|
|
163
168
|
"metadata" => metadata,
|
|
164
169
|
"disabled" => false
|
|
165
170
|
}
|
|
166
|
-
data["
|
|
171
|
+
data["referenceId"] = reference_id if reference_id
|
|
172
|
+
data["userId"] = owner_session[:user]["id"] if owner_session && !reference_id
|
|
167
173
|
created = ctx.context.adapter.create(model: model, data: data)
|
|
168
174
|
response = client_response(created).merge(
|
|
169
175
|
client_secret: client_secret ? apply_prefix(client_secret, prefix, :client_secret) : nil,
|
|
@@ -192,7 +198,7 @@ module BetterAuth
|
|
|
192
198
|
type: data["type"],
|
|
193
199
|
user_id: data["userId"],
|
|
194
200
|
reference_id: data["referenceId"],
|
|
195
|
-
require_pkce: data
|
|
201
|
+
require_pkce: client_require_pkce(data),
|
|
196
202
|
subject_type: data["subjectType"],
|
|
197
203
|
metadata: metadata,
|
|
198
204
|
contacts: data["contacts"] || [],
|
|
@@ -214,6 +220,9 @@ module BetterAuth
|
|
|
214
220
|
if dynamic_registration && (body["require_pkce"] == false || body["requirePKCE"] == false)
|
|
215
221
|
raise APIError.new("BAD_REQUEST", message: "pkce is required for registered clients")
|
|
216
222
|
end
|
|
223
|
+
if dynamic_registration && (body["enable_end_session"] || body["enableEndSession"])
|
|
224
|
+
raise APIError.new("BAD_REQUEST", message: "enable_end_session is not allowed during dynamic client registration")
|
|
225
|
+
end
|
|
217
226
|
if public_client && grant_types.include?(CLIENT_CREDENTIALS_GRANT)
|
|
218
227
|
raise APIError.new("BAD_REQUEST", message: "public clients cannot use client_credentials")
|
|
219
228
|
end
|
|
@@ -228,6 +237,53 @@ module BetterAuth
|
|
|
228
237
|
end
|
|
229
238
|
end
|
|
230
239
|
|
|
240
|
+
def validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
241
|
+
subject_type = body["subject_type"] || body["subjectType"]
|
|
242
|
+
return unless subject_type == "pairwise"
|
|
243
|
+
|
|
244
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires pairwise_secret") if pairwise_secret.to_s.empty?
|
|
245
|
+
|
|
246
|
+
hosts = redirects.map { |uri| URI.parse(uri).host }.uniq
|
|
247
|
+
raise APIError.new("BAD_REQUEST", message: "pairwise redirect_uris must share the same host") if hosts.length > 1
|
|
248
|
+
rescue URI::InvalidURIError
|
|
249
|
+
raise APIError.new("BAD_REQUEST", message: "invalid redirect_uris")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_safe_url!(value, field:)
|
|
253
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if value.to_s.empty?
|
|
254
|
+
|
|
255
|
+
uri = URI.parse(value.to_s)
|
|
256
|
+
scheme = uri.scheme.to_s.downcase
|
|
257
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if scheme.empty?
|
|
258
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") if %w[javascript data vbscript].include?(scheme)
|
|
259
|
+
|
|
260
|
+
if scheme == "http"
|
|
261
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid") unless ["localhost", "127.0.0.1", "::1"].include?(uri.hostname || uri.host)
|
|
262
|
+
end
|
|
263
|
+
true
|
|
264
|
+
rescue URI::InvalidURIError
|
|
265
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is invalid")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def client_metadata(body, strip_unknown: false)
|
|
269
|
+
metadata = stringify_keys(body["metadata"] || {})
|
|
270
|
+
metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
|
|
271
|
+
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
272
|
+
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
273
|
+
metadata["software_statement"] = body["software_statement"] if body["software_statement"]
|
|
274
|
+
metadata["tos_uri"] = body["tos_uri"] if body["tos_uri"]
|
|
275
|
+
metadata["policy_uri"] = body["policy_uri"] if body["policy_uri"]
|
|
276
|
+
metadata
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def client_require_pkce(data)
|
|
280
|
+
data = stringify_keys(data || {})
|
|
281
|
+
return data["requirePKCE"] if data.key?("requirePKCE")
|
|
282
|
+
return data["requirePkce"] if data.key?("requirePkce")
|
|
283
|
+
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
231
287
|
def validate_client_metadata_enums!(auth_method, body)
|
|
232
288
|
unless ["client_secret_basic", "client_secret_post", "none"].include?(auth_method)
|
|
233
289
|
raise APIError.new("BAD_REQUEST", message: "invalid token_endpoint_auth_method")
|
|
@@ -309,7 +365,11 @@ module BetterAuth
|
|
|
309
365
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data[:expires_at] <= Time.now
|
|
310
366
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
|
|
311
367
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
|
|
312
|
-
|
|
368
|
+
if data[:code_challenge]
|
|
369
|
+
verify_pkce!(data, code_verifier)
|
|
370
|
+
elsif !code_verifier.to_s.empty?
|
|
371
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
372
|
+
end
|
|
313
373
|
|
|
314
374
|
data
|
|
315
375
|
end
|
|
@@ -337,12 +397,13 @@ module BetterAuth
|
|
|
337
397
|
def pkce_required?(client, scopes)
|
|
338
398
|
data = stringify_keys(client)
|
|
339
399
|
return true if parse_scopes(scopes).include?("offline_access")
|
|
340
|
-
|
|
400
|
+
require_pkce = client_require_pkce(data)
|
|
401
|
+
return require_pkce unless require_pkce.nil?
|
|
341
402
|
|
|
342
403
|
true
|
|
343
404
|
end
|
|
344
405
|
|
|
345
|
-
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_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil)
|
|
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_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, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
|
|
346
407
|
data = stringify_keys(session || {})
|
|
347
408
|
user = stringify_keys(data["user"] || data[:user] || {})
|
|
348
409
|
session_data = stringify_keys(data["session"] || data[:session] || {})
|
|
@@ -372,7 +433,11 @@ module BetterAuth
|
|
|
372
433
|
"expiresAt" => Time.now + refresh_token_expires_in.to_i,
|
|
373
434
|
"createdAt" => Time.now,
|
|
374
435
|
"revoked" => nil,
|
|
375
|
-
"scopes" => parse_scopes(scope)
|
|
436
|
+
"scopes" => parse_scopes(scope),
|
|
437
|
+
"subject" => subject,
|
|
438
|
+
"audience" => audience,
|
|
439
|
+
"issuer" => issuer || issuer(ctx),
|
|
440
|
+
"issuedAt" => Time.now
|
|
376
441
|
}
|
|
377
442
|
created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
|
|
378
443
|
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
@@ -392,7 +457,9 @@ module BetterAuth
|
|
|
392
457
|
"referenceId" => token_reference_id,
|
|
393
458
|
"authTime" => token_auth_time,
|
|
394
459
|
"refreshId" => refresh_record && refresh_record["id"],
|
|
395
|
-
"audience" => audience
|
|
460
|
+
"audience" => audience,
|
|
461
|
+
"issuer" => issuer || issuer(ctx),
|
|
462
|
+
"issuedAt" => Time.now
|
|
396
463
|
}
|
|
397
464
|
ctx.context.adapter.create(model: model, data: record)
|
|
398
465
|
stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
|
|
@@ -404,19 +471,20 @@ module BetterAuth
|
|
|
404
471
|
access_token: access_token,
|
|
405
472
|
token_type: "Bearer",
|
|
406
473
|
expires_in: access_token_expires_in.to_i,
|
|
474
|
+
expires_at: expires_at.to_i,
|
|
407
475
|
scope: scope
|
|
408
476
|
}
|
|
409
477
|
response[:audience] = audience if audience
|
|
410
478
|
response[:refresh_token] = refresh_token if refresh_token
|
|
411
|
-
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) if parse_scopes(scope).include?("openid")
|
|
479
|
+
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: client_data, filter_claims_by_scope: filter_id_token_claims_by_scope) if parse_scopes(scope).include?("openid")
|
|
412
480
|
if custom_token_response_fields.respond_to?(:call)
|
|
413
481
|
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"] || {})})
|
|
414
|
-
response.merge!(extra) if extra.is_a?(Hash)
|
|
482
|
+
response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
|
|
415
483
|
end
|
|
416
484
|
response
|
|
417
485
|
end
|
|
418
486
|
|
|
419
|
-
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_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, jwt_access_token: false, pairwise_secret: nil)
|
|
487
|
+
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_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
|
|
420
488
|
refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
|
|
421
489
|
data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
|
|
422
490
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
@@ -450,10 +518,12 @@ module BetterAuth
|
|
|
450
518
|
grant_type: REFRESH_GRANT,
|
|
451
519
|
custom_token_response_fields: custom_token_response_fields,
|
|
452
520
|
custom_access_token_claims: custom_access_token_claims,
|
|
521
|
+
custom_id_token_claims: custom_id_token_claims,
|
|
453
522
|
jwt_access_token: jwt_access_token,
|
|
454
523
|
pairwise_secret: pairwise_secret,
|
|
455
524
|
auth_time: data["authTime"],
|
|
456
|
-
reference_id: data["referenceId"]
|
|
525
|
+
reference_id: data["referenceId"],
|
|
526
|
+
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
|
|
457
527
|
)
|
|
458
528
|
end
|
|
459
529
|
|
|
@@ -478,6 +548,9 @@ module BetterAuth
|
|
|
478
548
|
"azp" => client["clientId"],
|
|
479
549
|
"scope" => scope,
|
|
480
550
|
"sid" => session["id"],
|
|
551
|
+
"name" => user["name"],
|
|
552
|
+
"email" => user["email"],
|
|
553
|
+
"email_verified" => user["emailVerified"],
|
|
481
554
|
"iss" => issuer_value,
|
|
482
555
|
"iat" => Time.now.to_i,
|
|
483
556
|
"exp" => expires_at.to_i
|
|
@@ -485,13 +558,20 @@ module BetterAuth
|
|
|
485
558
|
::JWT.encode(payload, ctx.context.secret, "HS256")
|
|
486
559
|
end
|
|
487
560
|
|
|
488
|
-
def userinfo(store, authorization, additional_claim: nil, prefix: {})
|
|
561
|
+
def userinfo(store, authorization, additional_claim: nil, prefix: {}, jwt_secret: nil)
|
|
562
|
+
if authorization.to_s.strip.empty?
|
|
563
|
+
raise APIError.new(
|
|
564
|
+
"UNAUTHORIZED",
|
|
565
|
+
message: "authorization header not found",
|
|
566
|
+
body: {error: "invalid_request", error_description: "authorization header not found"}
|
|
567
|
+
)
|
|
568
|
+
end
|
|
489
569
|
token = authorization.to_s.delete_prefix("Bearer ").strip
|
|
490
570
|
record = token_record(store, token, prefix: prefix)
|
|
491
|
-
|
|
571
|
+
return jwt_userinfo(token, jwt_secret, additional_claim: additional_claim) unless record
|
|
492
572
|
user = stringify_keys(record["user"])
|
|
493
573
|
scopes = parse_scopes(record["scopes"])
|
|
494
|
-
raise
|
|
574
|
+
raise userinfo_openid_scope_error unless scopes.include?("openid")
|
|
495
575
|
|
|
496
576
|
response = {sub: record["subject"] || user["id"]}
|
|
497
577
|
response[:name] = user["name"] if scopes.include?("profile")
|
|
@@ -513,6 +593,38 @@ module BetterAuth
|
|
|
513
593
|
response
|
|
514
594
|
end
|
|
515
595
|
|
|
596
|
+
def jwt_userinfo(token, jwt_secret, additional_claim: nil)
|
|
597
|
+
payload = ::JWT.decode(token, jwt_secret.to_s, true, algorithm: "HS256").first
|
|
598
|
+
scopes = parse_scopes(payload["scope"])
|
|
599
|
+
raise userinfo_openid_scope_error unless scopes.include?("openid")
|
|
600
|
+
|
|
601
|
+
response = {sub: payload["sub"]}
|
|
602
|
+
if scopes.include?("profile")
|
|
603
|
+
response[:name] = payload["name"] if payload["name"]
|
|
604
|
+
response[:given_name] = payload["name"].to_s.split(/\s+/, 2).first if payload["name"]
|
|
605
|
+
response[:family_name] = payload["name"].to_s.split(/\s+/, 2).last if payload["name"].to_s.include?(" ")
|
|
606
|
+
end
|
|
607
|
+
if scopes.include?("email")
|
|
608
|
+
response[:email] = payload["email"]
|
|
609
|
+
response[:email_verified] = !!payload["email_verified"]
|
|
610
|
+
end
|
|
611
|
+
if additional_claim.respond_to?(:call)
|
|
612
|
+
extra = additional_claim.call({user: payload, scopes: scopes, jwt: payload, client: {}})
|
|
613
|
+
response.merge!(extra) if extra.is_a?(Hash)
|
|
614
|
+
end
|
|
615
|
+
response
|
|
616
|
+
rescue ::JWT::DecodeError
|
|
617
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_token")
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def userinfo_openid_scope_error
|
|
621
|
+
APIError.new(
|
|
622
|
+
"BAD_REQUEST",
|
|
623
|
+
message: "openid scope is required",
|
|
624
|
+
body: {error: "invalid_request", error_description: "openid scope is required"}
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
|
|
516
628
|
def find_token_by_hint(store, token, hint, prefix: {})
|
|
517
629
|
access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
|
|
518
630
|
refresh = -> { (value = strip_prefix(token, prefix, :refresh_token)) && store[:refresh_tokens][value] }
|
|
@@ -588,18 +700,30 @@ module BetterAuth
|
|
|
588
700
|
end
|
|
589
701
|
end
|
|
590
702
|
|
|
591
|
-
def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil)
|
|
703
|
+
def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil, custom_claims: nil, scopes: [], client: {}, filter_claims_by_scope: false)
|
|
704
|
+
requested_scopes = parse_scopes(scopes)
|
|
592
705
|
payload = {
|
|
593
706
|
sub: user["id"],
|
|
594
707
|
iss: issuer_value,
|
|
595
|
-
aud: audience || client_id
|
|
596
|
-
email: user["email"],
|
|
597
|
-
email_verified: !!user["emailVerified"],
|
|
598
|
-
name: user["name"]
|
|
708
|
+
aud: audience || client_id
|
|
599
709
|
}
|
|
710
|
+
include_profile_claims = !filter_claims_by_scope || requested_scopes.include?("profile")
|
|
711
|
+
include_email_claims = !filter_claims_by_scope || requested_scopes.include?("email")
|
|
712
|
+
payload[:name] = user["name"] if include_profile_claims
|
|
713
|
+
if include_email_claims
|
|
714
|
+
payload[:email] = user["email"]
|
|
715
|
+
payload[:email_verified] = !!user["emailVerified"]
|
|
716
|
+
end
|
|
600
717
|
payload[:sid] = session_id if include_sid && session_id
|
|
601
718
|
payload[:nonce] = nonce if nonce
|
|
602
719
|
payload[:auth_time] = timestamp_seconds(auth_time) if auth_time
|
|
720
|
+
if custom_claims.respond_to?(:call)
|
|
721
|
+
extra = custom_claims.call({user: user, scopes: requested_scopes, client: client})
|
|
722
|
+
if extra.is_a?(Hash)
|
|
723
|
+
pinned = %w[sub iss aud exp iat nonce sid]
|
|
724
|
+
payload.merge!(stringify_keys(extra).except(*pinned).transform_keys(&:to_sym))
|
|
725
|
+
end
|
|
726
|
+
end
|
|
603
727
|
return signer.call(ctx, payload) if signer.respond_to?(:call)
|
|
604
728
|
|
|
605
729
|
Crypto.sign_jwt(
|
|
@@ -609,6 +733,10 @@ module BetterAuth
|
|
|
609
733
|
)
|
|
610
734
|
end
|
|
611
735
|
|
|
736
|
+
def standard_token_response_field?(key)
|
|
737
|
+
%w[access_token token_type expires_in scope refresh_token id_token audience].include?(key.to_s)
|
|
738
|
+
end
|
|
739
|
+
|
|
612
740
|
def subject_identifier(user_id, client, pairwise_secret)
|
|
613
741
|
data = stringify_keys(client)
|
|
614
742
|
return user_id unless data["subjectType"] == "pairwise" && pairwise_secret && user_id
|
|
@@ -633,12 +761,27 @@ module BetterAuth
|
|
|
633
761
|
end
|
|
634
762
|
|
|
635
763
|
def timestamp_seconds(value)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
764
|
+
if value.is_a?(Numeric)
|
|
765
|
+
return nil unless value.finite?
|
|
766
|
+
return nil if value.abs > 8_640_000_000_000_000
|
|
767
|
+
|
|
768
|
+
return (value / 1000.0).floor if value.abs >= 100_000_000_000
|
|
769
|
+
|
|
770
|
+
return value.to_i
|
|
771
|
+
end
|
|
772
|
+
if value.is_a?(String) && value.match?(/\A[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[-+]?\d+)?\z/i)
|
|
773
|
+
numeric = value.to_f
|
|
774
|
+
return nil unless numeric.finite?
|
|
775
|
+
return nil if numeric.abs > 8_640_000_000_000_000
|
|
776
|
+
|
|
777
|
+
return (numeric / 1000.0).floor if numeric.abs >= 100_000_000_000
|
|
778
|
+
|
|
779
|
+
return numeric.to_i
|
|
780
|
+
end
|
|
781
|
+
return timestamp_seconds(value.to_i) if value.respond_to?(:to_i) && !value.is_a?(String)
|
|
639
782
|
|
|
640
783
|
Time.parse(value.to_s).to_i
|
|
641
|
-
rescue ArgumentError, TypeError
|
|
784
|
+
rescue ArgumentError, TypeError, FloatDomainError
|
|
642
785
|
nil
|
|
643
786
|
end
|
|
644
787
|
|
|
@@ -10,6 +10,7 @@ module BetterAuth
|
|
|
10
10
|
organization: {
|
|
11
11
|
model_name: "organizations",
|
|
12
12
|
fields: {
|
|
13
|
+
id: {type: "string", required: true},
|
|
13
14
|
name: {type: "string", required: true, sortable: true},
|
|
14
15
|
slug: {type: "string", required: true, unique: true, sortable: true, index: true},
|
|
15
16
|
logo: {type: "string", required: false},
|
|
@@ -21,6 +22,7 @@ module BetterAuth
|
|
|
21
22
|
member: {
|
|
22
23
|
model_name: "members",
|
|
23
24
|
fields: {
|
|
25
|
+
id: {type: "string", required: true},
|
|
24
26
|
organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
|
|
25
27
|
userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
|
|
26
28
|
role: {type: "string", required: true, default_value: "member", sortable: true},
|
|
@@ -30,6 +32,7 @@ module BetterAuth
|
|
|
30
32
|
invitation: {
|
|
31
33
|
model_name: "invitations",
|
|
32
34
|
fields: {
|
|
35
|
+
id: {type: "string", required: true},
|
|
33
36
|
organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
|
|
34
37
|
email: {type: "string", required: true, sortable: true, index: true},
|
|
35
38
|
role: {type: "string", required: true, sortable: true},
|
|
@@ -50,6 +53,7 @@ module BetterAuth
|
|
|
50
53
|
schema[:team] = {
|
|
51
54
|
model_name: "teams",
|
|
52
55
|
fields: {
|
|
56
|
+
id: {type: "string", required: true},
|
|
53
57
|
name: {type: "string", required: true},
|
|
54
58
|
organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
|
|
55
59
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
@@ -59,6 +63,7 @@ module BetterAuth
|
|
|
59
63
|
schema[:teamMember] = {
|
|
60
64
|
model_name: "team_members",
|
|
61
65
|
fields: {
|
|
66
|
+
id: {type: "string", required: true},
|
|
62
67
|
teamId: {type: "string", required: true, references: {model: "team", field: "id"}, index: true},
|
|
63
68
|
userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
|
|
64
69
|
createdAt: {type: "date", required: false, default_value: -> { Time.now }}
|
|
@@ -72,6 +77,7 @@ module BetterAuth
|
|
|
72
77
|
schema[:organizationRole] = {
|
|
73
78
|
model_name: "organization_roles",
|
|
74
79
|
fields: {
|
|
80
|
+
id: {type: "string", required: true},
|
|
75
81
|
organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
|
|
76
82
|
role: {type: "string", required: true, index: true},
|
|
77
83
|
permission: {type: "string", required: true},
|