better_auth 0.2.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +173 -20
  5. data/lib/better_auth/adapters/memory.rb +61 -12
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +44 -3
  8. data/lib/better_auth/api.rb +7 -2
  9. data/lib/better_auth/async.rb +70 -0
  10. data/lib/better_auth/context.rb +2 -1
  11. data/lib/better_auth/database_hooks.rb +3 -3
  12. data/lib/better_auth/deprecate.rb +28 -0
  13. data/lib/better_auth/endpoint.rb +5 -2
  14. data/lib/better_auth/host.rb +166 -0
  15. data/lib/better_auth/instrumentation.rb +74 -0
  16. data/lib/better_auth/logger.rb +31 -0
  17. data/lib/better_auth/middleware/origin_check.rb +2 -2
  18. data/lib/better_auth/oauth2.rb +94 -0
  19. data/lib/better_auth/plugin.rb +14 -1
  20. data/lib/better_auth/plugins/email_otp.rb +16 -5
  21. data/lib/better_auth/plugins/generic_oauth.rb +14 -28
  22. data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
  23. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  24. data/lib/better_auth/plugins/organization.rb +56 -20
  25. data/lib/better_auth/plugins/two_factor.rb +53 -18
  26. data/lib/better_auth/rate_limiter.rb +37 -2
  27. data/lib/better_auth/request_state.rb +44 -0
  28. data/lib/better_auth/router.rb +14 -1
  29. data/lib/better_auth/routes/account.rb +16 -4
  30. data/lib/better_auth/routes/email_verification.rb +5 -2
  31. data/lib/better_auth/routes/password.rb +21 -1
  32. data/lib/better_auth/routes/session.rb +27 -4
  33. data/lib/better_auth/routes/sign_in.rb +3 -1
  34. data/lib/better_auth/routes/sign_up.rb +60 -1
  35. data/lib/better_auth/routes/social.rb +231 -22
  36. data/lib/better_auth/routes/user.rb +23 -5
  37. data/lib/better_auth/schema/sql.rb +11 -0
  38. data/lib/better_auth/schema.rb +16 -0
  39. data/lib/better_auth/session.rb +12 -1
  40. data/lib/better_auth/social_providers/apple.rb +44 -8
  41. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  42. data/lib/better_auth/social_providers/base.rb +262 -4
  43. data/lib/better_auth/social_providers/cognito.rb +32 -0
  44. data/lib/better_auth/social_providers/discord.rb +27 -5
  45. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  46. data/lib/better_auth/social_providers/facebook.rb +35 -0
  47. data/lib/better_auth/social_providers/figma.rb +31 -0
  48. data/lib/better_auth/social_providers/github.rb +21 -6
  49. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  50. data/lib/better_auth/social_providers/google.rb +38 -13
  51. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  52. data/lib/better_auth/social_providers/kakao.rb +32 -0
  53. data/lib/better_auth/social_providers/kick.rb +32 -0
  54. data/lib/better_auth/social_providers/line.rb +33 -0
  55. data/lib/better_auth/social_providers/linear.rb +44 -0
  56. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  57. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  58. data/lib/better_auth/social_providers/naver.rb +31 -0
  59. data/lib/better_auth/social_providers/notion.rb +33 -0
  60. data/lib/better_auth/social_providers/paybin.rb +31 -0
  61. data/lib/better_auth/social_providers/paypal.rb +36 -0
  62. data/lib/better_auth/social_providers/polar.rb +31 -0
  63. data/lib/better_auth/social_providers/railway.rb +49 -0
  64. data/lib/better_auth/social_providers/reddit.rb +32 -0
  65. data/lib/better_auth/social_providers/roblox.rb +31 -0
  66. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  67. data/lib/better_auth/social_providers/slack.rb +30 -0
  68. data/lib/better_auth/social_providers/spotify.rb +31 -0
  69. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  70. data/lib/better_auth/social_providers/twitch.rb +39 -0
  71. data/lib/better_auth/social_providers/twitter.rb +32 -0
  72. data/lib/better_auth/social_providers/vercel.rb +47 -0
  73. data/lib/better_auth/social_providers/vk.rb +34 -0
  74. data/lib/better_auth/social_providers/wechat.rb +104 -0
  75. data/lib/better_auth/social_providers/zoom.rb +31 -0
  76. data/lib/better_auth/social_providers.rb +29 -0
  77. data/lib/better_auth/url_helpers.rb +195 -0
  78. data/lib/better_auth/version.rb +1 -1
  79. data/lib/better_auth.rb +8 -1
  80. metadata +38 -15
@@ -67,6 +67,7 @@ module BetterAuth
67
67
 
68
68
  session = current_session(ctx, sensitive: true)
69
69
  body = normalize_hash(ctx.body)
70
+ sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification)
70
71
  if body["password"]
71
72
  account = credential_account(ctx, session[:user]["id"])
72
73
  unless account && account["password"] && verify_password_value(ctx, body["password"], account["password"])
@@ -76,15 +77,18 @@ module BetterAuth
76
77
 
77
78
  if body["token"]
78
79
  delete_user_by_token!(ctx, session, body["token"])
79
- elsif (sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification))
80
+ elsif sender
80
81
  token = SecureRandom.hex(16)
82
+ expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
81
83
  ctx.context.internal_adapter.create_verification_value(
82
84
  identifier: "delete-account-#{token}",
83
85
  value: session[:user]["id"],
84
- expiresAt: Time.now + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
86
+ expiresAt: Time.now + expires_in.to_i
85
87
  )
86
88
  sender.call({user: session[:user], token: token}, ctx.request)
87
89
  next ctx.json({success: true, message: "Verification email sent"})
90
+ elsif !body["password"]
91
+ require_fresh_session!(ctx, session)
88
92
  end
89
93
 
90
94
  delete_current_user!(ctx, session)
@@ -99,8 +103,9 @@ module BetterAuth
99
103
  session = current_session(ctx)
100
104
  token = fetch_value(ctx.query, "token")
101
105
  delete_user_by_token!(ctx, session, token)
102
- delete_current_user!(ctx, session)
103
106
  callback_url = fetch_value(ctx.query, "callbackURL")
107
+ validate_callback_url!(ctx.context, callback_url)
108
+ delete_current_user!(ctx, session)
104
109
  raise ctx.redirect(callback_url) if callback_url
105
110
 
106
111
  ctx.json({success: true, message: "User deleted"})
@@ -116,9 +121,11 @@ module BetterAuth
116
121
  new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
117
122
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
118
123
  raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
119
- raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]) if ctx.context.internal_adapter.find_user_by_email(new_email)
124
+ existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
120
125
 
121
126
  if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
127
+ next ctx.json({status: true}) if existing_target
128
+
122
129
  updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
123
130
  Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
124
131
  next ctx.json({status: true})
@@ -126,6 +133,7 @@ module BetterAuth
126
133
 
127
134
  sender = ctx.context.options.email_verification[:send_verification_email]
128
135
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
136
+ next ctx.json({status: true}) if existing_target
129
137
 
130
138
  token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
131
139
  sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
@@ -144,12 +152,22 @@ module BetterAuth
144
152
  def self.delete_current_user!(ctx, session)
145
153
  config = ctx.context.options.user[:delete_user] || {}
146
154
  call_option(config[:before_delete], session[:user], ctx.request)
147
- ctx.context.internal_adapter.delete_user(session[:user]["id"])
155
+ deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"])
156
+ raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false
157
+
148
158
  ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
149
159
  Cookies.delete_session_cookie(ctx)
150
160
  call_option(config[:after_delete], session[:user], ctx.request)
151
161
  end
152
162
 
163
+ def self.require_fresh_session!(ctx, session)
164
+ fresh_age = ctx.context.session_config[:fresh_age].to_i
165
+ return if fresh_age <= 0
166
+
167
+ updated_at = Session.normalize_time(session[:session]["updatedAt"] || session[:session]["updated_at"] || session[:session]["createdAt"] || session[:session]["created_at"])
168
+ raise APIError.new("UNAUTHORIZED") unless updated_at && updated_at + fresh_age > Time.now
169
+ end
170
+
153
171
  def self.parse_declared_input(ctx, model, data, allowed_base: [])
154
172
  input = normalize_hash(data || {})
155
173
  table = Schema.auth_tables(ctx.context.options)[model.to_s]
@@ -108,6 +108,17 @@ module BetterAuth
108
108
  end
109
109
  when "number"
110
110
  attributes[:bigint] ? "bigint" : "integer"
111
+ when "json", "string[]", "number[]"
112
+ case dialect
113
+ when :postgres
114
+ "jsonb"
115
+ when :mysql
116
+ "json"
117
+ when :mssql
118
+ "varchar(8000)"
119
+ else
120
+ "text"
121
+ end
111
122
  else
112
123
  if dialect == :mysql
113
124
  indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references]
@@ -15,6 +15,7 @@ module BetterAuth
15
15
  }
16
16
 
17
17
  tables.delete("session") if secondary_storage?(options) && !session_option(options, :store_session_in_database)
18
+ tables.delete("verification") if secondary_storage?(options) && !verification_option(options, :store_in_database)
18
19
  tables.merge!(plugin_schema)
19
20
  tables["rateLimit"] = rate_limit_table(options) if rate_limit_option(options, :storage) == "database"
20
21
  tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
@@ -179,9 +180,16 @@ module BetterAuth
179
180
  private_class_method def self.normalize_field(value, key)
180
181
  data = symbolize_hash(value || {})
181
182
  data[:field_name] ||= physical_name(key)
183
+ data[:references] = normalize_reference(data[:references]) if data[:references]
182
184
  data
183
185
  end
184
186
 
187
+ private_class_method def self.normalize_reference(value)
188
+ reference = symbolize_hash(value || {})
189
+ reference[:on_delete] ||= "cascade"
190
+ reference
191
+ end
192
+
185
193
  private_class_method def self.mapped_field(options, model, field)
186
194
  fields = fetch_hash(model_options(options, model), :fields) || {}
187
195
  fetch_mapped_value(fields, field) || physical_name(field)
@@ -212,6 +220,10 @@ module BetterAuth
212
220
  fetch_hash(rate_limit_options(options), key)
213
221
  end
214
222
 
223
+ private_class_method def self.verification_option(options, key)
224
+ fetch_hash(verification_options(options), key)
225
+ end
226
+
215
227
  private_class_method def self.model_options(options, model)
216
228
  options.respond_to?(model) ? options.public_send(model) : fetch_hash(options, model)
217
229
  end
@@ -224,6 +236,10 @@ module BetterAuth
224
236
  options.respond_to?(:rate_limit) ? options.rate_limit : fetch_hash(options, :rate_limit)
225
237
  end
226
238
 
239
+ private_class_method def self.verification_options(options)
240
+ options.respond_to?(:verification) ? options.verification : fetch_hash(options, :verification)
241
+ end
242
+
227
243
  private_class_method def self.secondary_storage?(options)
228
244
  options.respond_to?(:secondary_storage) ? !!options.secondary_storage : !!fetch_hash(options, :secondary_storage)
229
245
  end
@@ -51,7 +51,7 @@ module BetterAuth
51
51
  return nil if payload["session"]["token"] && payload["session"]["token"] != token
52
52
 
53
53
  result = {session: payload["session"], user: payload["user"]}
54
- Cookies.set_cookie_cache(ctx, result, false) if should_refresh_cookie_cache?(config, payload)
54
+ result = refresh_cached_session(ctx, result) if should_refresh_cookie_cache?(config, payload)
55
55
  result
56
56
  end
57
57
 
@@ -89,6 +89,17 @@ module BetterAuth
89
89
  refreshed
90
90
  end
91
91
 
92
+ def refresh_cached_session(ctx, result)
93
+ now = Time.now
94
+ session = stringify_keys(result[:session]).merge(
95
+ "expiresAt" => now + ctx.context.session_config[:expires_in].to_i,
96
+ "updatedAt" => now
97
+ )
98
+ refreshed = {session: session, user: result[:user]}
99
+ Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
100
+ refreshed
101
+ end
102
+
92
103
  def should_refresh_cookie_cache?(config, payload)
93
104
  refresh_cache = config[:refresh_cache]
94
105
  return false if refresh_cache == false || refresh_cache.nil?
@@ -5,6 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def apple(client_id:, client_secret:, scopes: ["email", "name"], **options)
8
+ normalized = Base.normalize_options(options)
9
+ primary_client_id = Base.primary_client_id(client_id)
8
10
  {
9
11
  id: "apple",
10
12
  name: "Apple",
@@ -12,17 +14,17 @@ module BetterAuth
12
14
  client_secret: client_secret,
13
15
  create_authorization_url: lambda do |data|
14
16
  Base.authorization_url(options[:authorization_endpoint] || "https://appleid.apple.com/auth/authorize", {
15
- client_id: client_id,
17
+ client_id: primary_client_id,
16
18
  redirect_uri: data[:redirect_uri] || data[:redirectURI],
17
19
  response_type: "code id_token",
18
20
  response_mode: options[:response_mode] || options[:responseMode] || "form_post",
19
- scope: data[:scopes] || scopes,
21
+ scope: Base.selected_scopes(scopes, normalized, data),
20
22
  state: data[:state]
21
23
  })
22
24
  end,
23
25
  validate_authorization_code: lambda do |data|
24
26
  Base.post_form("https://appleid.apple.com/auth/token", {
25
- client_id: client_id,
27
+ client_id: primary_client_id,
26
28
  client_secret: client_secret,
27
29
  code: data[:code],
28
30
  code_verifier: data[:code_verifier] || data[:codeVerifier],
@@ -30,24 +32,58 @@ module BetterAuth
30
32
  redirect_uri: data[:redirect_uri] || data[:redirectURI]
31
33
  })
32
34
  end,
35
+ verify_id_token: normalized[:verify_id_token] || lambda do |token, nonce = nil|
36
+ return false if normalized[:disable_id_token_sign_in]
37
+
38
+ audiences = Array(normalized[:audience] || normalized[:app_bundle_identifier] || normalized[:appBundleIdentifier] || client_id)
39
+ return false if audiences.empty?
40
+
41
+ profile = Base.verify_jwt_with_jwks(
42
+ token,
43
+ jwks: normalized[:jwks],
44
+ jwks_endpoint: normalized[:jwks_endpoint] || "https://appleid.apple.com/auth/keys",
45
+ algorithms: ["RS256"],
46
+ issuers: "https://appleid.apple.com",
47
+ audience: audiences,
48
+ nonce: nonce
49
+ )
50
+ !!profile&.fetch("sub", nil)
51
+ end,
33
52
  get_user_info: lambda do |tokens|
53
+ custom = normalized[:get_user_info]
54
+ next custom.call(tokens) if custom
55
+
34
56
  profile = Base.decode_jwt_payload(Base.id_token(tokens))
35
57
  apple_user = tokens[:user] || tokens["user"] || {}
36
- name = apple_user.dig(:name, :firstName) || apple_user.dig("name", "firstName")
37
- last_name = apple_user.dig(:name, :lastName) || apple_user.dig("name", "lastName")
58
+ name = apple_user.dig(:name, :firstName) ||
59
+ apple_user.dig(:name, :first_name) ||
60
+ apple_user.dig("name", "firstName") ||
61
+ apple_user.dig("name", "first_name")
62
+ last_name = apple_user.dig(:name, :lastName) ||
63
+ apple_user.dig(:name, :last_name) ||
64
+ apple_user.dig("name", "lastName") ||
65
+ apple_user.dig("name", "last_name")
38
66
  full_name = [name, last_name].compact.join(" ").strip
39
- full_name = profile["name"] || " " if full_name.empty?
67
+ full_name = profile["name"] || "" if full_name.empty?
40
68
 
41
- {
42
- user: {
69
+ user = Base.apply_profile_mapping(
70
+ {
43
71
  id: profile["sub"],
44
72
  email: profile["email"],
45
73
  name: full_name,
46
74
  image: profile["picture"],
47
75
  emailVerified: profile["email_verified"] == true || profile["email_verified"] == "true"
48
76
  },
77
+ profile.merge("name" => full_name),
78
+ normalized
79
+ )
80
+ {
81
+ user: user,
49
82
  data: profile.merge("name" => full_name)
50
83
  }
84
+ end,
85
+ refresh_access_token: options[:refresh_access_token] || options[:refreshAccessToken] || lambda do |refresh_token|
86
+ Base.refresh_access_token("https://appleid.apple.com/auth/token", refresh_token, client_id: primary_client_id, client_secret: client_secret)
51
87
  end
52
88
  }
53
89
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def atlassian(client_id:, client_secret:, scopes: ["read:jira-user", "offline_access"], **options)
8
+ Base.oauth_provider(
9
+ id: "atlassian",
10
+ name: "Atlassian",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ authorization_endpoint: "https://auth.atlassian.com/authorize",
14
+ token_endpoint: "https://auth.atlassian.com/oauth/token",
15
+ user_info_endpoint: "https://api.atlassian.com/me",
16
+ scopes: scopes,
17
+ pkce: true,
18
+ auth_params: {audience: "api.atlassian.com"},
19
+ profile_map: ->(profile) {
20
+ {
21
+ id: profile["account_id"],
22
+ name: profile["name"],
23
+ email: profile["email"],
24
+ image: profile["picture"],
25
+ emailVerified: false
26
+ }
27
+ },
28
+ **options
29
+ )
30
+ end
31
+ end
32
+ end
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "base64"
4
4
  require "json"
5
+ require "jwt"
5
6
  require "net/http"
6
7
  require "openssl"
8
+ require "time"
7
9
  require "uri"
8
10
 
9
11
  module BetterAuth
@@ -23,23 +25,146 @@ module BetterAuth
23
25
  uri.to_s
24
26
  end
25
27
 
28
+ def oauth_provider(id:, name:, client_id:, authorization_endpoint:, token_endpoint:, profile_map:, client_secret: nil, user_info_endpoint: nil, scopes: [], scope_separator: " ", pkce: false, auth_params: {}, token_params: {}, user_info_method: :get, user_info_headers: {}, user_info_body: nil, **options)
29
+ opts = normalize_options(options.merge(client_id: client_id, client_secret: client_secret))
30
+ {
31
+ id: id,
32
+ name: name,
33
+ client_id: client_id,
34
+ client_secret: client_secret,
35
+ options: opts,
36
+ create_authorization_url: lambda do |data|
37
+ verifier = value(data, :code_verifier, :codeVerifier)
38
+ selected_scopes = selected_scopes(scopes, opts, data)
39
+ params = {
40
+ client_id: primary_client_id(client_id),
41
+ redirect_uri: opts[:redirect_uri] || value(data, :redirect_uri, :redirectURI),
42
+ response_type: "code",
43
+ scope: selected_scopes.empty? ? nil : selected_scopes.join(scope_separator),
44
+ state: value(data, :state),
45
+ code_challenge: (pkce && verifier) ? pkce_challenge(verifier) : nil,
46
+ code_challenge_method: (pkce && verifier) ? "S256" : nil,
47
+ login_hint: value(data, :loginHint, :login_hint),
48
+ prompt: opts[:prompt]
49
+ }.merge(resolve_hash(auth_params, data, opts))
50
+ authorization_url(option(opts, :authorization_endpoint, :authorizationEndpoint) || authorization_endpoint, params)
51
+ end,
52
+ validate_authorization_code: lambda do |data|
53
+ post_form_json(option(opts, :token_endpoint, :tokenEndpoint) || token_endpoint, {
54
+ client_id: primary_client_id(client_id),
55
+ client_secret: client_secret,
56
+ code: value(data, :code),
57
+ code_verifier: value(data, :code_verifier, :codeVerifier),
58
+ grant_type: "authorization_code",
59
+ redirect_uri: opts[:redirect_uri] || value(data, :redirect_uri, :redirectURI)
60
+ }.merge(resolve_hash(token_params, data, opts)))
61
+ end,
62
+ refresh_access_token: opts[:refresh_access_token] || lambda do |refresh_token|
63
+ refresh_access_token(
64
+ option(opts, :token_endpoint, :tokenEndpoint) || token_endpoint,
65
+ refresh_token,
66
+ client_id: primary_client_id(client_id),
67
+ client_secret: client_secret
68
+ )
69
+ end,
70
+ verify_id_token: opts[:verify_id_token] || lambda do |token, _nonce = nil|
71
+ return false if opts[:disable_id_token_sign_in]
72
+
73
+ !decode_jwt_payload(token).empty?
74
+ end,
75
+ get_user_info: lambda do |tokens|
76
+ custom = opts[:get_user_info]
77
+ profile = if custom
78
+ custom.call(tokens)
79
+ elsif user_info_endpoint
80
+ fetch_user_info(user_info_endpoint, tokens, method: user_info_method, headers: user_info_headers, body: user_info_body)
81
+ else
82
+ decode_jwt_payload(id_token(tokens))
83
+ end
84
+ return nil unless profile
85
+ return profile if provider_user_info?(profile)
86
+
87
+ mapped = profile_map.call(profile)
88
+ user_map = opts[:map_profile_to_user]&.call(profile) || {}
89
+ {user: mapped.merge(user_map), data: profile}
90
+ end
91
+ }
92
+ end
93
+
26
94
  def pkce_challenge(verifier)
27
95
  digest = OpenSSL::Digest.digest("SHA256", verifier.to_s)
28
96
  Base64.urlsafe_encode64(digest, padding: false)
29
97
  end
30
98
 
31
99
  def post_form(url, form)
100
+ post_form_json(url, form)
101
+ end
102
+
103
+ def post_form_json(url, form, headers = {})
32
104
  uri = URI(url)
33
- response = Net::HTTP.post_form(uri, form.transform_keys(&:to_s))
34
- JSON.parse(response.body)
105
+ request = Net::HTTP::Post.new(uri)
106
+ request.set_form_data(form.compact.transform_keys(&:to_s))
107
+ request["Accept"] = "application/json"
108
+ headers.each { |key, value| request[key.to_s] = value.to_s }
109
+ parse_response(request_json(uri, request))
35
110
  end
36
111
 
37
112
  def get_json(url, headers = {})
38
113
  uri = URI(url)
39
114
  request = Net::HTTP::Get.new(uri)
40
115
  headers.each { |key, value| request[key.to_s] = value.to_s }
41
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
42
- JSON.parse(response.body)
116
+ parse_response(request_json(uri, request))
117
+ end
118
+
119
+ def get_bytes(url, headers = {})
120
+ uri = URI(url)
121
+ request = Net::HTTP::Get.new(uri)
122
+ headers.each { |key, value| request[key.to_s] = value.to_s }
123
+ response = request_json(uri, request)
124
+ response.is_a?(Net::HTTPSuccess) ? response.body.to_s : nil
125
+ rescue URI::InvalidURIError, SocketError, SystemCallError
126
+ nil
127
+ end
128
+
129
+ def post_json(url, body = {}, headers = {})
130
+ uri = URI(url)
131
+ request = Net::HTTP::Post.new(uri)
132
+ headers.each { |key, value| request[key.to_s] = value.to_s }
133
+ request.set_form_data(body.compact.transform_keys(&:to_s))
134
+ parse_response(request_json(uri, request))
135
+ end
136
+
137
+ def fetch_user_info(endpoint, tokens, method: :get, headers: {}, body: nil)
138
+ resolved_headers = resolve_hash(headers, tokens, {})
139
+ resolved_body = resolve_hash(body || {}, tokens, {})
140
+ resolved_headers = {"Authorization" => "Bearer #{access_token(tokens)}"}.merge(resolved_headers)
141
+ (method == :post) ? post_json(endpoint, resolved_body, resolved_headers) : get_json(endpoint, resolved_headers)
142
+ end
143
+
144
+ def refresh_access_token(token_endpoint, refresh_token, client_id:, client_secret: nil, extra_params: {})
145
+ normalize_tokens(post_form_json(token_endpoint, {
146
+ client_id: client_id,
147
+ client_secret: client_secret,
148
+ grant_type: "refresh_token",
149
+ refresh_token: refresh_token
150
+ }.merge(extra_params || {})))
151
+ end
152
+
153
+ def normalize_tokens(tokens, now: Time.now)
154
+ data = stringify_keys(tokens || {})
155
+ result = {}
156
+ result["accessToken"] = data["accessToken"] || data["access_token"] if data["accessToken"] || data["access_token"]
157
+ result["refreshToken"] = data["refreshToken"] || data["refresh_token"] if data["refreshToken"] || data["refresh_token"]
158
+ result["idToken"] = data["idToken"] || data["id_token"] if data["idToken"] || data["id_token"]
159
+ result["tokenType"] = data["tokenType"] || data["token_type"] if data["tokenType"] || data["token_type"]
160
+ scope = data["scope"] || data["scopes"]
161
+ result["scope"] = Array(scope).join(",").tr(" ", ",").split(",").reject(&:empty?).join(",") if scope
162
+ result["accessTokenExpiresAt"] = time_from(data["accessTokenExpiresAt"] || data["access_token_expires_at"]) if data["accessTokenExpiresAt"] || data["access_token_expires_at"]
163
+ result["refreshTokenExpiresAt"] = time_from(data["refreshTokenExpiresAt"] || data["refresh_token_expires_at"]) if data["refreshTokenExpiresAt"] || data["refresh_token_expires_at"]
164
+ result["accessTokenExpiresAt"] ||= now + data["expires_in"].to_i if data["expires_in"]
165
+ result["refreshTokenExpiresAt"] ||= now + data["refresh_token_expires_in"].to_i if data["refresh_token_expires_in"]
166
+ data.each { |key, value| result[key] = value unless result.key?(key) || %w[access_token refresh_token id_token token_type expires_in refresh_token_expires_in scopes].include?(key) }
167
+ result
43
168
  end
44
169
 
45
170
  def access_token(tokens)
@@ -50,6 +175,81 @@ module BetterAuth
50
175
  tokens[:id_token] || tokens["id_token"] || tokens[:idToken] || tokens["idToken"]
51
176
  end
52
177
 
178
+ def value(hash, *keys)
179
+ return nil unless hash
180
+
181
+ keys.each do |key|
182
+ return hash[key] if hash.respond_to?(:key?) && hash.key?(key)
183
+
184
+ string_key = key.to_s
185
+ return hash[string_key] if hash.respond_to?(:key?) && hash.key?(string_key)
186
+ end
187
+ nil
188
+ end
189
+
190
+ def option(options, *keys)
191
+ value(options, *keys)
192
+ end
193
+
194
+ def normalize_options(options)
195
+ normalized = options.dup
196
+ {
197
+ clientId: :client_id,
198
+ clientSecret: :client_secret,
199
+ clientKey: :client_key,
200
+ disableDefaultScope: :disable_default_scope,
201
+ mapProfileToUser: :map_profile_to_user,
202
+ getUserInfo: :get_user_info,
203
+ verifyIdToken: :verify_id_token,
204
+ refreshAccessToken: :refresh_access_token,
205
+ disableIdTokenSignIn: :disable_id_token_sign_in,
206
+ disableImplicitSignUp: :disable_implicit_sign_up,
207
+ disableSignUp: :disable_sign_up,
208
+ authorizationEndpoint: :authorization_endpoint,
209
+ tokenEndpoint: :token_endpoint,
210
+ userInfoEndpoint: :user_info_endpoint,
211
+ emailsEndpoint: :emails_endpoint,
212
+ redirectURI: :redirect_uri,
213
+ jwksEndpoint: :jwks_endpoint,
214
+ appBundleIdentifier: :app_bundle_identifier,
215
+ profilePhotoSize: :profile_photo_size,
216
+ disableProfilePhoto: :disable_profile_photo,
217
+ tenantId: :tenant_id
218
+ }.each do |camel, snake|
219
+ normalized[snake] = normalized[camel] if normalized.key?(camel) && !normalized.key?(snake)
220
+ end
221
+ normalized
222
+ end
223
+
224
+ def selected_scopes(defaults, options, data)
225
+ scopes = options[:disable_default_scope] ? [] : Array(defaults).dup
226
+ scopes.concat(Array(options[:scope])) if options[:scope]
227
+ scopes.concat(Array(options[:scopes])) if options[:scopes]
228
+ request_scopes = value(data, :scopes)
229
+ scopes.concat(Array(request_scopes)) if request_scopes
230
+ scopes
231
+ end
232
+
233
+ def primary_client_id(client_id)
234
+ value = Array(client_id).first
235
+ raise Error, "CLIENT_ID_AND_SECRET_REQUIRED" if value.to_s.empty?
236
+
237
+ value
238
+ end
239
+
240
+ def apply_profile_mapping(user, profile, options)
241
+ user.merge(options[:map_profile_to_user]&.call(profile) || {})
242
+ end
243
+
244
+ def resolve_hash(value, data, options)
245
+ resolved = value.respond_to?(:call) ? value.call(data, options) : value
246
+ (resolved || {}).compact
247
+ end
248
+
249
+ def provider_user_info?(value)
250
+ value.is_a?(Hash) && (value.key?(:user) || value.key?("user"))
251
+ end
252
+
53
253
  def decode_jwt_payload(token)
54
254
  _header, payload, _signature = token.to_s.split(".", 3)
55
255
  return {} unless payload
@@ -59,6 +259,64 @@ module BetterAuth
59
259
  {}
60
260
  end
61
261
 
262
+ def verify_jwt_with_jwks(token, jwks:, jwks_endpoint:, algorithms:, issuers:, audience:, nonce: nil, max_age: 3600)
263
+ jwks_payload = jwks.respond_to?(:call) ? jwks.call : jwks
264
+ jwks_payload ||= fetch_jwks(jwks_endpoint)
265
+ return nil unless jwks_payload
266
+
267
+ options = {
268
+ algorithms: algorithms,
269
+ jwks: JWT::JWK::Set.new(stringify_keys(jwks_payload)),
270
+ aud: audience,
271
+ verify_aud: true
272
+ }
273
+ options[:iss] = issuers if issuers
274
+ options[:verify_iss] = true if issuers
275
+ payload, = JWT.decode(token.to_s, nil, true, options)
276
+ return nil if nonce && payload["nonce"] != nonce
277
+ return nil if max_age && payload["iat"] && payload["iat"].to_i < Time.now.to_i - max_age.to_i
278
+
279
+ payload
280
+ rescue JWT::DecodeError, JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError
281
+ nil
282
+ end
283
+
284
+ def fetch_jwks(endpoint)
285
+ return nil if endpoint.to_s.empty?
286
+
287
+ get_json(endpoint)
288
+ end
289
+
290
+ def stringify_keys(hash)
291
+ hash.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
292
+ end
293
+
294
+ def time_from(value)
295
+ return value if value.is_a?(Time)
296
+ return nil if value.nil? || value.to_s.empty?
297
+
298
+ Time.parse(value.to_s)
299
+ rescue ArgumentError
300
+ nil
301
+ end
302
+
303
+ def parse_response(response)
304
+ return nil unless response.is_a?(Net::HTTPSuccess)
305
+
306
+ body = response.body.to_s
307
+ return {} if body.empty?
308
+
309
+ JSON.parse(body)
310
+ rescue JSON::ParserError
311
+ URI.decode_www_form(body).to_h
312
+ end
313
+
314
+ def request_json(uri, request)
315
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
316
+ http.request(request)
317
+ end
318
+ end
319
+
62
320
  def padded_base64(value)
63
321
  value + ("=" * ((4 - value.length % 4) % 4))
64
322
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def cognito(client_id:, client_secret: nil, scopes: ["openid", "profile", "email"], **options)
8
+ domain = (options[:domain] || options[:issuer] || "https://cognito-idp.#{options[:region] || "us-east-1"}.amazonaws.com").to_s.sub(%r{/+\z}, "")
9
+ Base.oauth_provider(
10
+ id: "cognito",
11
+ name: "Cognito",
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ authorization_endpoint: "#{domain}/oauth2/authorize",
15
+ token_endpoint: "#{domain}/oauth2/token",
16
+ user_info_endpoint: "#{domain}/oauth2/userinfo",
17
+ scopes: scopes,
18
+ pkce: true,
19
+ profile_map: ->(profile) {
20
+ {
21
+ id: profile["sub"],
22
+ name: profile["name"] || profile["given_name"] || profile["username"] || "",
23
+ email: profile["email"],
24
+ image: profile["picture"],
25
+ emailVerified: !!profile["email_verified"]
26
+ }
27
+ },
28
+ **options
29
+ )
30
+ end
31
+ end
32
+ end