better_auth 0.8.0 → 0.11.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -1,710 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module BetterAuth
6
- module Plugins
7
- module OIDCProvider
8
- VALID_PROMPTS = %w[none login consent create select_account].freeze
9
- DEPRECATION_MESSAGE = 'The "oidc-provider" plugin is deprecated and will be removed in the next major version. Migrate to better_auth-oauth-provider. See: https://www.better-auth.com/docs/plugins/oauth-provider'
10
-
11
- module_function
12
-
13
- def warn_deprecation!(logger = nil)
14
- return if @deprecation_warned
15
-
16
- Deprecate.warn_once("[Deprecation] #{DEPRECATION_MESSAGE}", logger)
17
- @deprecation_warned = true
18
- end
19
-
20
- def reset_deprecation_warning!
21
- @deprecation_warned = false
22
- end
23
-
24
- def normalize_issuer(value)
25
- uri = URI.parse(value.to_s)
26
- uri.query = nil
27
- uri.fragment = nil
28
- if uri.scheme == "http" && !["localhost", "127.0.0.1"].include?(uri.host)
29
- uri.scheme = "https"
30
- end
31
- uri.to_s.sub(%r{/+\z}, "")
32
- rescue URI::InvalidURIError
33
- value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
34
- end
35
-
36
- def parse_prompt(value)
37
- prompts = value.to_s.split(/\s+/).select { |prompt| VALID_PROMPTS.include?(prompt) }
38
- if prompts.include?("none") && prompts.length > 1
39
- raise APIError.new("BAD_REQUEST", message: "invalid_request")
40
- end
41
-
42
- prompts.to_set
43
- end
44
- end
45
-
46
- module_function
47
-
48
- def oidc_provider(options = {})
49
- raw_options = normalize_hash(options)
50
- OIDCProvider.warn_deprecation!(raw_options[:logger]) unless raw_options[:__skip_deprecation_warning]
51
-
52
- config = {
53
- code_expires_in: 600,
54
- consent_page: "/oauth2/authorize",
55
- login_page: "/login",
56
- default_scope: "openid",
57
- access_token_expires_in: 3600,
58
- refresh_token_expires_in: 604_800,
59
- allow_plain_code_challenge_method: true,
60
- store_client_secret: "plain",
61
- scopes: %w[openid profile email offline_access],
62
- store: OAuthProtocol.stores
63
- }.merge(raw_options.except(:logger, :__skip_deprecation_warning))
64
-
65
- Plugin.new(
66
- id: "oidc-provider",
67
- endpoints: oidc_provider_endpoints(config),
68
- hooks: {
69
- after: [
70
- {
71
- matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") },
72
- handler: ->(ctx) { oidc_resume_login_prompt(ctx, config) }
73
- }
74
- ]
75
- },
76
- schema: oidc_provider_schema,
77
- options: config
78
- )
79
- end
80
-
81
- def oidc_provider_endpoints(config)
82
- {
83
- get_open_id_config: oidc_metadata_endpoint(config),
84
- o_auth2_authorize: oidc_authorize_endpoint(config),
85
- o_auth_consent: oidc_consent_endpoint(config),
86
- o_auth2_token: oidc_token_endpoint(config),
87
- o_auth2_introspect: oidc_introspect_endpoint(config),
88
- o_auth2_revoke: oidc_revoke_endpoint(config),
89
- o_auth2_user_info: oidc_userinfo_endpoint(config),
90
- register_o_auth_application: oidc_register_endpoint(config),
91
- get_o_auth_client: oidc_get_client_endpoint,
92
- list_o_auth_applications: oidc_list_clients_endpoint,
93
- update_o_auth_application: oidc_update_client_endpoint,
94
- rotate_o_auth_application_secret: oidc_rotate_client_secret_endpoint(config),
95
- delete_o_auth_application: oidc_delete_client_endpoint,
96
- end_session: oidc_end_session_endpoint
97
- }
98
- end
99
-
100
- def oidc_metadata_endpoint(config)
101
- Endpoint.new(path: "/.well-known/openid-configuration", method: "GET", metadata: {hide: true}) do |ctx|
102
- base = OAuthProtocol.endpoint_base(ctx)
103
- supported_algs = oidc_use_jwt_plugin?(ctx, config) ? ["RS256", "EdDSA", "none"] : ["HS256", "none"]
104
- ctx.json({
105
- issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
106
- authorization_endpoint: "#{base}/oauth2/authorize",
107
- token_endpoint: "#{base}/oauth2/token",
108
- userinfo_endpoint: "#{base}/oauth2/userinfo",
109
- jwks_uri: "#{base}/jwks",
110
- registration_endpoint: "#{base}/oauth2/register",
111
- introspection_endpoint: "#{base}/oauth2/introspect",
112
- revocation_endpoint: "#{base}/oauth2/revoke",
113
- end_session_endpoint: "#{base}/oauth2/endsession",
114
- scopes_supported: config[:scopes],
115
- response_types_supported: ["code"],
116
- response_modes_supported: ["query"],
117
- grant_types_supported: ["authorization_code", "refresh_token"],
118
- acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
119
- subject_types_supported: ["public"],
120
- id_token_signing_alg_values_supported: supported_algs,
121
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
122
- introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
123
- revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
124
- code_challenge_methods_supported: ["S256"],
125
- claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
126
- }.merge(config[:metadata] || {}))
127
- end
128
- end
129
-
130
- def oidc_register_endpoint(config)
131
- Endpoint.new(path: "/oauth2/register", method: "POST", metadata: oidc_openapi("registerOAuthApplication", "Register an OAuth2 application", "OAuth2 application registered successfully", oidc_client_schema)) do |ctx|
132
- session = Routes.current_session(ctx, allow_nil: true)
133
- unless session || config[:allow_dynamic_client_registration]
134
- raise APIError.new("UNAUTHORIZED", message: "invalid_token")
135
- end
136
-
137
- body = OAuthProtocol.stringify_keys(ctx.body)
138
- grant_types = Array(body["grant_types"] || [OAuthProtocol::AUTH_CODE_GRANT])
139
- response_types = Array(body["response_types"] || ["code"])
140
- redirects = Array(body["redirect_uris"]).map(&:to_s)
141
- if (grant_types.empty? || grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) || grant_types.include?("implicit")) && redirects.empty?
142
- raise APIError.new("BAD_REQUEST", message: "invalid_redirect_uri")
143
- end
144
- if grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) && !response_types.include?("code")
145
- raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
146
- end
147
- if grant_types.include?("implicit") && !response_types.include?("token")
148
- raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
149
- end
150
-
151
- client = OAuthProtocol.create_client(
152
- ctx,
153
- model: "oauthApplication",
154
- body: body,
155
- owner_session: session,
156
- default_auth_method: "client_secret_basic",
157
- store_client_secret: config[:store_client_secret]
158
- )
159
- ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
160
- end
161
- end
162
-
163
- def oidc_get_client_endpoint
164
- Endpoint.new(path: "/oauth2/client/:id", method: "GET", metadata: oidc_openapi("getOAuthClient", "Get OAuth2 client details", "OAuth2 client retrieved successfully", oidc_client_schema)) do |ctx|
165
- client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
166
- raise APIError.new("NOT_FOUND", message: "client not found") unless client
167
-
168
- ctx.json(OAuthProtocol.client_response(client, include_secret: false))
169
- end
170
- end
171
-
172
- def oidc_list_clients_endpoint
173
- Endpoint.new(path: "/oauth2/clients", method: "GET", metadata: oidc_openapi("listOAuthApplications", "List OAuth2 applications", "OAuth2 applications retrieved successfully", {type: "array", items: oidc_client_schema})) do |ctx|
174
- session = Routes.current_session(ctx)
175
- clients = ctx.context.adapter.find_many(model: "oauthApplication", where: [{field: "userId", value: session[:user]["id"]}])
176
- ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) })
177
- end
178
- end
179
-
180
- def oidc_update_client_endpoint
181
- Endpoint.new(path: "/oauth2/client/:id", method: "PATCH", metadata: oidc_openapi("updateOAuthApplication", "Update an OAuth2 application", "OAuth2 application updated successfully", oidc_client_schema)) do |ctx|
182
- session = Routes.current_session(ctx)
183
- client = oidc_find_owned_client!(ctx, session)
184
- body = OAuthProtocol.stringify_keys(ctx.body)
185
- update_source = OAuthProtocol.stringify_keys(body["update"] || body)
186
- update = {}
187
- if update_source.key?("client_name") || update_source.key?("name")
188
- update["name"] = update_source["client_name"] || update_source["name"]
189
- end
190
- update["uri"] = update_source["client_uri"] if update_source.key?("client_uri")
191
- update["icon"] = update_source["logo_uri"] if update_source.key?("logo_uri")
192
- if update_source.key?("redirect_uris")
193
- redirects = Array(update_source["redirect_uris"]).map(&:to_s)
194
- update["redirectUris"] = redirects
195
- update["redirectUrls"] = redirects.join(",")
196
- end
197
- update["postLogoutRedirectUris"] = Array(update_source["post_logout_redirect_uris"]).map(&:to_s) if update_source.key?("post_logout_redirect_uris")
198
- update["grantTypes"] = Array(update_source["grant_types"]).map(&:to_s) if update_source.key?("grant_types")
199
- update["responseTypes"] = Array(update_source["response_types"]).map(&:to_s) if update_source.key?("response_types")
200
- update["scopes"] = OAuthProtocol.parse_scopes(update_source["scope"] || update_source["scopes"]) if update_source.key?("scope") || update_source.key?("scopes")
201
- update["metadata"] = update_source["metadata"] if update_source.key?("metadata")
202
- update["updatedAt"] = Time.now
203
- updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}], update: update)
204
- ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
205
- end
206
- end
207
-
208
- def oidc_rotate_client_secret_endpoint(config)
209
- Endpoint.new(path: "/oauth2/client/:id/rotate-secret", method: "POST", metadata: oidc_openapi("rotateOAuthApplicationSecret", "Rotate an OAuth2 application secret", "OAuth2 application secret rotated successfully", oidc_client_schema)) do |ctx|
210
- session = Routes.current_session(ctx)
211
- client = oidc_find_owned_client!(ctx, session)
212
- if OAuthProtocol.stringify_keys(client)["tokenEndpointAuthMethod"] == "none"
213
- raise APIError.new("BAD_REQUEST", message: "invalid_client")
214
- end
215
-
216
- client_secret = Crypto.random_string(32)
217
- updated = ctx.context.adapter.update(
218
- model: "oauthApplication",
219
- where: [{field: "id", value: client.fetch("id")}],
220
- update: {clientSecret: OAuthProtocol.store_client_secret_value(ctx, client_secret, config[:store_client_secret]), updatedAt: Time.now}
221
- )
222
- ctx.json(OAuthProtocol.client_response(updated, include_secret: false).merge(client_secret: client_secret))
223
- end
224
- end
225
-
226
- def oidc_delete_client_endpoint
227
- Endpoint.new(path: "/oauth2/client/:id", method: "DELETE", metadata: oidc_openapi("deleteOAuthApplication", "Delete an OAuth2 application", "OAuth2 application deleted successfully", OpenAPI.success_response_schema)) do |ctx|
228
- session = Routes.current_session(ctx)
229
- client = oidc_find_owned_client!(ctx, session)
230
- ctx.context.adapter.delete(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}])
231
- ctx.json({success: true})
232
- end
233
- end
234
-
235
- def oidc_authorize_endpoint(config)
236
- Endpoint.new(path: "/oauth2/authorize", method: "GET", metadata: oidc_openapi("oauth2Authorize", "Authorize an OAuth2 request", "Authorization response generated successfully", {type: "object", additionalProperties: true})) do |ctx|
237
- query = OAuthProtocol.stringify_keys(ctx.query)
238
- prompts = OIDCProvider.parse_prompt(query["prompt"])
239
- session = Routes.current_session(ctx, allow_nil: true)
240
- if !session && prompts.include?("none")
241
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "login_required", error_description: "Authentication required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx))
242
- raise ctx.redirect(redirect)
243
- end
244
- unless session
245
- ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
246
- raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
247
- end
248
-
249
- client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
250
- raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
251
- OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
252
-
253
- scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
254
- invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
255
- unless invalid_scopes.empty?
256
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_scope", error_description: "The following scopes are invalid: #{invalid_scopes.join(", ")}", state: query["state"], iss: OAuthProtocol.issuer(ctx))
257
- raise ctx.redirect(redirect)
258
- end
259
- if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
260
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"], iss: OAuthProtocol.issuer(ctx))
261
- raise ctx.redirect(redirect)
262
- end
263
- challenge_method = query["code_challenge_method"].to_s
264
- if !challenge_method.empty? && !valid_code_challenge_method?(challenge_method, config)
265
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"], iss: OAuthProtocol.issuer(ctx))
266
- raise ctx.redirect(redirect)
267
- end
268
-
269
- client_data = OAuthProtocol.stringify_keys(client)
270
- requires_consent = !client_data["skipConsent"] && (prompts.include?("consent") || !oidc_consent_granted?(ctx, client_data["clientId"], session[:user]["id"], scopes))
271
- if oidc_requires_login?(session, prompts, query)
272
- ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
273
- raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], client_id: client_data["clientId"], state: query["state"]))
274
- end
275
-
276
- if requires_consent
277
- if prompts.include?("none")
278
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "consent_required", error_description: "Consent required but prompt is none", state: query["state"], iss: OAuthProtocol.issuer(ctx))
279
- raise ctx.redirect(redirect)
280
- end
281
-
282
- consent_code = Crypto.random_string(32)
283
- config[:store][:consents][consent_code] = {
284
- query: query,
285
- session: session,
286
- client: client,
287
- scopes: scopes,
288
- expires_at: Time.now + config[:code_expires_in].to_i
289
- }
290
- unless config[:consent_page]
291
- renderer = config[:get_consent_html]
292
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "No consent page provided") unless renderer.respond_to?(:call)
293
-
294
- ctx.set_header("content-type", "text/html")
295
- next renderer.call(
296
- scopes: scopes,
297
- clientMetadata: client_data["metadata"] || {},
298
- clientIcon: client_data["icon"],
299
- clientId: client_data["clientId"],
300
- clientName: client_data["name"],
301
- code: consent_code
302
- )
303
- end
304
-
305
- raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:consent_page], consent_code: consent_code, client_id: client_data["clientId"], scope: OAuthProtocol.scope_string(scopes)))
306
- end
307
-
308
- code = Crypto.random_string(32)
309
- OAuthProtocol.store_code(
310
- config[:store],
311
- code: code,
312
- client_id: query["client_id"],
313
- redirect_uri: query["redirect_uri"],
314
- session: session,
315
- scopes: scopes,
316
- code_challenge: query["code_challenge"],
317
- code_challenge_method: query["code_challenge_method"]
318
- )
319
-
320
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))
321
- raise ctx.redirect(redirect)
322
- end
323
- end
324
-
325
- def oidc_consent_endpoint(config)
326
- Endpoint.new(path: "/oauth2/consent", method: "POST", metadata: oidc_openapi("oauth2Consent", "Handle OAuth2 consent", "OAuth2 consent handled successfully", oidc_redirect_response_schema)) do |ctx|
327
- Routes.current_session(ctx)
328
- body = OAuthProtocol.stringify_keys(ctx.body)
329
- consent = config[:store][:consents].delete(body["consent_code"].to_s)
330
- raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
331
- raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now
332
-
333
- query = consent[:query]
334
- if body["accept"] == false || body["accept"].to_s == "false"
335
- redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: OAuthProtocol.issuer(ctx))
336
- next ctx.json({redirectURI: redirect})
337
- end
338
-
339
- oidc_store_consent(ctx, consent[:client], consent[:session], consent[:scopes])
340
- code = Crypto.random_string(32)
341
- OAuthProtocol.store_code(
342
- config[:store],
343
- code: code,
344
- client_id: query["client_id"],
345
- redirect_uri: query["redirect_uri"],
346
- session: consent[:session],
347
- scopes: consent[:scopes],
348
- code_challenge: query["code_challenge"],
349
- code_challenge_method: query["code_challenge_method"]
350
- )
351
- ctx.json({redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))})
352
- end
353
- end
354
-
355
- def oidc_token_endpoint(config)
356
- Endpoint.new(
357
- path: "/oauth2/token",
358
- method: "POST",
359
- metadata: oidc_openapi("oauth2Token", "Exchange OAuth2 code for tokens", "OAuth2 tokens issued successfully", oidc_token_response_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
360
- ) do |ctx|
361
- body = OAuthProtocol.stringify_keys(ctx.body)
362
- client = OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
363
- raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
364
-
365
- response = case body["grant_type"]
366
- when OAuthProtocol::AUTH_CODE_GRANT
367
- code = OAuthProtocol.consume_code!(
368
- config[:store],
369
- body["code"],
370
- client_id: body["client_id"],
371
- redirect_uri: body["redirect_uri"],
372
- code_verifier: body["code_verifier"]
373
- )
374
- OAuthProtocol.issue_tokens(
375
- ctx,
376
- config[:store],
377
- model: "oauthAccessToken",
378
- client: client,
379
- session: code[:session],
380
- scopes: code[:scopes],
381
- include_refresh: code[:scopes].include?("offline_access"),
382
- issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
383
- access_token_expires_in: config[:access_token_expires_in],
384
- id_token_signer: oidc_id_token_signer(ctx, config)
385
- )
386
- when OAuthProtocol::REFRESH_GRANT
387
- OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)), access_token_expires_in: config[:access_token_expires_in], id_token_signer: oidc_id_token_signer(ctx, config))
388
- else
389
- raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
390
- end
391
- ctx.json(response)
392
- end
393
- end
394
-
395
- def oidc_userinfo_endpoint(config)
396
- Endpoint.new(path: "/oauth2/userinfo", method: "GET", metadata: oidc_openapi("oauth2Userinfo", "Get OAuth2 user information", "User information retrieved successfully", oidc_userinfo_schema)) do |ctx|
397
- ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:get_additional_user_info_claim]))
398
- end
399
- end
400
-
401
- def oidc_introspect_endpoint(config)
402
- Endpoint.new(
403
- path: "/oauth2/introspect",
404
- method: "POST",
405
- metadata: oidc_openapi("oauth2Introspect", "Introspect an OAuth2 token", "OAuth2 token introspection result", oidc_introspection_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
406
- ) do |ctx|
407
- OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
408
- body = OAuthProtocol.stringify_keys(ctx.body)
409
- token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s]
410
- active = token && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
411
- ctx.json(active ? {
412
- active: true,
413
- client_id: token["clientId"],
414
- scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]),
415
- sub: token.dig("user", "id"),
416
- exp: token["expiresAt"]&.to_i
417
- } : {active: false})
418
- end
419
- end
420
-
421
- def oidc_revoke_endpoint(config)
422
- Endpoint.new(
423
- path: "/oauth2/revoke",
424
- method: "POST",
425
- metadata: oidc_openapi("oauth2Revoke", "Revoke an OAuth2 token", "OAuth2 token revoked successfully", OpenAPI.object_schema({revoked: {type: "boolean"}}, required: ["revoked"])).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
426
- ) do |ctx|
427
- OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
428
- body = OAuthProtocol.stringify_keys(ctx.body)
429
- if (token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s])
430
- token["revoked"] = Time.now
431
- end
432
- ctx.json({revoked: true})
433
- end
434
- end
435
-
436
- def oidc_end_session_endpoint
437
- Endpoint.new(
438
- path: "/oauth2/endsession",
439
- method: ["GET", "POST"],
440
- metadata: {
441
- openapi: {
442
- operationId: "oauth2EndSession",
443
- description: "RP-Initiated Logout endpoint",
444
- responses: {
445
- "302" => {description: "Redirects after clearing the session cookie"}
446
- }
447
- },
448
- allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]
449
- }
450
- ) do |ctx|
451
- input_source = (ctx.method == "GET") ? ctx.query : ctx.body
452
- input = OAuthProtocol.stringify_keys(input_source)
453
- if input["post_logout_redirect_uri"]
454
- client = OAuthProtocol.find_client(ctx, "oauthApplication", input["client_id"])
455
- raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
456
- unless OAuthProtocol.client_logout_redirect_uris(client).include?(input["post_logout_redirect_uri"])
457
- raise APIError.new("BAD_REQUEST", message: "invalid_request")
458
- end
459
- end
460
-
461
- Cookies.delete_session_cookie(ctx)
462
- redirect = input["post_logout_redirect_uri"] || "/"
463
- redirect = OAuthProtocol.redirect_uri_with_params(redirect, state: input["state"]) if input["state"]
464
- raise ctx.redirect(redirect)
465
- end
466
- end
467
-
468
- def oidc_openapi(operation_id, description, response_description = "Success", response_schema = {type: "object"})
469
- {
470
- openapi: {
471
- operationId: operation_id,
472
- description: description,
473
- responses: {
474
- "200" => OpenAPI.json_response(response_description, response_schema)
475
- }
476
- }
477
- }
478
- end
479
-
480
- def oidc_client_schema
481
- OpenAPI.object_schema(
482
- {
483
- clientId: {type: "string"},
484
- clientSecret: {type: ["string", "null"]},
485
- name: {type: "string"},
486
- redirectUris: {type: "array", items: {type: "string"}},
487
- grantTypes: {type: "array", items: {type: "string"}},
488
- responseTypes: {type: "array", items: {type: "string"}}
489
- },
490
- required: ["clientId", "name"]
491
- )
492
- end
493
-
494
- def oidc_redirect_response_schema
495
- OpenAPI.object_schema(
496
- {redirectURI: {type: "string", format: "uri"}},
497
- required: ["redirectURI"]
498
- )
499
- end
500
-
501
- def oidc_token_response_schema
502
- OpenAPI.object_schema(
503
- {
504
- access_token: {type: "string"},
505
- token_type: {type: "string"},
506
- expires_in: {type: "number"},
507
- refresh_token: {type: ["string", "null"]},
508
- id_token: {type: ["string", "null"]},
509
- scope: {type: ["string", "null"]}
510
- },
511
- required: ["access_token", "token_type", "expires_in"]
512
- )
513
- end
514
-
515
- def oidc_userinfo_schema
516
- OpenAPI.object_schema(
517
- {
518
- sub: {type: "string"},
519
- email: {type: ["string", "null"]},
520
- email_verified: {type: ["boolean", "null"]},
521
- name: {type: ["string", "null"]},
522
- picture: {type: ["string", "null"]}
523
- },
524
- required: ["sub"]
525
- )
526
- end
527
-
528
- def oidc_introspection_schema
529
- OpenAPI.object_schema(
530
- {
531
- active: {type: "boolean"},
532
- client_id: {type: ["string", "null"]},
533
- scope: {type: ["string", "null"]},
534
- sub: {type: ["string", "null"]},
535
- exp: {type: ["number", "null"]}
536
- },
537
- required: ["active"]
538
- )
539
- end
540
-
541
- def oidc_provider_schema
542
- {
543
- oauthApplication: {
544
- modelName: "oauthApplication",
545
- fields: {
546
- name: {type: "string"},
547
- icon: {type: "string", required: false},
548
- uri: {type: "string", required: false},
549
- metadata: {type: "json", required: false},
550
- clientId: {type: "string", unique: true},
551
- clientSecret: {type: "string", required: false},
552
- redirectUrls: {type: "string"},
553
- redirectUris: {type: "string[]", required: false},
554
- postLogoutRedirectUris: {type: "string[]", required: false},
555
- tokenEndpointAuthMethod: {type: "string", required: false},
556
- skipConsent: {type: "boolean", required: false},
557
- grantTypes: {type: "string[]", required: false},
558
- responseTypes: {type: "string[]", required: false},
559
- scopes: {type: "string[]", required: false},
560
- type: {type: "string"},
561
- disabled: {type: "boolean", required: false, default_value: false},
562
- userId: {type: "string", required: false, references: {model: "users", field: "id", on_delete: "cascade"}, index: true},
563
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
564
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
565
- }
566
- },
567
- oauthAccessToken: {
568
- modelName: "oauthAccessToken",
569
- fields: {
570
- accessToken: {type: "string", unique: true, required: false},
571
- token: {type: "string", unique: true, required: false},
572
- refreshToken: {type: "string", unique: true, required: false},
573
- accessTokenExpiresAt: {type: "date", required: false},
574
- expiresAt: {type: "date", required: false},
575
- clientId: {type: "string", required: true},
576
- userId: {type: "string", required: false},
577
- sessionId: {type: "string", required: false},
578
- scope: {type: "string", required: false},
579
- scopes: {type: "string[]", required: false},
580
- revoked: {type: "date", required: false},
581
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
582
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
583
- }
584
- },
585
- oauthConsent: {
586
- modelName: "oauthConsent",
587
- fields: {
588
- clientId: {type: "string", required: true},
589
- userId: {type: "string", required: true},
590
- scopes: {type: "string[]", required: false},
591
- consentGiven: {type: "boolean", required: false},
592
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
593
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
594
- }
595
- }
596
- }
597
- end
598
-
599
- def oidc_find_owned_client!(ctx, session)
600
- client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
601
- raise APIError.new("NOT_FOUND", message: "client not found") unless client
602
- raise APIError.new("FORBIDDEN", message: "Access denied") unless client["userId"] == session[:user]["id"]
603
-
604
- client
605
- end
606
-
607
- def valid_code_challenge_method?(method, config)
608
- normalized = method.to_s.downcase
609
- return true if normalized == "s256"
610
-
611
- normalized == "plain" && config[:allow_plain_code_challenge_method]
612
- end
613
-
614
- def oidc_requires_login?(session, prompts, query)
615
- return true if prompts.include?("login")
616
- return false unless query.key?("max_age")
617
-
618
- max_age = Integer(query["max_age"])
619
- return false if max_age.negative?
620
-
621
- created_at = session.dig(:session, "createdAt") || session.dig(:session, :createdAt)
622
- created_at = Time.parse(created_at.to_s) unless created_at.is_a?(Time)
623
- (Time.now - created_at) > max_age
624
- rescue ArgumentError, TypeError
625
- false
626
- end
627
-
628
- def oidc_use_jwt_plugin?(ctx, config)
629
- return false unless config[:use_jwt_plugin]
630
-
631
- oidc_jwt_plugin(ctx)
632
- end
633
-
634
- def oidc_jwt_plugin(ctx)
635
- ctx.context.options.plugins.find { |plugin| plugin[:id] == "jwt" }
636
- end
637
-
638
- def oidc_id_token_signer(ctx, config)
639
- jwt_plugin = oidc_use_jwt_plugin?(ctx, config)
640
- return nil unless jwt_plugin
641
-
642
- lambda do |sign_ctx, payload|
643
- BetterAuth::Plugins.sign_jwt_payload(
644
- sign_ctx,
645
- OAuthProtocol.stringify_keys(payload),
646
- jwt_plugin[:options] || {}
647
- )
648
- end
649
- end
650
-
651
- def oidc_consent_granted?(ctx, client_id, user_id, scopes)
652
- consent = ctx.context.adapter.find_one(
653
- model: "oauthConsent",
654
- where: [
655
- {field: "clientId", value: client_id},
656
- {field: "userId", value: user_id}
657
- ]
658
- )
659
- return false unless consent && consent["consentGiven"]
660
-
661
- granted = OAuthProtocol.parse_scopes(consent["scopes"])
662
- scopes.all? { |scope| granted.include?(scope) }
663
- end
664
-
665
- def oidc_store_consent(ctx, client, session, scopes)
666
- client_id = OAuthProtocol.stringify_keys(client)["clientId"]
667
- user_id = session[:user]["id"]
668
- existing = ctx.context.adapter.find_one(
669
- model: "oauthConsent",
670
- where: [
671
- {field: "clientId", value: client_id},
672
- {field: "userId", value: user_id}
673
- ]
674
- )
675
- data = {clientId: client_id, userId: user_id, scopes: scopes, consentGiven: true}
676
- if existing
677
- ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
678
- else
679
- ctx.context.adapter.create(model: "oauthConsent", data: data)
680
- end
681
- end
682
-
683
- def oidc_resume_login_prompt(ctx, config)
684
- prompt = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
685
- return unless prompt
686
- return unless ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
687
-
688
- ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
689
- query = JSON.parse(prompt)
690
- prompts = OIDCProvider.parse_prompt(query["prompt"])
691
- if prompts.include?("login")
692
- prompts.delete("login")
693
- query["prompt"] = prompts.to_a.join(" ")
694
- end
695
- ctx.query = query
696
- ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
697
- oidc_authorize_endpoint(config).call(ctx)
698
- rescue APIError => error
699
- raise APIError.new(
700
- error.status,
701
- message: error.message,
702
- headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
703
- code: error.code,
704
- body: error.body
705
- )
706
- rescue JSON::ParserError
707
- nil
708
- end
709
- end
710
- end