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.
@@ -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: BASE_ERROR_CODES["USER_NOT_FOUND"]) if config[:disable_sign_up]
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
- additional = body.reject { |key, _value| reserved.include?(key.to_sym) }
425
- additional = additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }
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, user_info_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) { generic_oauth_user_info(provider, 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
- return false unless loopback_host?(requested.host)
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?(allowed_uri.host) &&
72
- allowed_uri.host == requested.host &&
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 = stringify_keys(body["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["userId"] = owner_session[:user]["id"] if owner_session
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["requirePKCE"],
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
- verify_pkce!(data, code_verifier) if data[:code_challenge]
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
- return data["requirePKCE"] unless data["requirePKCE"].nil?
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
- raise APIError.new("UNAUTHORIZED", message: "invalid_token") unless record
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 APIError.new("FORBIDDEN", message: "openid scope is required") unless scopes.include?("openid")
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
- return value.to_i if value.is_a?(Integer)
637
- return value.to_i if value.is_a?(Float)
638
- return value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
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},