better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -1,769 +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
- parameters: oidc_end_session_schema[:properties].keys.map { |name| OpenAPI.query_parameter(name.to_s, schema: oidc_end_session_schema[:properties][name]) },
445
- requestBody: OpenAPI.json_request_body(oidc_end_session_schema, required: false),
446
- responses: {
447
- "302" => {description: "Redirects after clearing the session cookie"}
448
- }
449
- },
450
- allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]
451
- }
452
- ) do |ctx|
453
- input_source = (ctx.method == "GET") ? ctx.query : ctx.body
454
- input = OAuthProtocol.stringify_keys(input_source)
455
- if input["post_logout_redirect_uri"]
456
- client = OAuthProtocol.find_client(ctx, "oauthApplication", input["client_id"])
457
- raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
458
- unless OAuthProtocol.client_logout_redirect_uris(client).include?(input["post_logout_redirect_uri"])
459
- raise APIError.new("BAD_REQUEST", message: "invalid_request")
460
- end
461
- end
462
-
463
- Cookies.delete_session_cookie(ctx)
464
- redirect = input["post_logout_redirect_uri"] || "/"
465
- redirect = OAuthProtocol.redirect_uri_with_params(redirect, state: input["state"]) if input["state"]
466
- raise ctx.redirect(redirect)
467
- end
468
- end
469
-
470
- def oidc_openapi(operation_id, description, response_description = "Success", response_schema = {type: "object"})
471
- {
472
- openapi: {
473
- operationId: operation_id,
474
- description: description,
475
- requestBody: oidc_request_body_for(operation_id),
476
- responses: {
477
- "200" => OpenAPI.json_response(response_description, response_schema)
478
- }
479
- }
480
- }
481
- end
482
-
483
- def oidc_request_body_for(operation_id)
484
- schema = case operation_id
485
- when "registerOAuthApplication", "updateOAuthApplication"
486
- oidc_client_registration_schema
487
- when "rotateOAuthApplicationSecret"
488
- OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
489
- when "oauth2Consent"
490
- OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}}, required: ["consent_code"])
491
- when "oauth2Token"
492
- OpenAPI.object_schema(
493
- {
494
- grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
495
- code: {type: "string"},
496
- redirect_uri: {type: "string", format: "uri"},
497
- code_verifier: {type: "string"},
498
- client_id: {type: "string"},
499
- client_secret: {type: "string"},
500
- refresh_token: {type: "string"},
501
- scope: {type: "string"}
502
- },
503
- required: ["grant_type"]
504
- )
505
- when "oauth2Introspect", "oauth2Revoke"
506
- OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
507
- end
508
- schema ? OpenAPI.json_request_body(schema) : nil
509
- end
510
-
511
- def oidc_client_registration_schema
512
- OpenAPI.object_schema(
513
- {
514
- redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
515
- post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
516
- client_name: {type: "string"},
517
- client_uri: {type: "string", format: "uri"},
518
- logo_uri: {type: "string", format: "uri"},
519
- grant_types: {type: "array", items: {type: "string"}},
520
- response_types: {type: "array", items: {type: "string"}},
521
- scope: {type: "string"},
522
- scopes: {type: "array", items: {type: "string"}},
523
- metadata: {type: "object", additionalProperties: true}
524
- }
525
- )
526
- end
527
-
528
- def oidc_end_session_schema
529
- OpenAPI.object_schema(
530
- {
531
- id_token_hint: {type: "string"},
532
- client_id: {type: "string"},
533
- post_logout_redirect_uri: {type: "string", format: "uri"},
534
- state: {type: "string"}
535
- }
536
- )
537
- end
538
-
539
- def oidc_client_schema
540
- OpenAPI.object_schema(
541
- {
542
- clientId: {type: "string"},
543
- clientSecret: {type: ["string", "null"]},
544
- name: {type: "string"},
545
- redirectUris: {type: "array", items: {type: "string"}},
546
- grantTypes: {type: "array", items: {type: "string"}},
547
- responseTypes: {type: "array", items: {type: "string"}}
548
- },
549
- required: ["clientId", "name"]
550
- )
551
- end
552
-
553
- def oidc_redirect_response_schema
554
- OpenAPI.object_schema(
555
- {redirectURI: {type: "string", format: "uri"}},
556
- required: ["redirectURI"]
557
- )
558
- end
559
-
560
- def oidc_token_response_schema
561
- OpenAPI.object_schema(
562
- {
563
- access_token: {type: "string"},
564
- token_type: {type: "string"},
565
- expires_in: {type: "number"},
566
- refresh_token: {type: ["string", "null"]},
567
- id_token: {type: ["string", "null"]},
568
- scope: {type: ["string", "null"]}
569
- },
570
- required: ["access_token", "token_type", "expires_in"]
571
- )
572
- end
573
-
574
- def oidc_userinfo_schema
575
- OpenAPI.object_schema(
576
- {
577
- sub: {type: "string"},
578
- email: {type: ["string", "null"]},
579
- email_verified: {type: ["boolean", "null"]},
580
- name: {type: ["string", "null"]},
581
- picture: {type: ["string", "null"]}
582
- },
583
- required: ["sub"]
584
- )
585
- end
586
-
587
- def oidc_introspection_schema
588
- OpenAPI.object_schema(
589
- {
590
- active: {type: "boolean"},
591
- client_id: {type: ["string", "null"]},
592
- scope: {type: ["string", "null"]},
593
- sub: {type: ["string", "null"]},
594
- exp: {type: ["number", "null"]}
595
- },
596
- required: ["active"]
597
- )
598
- end
599
-
600
- def oidc_provider_schema
601
- {
602
- oauthApplication: {
603
- model_name: "oauth_applications",
604
- fields: {
605
- name: {type: "string"},
606
- icon: {type: "string", required: false},
607
- uri: {type: "string", required: false},
608
- metadata: {type: "json", required: false},
609
- clientId: {type: "string", unique: true},
610
- clientSecret: {type: "string", required: false},
611
- redirectUrls: {type: "string"},
612
- redirectUris: {type: "string[]", required: false},
613
- postLogoutRedirectUris: {type: "string[]", required: false},
614
- tokenEndpointAuthMethod: {type: "string", required: false},
615
- skipConsent: {type: "boolean", required: false},
616
- grantTypes: {type: "string[]", required: false},
617
- responseTypes: {type: "string[]", required: false},
618
- scopes: {type: "string[]", required: false},
619
- type: {type: "string"},
620
- disabled: {type: "boolean", required: false, default_value: false},
621
- userId: {type: "string", required: false, references: {model: "users", field: "id", on_delete: "cascade"}, index: true},
622
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
623
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
624
- }
625
- },
626
- oauthAccessToken: {
627
- model_name: "oauth_access_tokens",
628
- fields: {
629
- accessToken: {type: "string", unique: true, required: false},
630
- token: {type: "string", unique: true, required: false},
631
- refreshToken: {type: "string", unique: true, required: false},
632
- accessTokenExpiresAt: {type: "date", required: false},
633
- expiresAt: {type: "date", required: false},
634
- clientId: {type: "string", required: true},
635
- userId: {type: "string", required: false},
636
- sessionId: {type: "string", required: false},
637
- scope: {type: "string", required: false},
638
- scopes: {type: "string[]", required: false},
639
- revoked: {type: "date", required: false},
640
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
641
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
642
- }
643
- },
644
- oauthConsent: {
645
- model_name: "oauth_consents",
646
- fields: {
647
- clientId: {type: "string", required: true},
648
- userId: {type: "string", required: true},
649
- scopes: {type: "string[]", required: false},
650
- consentGiven: {type: "boolean", required: false},
651
- createdAt: {type: "date", required: true, default_value: -> { Time.now }},
652
- updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
653
- }
654
- }
655
- }
656
- end
657
-
658
- def oidc_find_owned_client!(ctx, session)
659
- client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
660
- raise APIError.new("NOT_FOUND", message: "client not found") unless client
661
- raise APIError.new("FORBIDDEN", message: "Access denied") unless client["userId"] == session[:user]["id"]
662
-
663
- client
664
- end
665
-
666
- def valid_code_challenge_method?(method, config)
667
- normalized = method.to_s.downcase
668
- return true if normalized == "s256"
669
-
670
- normalized == "plain" && config[:allow_plain_code_challenge_method]
671
- end
672
-
673
- def oidc_requires_login?(session, prompts, query)
674
- return true if prompts.include?("login")
675
- return false unless query.key?("max_age")
676
-
677
- max_age = Integer(query["max_age"])
678
- return false if max_age.negative?
679
-
680
- created_at = session.dig(:session, "createdAt") || session.dig(:session, :createdAt)
681
- created_at = Time.parse(created_at.to_s) unless created_at.is_a?(Time)
682
- (Time.now - created_at) > max_age
683
- rescue ArgumentError, TypeError
684
- false
685
- end
686
-
687
- def oidc_use_jwt_plugin?(ctx, config)
688
- return false unless config[:use_jwt_plugin]
689
-
690
- oidc_jwt_plugin(ctx)
691
- end
692
-
693
- def oidc_jwt_plugin(ctx)
694
- ctx.context.options.plugins.find { |plugin| plugin[:id] == "jwt" }
695
- end
696
-
697
- def oidc_id_token_signer(ctx, config)
698
- jwt_plugin = oidc_use_jwt_plugin?(ctx, config)
699
- return nil unless jwt_plugin
700
-
701
- lambda do |sign_ctx, payload|
702
- BetterAuth::Plugins.sign_jwt_payload(
703
- sign_ctx,
704
- OAuthProtocol.stringify_keys(payload),
705
- jwt_plugin[:options] || {}
706
- )
707
- end
708
- end
709
-
710
- def oidc_consent_granted?(ctx, client_id, user_id, scopes)
711
- consent = ctx.context.adapter.find_one(
712
- model: "oauthConsent",
713
- where: [
714
- {field: "clientId", value: client_id},
715
- {field: "userId", value: user_id}
716
- ]
717
- )
718
- return false unless consent && consent["consentGiven"]
719
-
720
- granted = OAuthProtocol.parse_scopes(consent["scopes"])
721
- scopes.all? { |scope| granted.include?(scope) }
722
- end
723
-
724
- def oidc_store_consent(ctx, client, session, scopes)
725
- client_id = OAuthProtocol.stringify_keys(client)["clientId"]
726
- user_id = session[:user]["id"]
727
- existing = ctx.context.adapter.find_one(
728
- model: "oauthConsent",
729
- where: [
730
- {field: "clientId", value: client_id},
731
- {field: "userId", value: user_id}
732
- ]
733
- )
734
- data = {clientId: client_id, userId: user_id, scopes: scopes, consentGiven: true}
735
- if existing
736
- ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
737
- else
738
- ctx.context.adapter.create(model: "oauthConsent", data: data)
739
- end
740
- end
741
-
742
- def oidc_resume_login_prompt(ctx, config)
743
- prompt = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
744
- return unless prompt
745
- return unless ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
746
-
747
- ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
748
- query = JSON.parse(prompt)
749
- prompts = OIDCProvider.parse_prompt(query["prompt"])
750
- if prompts.include?("login")
751
- prompts.delete("login")
752
- query["prompt"] = prompts.to_a.join(" ")
753
- end
754
- ctx.query = query
755
- ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
756
- oidc_authorize_endpoint(config).call(ctx)
757
- rescue APIError => error
758
- raise APIError.new(
759
- error.status,
760
- message: error.message,
761
- headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
762
- code: error.code,
763
- body: error.body
764
- )
765
- rescue JSON::ParserError
766
- nil
767
- end
768
- end
769
- end