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,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ module_function
9
+
10
+ def magic_link(options = {})
11
+ config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash(options))
12
+
13
+ Plugin.new(
14
+ id: "magic-link",
15
+ endpoints: {
16
+ sign_in_magic_link: sign_in_magic_link_endpoint(config),
17
+ magic_link_verify: magic_link_verify_endpoint(config)
18
+ },
19
+ rate_limit: [
20
+ {
21
+ path_matcher: ->(path) { path.start_with?("/sign-in/magic-link", "/magic-link/verify") },
22
+ window: config.dig(:rate_limit, :window) || 60,
23
+ max: config.dig(:rate_limit, :max) || 5
24
+ }
25
+ ],
26
+ options: config
27
+ )
28
+ end
29
+
30
+ def sign_in_magic_link_endpoint(config)
31
+ Endpoint.new(path: "/sign-in/magic-link", method: "POST") do |ctx|
32
+ body = normalize_hash(ctx.body)
33
+ email = body[:email].to_s.downcase
34
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
35
+
36
+ token = magic_link_token(email, config)
37
+ stored_token = store_magic_link_token(token, config)
38
+ ctx.context.internal_adapter.create_verification_value(
39
+ identifier: stored_token,
40
+ value: JSON.generate({"email" => email, "name" => body[:name], "attempt" => 0}),
41
+ expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i
42
+ )
43
+
44
+ link = magic_link_url(ctx, token, body)
45
+ sender = config[:send_magic_link]
46
+ data = {email: email, url: link, token: token}
47
+ data[:metadata] = body[:metadata] if body.key?(:metadata)
48
+ sender.call(data, ctx) if sender.respond_to?(:call)
49
+ ctx.json({status: true})
50
+ end
51
+ end
52
+
53
+ def magic_link_verify_endpoint(config)
54
+ Endpoint.new(path: "/magic-link/verify", method: "GET") do |ctx|
55
+ query = normalize_hash(ctx.query)
56
+ token = query[:token].to_s
57
+ callback_url = query[:callback_url] || "/"
58
+ error_callback_url = query[:error_callback_url] || callback_url
59
+ new_user_callback_url = query[:new_user_callback_url] || callback_url
60
+
61
+ validate_magic_link_callback!(ctx, callback_url, "callbackURL")
62
+ validate_magic_link_callback!(ctx, error_callback_url, "errorCallbackURL")
63
+ validate_magic_link_callback!(ctx, new_user_callback_url, "newUserCallbackURL")
64
+
65
+ redirect_with_error = lambda do |error|
66
+ raise ctx.redirect(magic_link_error_url(error_callback_url, error))
67
+ end
68
+
69
+ stored_token = store_magic_link_token(token, config)
70
+ verification = ctx.context.internal_adapter.find_verification_value(stored_token)
71
+ redirect_with_error.call("INVALID_TOKEN") unless verification
72
+
73
+ if Routes.expired_time?(verification["expiresAt"])
74
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
75
+ redirect_with_error.call("EXPIRED_TOKEN")
76
+ end
77
+
78
+ payload = JSON.parse(verification["value"])
79
+ email = payload.fetch("email").to_s.downcase
80
+ name = payload["name"]
81
+ attempt = payload["attempt"].to_i
82
+ if magic_link_attempts_exceeded?(attempt, config)
83
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
84
+ redirect_with_error.call("ATTEMPTS_EXCEEDED")
85
+ end
86
+ ctx.context.internal_adapter.update_verification_value(
87
+ verification["id"],
88
+ value: JSON.generate(payload.merge("attempt" => attempt + 1))
89
+ )
90
+ found = ctx.context.internal_adapter.find_user_by_email(email)
91
+ user = found && found[:user]
92
+ new_user = false
93
+
94
+ unless user
95
+ redirect_with_error.call("new_user_signup_disabled") if config[:disable_sign_up]
96
+
97
+ user = ctx.context.internal_adapter.create_user(
98
+ email: email,
99
+ emailVerified: true,
100
+ name: name || ""
101
+ )
102
+ new_user = true
103
+ redirect_with_error.call("failed_to_create_user") unless user
104
+ end
105
+
106
+ unless user["emailVerified"]
107
+ user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
108
+ end
109
+
110
+ session = ctx.context.internal_adapter.create_session(user["id"])
111
+ redirect_with_error.call("failed_to_create_session") unless session
112
+
113
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
114
+ unless query.key?(:callback_url)
115
+ next ctx.json({
116
+ token: session["token"],
117
+ user: Schema.parse_output(ctx.context.options, "user", user),
118
+ session: Schema.parse_output(ctx.context.options, "session", session)
119
+ })
120
+ end
121
+
122
+ raise ctx.redirect(new_user ? new_user_callback_url : callback_url)
123
+ rescue JSON::ParserError, KeyError
124
+ raise ctx.redirect(magic_link_error_url(error_callback_url || "/", "INVALID_TOKEN"))
125
+ end
126
+ end
127
+
128
+ def magic_link_token(email, config)
129
+ generator = config[:generate_token]
130
+ return generator.call(email) if generator.respond_to?(:call)
131
+
132
+ Array.new(32) { [*"a".."z", *"A".."Z"].sample }.join
133
+ end
134
+
135
+ def magic_link_attempts_exceeded?(attempt, config)
136
+ allowed = config[:allowed_attempts]
137
+ return false if allowed.respond_to?(:infinite?) && allowed.infinite?
138
+
139
+ attempt >= allowed.to_i
140
+ end
141
+
142
+ def store_magic_link_token(token, config)
143
+ storage = config[:store_token]
144
+ return Crypto.sha256(token, encoding: :base64url) if storage.to_s == "hashed"
145
+
146
+ if storage.is_a?(Hash) && %w[custom-hasher custom_hasher].include?(storage[:type].to_s)
147
+ hasher = storage[:hash]
148
+ return hasher.call(token) if hasher.respond_to?(:call)
149
+ end
150
+
151
+ token
152
+ end
153
+
154
+ def magic_link_url(ctx, token, body)
155
+ params = {
156
+ token: token,
157
+ callbackURL: body[:callback_url] || "/"
158
+ }
159
+ params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url]
160
+ params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url]
161
+ "#{ctx.context.base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
162
+ end
163
+
164
+ def validate_magic_link_callback!(ctx, value, label)
165
+ return if value.nil? || value.to_s.empty?
166
+ return if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true)
167
+
168
+ raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
169
+ end
170
+
171
+ def magic_link_error_url(url, error)
172
+ uri = URI.parse(url.to_s.empty? ? "/" : url.to_s)
173
+ query = URI.decode_www_form(uri.query.to_s)
174
+ query << ["error", error]
175
+ uri.query = URI.encode_www_form(query)
176
+ uri.to_s
177
+ rescue URI::InvalidURIError
178
+ "/?error=#{URI.encode_www_form_component(error)}"
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ module MCP
8
+ module_function
9
+
10
+ def with_mcp_auth(app, resource_metadata_url:, auth: nil)
11
+ lambda do |env|
12
+ authorization = env["HTTP_AUTHORIZATION"].to_s
13
+ unless authorization.start_with?("Bearer ")
14
+ return unauthorized(resource_metadata_url)
15
+ end
16
+
17
+ session = auth&.api&.get_mcp_session(headers: {"authorization" => authorization})
18
+ return unauthorized(resource_metadata_url) unless session
19
+
20
+ env["better_auth.mcp_session"] = session
21
+
22
+ app.call(env)
23
+ rescue APIError
24
+ unauthorized(resource_metadata_url)
25
+ end
26
+ end
27
+
28
+ def unauthorized(resource_metadata_url)
29
+ [
30
+ 401,
31
+ {
32
+ "www-authenticate" => %(Bearer resource_metadata="#{resource_metadata_url}"),
33
+ "access-control-expose-headers" => "WWW-Authenticate"
34
+ },
35
+ ["unauthorized"]
36
+ ]
37
+ end
38
+ end
39
+
40
+ module_function
41
+
42
+ def mcp(options = {})
43
+ config = {
44
+ login_page: "/login",
45
+ consent_page: "/oauth/consent",
46
+ resource: nil,
47
+ oidc_config: {},
48
+ code_expires_in: 600,
49
+ default_scope: "openid",
50
+ access_token_expires_in: 3600,
51
+ refresh_token_expires_in: 604_800,
52
+ allow_plain_code_challenge_method: true,
53
+ scopes: %w[openid profile email offline_access],
54
+ store: OAuthProtocol.stores
55
+ }.merge(normalize_hash(options))
56
+ config = mcp_normalize_config(config)
57
+
58
+ Plugin.new(
59
+ id: "mcp",
60
+ endpoints: mcp_endpoints(config),
61
+ hooks: {
62
+ after: [
63
+ {
64
+ matcher: ->(_ctx) { true },
65
+ handler: ->(ctx) { mcp_restore_login_prompt(ctx, config) }
66
+ }
67
+ ]
68
+ },
69
+ schema: oidc_provider_schema,
70
+ options: config
71
+ )
72
+ end
73
+
74
+ def mcp_endpoints(config)
75
+ {
76
+ get_mcp_o_auth_config: mcp_oauth_config_endpoint(config),
77
+ get_mcp_protected_resource: mcp_protected_resource_endpoint(config),
78
+ mcp_o_auth_authorize: mcp_authorize_endpoint(config),
79
+ mcp_o_auth_token: mcp_token_endpoint(config),
80
+ mcp_o_auth_user_info: mcp_userinfo_endpoint(config),
81
+ mcp_register: mcp_register_endpoint(config),
82
+ get_mcp_session: mcp_get_session_endpoint(config),
83
+ o_auth_consent: oidc_consent_endpoint(config),
84
+ mcp_jwks: mcp_jwks_endpoint(config)
85
+ }
86
+ end
87
+
88
+ def mcp_oauth_config_endpoint(config)
89
+ Endpoint.new(path: "/.well-known/oauth-authorization-server", method: "GET", metadata: {hide: true}) do |ctx|
90
+ base = OAuthProtocol.endpoint_base(ctx)
91
+ ctx.json({
92
+ issuer: OAuthProtocol.issuer(ctx),
93
+ authorization_endpoint: "#{base}/mcp/authorize",
94
+ token_endpoint: "#{base}/mcp/token",
95
+ userinfo_endpoint: "#{base}/mcp/userinfo",
96
+ jwks_uri: "#{base}/mcp/jwks",
97
+ registration_endpoint: "#{base}/mcp/register",
98
+ scopes_supported: config[:scopes],
99
+ response_types_supported: ["code"],
100
+ response_modes_supported: ["query"],
101
+ grant_types_supported: ["authorization_code", "refresh_token"],
102
+ acr_values_supported: ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"],
103
+ subject_types_supported: ["public"],
104
+ id_token_signing_alg_values_supported: ["RS256", "none"],
105
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
106
+ code_challenge_methods_supported: ["S256"],
107
+ claims_supported: %w[sub iss aud exp nbf iat jti email email_verified name]
108
+ }.merge(config[:oidc_config][:metadata] || {}))
109
+ end
110
+ end
111
+
112
+ def mcp_protected_resource_endpoint(config)
113
+ Endpoint.new(path: "/.well-known/oauth-protected-resource", method: "GET", metadata: {hide: true}) do |ctx|
114
+ origin = OAuthProtocol.origin_for(OAuthProtocol.endpoint_base(ctx))
115
+ ctx.json({
116
+ resource: config[:resource] || origin,
117
+ authorization_servers: [origin],
118
+ jwks_uri: config.dig(:oidc_config, :metadata, :jwks_uri) || "#{OAuthProtocol.endpoint_base(ctx)}/mcp/jwks",
119
+ scopes_supported: config.dig(:oidc_config, :metadata, :scopes_supported) || config[:scopes],
120
+ bearer_methods_supported: ["header"],
121
+ resource_signing_alg_values_supported: ["RS256", "none"]
122
+ })
123
+ end
124
+ end
125
+
126
+ def mcp_register_endpoint(config)
127
+ Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
128
+ mcp_set_cors_headers(ctx)
129
+ ctx.json(
130
+ OAuthProtocol.create_client(
131
+ ctx,
132
+ model: "oauthApplication",
133
+ body: ctx.body,
134
+ default_auth_method: "none",
135
+ store_client_secret: config[:store_client_secret] || "plain"
136
+ ),
137
+ status: 201,
138
+ headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"}
139
+ )
140
+ end
141
+ end
142
+
143
+ def mcp_authorize_endpoint(config)
144
+ Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
145
+ query = OAuthProtocol.stringify_keys(ctx.query)
146
+ session = Routes.current_session(ctx, allow_nil: true)
147
+ unless session
148
+ ctx.set_signed_cookie("oidc_login_prompt", JSON.generate(query), ctx.context.secret, max_age: 600, path: "/", same_site: "lax")
149
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(config[:login_page], query))
150
+ end
151
+
152
+ raise ctx.redirect(mcp_authorization_redirect(ctx, config, query, session))
153
+ end
154
+ end
155
+
156
+ def mcp_restore_login_prompt(ctx, config)
157
+ cookie = ctx.get_signed_cookie("oidc_login_prompt", ctx.context.secret)
158
+ return unless cookie
159
+
160
+ session = ctx.context.new_session
161
+ return unless session && session[:session] && ctx.response_headers["set-cookie"].to_s.include?(ctx.context.auth_cookies[:session_token].name)
162
+
163
+ query = mcp_parse_login_prompt(cookie)
164
+ return unless query
165
+
166
+ ctx.set_cookie("oidc_login_prompt", "", path: "/", max_age: 0)
167
+ ctx.context.set_current_session(session) if ctx.context.respond_to?(:set_current_session)
168
+ [302, ctx.response_headers.merge("location" => mcp_authorization_redirect(ctx, config, query, session)), [""]]
169
+ end
170
+
171
+ def mcp_authorization_redirect(ctx, config, query, session)
172
+ query = OAuthProtocol.stringify_keys(query)
173
+ query["prompt"] = mcp_prompt_without_login(query["prompt"]) if query.key?("prompt")
174
+ prompts = OIDCProvider.parse_prompt(query["prompt"])
175
+ unless query["client_id"]
176
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client")
177
+ end
178
+ unless query["response_type"]
179
+ raise ctx.redirect(OAuthProtocol.redirect_uri_with_params(ctx.context.base_url + "/error", error: "invalid_request", error_description: "response_type is required"))
180
+ end
181
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", query["client_id"])
182
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=invalid_client") unless client
183
+ OAuthProtocol.validate_redirect_uri!(client, query["redirect_uri"])
184
+ client_data = OAuthProtocol.stringify_keys(client)
185
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=client_disabled") if client_data["disabled"]
186
+ unless query["response_type"] == "code"
187
+ raise ctx.redirect("#{ctx.context.base_url}/error?error=unsupported_response_type")
188
+ end
189
+
190
+ scopes = OAuthProtocol.parse_scopes(query["scope"] || config[:default_scope])
191
+ invalid_scopes = scopes.reject { |scope| config[:scopes].include?(scope) }
192
+ unless invalid_scopes.empty?
193
+ 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"])
194
+ raise ctx.redirect(redirect)
195
+ end
196
+ if config[:require_pkce] && (query["code_challenge"].to_s.empty? || query["code_challenge_method"].to_s.empty?)
197
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "pkce is required", state: query["state"])
198
+ raise ctx.redirect(redirect)
199
+ end
200
+ challenge_method = query["code_challenge_method"].to_s
201
+ if challenge_method.empty?
202
+ query["code_challenge_method"] = "plain" if query["code_challenge"]
203
+ elsif !valid_code_challenge_method?(challenge_method, config)
204
+ redirect = OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], error: "invalid_request", error_description: "invalid code_challenge method", state: query["state"])
205
+ raise ctx.redirect(redirect)
206
+ end
207
+
208
+ if prompts.include?("consent")
209
+ consent_code = Crypto.random_string(32)
210
+ config[:store][:consents][consent_code] = {
211
+ query: query,
212
+ session: session,
213
+ client: client,
214
+ scopes: scopes,
215
+ expires_at: Time.now + config[:code_expires_in].to_i
216
+ }
217
+ 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)))
218
+ end
219
+
220
+ code = Crypto.random_string(32)
221
+ OAuthProtocol.store_code(
222
+ config[:store],
223
+ code: code,
224
+ client_id: query["client_id"],
225
+ redirect_uri: query["redirect_uri"],
226
+ session: session,
227
+ scopes: scopes,
228
+ code_challenge: query["code_challenge"],
229
+ code_challenge_method: query["code_challenge_method"]
230
+ )
231
+ OAuthProtocol.redirect_uri_with_params(query["redirect_uri"], code: code, state: query["state"])
232
+ end
233
+
234
+ def mcp_prompt_without_login(value)
235
+ prompts = value.to_s.split(/\s+/).reject(&:empty?)
236
+ prompts.delete("login")
237
+ prompts.join(" ")
238
+ end
239
+
240
+ def mcp_parse_login_prompt(value)
241
+ parsed = JSON.parse(value.to_s)
242
+ parsed.is_a?(Hash) ? parsed : nil
243
+ rescue JSON::ParserError
244
+ nil
245
+ end
246
+
247
+ def mcp_token_endpoint(config)
248
+ Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
249
+ mcp_set_cors_headers(ctx)
250
+ body = OAuthProtocol.stringify_keys(ctx.body)
251
+ client = mcp_authenticate_token_client!(ctx, body, config)
252
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
253
+
254
+ response = case body["grant_type"]
255
+ when OAuthProtocol::AUTH_CODE_GRANT
256
+ client_data = OAuthProtocol.stringify_keys(client)
257
+ if client_data["type"] == "public" && body["code_verifier"].to_s.empty?
258
+ raise APIError.new("BAD_REQUEST", message: "invalid_request")
259
+ end
260
+ code = OAuthProtocol.consume_code!(
261
+ config[:store],
262
+ body["code"],
263
+ client_id: client_data["clientId"],
264
+ redirect_uri: body["redirect_uri"],
265
+ code_verifier: body["code_verifier"]
266
+ )
267
+ OAuthProtocol.issue_tokens(
268
+ ctx,
269
+ config[:store],
270
+ model: "oauthAccessToken",
271
+ client: client,
272
+ session: code[:session],
273
+ scopes: code[:scopes],
274
+ include_refresh: code[:scopes].include?("offline_access"),
275
+ issuer: OAuthProtocol.issuer(ctx),
276
+ access_token_expires_in: config[:access_token_expires_in]
277
+ )
278
+ when OAuthProtocol::REFRESH_GRANT
279
+ OAuthProtocol.refresh_tokens(ctx, config[:store], model: "oauthAccessToken", client: client, refresh_token: body["refresh_token"], scopes: body["scope"], issuer: OAuthProtocol.issuer(ctx), access_token_expires_in: config[:access_token_expires_in])
280
+ else
281
+ raise APIError.new("BAD_REQUEST", message: "unsupported_grant_type")
282
+ end
283
+ ctx.json(response, headers: {"Cache-Control" => "no-store", "Pragma" => "no-cache"})
284
+ end
285
+ end
286
+
287
+ def mcp_userinfo_endpoint(config)
288
+ Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
289
+ ctx.json(OAuthProtocol.userinfo(config[:store], ctx.headers["authorization"]))
290
+ end
291
+ end
292
+
293
+ def mcp_get_session_endpoint(config)
294
+ Endpoint.new(path: "/mcp/get-session", method: "GET") do |ctx|
295
+ authorization = ctx.headers["authorization"].to_s
296
+ token = authorization.start_with?("Bearer ") ? authorization.delete_prefix("Bearer ").strip : ""
297
+ next ctx.json(nil) if token.empty?
298
+
299
+ ctx.json(OAuthProtocol.token_record(config[:store], token))
300
+ end
301
+ end
302
+
303
+ def mcp_jwks_endpoint(config)
304
+ Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
305
+ jwt_config = config[:jwt] || {}
306
+ create_jwk(ctx, jwt_config) if all_jwks(ctx, jwt_config).empty?
307
+ ctx.json({keys: public_jwks(ctx, jwt_config).map { |key| public_jwk(key, jwt_config) }})
308
+ end
309
+ end
310
+
311
+ def mcp_normalize_config(config)
312
+ oidc = normalize_hash(config[:oidc_config] || {})
313
+ merged = config.merge(oidc.except(:metadata))
314
+ merged[:scopes] = (Array(config[:scopes]) + Array(oidc[:scopes])).compact.map(&:to_s).uniq
315
+ merged
316
+ end
317
+
318
+ def mcp_set_cors_headers(ctx)
319
+ ctx.set_header("Access-Control-Allow-Origin", "*")
320
+ ctx.set_header("Access-Control-Allow-Methods", "POST, OPTIONS")
321
+ ctx.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
322
+ ctx.set_header("Access-Control-Max-Age", "86400")
323
+ end
324
+
325
+ def mcp_authenticate_token_client!(ctx, body, config)
326
+ authorization = ctx.headers["authorization"].to_s
327
+ if authorization.start_with?("Basic ") && body["client_id"].to_s.empty?
328
+ return OAuthProtocol.authenticate_client!(ctx, "oauthApplication", store_client_secret: config[:store_client_secret] || "plain")
329
+ end
330
+
331
+ client = OAuthProtocol.find_client(ctx, "oauthApplication", body["client_id"])
332
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client
333
+
334
+ data = OAuthProtocol.stringify_keys(client)
335
+ method = data["tokenEndpointAuthMethod"] || "client_secret_basic"
336
+ if method != "none" && !OAuthProtocol.verify_client_secret(ctx, data["clientSecret"], body["client_secret"], config[:store_client_secret] || "plain")
337
+ raise APIError.new("UNAUTHORIZED", message: "invalid_client")
338
+ end
339
+ client
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ MULTI_SESSION_ERROR_CODES = {
8
+ "INVALID_SESSION_TOKEN" => "Invalid session token"
9
+ }.freeze
10
+
11
+ def multi_session(options = {})
12
+ config = {maximum_sessions: 5}.merge(normalize_hash(options))
13
+
14
+ Plugin.new(
15
+ id: "multi-session",
16
+ endpoints: {
17
+ list_device_sessions: list_device_sessions_endpoint,
18
+ set_active_session: set_active_session_endpoint,
19
+ revoke_device_session: revoke_device_session_endpoint
20
+ },
21
+ hooks: {
22
+ after: [
23
+ {
24
+ matcher: ->(_ctx) { true },
25
+ handler: ->(ctx) { set_multi_session_cookie(ctx, config) }
26
+ },
27
+ {
28
+ matcher: ->(ctx) { ctx.path == "/sign-out" },
29
+ handler: ->(ctx) { clear_multi_session_cookies(ctx) }
30
+ }
31
+ ]
32
+ },
33
+ error_codes: MULTI_SESSION_ERROR_CODES,
34
+ options: config
35
+ )
36
+ end
37
+
38
+ def list_device_sessions_endpoint
39
+ Endpoint.new(path: "/multi-session/list-device-sessions", method: "GET") do |ctx|
40
+ tokens = verified_multi_session_tokens(ctx)
41
+ sessions = ctx.context.internal_adapter.find_sessions(tokens)
42
+ .reject { |entry| entry[:session]["expiresAt"] && entry[:session]["expiresAt"] <= Time.now }
43
+ unique = sessions.each_with_object({}) { |entry, by_user| by_user[entry[:user]["id"]] ||= entry }.values
44
+
45
+ ctx.json(unique.map { |entry| parsed_session(ctx, entry) })
46
+ end
47
+ end
48
+
49
+ def set_active_session_endpoint
50
+ Endpoint.new(path: "/multi-session/set-active", method: "POST") do |ctx|
51
+ token = fetch_value(ctx.body, "sessionToken").to_s
52
+ cookie_name = multi_session_cookie_name(ctx, token)
53
+ unless !token.empty? && ctx.get_signed_cookie(cookie_name, ctx.context.secret)
54
+ raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
55
+ end
56
+
57
+ session = ctx.context.internal_adapter.find_session(token)
58
+ unless session && session[:session]["expiresAt"] > Time.now
59
+ expire_cookie(ctx, cookie_name)
60
+ raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
61
+ end
62
+
63
+ Cookies.set_session_cookie(ctx, session)
64
+ ctx.json(parsed_session(ctx, session))
65
+ end
66
+ end
67
+
68
+ def revoke_device_session_endpoint
69
+ Endpoint.new(path: "/multi-session/revoke", method: "POST") do |ctx|
70
+ current = Routes.current_session(ctx)
71
+ token = fetch_value(ctx.body, "sessionToken").to_s
72
+ cookie_name = multi_session_cookie_name(ctx, token)
73
+ unless !token.empty? && ctx.get_signed_cookie(cookie_name, ctx.context.secret)
74
+ raise APIError.new("UNAUTHORIZED", message: MULTI_SESSION_ERROR_CODES["INVALID_SESSION_TOKEN"])
75
+ end
76
+
77
+ ctx.context.internal_adapter.delete_session(token)
78
+ expire_cookie(ctx, cookie_name)
79
+
80
+ if current && current[:session]["token"] == token
81
+ next_session = ctx.context.internal_adapter
82
+ .find_sessions(verified_multi_session_tokens(ctx).reject { |entry| entry == token })
83
+ .find { |entry| !entry[:session]["expiresAt"] || entry[:session]["expiresAt"] > Time.now }
84
+ if next_session
85
+ Cookies.set_session_cookie(ctx, next_session)
86
+ else
87
+ Cookies.delete_session_cookie(ctx)
88
+ end
89
+ end
90
+
91
+ ctx.json({status: true})
92
+ end
93
+ end
94
+
95
+ def set_multi_session_cookie(ctx, config)
96
+ new_session = ctx.context.new_session
97
+ return unless new_session && new_session[:session]
98
+ set_cookie = ctx.response_headers["set-cookie"].to_s
99
+
100
+ token = new_session[:session]["token"]
101
+ cookie_config = ctx.context.auth_cookies[:session_token]
102
+ cookie_name = multi_session_cookie_name(ctx, token)
103
+ cookies = ctx.cookies
104
+ return unless set_cookie.include?(cookie_config.name)
105
+ return if cookies.key?(cookie_name)
106
+
107
+ deleted_count = 0
108
+ existing_multi_cookie_names = multi_cookie_names(ctx)
109
+ existing_multi_cookie_names.each do |name|
110
+ existing_token = ctx.get_signed_cookie(name, ctx.context.secret)
111
+ next unless existing_token
112
+
113
+ existing_session = ctx.context.internal_adapter.find_session(existing_token)
114
+ next unless existing_session && existing_session[:user]["id"] == new_session[:user]["id"]
115
+
116
+ ctx.context.internal_adapter.delete_session(existing_token)
117
+ expire_cookie(ctx, name)
118
+ deleted_count += 1
119
+ end
120
+
121
+ current_count = existing_multi_cookie_names.length - deleted_count + 1
122
+ return if current_count > config[:maximum_sessions].to_i
123
+
124
+ ctx.set_signed_cookie(cookie_name, token, ctx.context.secret, cookie_config.attributes)
125
+ nil
126
+ end
127
+
128
+ def clear_multi_session_cookies(ctx)
129
+ tokens = []
130
+ multi_cookie_names(ctx).each do |name|
131
+ token = ctx.get_signed_cookie(name, ctx.context.secret)
132
+ next unless token
133
+
134
+ tokens << token if token
135
+ expire_cookie(ctx, canonical_multi_session_cookie_name(name))
136
+ end
137
+ ctx.context.internal_adapter.delete_sessions(tokens) unless tokens.empty?
138
+ nil
139
+ end
140
+
141
+ def verified_multi_session_tokens(ctx)
142
+ multi_cookie_names(ctx).filter_map { |name| ctx.get_signed_cookie(name, ctx.context.secret) }
143
+ end
144
+
145
+ def multi_cookie_names(ctx)
146
+ ctx.cookies.keys.select { |name| multi_session_cookie?(name) }
147
+ end
148
+
149
+ def multi_session_cookie?(name)
150
+ name.to_s.include?("_multi-")
151
+ end
152
+
153
+ def multi_session_cookie_name(ctx, token)
154
+ "#{ctx.context.auth_cookies[:session_token].name}_multi-#{token.to_s.downcase}"
155
+ end
156
+
157
+ def parsed_session(ctx, entry)
158
+ {
159
+ session: Schema.parse_output(ctx.context.options, "session", entry[:session]),
160
+ user: Schema.parse_output(ctx.context.options, "user", entry[:user])
161
+ }
162
+ end
163
+
164
+ def expire_cookie(ctx, name)
165
+ ctx.set_cookie(name, "", ctx.context.auth_cookies[:session_token].attributes.merge(max_age: 0))
166
+ end
167
+
168
+ def canonical_multi_session_cookie_name(name)
169
+ prefix = "__Secure-"
170
+ name.to_s.sub(/\A#{Regexp.escape(prefix)}/i, prefix)
171
+ end
172
+ end
173
+ end