better_auth 0.2.0 → 0.3.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +168 -18
  5. data/lib/better_auth/adapters/memory.rb +4 -1
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +17 -1
  8. data/lib/better_auth/api.rb +1 -1
  9. data/lib/better_auth/context.rb +2 -1
  10. data/lib/better_auth/plugin.rb +14 -1
  11. data/lib/better_auth/plugins/oauth_protocol.rb +403 -57
  12. data/lib/better_auth/plugins/organization.rb +5 -0
  13. data/lib/better_auth/rate_limiter.rb +19 -2
  14. data/lib/better_auth/router.rb +14 -1
  15. data/lib/better_auth/routes/email_verification.rb +5 -2
  16. data/lib/better_auth/routes/password.rb +19 -0
  17. data/lib/better_auth/routes/session.rb +27 -4
  18. data/lib/better_auth/routes/sign_in.rb +1 -1
  19. data/lib/better_auth/routes/sign_up.rb +52 -1
  20. data/lib/better_auth/routes/social.rb +201 -22
  21. data/lib/better_auth/routes/user.rb +14 -2
  22. data/lib/better_auth/schema/sql.rb +11 -0
  23. data/lib/better_auth/schema.rb +16 -0
  24. data/lib/better_auth/social_providers/apple.rb +44 -8
  25. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  26. data/lib/better_auth/social_providers/base.rb +262 -4
  27. data/lib/better_auth/social_providers/cognito.rb +32 -0
  28. data/lib/better_auth/social_providers/discord.rb +27 -5
  29. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  30. data/lib/better_auth/social_providers/facebook.rb +35 -0
  31. data/lib/better_auth/social_providers/figma.rb +31 -0
  32. data/lib/better_auth/social_providers/github.rb +21 -6
  33. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  34. data/lib/better_auth/social_providers/google.rb +38 -13
  35. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  36. data/lib/better_auth/social_providers/kakao.rb +32 -0
  37. data/lib/better_auth/social_providers/kick.rb +32 -0
  38. data/lib/better_auth/social_providers/line.rb +33 -0
  39. data/lib/better_auth/social_providers/linear.rb +44 -0
  40. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  41. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  42. data/lib/better_auth/social_providers/naver.rb +31 -0
  43. data/lib/better_auth/social_providers/notion.rb +33 -0
  44. data/lib/better_auth/social_providers/paybin.rb +31 -0
  45. data/lib/better_auth/social_providers/paypal.rb +36 -0
  46. data/lib/better_auth/social_providers/polar.rb +31 -0
  47. data/lib/better_auth/social_providers/railway.rb +49 -0
  48. data/lib/better_auth/social_providers/reddit.rb +32 -0
  49. data/lib/better_auth/social_providers/roblox.rb +31 -0
  50. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  51. data/lib/better_auth/social_providers/slack.rb +30 -0
  52. data/lib/better_auth/social_providers/spotify.rb +31 -0
  53. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  54. data/lib/better_auth/social_providers/twitch.rb +39 -0
  55. data/lib/better_auth/social_providers/twitter.rb +32 -0
  56. data/lib/better_auth/social_providers/vercel.rb +47 -0
  57. data/lib/better_auth/social_providers/vk.rb +34 -0
  58. data/lib/better_auth/social_providers/wechat.rb +104 -0
  59. data/lib/better_auth/social_providers/zoom.rb +31 -0
  60. data/lib/better_auth/social_providers.rb +29 -0
  61. data/lib/better_auth/version.rb +1 -1
  62. data/lib/better_auth.rb +0 -1
  63. metadata +30 -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,7 +77,7 @@ 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)
81
82
  ctx.context.internal_adapter.create_verification_value(
82
83
  identifier: "delete-account-#{token}",
@@ -85,6 +86,8 @@ module BetterAuth
85
86
  )
86
87
  sender.call({user: session[:user], token: token}, ctx.request)
87
88
  next ctx.json({success: true, message: "Verification email sent"})
89
+ elsif !body["password"]
90
+ require_fresh_session!(ctx, session)
88
91
  end
89
92
 
90
93
  delete_current_user!(ctx, session)
@@ -99,8 +102,9 @@ module BetterAuth
99
102
  session = current_session(ctx)
100
103
  token = fetch_value(ctx.query, "token")
101
104
  delete_user_by_token!(ctx, session, token)
102
- delete_current_user!(ctx, session)
103
105
  callback_url = fetch_value(ctx.query, "callbackURL")
106
+ validate_callback_url!(ctx.context, callback_url)
107
+ delete_current_user!(ctx, session)
104
108
  raise ctx.redirect(callback_url) if callback_url
105
109
 
106
110
  ctx.json({success: true, message: "User deleted"})
@@ -150,6 +154,14 @@ module BetterAuth
150
154
  call_option(config[:after_delete], session[:user], ctx.request)
151
155
  end
152
156
 
157
+ def self.require_fresh_session!(ctx, session)
158
+ fresh_age = ctx.context.session_config[:fresh_age].to_i
159
+ return if fresh_age <= 0
160
+
161
+ updated_at = Session.normalize_time(session[:session]["updatedAt"] || session[:session]["updated_at"] || session[:session]["createdAt"] || session[:session]["created_at"])
162
+ raise APIError.new("UNAUTHORIZED") unless updated_at && updated_at + fresh_age > Time.now
163
+ end
164
+
153
165
  def self.parse_declared_input(ctx, model, data, allowed_base: [])
154
166
  input = normalize_hash(data || {})
155
167
  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
@@ -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
@@ -5,13 +5,14 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def discord(client_id:, client_secret:, scopes: ["identify", "email"], **options)
8
+ normalized = Base.normalize_options(options)
8
9
  {
9
10
  id: "discord",
10
11
  name: "Discord",
11
12
  client_id: client_id,
12
13
  client_secret: client_secret,
13
14
  create_authorization_url: lambda do |data|
14
- selected_scopes = data[:scopes] || scopes
15
+ selected_scopes = Base.selected_scopes(scopes, normalized, data)
15
16
  params = {
16
17
  client_id: client_id,
17
18
  redirect_uri: data[:redirect_uri] || data[:redirectURI],
@@ -33,24 +34,45 @@ module BetterAuth
33
34
  })
34
35
  end,
35
36
  get_user_info: lambda do |tokens|
37
+ custom = normalized[:get_user_info]
38
+ next custom.call(tokens) if custom
39
+
36
40
  profile = Base.get_json("https://discord.com/api/users/@me", "Authorization" => "Bearer #{Base.access_token(tokens)}")
37
- {
38
- user: {
41
+ image = discord_avatar_url(profile)
42
+ profile["image_url"] = image
43
+ user = Base.apply_profile_mapping(
44
+ {
39
45
  id: profile["id"],
40
46
  email: profile["email"],
41
47
  name: profile["global_name"] || profile["username"] || "",
42
- image: discord_avatar_url(profile),
48
+ image: image,
43
49
  emailVerified: !!profile["verified"]
44
50
  },
51
+ profile,
52
+ normalized
53
+ )
54
+ {
55
+ user: user,
45
56
  data: profile
46
57
  }
58
+ end,
59
+ refresh_access_token: options[:refresh_access_token] || options[:refreshAccessToken] || lambda do |refresh_token|
60
+ Base.refresh_access_token("https://discord.com/api/oauth2/token", refresh_token, client_id: client_id, client_secret: client_secret)
47
61
  end
48
62
  }
49
63
  end
50
64
 
51
65
  def discord_avatar_url(profile)
52
66
  avatar = profile["avatar"]
53
- return nil unless avatar
67
+ unless avatar
68
+ discriminator = profile["discriminator"].to_s
69
+ default_avatar_number = if discriminator == "0"
70
+ (profile["id"].to_i >> 22) % 6
71
+ else
72
+ discriminator.to_i % 5
73
+ end
74
+ return "https://cdn.discordapp.com/embed/avatars/#{default_avatar_number}.png"
75
+ end
54
76
 
55
77
  format = avatar.start_with?("a_") ? "gif" : "png"
56
78
  "https://cdn.discordapp.com/avatars/#{profile["id"]}/#{avatar}.#{format}"