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,597 @@
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
+
10
+ module_function
11
+
12
+ def normalize_issuer(value)
13
+ uri = URI.parse(value.to_s)
14
+ uri.query = nil
15
+ uri.fragment = nil
16
+ if uri.scheme == "http" && !["localhost", "127.0.0.1"].include?(uri.host)
17
+ uri.scheme = "https"
18
+ end
19
+ uri.to_s.sub(%r{/+\z}, "")
20
+ rescue URI::InvalidURIError
21
+ value.to_s.split(/[?#]/).first.sub(%r{/+\z}, "")
22
+ end
23
+
24
+ def parse_prompt(value)
25
+ prompts = value.to_s.split(/\s+/).select { |prompt| VALID_PROMPTS.include?(prompt) }
26
+ if prompts.include?("none") && prompts.length > 1
27
+ raise APIError.new("BAD_REQUEST", message: "invalid_request")
28
+ end
29
+
30
+ prompts.to_set
31
+ end
32
+ end
33
+
34
+ module_function
35
+
36
+ def oidc_provider(options = {})
37
+ config = {
38
+ code_expires_in: 600,
39
+ consent_page: "/oauth2/authorize",
40
+ login_page: "/login",
41
+ default_scope: "openid",
42
+ access_token_expires_in: 3600,
43
+ refresh_token_expires_in: 604_800,
44
+ allow_plain_code_challenge_method: true,
45
+ store_client_secret: "plain",
46
+ scopes: %w[openid profile email offline_access],
47
+ store: OAuthProtocol.stores
48
+ }.merge(normalize_hash(options))
49
+
50
+ Plugin.new(
51
+ id: "oidc-provider",
52
+ endpoints: oidc_provider_endpoints(config),
53
+ hooks: {
54
+ after: [
55
+ {
56
+ matcher: ->(ctx) { ctx.path.start_with?("/sign-in/", "/sign-up/") },
57
+ handler: ->(ctx) { oidc_resume_login_prompt(ctx, config) }
58
+ }
59
+ ]
60
+ },
61
+ schema: oidc_provider_schema,
62
+ options: config
63
+ )
64
+ end
65
+
66
+ def oidc_provider_endpoints(config)
67
+ {
68
+ get_open_id_config: oidc_metadata_endpoint(config),
69
+ o_auth2_authorize: oidc_authorize_endpoint(config),
70
+ o_auth_consent: oidc_consent_endpoint(config),
71
+ o_auth2_token: oidc_token_endpoint(config),
72
+ o_auth2_introspect: oidc_introspect_endpoint(config),
73
+ o_auth2_revoke: oidc_revoke_endpoint(config),
74
+ o_auth2_user_info: oidc_userinfo_endpoint(config),
75
+ register_o_auth_application: oidc_register_endpoint(config),
76
+ get_o_auth_client: oidc_get_client_endpoint,
77
+ list_o_auth_applications: oidc_list_clients_endpoint,
78
+ update_o_auth_application: oidc_update_client_endpoint,
79
+ rotate_o_auth_application_secret: oidc_rotate_client_secret_endpoint(config),
80
+ delete_o_auth_application: oidc_delete_client_endpoint,
81
+ end_session: oidc_end_session_endpoint
82
+ }
83
+ end
84
+
85
+ def oidc_metadata_endpoint(config)
86
+ Endpoint.new(path: "/.well-known/openid-configuration", method: "GET", metadata: {hide: true}) do |ctx|
87
+ base = OAuthProtocol.endpoint_base(ctx)
88
+ supported_algs = oidc_use_jwt_plugin?(ctx, config) ? ["RS256", "EdDSA", "none"] : ["HS256", "none"]
89
+ ctx.json({
90
+ issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
91
+ authorization_endpoint: "#{base}/oauth2/authorize",
92
+ token_endpoint: "#{base}/oauth2/token",
93
+ userinfo_endpoint: "#{base}/oauth2/userinfo",
94
+ jwks_uri: "#{base}/jwks",
95
+ registration_endpoint: "#{base}/oauth2/register",
96
+ introspection_endpoint: "#{base}/oauth2/introspect",
97
+ revocation_endpoint: "#{base}/oauth2/revoke",
98
+ end_session_endpoint: "#{base}/oauth2/endsession",
99
+ scopes_supported: config[:scopes],
100
+ response_types_supported: ["code"],
101
+ response_modes_supported: ["query"],
102
+ grant_types_supported: ["authorization_code", "refresh_token"],
103
+ acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
104
+ subject_types_supported: ["public"],
105
+ id_token_signing_alg_values_supported: supported_algs,
106
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
107
+ introspection_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
108
+ revocation_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
109
+ code_challenge_methods_supported: ["S256"],
110
+ claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
111
+ }.merge(config[:metadata] || {}))
112
+ end
113
+ end
114
+
115
+ def oidc_register_endpoint(config)
116
+ Endpoint.new(path: "/oauth2/register", method: "POST") do |ctx|
117
+ session = Routes.current_session(ctx, allow_nil: true)
118
+ unless session || config[:allow_dynamic_client_registration]
119
+ raise APIError.new("UNAUTHORIZED", message: "invalid_token")
120
+ end
121
+
122
+ body = OAuthProtocol.stringify_keys(ctx.body)
123
+ grant_types = Array(body["grant_types"] || [OAuthProtocol::AUTH_CODE_GRANT])
124
+ response_types = Array(body["response_types"] || ["code"])
125
+ redirects = Array(body["redirect_uris"]).map(&:to_s)
126
+ if (grant_types.empty? || grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) || grant_types.include?("implicit")) && redirects.empty?
127
+ raise APIError.new("BAD_REQUEST", message: "invalid_redirect_uri")
128
+ end
129
+ if grant_types.include?(OAuthProtocol::AUTH_CODE_GRANT) && !response_types.include?("code")
130
+ raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
131
+ end
132
+ if grant_types.include?("implicit") && !response_types.include?("token")
133
+ raise APIError.new("BAD_REQUEST", message: "invalid_client_metadata")
134
+ end
135
+
136
+ client = OAuthProtocol.create_client(
137
+ ctx,
138
+ model: "oauthApplication",
139
+ body: body,
140
+ owner_session: session,
141
+ default_auth_method: "client_secret_basic",
142
+ store_client_secret: config[:store_client_secret]
143
+ )
144
+ ctx.json(client, status: 201, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
145
+ end
146
+ end
147
+
148
+ def oidc_get_client_endpoint
149
+ Endpoint.new(path: "/oauth2/client/:id", method: "GET") do |ctx|
150
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
151
+ raise APIError.new("NOT_FOUND", message: "client not found") unless client
152
+
153
+ ctx.json(OAuthProtocol.client_response(client, include_secret: false))
154
+ end
155
+ end
156
+
157
+ def oidc_list_clients_endpoint
158
+ Endpoint.new(path: "/oauth2/clients", method: "GET") do |ctx|
159
+ session = Routes.current_session(ctx)
160
+ clients = ctx.context.adapter.find_many(model: "oauthApplication", where: [{field: "userId", value: session[:user]["id"]}])
161
+ ctx.json(clients.map { |client| OAuthProtocol.client_response(client, include_secret: false) })
162
+ end
163
+ end
164
+
165
+ def oidc_update_client_endpoint
166
+ Endpoint.new(path: "/oauth2/client/:id", method: "PATCH") do |ctx|
167
+ session = Routes.current_session(ctx)
168
+ client = oidc_find_owned_client!(ctx, session)
169
+ body = OAuthProtocol.stringify_keys(ctx.body)
170
+ update_source = OAuthProtocol.stringify_keys(body["update"] || body)
171
+ update = {}
172
+ if update_source.key?("client_name") || update_source.key?("name")
173
+ update["name"] = update_source["client_name"] || update_source["name"]
174
+ end
175
+ update["uri"] = update_source["client_uri"] if update_source.key?("client_uri")
176
+ update["icon"] = update_source["logo_uri"] if update_source.key?("logo_uri")
177
+ if update_source.key?("redirect_uris")
178
+ redirects = Array(update_source["redirect_uris"]).map(&:to_s)
179
+ update["redirectUris"] = redirects
180
+ update["redirectUrls"] = redirects.join(",")
181
+ end
182
+ update["postLogoutRedirectUris"] = Array(update_source["post_logout_redirect_uris"]).map(&:to_s) if update_source.key?("post_logout_redirect_uris")
183
+ update["grantTypes"] = Array(update_source["grant_types"]).map(&:to_s) if update_source.key?("grant_types")
184
+ update["responseTypes"] = Array(update_source["response_types"]).map(&:to_s) if update_source.key?("response_types")
185
+ update["scopes"] = OAuthProtocol.parse_scopes(update_source["scope"] || update_source["scopes"]) if update_source.key?("scope") || update_source.key?("scopes")
186
+ update["metadata"] = update_source["metadata"] if update_source.key?("metadata")
187
+ update["updatedAt"] = Time.now
188
+ updated = update.empty? ? client : ctx.context.adapter.update(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}], update: update)
189
+ ctx.json(OAuthProtocol.client_response(updated, include_secret: false))
190
+ end
191
+ end
192
+
193
+ def oidc_rotate_client_secret_endpoint(config)
194
+ Endpoint.new(path: "/oauth2/client/:id/rotate-secret", method: "POST") do |ctx|
195
+ session = Routes.current_session(ctx)
196
+ client = oidc_find_owned_client!(ctx, session)
197
+ if OAuthProtocol.stringify_keys(client)["tokenEndpointAuthMethod"] == "none"
198
+ raise APIError.new("BAD_REQUEST", message: "invalid_client")
199
+ end
200
+
201
+ client_secret = Crypto.random_string(32)
202
+ updated = ctx.context.adapter.update(
203
+ model: "oauthApplication",
204
+ where: [{field: "id", value: client.fetch("id")}],
205
+ update: {clientSecret: OAuthProtocol.store_client_secret_value(ctx, client_secret, config[:store_client_secret]), updatedAt: Time.now}
206
+ )
207
+ ctx.json(OAuthProtocol.client_response(updated, include_secret: false).merge(client_secret: client_secret))
208
+ end
209
+ end
210
+
211
+ def oidc_delete_client_endpoint
212
+ Endpoint.new(path: "/oauth2/client/:id", method: "DELETE") do |ctx|
213
+ session = Routes.current_session(ctx)
214
+ client = oidc_find_owned_client!(ctx, session)
215
+ ctx.context.adapter.delete(model: "oauthApplication", where: [{field: "id", value: client.fetch("id")}])
216
+ ctx.json({success: true})
217
+ end
218
+ end
219
+
220
+ def oidc_authorize_endpoint(config)
221
+ Endpoint.new(path: "/oauth2/authorize", method: "GET") do |ctx|
222
+ query = OAuthProtocol.stringify_keys(ctx.query)
223
+ prompts = OIDCProvider.parse_prompt(query["prompt"])
224
+ session = Routes.current_session(ctx, allow_nil: true)
225
+ if !session && prompts.include?("none")
226
+ 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))
227
+ raise ctx.redirect(redirect)
228
+ end
229
+ unless session
230
+ ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
231
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
232
+ end
233
+
234
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
235
+ raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
236
+ OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
237
+
238
+ scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
239
+ invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
240
+ unless invalid_scopes.empty?
241
+ 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))
242
+ raise ctx.redirect(redirect)
243
+ end
244
+ if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
245
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"], iss: OAuthProtocol.issuer(ctx))
246
+ raise ctx.redirect(redirect)
247
+ end
248
+ challenge_method = query["code_challenge_method"].to_s
249
+ if !challenge_method.empty? && !valid_code_challenge_method?(challenge_method, config)
250
+ 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))
251
+ raise ctx.redirect(redirect)
252
+ end
253
+
254
+ client_data = OAuthProtocol.stringify_keys(client)
255
+ requires_consent = !client_data["skipConsent"] && (prompts.include?("consent") || !oidc_consent_granted?(ctx, client_data["clientId"], session[:user]["id"], scopes))
256
+ if oidc_requires_login?(session, prompts, query)
257
+ ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
258
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], client_id: client_data["clientId"], state: query["state"]))
259
+ end
260
+
261
+ if requires_consent
262
+ if prompts.include?("none")
263
+ 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))
264
+ raise ctx.redirect(redirect)
265
+ end
266
+
267
+ consent_code = Crypto.random_string(32)
268
+ config[:store][:consents][consent_code] = {
269
+ query: query,
270
+ session: session,
271
+ client: client,
272
+ scopes: scopes,
273
+ expires_at: Time.now + config[:code_expires_in].to_i
274
+ }
275
+ unless config[:consent_page]
276
+ renderer = config[:get_consent_html]
277
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "No consent page provided") unless renderer.respond_to?(:call)
278
+
279
+ ctx.set_header("content-type", "text/html")
280
+ next renderer.call(
281
+ scopes: scopes,
282
+ clientMetadata: client_data["metadata"] || {},
283
+ clientIcon: client_data["icon"],
284
+ clientId: client_data["clientId"],
285
+ clientName: client_data["name"],
286
+ code: consent_code
287
+ )
288
+ end
289
+
290
+ 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)))
291
+ end
292
+
293
+ code = Crypto.random_string(32)
294
+ OAuthProtocol.store_code(
295
+ config[:store],
296
+ code: code,
297
+ client_id: query["client_id"],
298
+ redirect_uri: query["redirect_uri"],
299
+ session: session,
300
+ scopes: scopes,
301
+ code_challenge: query["code_challenge"],
302
+ code_challenge_method: query["code_challenge_method"]
303
+ )
304
+
305
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))
306
+ raise ctx.redirect(redirect)
307
+ end
308
+ end
309
+
310
+ def oidc_consent_endpoint(config)
311
+ Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
312
+ Routes.current_session(ctx)
313
+ body = OAuthProtocol.stringify_keys(ctx.body)
314
+ consent = config[:store][:consents].delete(body["consent_code"].to_s)
315
+ raise APIError.new("BAD_REQUEST", message: "invalid consent_code") unless consent
316
+ raise APIError.new("BAD_REQUEST", message: "expired consent_code") if consent[:expires_at] <= Time.now
317
+
318
+ query = consent[:query]
319
+ if body["accept"] == false || body["accept"].to_s == "false"
320
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "access_denied", state: query["state"], iss: OAuthProtocol.issuer(ctx))
321
+ next ctx.json({redirectURI: redirect})
322
+ end
323
+
324
+ oidc_store_consent(ctx, consent[:client], consent[:session], consent[:scopes])
325
+ code = Crypto.random_string(32)
326
+ OAuthProtocol.store_code(
327
+ config[:store],
328
+ code: code,
329
+ client_id: query["client_id"],
330
+ redirect_uri: query["redirect_uri"],
331
+ session: consent[:session],
332
+ scopes: consent[:scopes],
333
+ code_challenge: query["code_challenge"],
334
+ code_challenge_method: query["code_challenge_method"]
335
+ )
336
+ ctx.json({redirectURI: OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"], iss: OAuthProtocol.issuer(ctx))})
337
+ end
338
+ end
339
+
340
+ def oidc_token_endpoint(config)
341
+ Endpoint.new(path: "/oauth2/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
342
+ body = OAuthProtocol.stringify_keys(ctx.body)
343
+ client = OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
344
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
345
+
346
+ response = case body["grant_type"]
347
+ when OAuthProtocol::AUTH_CODE_GRANT
348
+ code = OAuthProtocol.consume_code!(
349
+ config[:store],
350
+ body["code"],
351
+ client_id: body["client_id"],
352
+ redirect_uri: body["redirect_uri"],
353
+ code_verifier: body["code_verifier"]
354
+ )
355
+ OAuthProtocol.issue_tokens(
356
+ ctx,
357
+ config[:store],
358
+ model: "oauthAccessToken",
359
+ client: client,
360
+ session: code[:session],
361
+ scopes: code[:scopes],
362
+ include_refresh: code[:scopes].include?("offline_access"),
363
+ issuer: OIDCProvider.normalize_issuer(OAuthProtocol.issuer(ctx)),
364
+ access_token_expires_in: config[:access_token_expires_in],
365
+ id_token_signer: oidc_id_token_signer(ctx, config)
366
+ )
367
+ when OAuthProtocol::REFRESH_GRANT
368
+ 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))
369
+ else
370
+ raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
371
+ end
372
+ ctx.json(response)
373
+ end
374
+ end
375
+
376
+ def oidc_userinfo_endpoint(config)
377
+ Endpoint.new(path: "/oauth2/userinfo", method: "GET") do |ctx|
378
+ ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"], additional_claim: config[:get_additional_user_info_claim]))
379
+ end
380
+ end
381
+
382
+ def oidc_introspect_endpoint(config)
383
+ Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
384
+ OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
385
+ body = OAuthProtocol.stringify_keys(ctx.body)
386
+ token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s]
387
+ active = token && !token["revoked"] && (!token["expiresAt"] || token["expiresAt"] > Time.now)
388
+ ctx.json(active ? {
389
+ active: true,
390
+ client_id: token["clientId"],
391
+ scope: OAuthProtocol.scope_string(token["scope"] || token["scopes"]),
392
+ sub: token.dig("user", "id"),
393
+ exp: token["expiresAt"]&.to_i
394
+ } : {active: false})
395
+ end
396
+ end
397
+
398
+ def oidc_revoke_endpoint(config)
399
+ Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
400
+ OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret])
401
+ body = OAuthProtocol.stringify_keys(ctx.body)
402
+ if (token = config[:store][:tokens][body["token"].to_s] || config[:store][:refresh_tokens][body["token"].to_s])
403
+ token["revoked"] = Time.now
404
+ end
405
+ ctx.json({revoked: true})
406
+ end
407
+ end
408
+
409
+ def oidc_end_session_endpoint
410
+ Endpoint.new(path: "/oauth2/endsession", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
411
+ input_source = (ctx.method == "GET") ? ctx.query : ctx.body
412
+ input = OAuthProtocol.stringify_keys(input_source)
413
+ if input["post_logout_redirect_uri"]
414
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", input["client_id"])
415
+ raise APIError.new("BAD_REQUEST", message: "invalid_client") unless client
416
+ unless OAuthProtocol.client_logout_redirect_uris(client).include?(input["post_logout_redirect_uri"])
417
+ raise APIError.new("BAD_REQUEST", message: "invalid_request")
418
+ end
419
+ end
420
+
421
+ Cookies.delete_session_cookie(ctx)
422
+ redirect = input["post_logout_redirect_uri"] || "/"
423
+ redirect = OAuthProtocol.redirect_uri_with_params(redirect, state: input["state"]) if input["state"]
424
+ raise ctx.redirect(redirect)
425
+ end
426
+ end
427
+
428
+ def oidc_provider_schema
429
+ {
430
+ oauthApplication: {
431
+ modelName: "oauthApplication",
432
+ fields: {
433
+ name: {type: "string"},
434
+ icon: {type: "string", required: false},
435
+ uri: {type: "string", required: false},
436
+ metadata: {type: "json", required: false},
437
+ clientId: {type: "string", unique: true},
438
+ clientSecret: {type: "string", required: false},
439
+ redirectUrls: {type: "string"},
440
+ redirectUris: {type: "string[]", required: false},
441
+ postLogoutRedirectUris: {type: "string[]", required: false},
442
+ tokenEndpointAuthMethod: {type: "string", required: false},
443
+ skipConsent: {type: "boolean", required: false},
444
+ grantTypes: {type: "string[]", required: false},
445
+ responseTypes: {type: "string[]", required: false},
446
+ scopes: {type: "string[]", required: false},
447
+ type: {type: "string"},
448
+ disabled: {type: "boolean", required: false, default_value: false},
449
+ userId: {type: "string", required: false, references: {model: "users", field: "id", on_delete: "cascade"}, index: true},
450
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
451
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
452
+ }
453
+ },
454
+ oauthAccessToken: {
455
+ modelName: "oauthAccessToken",
456
+ fields: {
457
+ accessToken: {type: "string", unique: true, required: false},
458
+ token: {type: "string", unique: true, required: false},
459
+ refreshToken: {type: "string", unique: true, required: false},
460
+ accessTokenExpiresAt: {type: "date", required: false},
461
+ expiresAt: {type: "date", required: false},
462
+ clientId: {type: "string", required: true},
463
+ userId: {type: "string", required: false},
464
+ sessionId: {type: "string", required: false},
465
+ scope: {type: "string", required: false},
466
+ scopes: {type: "string[]", required: false},
467
+ revoked: {type: "date", required: false},
468
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
469
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
470
+ }
471
+ },
472
+ oauthConsent: {
473
+ modelName: "oauthConsent",
474
+ fields: {
475
+ clientId: {type: "string", required: true},
476
+ userId: {type: "string", required: true},
477
+ scopes: {type: "string[]", required: false},
478
+ consentGiven: {type: "boolean", required: false},
479
+ createdAt: {type: "date", required: true, default_value: -> { Time.now }},
480
+ updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
481
+ }
482
+ }
483
+ }
484
+ end
485
+
486
+ def oidc_find_owned_client!(ctx, session)
487
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", ctx.params["id"] || ctx.params[:id])
488
+ raise APIError.new("NOT_FOUND", message: "client not found") unless client
489
+ raise APIError.new("FORBIDDEN", message: "Access denied") unless client["userId"] == session[:user]["id"]
490
+
491
+ client
492
+ end
493
+
494
+ def valid_code_challenge_method?(method, config)
495
+ normalized = method.to_s.downcase
496
+ return true if normalized == "s256"
497
+
498
+ normalized == "plain" && config[:allow_plain_code_challenge_method]
499
+ end
500
+
501
+ def oidc_requires_login?(session, prompts, query)
502
+ return true if prompts.include?("login")
503
+ return false unless query.key?("max_age")
504
+
505
+ max_age = Integer(query["max_age"])
506
+ return false if max_age.negative?
507
+
508
+ created_at = session.dig(:session, "createdAt") || session.dig(:session, :createdAt)
509
+ created_at = Time.parse(created_at.to_s) unless created_at.is_a?(Time)
510
+ (Time.now - created_at) > max_age
511
+ rescue ArgumentError, TypeError
512
+ false
513
+ end
514
+
515
+ def oidc_use_jwt_plugin?(ctx, config)
516
+ return false unless config[:use_jwt_plugin]
517
+
518
+ oidc_jwt_plugin(ctx)
519
+ end
520
+
521
+ def oidc_jwt_plugin(ctx)
522
+ ctx.context.options.plugins.find { |plugin| plugin[:id] == "jwt" }
523
+ end
524
+
525
+ def oidc_id_token_signer(ctx, config)
526
+ jwt_plugin = oidc_use_jwt_plugin?(ctx, config)
527
+ return nil unless jwt_plugin
528
+
529
+ lambda do |sign_ctx, payload|
530
+ BetterAuth::Plugins.sign_jwt_payload(
531
+ sign_ctx,
532
+ OAuthProtocol.stringify_keys(payload),
533
+ jwt_plugin[:options] || {}
534
+ )
535
+ end
536
+ end
537
+
538
+ def oidc_consent_granted?(ctx, client_id, user_id, scopes)
539
+ consent = ctx.context.adapter.find_one(
540
+ model: "oauthConsent",
541
+ where: [
542
+ {field: "clientId", value: client_id},
543
+ {field: "userId", value: user_id}
544
+ ]
545
+ )
546
+ return false unless consent && consent["consentGiven"]
547
+
548
+ granted = OAuthProtocol.parse_scopes(consent["scopes"])
549
+ scopes.all? { |scope| granted.include?(scope) }
550
+ end
551
+
552
+ def oidc_store_consent(ctx, client, session, scopes)
553
+ client_id = OAuthProtocol.stringify_keys(client)["clientId"]
554
+ user_id = session[:user]["id"]
555
+ existing = ctx.context.adapter.find_one(
556
+ model: "oauthConsent",
557
+ where: [
558
+ {field: "clientId", value: client_id},
559
+ {field: "userId", value: user_id}
560
+ ]
561
+ )
562
+ data = {clientId: client_id, userId: user_id, scopes: scopes, consentGiven: true}
563
+ if existing
564
+ ctx.context.adapter.update(model: "oauthConsent", where: [{field: "id", value: existing.fetch("id")}], update: data)
565
+ else
566
+ ctx.context.adapter.create(model: "oauthConsent", data: data)
567
+ end
568
+ end
569
+
570
+ def oidc_resume_login_prompt(ctx, config)
571
+ prompt = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
572
+ return unless prompt
573
+ return unless ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
574
+
575
+ ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
576
+ query = JSON.parse(prompt)
577
+ prompts = OIDCProvider.parse_prompt(query["prompt"])
578
+ if prompts.include?("login")
579
+ prompts.delete("login")
580
+ query["prompt"] = prompts.to_a.join(" ")
581
+ end
582
+ ctx.query = query
583
+ ctx.context.set_current_session(ctx.context.new_session) if ctx.context.respond_to?(:set_current_session) && ctx.context.new_session
584
+ oidc_authorize_endpoint(config).call(ctx)
585
+ rescue APIError => error
586
+ raise APIError.new(
587
+ error.status,
588
+ message: error.message,
589
+ headers: Endpoint::Result.merge_headers(ctx.response_headers, error.headers),
590
+ code: error.code,
591
+ body: error.body
592
+ )
593
+ rescue JSON::ParserError
594
+ nil
595
+ end
596
+ end
597
+ end