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,780 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "base64"
6
+ require "openssl"
7
+
8
+ module BetterAuth
9
+ module Plugins
10
+ module_function
11
+
12
+ GENERIC_OAUTH_ERROR_CODES = {
13
+ "INVALID_OAUTH_CONFIGURATION" => "Invalid OAuth configuration",
14
+ "TOKEN_URL_NOT_FOUND" => "Invalid OAuth configuration. Token URL not found.",
15
+ "PROVIDER_CONFIG_NOT_FOUND" => "No config found for provider",
16
+ "PROVIDER_ID_REQUIRED" => "Provider ID is required",
17
+ "INVALID_OAUTH_CONFIG" => "Invalid OAuth configuration.",
18
+ "SESSION_REQUIRED" => "Session is required",
19
+ "ISSUER_MISMATCH" => "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).",
20
+ "ISSUER_MISSING" => "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207)."
21
+ }.freeze
22
+
23
+ def generic_oauth(options = {})
24
+ config = normalize_hash(options)
25
+ providers = Array(config[:config]).map { |provider| normalize_hash(provider) }
26
+ generic_oauth_warn_duplicate_providers(providers)
27
+ config[:config] = providers
28
+
29
+ Plugin.new(
30
+ id: "generic-oauth",
31
+ init: ->(context) {
32
+ {
33
+ options: {
34
+ social_providers: generic_oauth_social_providers(config, context).merge(context.social_providers)
35
+ }
36
+ }
37
+ },
38
+ endpoints: {
39
+ sign_in_with_oauth2: sign_in_with_oauth2_endpoint(config),
40
+ o_auth2_callback: o_auth2_callback_endpoint(config),
41
+ o_auth2_link_account: o_auth2_link_account_endpoint(config)
42
+ },
43
+ error_codes: GENERIC_OAUTH_ERROR_CODES,
44
+ options: config
45
+ )
46
+ end
47
+
48
+ def auth0(options = {})
49
+ data = normalize_hash(options)
50
+ domain = data.fetch(:domain).to_s.sub(%r{\Ahttps?://}, "")
51
+ generic_oauth_provider_config(
52
+ data,
53
+ provider_id: "auth0",
54
+ discovery_url: "https://#{domain}/.well-known/openid-configuration",
55
+ scopes: ["openid", "profile", "email"],
56
+ get_user_info: ->(tokens) {
57
+ profile = generic_oauth_fetch_json("https://#{domain}/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
58
+ return nil unless profile
59
+
60
+ {
61
+ id: fetch_value(profile, "sub"),
62
+ name: fetch_value(profile, "name") || fetch_value(profile, "nickname"),
63
+ email: fetch_value(profile, "email"),
64
+ image: fetch_value(profile, "picture"),
65
+ emailVerified: fetch_value(profile, "email_verified") || false
66
+ }
67
+ }
68
+ )
69
+ end
70
+
71
+ def gumroad(options = {})
72
+ data = normalize_hash(options)
73
+ generic_oauth_provider_config(
74
+ data,
75
+ provider_id: "gumroad",
76
+ authorization_url: "https://gumroad.com/oauth/authorize",
77
+ token_url: "https://api.gumroad.com/oauth/token",
78
+ scopes: ["view_profile"],
79
+ get_user_info: ->(tokens) {
80
+ profile = generic_oauth_fetch_json("https://api.gumroad.com/v2/user", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
81
+ user = fetch_value(profile, "user")
82
+ return nil unless fetch_value(profile, "success") && user
83
+
84
+ {
85
+ id: fetch_value(user, "user_id"),
86
+ name: fetch_value(user, "name"),
87
+ email: fetch_value(user, "email"),
88
+ image: fetch_value(user, "profile_url"),
89
+ emailVerified: false
90
+ }
91
+ }
92
+ )
93
+ end
94
+
95
+ def hubspot(options = {})
96
+ data = normalize_hash(options)
97
+ generic_oauth_provider_config(
98
+ data,
99
+ provider_id: "hubspot",
100
+ authorization_url: "https://app.hubspot.com/oauth/authorize",
101
+ token_url: "https://api.hubapi.com/oauth/v1/token",
102
+ scopes: ["oauth"],
103
+ authentication: "post",
104
+ get_user_info: ->(tokens) {
105
+ profile = generic_oauth_fetch_json("https://api.hubapi.com/oauth/v1/access-tokens/#{fetch_value(tokens, "accessToken")}", "Content-Type" => "application/json")
106
+ return nil unless profile
107
+
108
+ id = fetch_value(profile, "user_id") || fetch_value(fetch_value(profile, "signed_access_token"), "userId")
109
+ return nil if id.to_s.empty?
110
+
111
+ {id: id, name: fetch_value(profile, "user"), email: fetch_value(profile, "user"), emailVerified: false}
112
+ }
113
+ )
114
+ end
115
+
116
+ def keycloak(options = {})
117
+ data = normalize_hash(options)
118
+ issuer = data.fetch(:issuer).to_s.sub(%r{/\z}, "")
119
+ generic_oidc_helper_provider(data, "keycloak", issuer, "#{issuer}/.well-known/openid-configuration", "#{issuer}/protocol/openid-connect/userinfo")
120
+ end
121
+
122
+ def line(options = {})
123
+ data = normalize_hash(options)
124
+ generic_oauth_provider_config(
125
+ data,
126
+ provider_id: data[:provider_id] || "line",
127
+ authorization_url: "https://access.line.me/oauth2/v2.1/authorize",
128
+ token_url: "https://api.line.me/oauth2/v2.1/token",
129
+ user_info_url: "https://api.line.me/oauth2/v2.1/userinfo",
130
+ scopes: ["openid", "profile", "email"],
131
+ get_user_info: ->(tokens) {
132
+ profile = generic_oauth_user_from_id_token(fetch_value(tokens, "idToken"))
133
+ profile ||= generic_oauth_fetch_json("https://api.line.me/oauth2/v2.1/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
134
+ return nil unless profile
135
+
136
+ {
137
+ id: fetch_value(profile, "sub") || fetch_value(profile, "id"),
138
+ name: fetch_value(profile, "name"),
139
+ email: fetch_value(profile, "email"),
140
+ image: fetch_value(profile, "picture") || fetch_value(profile, "image"),
141
+ emailVerified: false
142
+ }
143
+ }
144
+ )
145
+ end
146
+
147
+ def microsoft_entra_id(options = {})
148
+ data = normalize_hash(options)
149
+ tenant_id = data.fetch(:tenant_id).to_s
150
+ generic_oauth_provider_config(
151
+ data,
152
+ provider_id: "microsoft-entra-id",
153
+ authorization_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize",
154
+ token_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token",
155
+ user_info_url: "https://graph.microsoft.com/oidc/userinfo",
156
+ scopes: ["openid", "profile", "email"],
157
+ get_user_info: ->(tokens) {
158
+ profile = generic_oauth_fetch_json("https://graph.microsoft.com/oidc/userinfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
159
+ return nil unless profile
160
+
161
+ {
162
+ id: fetch_value(profile, "sub"),
163
+ name: fetch_value(profile, "name") || [fetch_value(profile, "given_name"), fetch_value(profile, "family_name")].compact.join(" ").strip,
164
+ email: fetch_value(profile, "email") || fetch_value(profile, "preferred_username"),
165
+ image: fetch_value(profile, "picture"),
166
+ emailVerified: fetch_value(profile, "email_verified") || false
167
+ }
168
+ }
169
+ )
170
+ end
171
+
172
+ def okta(options = {})
173
+ data = normalize_hash(options)
174
+ issuer = data.fetch(:issuer).to_s.sub(%r{/\z}, "")
175
+ generic_oidc_helper_provider(data, "okta", issuer, "#{issuer}/.well-known/openid-configuration", "#{issuer}/oauth2/v1/userinfo")
176
+ end
177
+
178
+ def patreon(options = {})
179
+ data = normalize_hash(options)
180
+ generic_oauth_provider_config(
181
+ data,
182
+ provider_id: "patreon",
183
+ authorization_url: "https://www.patreon.com/oauth2/authorize",
184
+ token_url: "https://www.patreon.com/api/oauth2/token",
185
+ scopes: ["identity[email]"],
186
+ get_user_info: ->(tokens) {
187
+ profile = generic_oauth_fetch_json("https://www.patreon.com/api/oauth2/v2/identity?fields[user]=email,full_name,image_url,is_email_verified", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
188
+ data = fetch_value(profile, "data")
189
+ attributes = fetch_value(data, "attributes")
190
+ return nil unless data && attributes
191
+
192
+ {
193
+ id: fetch_value(data, "id"),
194
+ name: fetch_value(attributes, "full_name"),
195
+ email: fetch_value(attributes, "email"),
196
+ image: fetch_value(attributes, "image_url"),
197
+ emailVerified: fetch_value(attributes, "is_email_verified")
198
+ }
199
+ }
200
+ )
201
+ end
202
+
203
+ def slack(options = {})
204
+ data = normalize_hash(options)
205
+ generic_oauth_provider_config(
206
+ data,
207
+ provider_id: "slack",
208
+ authorization_url: "https://slack.com/openid/connect/authorize",
209
+ token_url: "https://slack.com/api/openid.connect.token",
210
+ user_info_url: "https://slack.com/api/openid.connect.userInfo",
211
+ scopes: ["openid", "profile", "email"],
212
+ get_user_info: ->(tokens) {
213
+ profile = generic_oauth_fetch_json("https://slack.com/api/openid.connect.userInfo", authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
214
+ return nil unless profile
215
+
216
+ {
217
+ id: fetch_value(profile, "https://slack.com/user_id") || fetch_value(profile, "sub"),
218
+ name: fetch_value(profile, "name"),
219
+ email: fetch_value(profile, "email"),
220
+ image: fetch_value(profile, "picture") || fetch_value(profile, "https://slack.com/user_image_512"),
221
+ emailVerified: fetch_value(profile, "email_verified") || false
222
+ }
223
+ }
224
+ )
225
+ end
226
+
227
+ def sign_in_with_oauth2_endpoint(config)
228
+ Endpoint.new(path: "/sign-in/oauth2", method: "POST") do |ctx|
229
+ body = normalize_hash(ctx.body)
230
+ provider_id = body[:provider_id].to_s
231
+ provider = generic_oauth_provider!(config, provider_id)
232
+ auth_url = generic_oauth_authorization_url(ctx, provider, body, link: nil)
233
+ ctx.json({url: auth_url, redirect: !body[:disable_redirect]})
234
+ end
235
+ end
236
+
237
+ def o_auth2_link_account_endpoint(config)
238
+ Endpoint.new(path: "/oauth2/link", method: "POST") do |ctx|
239
+ session = Routes.current_session(ctx)
240
+ body = normalize_hash(ctx.body)
241
+ provider_id = body[:provider_id].to_s
242
+ provider = generic_oauth_provider(config, provider_id)
243
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
244
+
245
+ auth_url = generic_oauth_authorization_url(
246
+ ctx,
247
+ provider,
248
+ body,
249
+ link: {user_id: session[:user]["id"], email: session[:user]["email"]}
250
+ )
251
+ ctx.json({url: auth_url, redirect: true})
252
+ end
253
+ end
254
+
255
+ def o_auth2_callback_endpoint(config)
256
+ Endpoint.new(
257
+ path: "/oauth2/callback/:providerId",
258
+ method: ["GET", "POST"],
259
+ metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
260
+ ) do |ctx|
261
+ query = normalize_hash(ctx.query)
262
+ provider_id = (fetch_value(ctx.params, "providerId") || query[:provider_id]).to_s
263
+ raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["PROVIDER_ID_REQUIRED"]) if provider_id.empty?
264
+
265
+ provider = generic_oauth_provider!(config, provider_id)
266
+ state_data = generic_oauth_parse_state(ctx, query[:state].to_s)
267
+ error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
268
+ redirect_error = ->(error) { raise ctx.redirect(generic_oauth_error_url(error_url, error)) }
269
+
270
+ redirect_error.call(query[:error] || "oAuth_code_missing") if query[:error] || query[:code].to_s.empty?
271
+ generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
272
+
273
+ tokens = begin
274
+ generic_oauth_exchange_token(ctx, provider, query[:code].to_s, state_data)
275
+ rescue
276
+ nil
277
+ end
278
+ redirect_error.call("oauth_code_verification_failed") unless tokens
279
+ user_info = generic_oauth_user_info(provider, tokens)
280
+ redirect_error.call("user_info_is_missing") unless user_info
281
+
282
+ mapped_user = generic_oauth_map_user(provider, user_info)
283
+ email = fetch_value(mapped_user, "email").to_s.downcase
284
+ name = fetch_value(mapped_user, "name").to_s
285
+ account_id = fetch_value(mapped_user, "id").to_s
286
+ redirect_error.call("email_is_missing") if email.empty?
287
+ redirect_error.call("name_is_missing") if name.empty?
288
+
289
+ link = state_data["link"]
290
+ callback_url = state_data["callbackURL"] || "/"
291
+ if link
292
+ generic_oauth_link_account(ctx, provider, tokens, mapped_user, link, redirect_error)
293
+ raise ctx.redirect(callback_url)
294
+ end
295
+
296
+ existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
297
+ if !existing && (provider[:disable_sign_up] || (provider[:disable_implicit_sign_up] && !state_data["requestSignUp"]))
298
+ redirect_error.call("signup_disabled")
299
+ end
300
+ if existing && provider[:override_user_info]
301
+ ctx.context.internal_adapter.update_user(
302
+ existing[:user]["id"],
303
+ "name" => name,
304
+ "image" => fetch_value(mapped_user, "image"),
305
+ "emailVerified" => !!fetch_value(mapped_user, "emailVerified")
306
+ )
307
+ end
308
+
309
+ session_data = Routes.persist_social_user(
310
+ ctx,
311
+ provider_id,
312
+ mapped_user.merge("email" => email, "name" => name, "id" => account_id),
313
+ generic_oauth_account_info(ctx, provider_id, account_id, tokens)
314
+ )
315
+ generic_oauth_set_account_cookie(ctx, provider_id, account_id, session_data[:user]["id"])
316
+ Cookies.set_session_cookie(ctx, session_data)
317
+ raise ctx.redirect(existing ? callback_url : (state_data["newUserURL"] || state_data["newUserCallbackURL"] || callback_url))
318
+ end
319
+ end
320
+
321
+ def generic_oauth_authorization_url(ctx, provider, body, link:)
322
+ authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
323
+ token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
324
+ raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIGURATION"]) if authorization_url.to_s.empty? || token_url.to_s.empty?
325
+
326
+ code_verifier = Crypto.random_string(43)
327
+ state_data = normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s).merge(
328
+ "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
329
+ "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
330
+ "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
331
+ "requestSignUp" => body[:request_sign_up] || body[:requestSignUp],
332
+ "codeVerifier" => provider[:pkce] ? code_verifier : nil,
333
+ "link" => link,
334
+ "expiresAt" => Time.now.to_i + 600
335
+ )
336
+ state = generic_oauth_generate_state(ctx, state_data)
337
+ legacy_state = Crypto.sign_jwt(
338
+ {
339
+ "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
340
+ "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
341
+ "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
342
+ "requestSignUp" => body[:request_sign_up] || body[:requestSignUp],
343
+ "codeVerifier" => code_verifier,
344
+ "link" => link
345
+ },
346
+ ctx.context.secret,
347
+ expires_in: 600
348
+ )
349
+ state ||= legacy_state
350
+
351
+ uri = URI.parse(authorization_url.to_s)
352
+ params = URI.decode_www_form(uri.query.to_s)
353
+ params.concat([
354
+ ["client_id", provider[:client_id].to_s],
355
+ ["response_type", provider[:response_type] || "code"],
356
+ ["redirect_uri", generic_oauth_redirect_uri(ctx, provider)],
357
+ ["state", state]
358
+ ])
359
+ scopes = Array(body[:scopes]) + Array(provider[:scopes])
360
+ params << ["scope", scopes.join(" ")] unless scopes.empty?
361
+ if provider[:pkce]
362
+ params << ["code_challenge", generic_oauth_pkce_challenge(code_verifier)]
363
+ params << ["code_challenge_method", "S256"]
364
+ end
365
+ params << ["prompt", provider[:prompt]] if provider[:prompt]
366
+ params << ["access_type", provider[:access_type]] if provider[:access_type]
367
+ params << ["response_mode", provider[:response_mode]] if provider[:response_mode]
368
+ authorization_params = if provider[:authorization_url_params].respond_to?(:call)
369
+ provider[:authorization_url_params].call(ctx)
370
+ else
371
+ provider[:authorization_url_params]
372
+ end
373
+ normalize_hash(authorization_params || {}).each { |key, value| params << [key.to_s, value.to_s] }
374
+ uri.query = URI.encode_www_form(params)
375
+ uri.to_s
376
+ end
377
+
378
+ def generic_oauth_generate_state(ctx, state_data)
379
+ strategy = ctx.context.options.account[:store_state_strategy]
380
+ state = Crypto.random_string(32)
381
+ if strategy.to_s == "cookie"
382
+ cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
383
+ encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret, data: JSON.generate(state_data.merge("state" => state)))
384
+ ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
385
+ return state
386
+ end
387
+
388
+ cookie = ctx.context.create_auth_cookie("state", max_age: 300)
389
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
390
+ ctx.context.internal_adapter.create_verification_value(
391
+ identifier: state,
392
+ value: JSON.generate(state_data),
393
+ expiresAt: Time.now + 600
394
+ )
395
+ state
396
+ rescue
397
+ nil
398
+ end
399
+
400
+ def generic_oauth_exchange_token(ctx, provider, code, state_data)
401
+ token_callback = provider[:get_token]
402
+ if token_callback.respond_to?(:call)
403
+ return normalize_hash(token_callback.call(
404
+ code: code,
405
+ redirectURI: generic_oauth_redirect_uri(ctx, provider),
406
+ redirect_uri: generic_oauth_redirect_uri(ctx, provider),
407
+ codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil,
408
+ code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil
409
+ ))
410
+ end
411
+
412
+ token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
413
+ raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?
414
+
415
+ generic_oauth_post_token(ctx, token_url, provider, code, provider[:pkce] ? state_data["codeVerifier"] : nil, generic_oauth_redirect_uri(ctx, provider))
416
+ end
417
+
418
+ def generic_oauth_parse_state(ctx, state)
419
+ if state.to_s.empty?
420
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
421
+ end
422
+
423
+ if ctx.context.options.account[:store_state_strategy].to_s == "cookie"
424
+ cookie = ctx.context.create_auth_cookie("oauth_state")
425
+ encrypted = ctx.get_cookie(cookie.name)
426
+ unless encrypted
427
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
428
+ end
429
+
430
+ begin
431
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: encrypted)
432
+ unless decrypted
433
+ Cookies.expire_cookie(ctx, cookie)
434
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
435
+ end
436
+
437
+ parsed = JSON.parse(decrypted)
438
+ rescue JSON::ParserError
439
+ Cookies.expire_cookie(ctx, cookie)
440
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
441
+ end
442
+
443
+ Cookies.expire_cookie(ctx, cookie)
444
+ if parsed["state"] != state
445
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
446
+ end
447
+ if parsed["expiresAt"].to_i.positive? && parsed["expiresAt"].to_i < Time.now.to_i
448
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
449
+ end
450
+ return parsed
451
+ else
452
+ verification = ctx.context.internal_adapter.find_verification_value(state)
453
+ if verification
454
+ cookie = ctx.context.create_auth_cookie("state")
455
+ cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
456
+ if cookie_state && cookie_state != state
457
+ Cookies.expire_cookie(ctx, cookie)
458
+ raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
459
+ end
460
+
461
+ parsed = JSON.parse(verification.fetch("value"))
462
+ ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
463
+ Cookies.expire_cookie(ctx, cookie) if cookie_state
464
+ return parsed
465
+ end
466
+ end
467
+
468
+ Crypto.verify_jwt(state.to_s, ctx.context.secret) || {}
469
+ rescue JSON::ParserError
470
+ {}
471
+ end
472
+
473
+ def generic_oauth_state_error_url(ctx)
474
+ ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
475
+ end
476
+
477
+ def generic_oauth_user_info(provider, tokens)
478
+ callback = provider[:get_user_info]
479
+ return normalize_hash(callback.call(tokens)) if callback.respond_to?(:call)
480
+
481
+ id_token = tokens[:id_token] || tokens[:idToken]
482
+ return generic_oauth_user_from_id_token(id_token) if id_token
483
+
484
+ user_info_url = provider[:user_info_url] || generic_oauth_discovery(provider)["userinfo_endpoint"]
485
+ return nil if user_info_url.to_s.empty?
486
+
487
+ uri = URI(user_info_url)
488
+ request = Net::HTTP::Get.new(uri)
489
+ request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
490
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
491
+ return nil unless response.is_a?(Net::HTTPSuccess)
492
+
493
+ generic_oauth_normalize_user_info(JSON.parse(response.body))
494
+ rescue
495
+ nil
496
+ end
497
+
498
+ def generic_oauth_map_user(provider, user_info)
499
+ mapper = provider[:map_profile_to_user]
500
+ mapped = mapper.respond_to?(:call) ? mapper.call(user_info) : user_info
501
+ normalize_hash(user_info).merge(normalize_hash(mapped || {}))
502
+ end
503
+
504
+ def generic_oauth_link_account(ctx, provider, tokens, user_info, link, redirect_error)
505
+ if !ctx.context.options.account.dig(:account_linking, :allow_different_emails) &&
506
+ link["email"].to_s.downcase != fetch_value(user_info, "email").to_s.downcase
507
+ redirect_error.call("email_doesn't_match")
508
+ end
509
+
510
+ account_id = fetch_value(user_info, "id").to_s
511
+ existing_account = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider[:provider_id].to_s)
512
+ account_info = generic_oauth_account_info(ctx, provider[:provider_id].to_s, account_id, tokens).merge("userId" => link["user_id"])
513
+ if existing_account
514
+ redirect_error.call("account_already_linked_to_different_user") if existing_account["userId"] != link["user_id"]
515
+ account = ctx.context.internal_adapter.update_account(existing_account["id"], account_info)
516
+ else
517
+ account = ctx.context.internal_adapter.create_account(account_info)
518
+ end
519
+ Cookies.set_account_cookie(ctx, account) if account
520
+ end
521
+
522
+ def generic_oauth_account_info(ctx, provider_id, account_id, tokens)
523
+ data = normalize_hash(tokens || {})
524
+ {
525
+ "providerId" => provider_id,
526
+ "accountId" => account_id,
527
+ "accessToken" => generic_oauth_token_for_storage(ctx, data[:access_token] || data[:accessToken]),
528
+ "refreshToken" => generic_oauth_token_for_storage(ctx, data[:refresh_token] || data[:refreshToken]),
529
+ "idToken" => data[:id_token] || data[:idToken],
530
+ "accessTokenExpiresAt" => data[:access_token_expires_at] || data[:accessTokenExpiresAt],
531
+ "refreshTokenExpiresAt" => data[:refresh_token_expires_at] || data[:refreshTokenExpiresAt],
532
+ "scope" => Array(data[:scopes] || data[:scope]).join(",")
533
+ }
534
+ end
535
+
536
+ def generic_oauth_token_for_storage(ctx, token)
537
+ return token if token.to_s.empty?
538
+ return token unless ctx.context.options.account[:encrypt_oauth_tokens]
539
+
540
+ Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
541
+ end
542
+
543
+ def generic_oauth_set_account_cookie(ctx, provider_id, account_id, user_id)
544
+ account = ctx.context.internal_adapter.find_accounts(user_id).find do |entry|
545
+ entry["providerId"] == provider_id && entry["accountId"] == account_id
546
+ end
547
+ Cookies.set_account_cookie(ctx, account) if account
548
+ end
549
+
550
+ def generic_oauth_provider!(config, provider_id)
551
+ provider = generic_oauth_provider(config, provider_id)
552
+ raise APIError.new("BAD_REQUEST", message: "#{GENERIC_OAUTH_ERROR_CODES["PROVIDER_CONFIG_NOT_FOUND"]} #{provider_id}") unless provider
553
+
554
+ provider
555
+ end
556
+
557
+ def generic_oauth_provider(config, provider_id)
558
+ Array(config[:config]).find { |provider| provider[:provider_id].to_s == provider_id.to_s }
559
+ end
560
+
561
+ def generic_oauth_redirect_uri(ctx, provider)
562
+ provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.base_url}/oauth2/callback/#{provider[:provider_id]}"
563
+ end
564
+
565
+ def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
566
+ expected = provider[:issuer] || generic_oauth_discovery(provider)["issuer"]
567
+ return if expected.to_s.empty?
568
+ return if query[:iss].to_s == expected.to_s
569
+ return redirect_error.call("issuer_missing") if query[:iss].to_s.empty? && provider[:require_issuer_validation]
570
+ return if query[:iss].to_s.empty?
571
+
572
+ redirect_error.call("issuer_mismatch")
573
+ end
574
+
575
+ def generic_oauth_discovery(provider)
576
+ return {} if provider[:discovery_url].to_s.empty?
577
+ return provider[:_discovery] if provider[:_discovery]
578
+
579
+ uri = URI(provider[:discovery_url])
580
+ request = Net::HTTP::Get.new(uri)
581
+ normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
582
+ request[key.to_s.tr("_", "-")] = value.to_s
583
+ end
584
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
585
+ provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
586
+ rescue
587
+ {}
588
+ end
589
+
590
+ def generic_oauth_post_token(ctx, token_url, provider, code, code_verifier, redirect_uri)
591
+ uri = URI(token_url)
592
+ request = Net::HTTP::Post.new(uri)
593
+ normalize_hash(provider[:authorization_headers] || provider[:authorizationHeaders]).each do |key, value|
594
+ request[key.to_s.tr("_", "-")] = value.to_s
595
+ end
596
+ form_data = {
597
+ grant_type: "authorization_code",
598
+ code: code,
599
+ redirect_uri: redirect_uri
600
+ }.compact
601
+ form_data[:code_verifier] = code_verifier if code_verifier
602
+ authentication = (provider[:authentication] || "post").to_s
603
+ if authentication == "basic"
604
+ request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}"
605
+ else
606
+ form_data[:client_id] = provider[:client_id]
607
+ form_data[:client_secret] = provider[:client_secret] if provider[:client_secret]
608
+ end
609
+ token_url_params = if provider[:token_url_params].respond_to?(:call)
610
+ provider[:token_url_params].call(ctx)
611
+ else
612
+ provider[:token_url_params] || provider[:tokenUrlParams]
613
+ end
614
+ normalize_hash(token_url_params || {}).each do |key, value|
615
+ form_data[key] = value unless form_data.key?(key)
616
+ end
617
+ request.set_form_data(form_data)
618
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
619
+ return nil unless response.is_a?(Net::HTTPSuccess)
620
+
621
+ generic_oauth_normalize_tokens(JSON.parse(response.body))
622
+ rescue
623
+ nil
624
+ end
625
+
626
+ def generic_oauth_user_from_id_token(id_token)
627
+ payload = JWT.decode(id_token, nil, false).first
628
+ normalize_hash(
629
+ id: payload["sub"],
630
+ email: payload["email"],
631
+ emailVerified: payload["email_verified"],
632
+ name: payload["name"],
633
+ image: payload["picture"]
634
+ )
635
+ rescue
636
+ nil
637
+ end
638
+
639
+ def generic_oauth_normalize_tokens(data)
640
+ token_data = normalize_hash(data)
641
+ token_data.merge(
642
+ access_token: token_data[:access_token],
643
+ refresh_token: token_data[:refresh_token],
644
+ id_token: token_data[:id_token],
645
+ access_token_expires_at: generic_oauth_expiry_time(token_data[:expires_in]),
646
+ refresh_token_expires_at: generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
647
+ scopes: generic_oauth_token_scopes(token_data[:scope]),
648
+ raw: token_data
649
+ ).compact
650
+ end
651
+
652
+ def generic_oauth_expiry_time(seconds)
653
+ return nil if seconds.to_i <= 0
654
+
655
+ Time.now + seconds.to_i
656
+ end
657
+
658
+ def generic_oauth_token_scopes(scope)
659
+ return [] unless scope
660
+ return scope if scope.is_a?(Array)
661
+
662
+ scope.to_s.split(/\s+/)
663
+ end
664
+
665
+ def generic_oauth_pkce_challenge(code_verifier)
666
+ Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", code_verifier.to_s))
667
+ end
668
+
669
+ def generic_oauth_normalize_user_info(data)
670
+ profile = normalize_hash(data)
671
+ profile.merge(
672
+ id: profile[:id] || profile[:sub],
673
+ email_verified: profile[:email_verified] || false,
674
+ emailVerified: profile[:email_verified] || false,
675
+ image: profile[:image] || profile[:picture]
676
+ )
677
+ end
678
+
679
+ def generic_oauth_fetch_json(url, headers = {})
680
+ uri = URI(url)
681
+ request = Net::HTTP::Get.new(uri)
682
+ normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
683
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
684
+ return nil unless response.is_a?(Net::HTTPSuccess)
685
+
686
+ JSON.parse(response.body)
687
+ rescue
688
+ nil
689
+ end
690
+
691
+ def generic_oidc_helper_provider(options, provider_id, issuer, discovery_url, user_info_url)
692
+ generic_oauth_provider_config(
693
+ options,
694
+ provider_id: provider_id,
695
+ discovery_url: discovery_url,
696
+ scopes: ["openid", "profile", "email"],
697
+ get_user_info: ->(tokens) {
698
+ profile = generic_oauth_fetch_json(user_info_url, authorization: "Bearer #{fetch_value(tokens, "accessToken")}")
699
+ return nil unless profile
700
+
701
+ {
702
+ id: fetch_value(profile, "sub"),
703
+ name: fetch_value(profile, "name") || fetch_value(profile, "preferred_username"),
704
+ email: fetch_value(profile, "email"),
705
+ image: fetch_value(profile, "picture"),
706
+ emailVerified: fetch_value(profile, "email_verified") || false
707
+ }
708
+ }
709
+ )
710
+ end
711
+
712
+ def generic_oauth_provider_config(options, defaults)
713
+ data = normalize_hash(options)
714
+ config = defaults.merge(
715
+ client_id: data[:client_id],
716
+ client_secret: data[:client_secret],
717
+ redirect_uri: data[:redirect_uri],
718
+ pkce: data[:pkce],
719
+ disable_implicit_sign_up: data[:disable_implicit_sign_up],
720
+ disable_sign_up: data[:disable_sign_up],
721
+ override_user_info: data[:override_user_info]
722
+ )
723
+ config[:scopes] = data[:scopes] if data[:scopes]
724
+ config.compact
725
+ end
726
+
727
+ def generic_oauth_social_providers(config, context)
728
+ Array(config[:config]).each_with_object({}) do |provider, result|
729
+ provider_id = provider[:provider_id].to_s
730
+ result[provider_id.to_sym] = {
731
+ id: provider_id,
732
+ name: provider_id,
733
+ get_user_info: ->(tokens) { generic_oauth_user_info(provider, tokens) },
734
+ refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }
735
+ }
736
+ end
737
+ end
738
+
739
+ def generic_oauth_refresh_access_token(ctx, provider, refresh_token)
740
+ token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
741
+ raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["TOKEN_URL_NOT_FOUND"]) if token_url.to_s.empty?
742
+
743
+ generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token)
744
+ end
745
+
746
+ def generic_oauth_post_refresh_token(ctx, token_url, provider, refresh_token)
747
+ uri = URI(token_url)
748
+ request = Net::HTTP::Post.new(uri)
749
+ form_data = {grant_type: "refresh_token", refresh_token: refresh_token}
750
+ authentication = (provider[:authentication] || "post").to_s
751
+ if authentication == "basic"
752
+ request["authorization"] = "Basic #{Base64.strict_encode64("#{provider[:client_id]}:#{provider[:client_secret]}")}"
753
+ else
754
+ form_data[:client_id] = provider[:client_id]
755
+ form_data[:client_secret] = provider[:client_secret] if provider[:client_secret]
756
+ end
757
+ token_url_params = provider[:token_url_params] || provider[:tokenUrlParams]
758
+ token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
759
+ normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
760
+ request.set_form_data(form_data.compact)
761
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
762
+ raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
763
+
764
+ generic_oauth_normalize_tokens(JSON.parse(response.body))
765
+ end
766
+
767
+ def generic_oauth_error_url(base_url, error)
768
+ uri = URI.parse(base_url.to_s)
769
+ query = URI.decode_www_form(uri.query.to_s)
770
+ query << ["error", error.to_s]
771
+ uri.query = URI.encode_www_form(query)
772
+ uri.to_s
773
+ end
774
+
775
+ def generic_oauth_warn_duplicate_providers(providers)
776
+ duplicates = providers.group_by { |provider| provider[:provider_id].to_s }.select { |id, entries| !id.empty? && entries.length > 1 }.keys
777
+ warn "Duplicate provider IDs found: #{duplicates.join(", ")}" unless duplicates.empty?
778
+ end
779
+ end
780
+ end