better_auth 0.1.1 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "jwt"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "time"
9
+ require "uri"
10
+
11
+ module BetterAuth
12
+ module SocialProviders
13
+ module Base
14
+ module_function
15
+
16
+ def authorization_url(endpoint, params)
17
+ uri = URI(endpoint)
18
+ query = URI.decode_www_form(uri.query.to_s)
19
+ params.compact.each do |key, value|
20
+ next if value == ""
21
+
22
+ query << [key.to_s, Array(value).join(" ")]
23
+ end
24
+ uri.query = URI.encode_www_form(query)
25
+ uri.to_s
26
+ end
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
+
94
+ def pkce_challenge(verifier)
95
+ digest = OpenSSL::Digest.digest("SHA256", verifier.to_s)
96
+ Base64.urlsafe_encode64(digest, padding: false)
97
+ end
98
+
99
+ def post_form(url, form)
100
+ post_form_json(url, form)
101
+ end
102
+
103
+ def post_form_json(url, form, headers = {})
104
+ uri = URI(url)
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))
110
+ end
111
+
112
+ def get_json(url, headers = {})
113
+ uri = URI(url)
114
+ request = Net::HTTP::Get.new(uri)
115
+ headers.each { |key, value| request[key.to_s] = value.to_s }
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
168
+ end
169
+
170
+ def access_token(tokens)
171
+ tokens[:access_token] || tokens["access_token"] || tokens[:accessToken] || tokens["accessToken"]
172
+ end
173
+
174
+ def id_token(tokens)
175
+ tokens[:id_token] || tokens["id_token"] || tokens[:idToken] || tokens["idToken"]
176
+ end
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
+
253
+ def decode_jwt_payload(token)
254
+ _header, payload, _signature = token.to_s.split(".", 3)
255
+ return {} unless payload
256
+
257
+ JSON.parse(Base64.urlsafe_decode64(padded_base64(payload)))
258
+ rescue JSON::ParserError, ArgumentError
259
+ {}
260
+ end
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
+
320
+ def padded_base64(value)
321
+ value + ("=" * ((4 - value.length % 4) % 4))
322
+ end
323
+ end
324
+ end
325
+ 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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def discord(client_id:, client_secret:, scopes: ["identify", "email"], **options)
8
+ normalized = Base.normalize_options(options)
9
+ {
10
+ id: "discord",
11
+ name: "Discord",
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ create_authorization_url: lambda do |data|
15
+ selected_scopes = Base.selected_scopes(scopes, normalized, data)
16
+ params = {
17
+ client_id: client_id,
18
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
19
+ response_type: "code",
20
+ scope: selected_scopes,
21
+ state: data[:state],
22
+ prompt: options.fetch(:prompt, "none")
23
+ }
24
+ params[:permissions] = options[:permissions] if selected_scopes.include?("bot") && options.key?(:permissions)
25
+ Base.authorization_url("https://discord.com/api/oauth2/authorize", params)
26
+ end,
27
+ validate_authorization_code: lambda do |data|
28
+ Base.post_form("https://discord.com/api/oauth2/token", {
29
+ client_id: client_id,
30
+ client_secret: client_secret,
31
+ code: data[:code],
32
+ grant_type: "authorization_code",
33
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
34
+ })
35
+ end,
36
+ get_user_info: lambda do |tokens|
37
+ custom = normalized[:get_user_info]
38
+ next custom.call(tokens) if custom
39
+
40
+ profile = Base.get_json("https://discord.com/api/users/@me", "Authorization" => "Bearer #{Base.access_token(tokens)}")
41
+ image = discord_avatar_url(profile)
42
+ profile["image_url"] = image
43
+ user = Base.apply_profile_mapping(
44
+ {
45
+ id: profile["id"],
46
+ email: profile["email"],
47
+ name: profile["global_name"] || profile["username"] || "",
48
+ image: image,
49
+ emailVerified: !!profile["verified"]
50
+ },
51
+ profile,
52
+ normalized
53
+ )
54
+ {
55
+ user: user,
56
+ data: profile
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)
61
+ end
62
+ }
63
+ end
64
+
65
+ def discord_avatar_url(profile)
66
+ avatar = profile["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
76
+
77
+ format = avatar.start_with?("a_") ? "gif" : "png"
78
+ "https://cdn.discordapp.com/avatars/#{profile["id"]}/#{avatar}.#{format}"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def dropbox(client_id:, client_secret:, scopes: ["account_info.read"], **options)
8
+ Base.oauth_provider(
9
+ id: "dropbox",
10
+ name: "Dropbox",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ authorization_endpoint: "https://www.dropbox.com/oauth2/authorize",
14
+ token_endpoint: "https://api.dropboxapi.com/oauth2/token",
15
+ user_info_endpoint: "https://api.dropboxapi.com/2/users/get_current_account",
16
+ user_info_method: :post,
17
+ scopes: scopes,
18
+ pkce: true,
19
+ auth_params: ->(_data, opts) { {token_access_type: opts[:access_type] || opts[:accessType]} },
20
+ profile_map: ->(profile) {
21
+ {
22
+ id: profile["account_id"],
23
+ name: profile.dig("name", "display_name"),
24
+ email: profile["email"],
25
+ image: profile["profile_photo_url"],
26
+ emailVerified: !!profile["email_verified"]
27
+ }
28
+ },
29
+ **options
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def facebook(client_id:, client_secret:, scopes: ["email", "public_profile"], **options)
8
+ fields = Array(options[:fields] || %w[id name email picture email_verified]).join(",")
9
+ provider = Base.oauth_provider(
10
+ id: "facebook",
11
+ name: "Facebook",
12
+ client_id: client_id,
13
+ client_secret: client_secret,
14
+ authorization_endpoint: "https://www.facebook.com/v24.0/dialog/oauth",
15
+ token_endpoint: "https://graph.facebook.com/v24.0/oauth/access_token",
16
+ user_info_endpoint: "https://graph.facebook.com/me?fields=#{URI.encode_www_form_component(fields)}",
17
+ scopes: scopes,
18
+ auth_params: ->(_data, opts) { {config_id: opts[:config_id] || opts[:configId]} },
19
+ profile_map: ->(profile) {
20
+ picture = profile.dig("picture", "data", "url") || profile["picture"]
21
+ {
22
+ id: profile["id"] || profile["sub"],
23
+ name: profile["name"],
24
+ email: profile["email"],
25
+ image: picture,
26
+ emailVerified: !!profile["email_verified"]
27
+ }
28
+ },
29
+ **options
30
+ )
31
+ provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !token.to_s.empty? }
32
+ provider
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def figma(client_id:, client_secret:, scopes: ["current_user:read"], **options)
8
+ Base.oauth_provider(
9
+ id: "figma",
10
+ name: "Figma",
11
+ client_id: client_id,
12
+ client_secret: client_secret,
13
+ authorization_endpoint: "https://www.figma.com/oauth",
14
+ token_endpoint: "https://api.figma.com/v1/oauth/token",
15
+ user_info_endpoint: "https://api.figma.com/v1/me",
16
+ scopes: scopes,
17
+ pkce: true,
18
+ profile_map: ->(profile) {
19
+ {
20
+ id: profile["id"],
21
+ name: profile["handle"],
22
+ email: profile["email"],
23
+ image: profile["img_url"],
24
+ emailVerified: false
25
+ }
26
+ },
27
+ **options
28
+ )
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def github(client_id:, client_secret:, scopes: ["read:user", "user:email"], **options)
8
+ normalized = Base.normalize_options(options)
9
+ token_endpoint = normalized[:token_endpoint] || "https://github.com/login/oauth/access_token"
10
+ user_info_endpoint = normalized[:user_info_endpoint] || "https://api.github.com/user"
11
+ emails_endpoint = normalized[:emails_endpoint] || "https://api.github.com/user/emails"
12
+ {
13
+ id: "github",
14
+ name: "GitHub",
15
+ client_id: client_id,
16
+ client_secret: client_secret,
17
+ create_authorization_url: lambda do |data|
18
+ Base.authorization_url(options[:authorization_endpoint] || "https://github.com/login/oauth/authorize", {
19
+ client_id: client_id,
20
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
21
+ scope: Base.selected_scopes(scopes, normalized, data),
22
+ state: data[:state],
23
+ login_hint: data[:loginHint] || data[:login_hint],
24
+ prompt: options[:prompt]
25
+ })
26
+ end,
27
+ validate_authorization_code: lambda do |data|
28
+ Base.post_form(token_endpoint, {
29
+ client_id: client_id,
30
+ client_secret: client_secret,
31
+ code: data[:code],
32
+ code_verifier: data[:code_verifier] || data[:codeVerifier],
33
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
34
+ })
35
+ end,
36
+ get_user_info: lambda do |tokens|
37
+ custom = normalized[:get_user_info]
38
+ next custom.call(tokens) if custom
39
+
40
+ headers = {
41
+ "Authorization" => "Bearer #{Base.access_token(tokens)}",
42
+ "Accept" => "application/json",
43
+ "User-Agent" => "better-auth"
44
+ }
45
+ profile = Base.get_json(user_info_endpoint, headers)
46
+ emails = Base.get_json(emails_endpoint, headers)
47
+ primary = Array(emails).find { |email| email["email"] == profile["email"] } ||
48
+ Array(emails).find { |email| email["primary"] } ||
49
+ Array(emails).first ||
50
+ {}
51
+
52
+ user = Base.apply_profile_mapping(
53
+ {
54
+ id: profile["id"].to_s,
55
+ email: profile["email"] || primary["email"],
56
+ name: profile["name"] || profile["login"],
57
+ image: profile["avatar_url"],
58
+ emailVerified: !!primary["verified"]
59
+ },
60
+ profile,
61
+ normalized
62
+ )
63
+ {
64
+ user: user,
65
+ data: profile
66
+ }
67
+ end,
68
+ refresh_access_token: options[:refresh_access_token] || options[:refreshAccessToken] || lambda do |refresh_token|
69
+ Base.refresh_access_token(token_endpoint, refresh_token, client_id: client_id, client_secret: client_secret)
70
+ end
71
+ }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SocialProviders
5
+ module_function
6
+
7
+ def gitlab(client_id:, client_secret:, issuer: "https://gitlab.com", scopes: ["read_user"], **options)
8
+ base = issuer.to_s.sub(%r{/+\z}, "")
9
+ normalized = Base.normalize_options(options)
10
+ {
11
+ id: "gitlab",
12
+ name: "GitLab",
13
+ client_id: client_id,
14
+ client_secret: client_secret,
15
+ create_authorization_url: lambda do |data|
16
+ Base.authorization_url(options[:authorization_endpoint] || "#{base}/oauth/authorize", {
17
+ client_id: client_id,
18
+ redirect_uri: data[:redirect_uri] || data[:redirectURI],
19
+ response_type: "code",
20
+ scope: Base.selected_scopes(scopes, normalized, data),
21
+ state: data[:state],
22
+ code_challenge: (data[:code_verifier] || data[:codeVerifier]) && Base.pkce_challenge(data[:code_verifier] || data[:codeVerifier]),
23
+ code_challenge_method: (data[:code_verifier] || data[:codeVerifier]) && "S256",
24
+ login_hint: data[:loginHint] || data[:login_hint]
25
+ })
26
+ end,
27
+ validate_authorization_code: lambda do |data|
28
+ Base.post_form("#{base}/oauth/token", {
29
+ client_id: client_id,
30
+ client_secret: client_secret,
31
+ code: data[:code],
32
+ code_verifier: data[:code_verifier] || data[:codeVerifier],
33
+ grant_type: "authorization_code",
34
+ redirect_uri: data[:redirect_uri] || data[:redirectURI]
35
+ })
36
+ end,
37
+ get_user_info: lambda do |tokens|
38
+ custom = normalized[:get_user_info]
39
+ next custom.call(tokens) if custom
40
+
41
+ profile = Base.get_json("#{base}/api/v4/user", "Authorization" => "Bearer #{Base.access_token(tokens)}")
42
+ return nil if profile["state"] && profile["state"] != "active"
43
+ return nil if profile["locked"] == true
44
+
45
+ user = Base.apply_profile_mapping(
46
+ {
47
+ id: profile["id"].to_s,
48
+ email: profile["email"],
49
+ name: profile["name"] || profile["username"],
50
+ image: profile["avatar_url"],
51
+ emailVerified: !!profile["email_verified"]
52
+ },
53
+ profile,
54
+ normalized
55
+ )
56
+ {
57
+ user: user,
58
+ data: profile
59
+ }
60
+ end,
61
+ refresh_access_token: options[:refresh_access_token] || options[:refreshAccessToken] || lambda do |refresh_token|
62
+ Base.refresh_access_token("#{base}/oauth/token", refresh_token, client_id: client_id, client_secret: client_secret)
63
+ end
64
+ }
65
+ end
66
+ end
67
+ end