better_auth 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +106 -16
- data/lib/better_auth/adapters/base.rb +49 -0
- data/lib/better_auth/adapters/internal_adapter.rb +439 -0
- data/lib/better_auth/adapters/memory.rb +232 -0
- data/lib/better_auth/adapters/mongodb.rb +369 -0
- data/lib/better_auth/adapters/mssql.rb +42 -0
- data/lib/better_auth/adapters/mysql.rb +33 -0
- data/lib/better_auth/adapters/postgres.rb +17 -0
- data/lib/better_auth/adapters/sql.rb +425 -0
- data/lib/better_auth/adapters/sqlite.rb +20 -0
- data/lib/better_auth/api.rb +226 -0
- data/lib/better_auth/api_error.rb +53 -0
- data/lib/better_auth/auth.rb +42 -0
- data/lib/better_auth/configuration.rb +399 -0
- data/lib/better_auth/context.rb +210 -0
- data/lib/better_auth/cookies.rb +278 -0
- data/lib/better_auth/core.rb +37 -1
- data/lib/better_auth/crypto/jwe.rb +76 -0
- data/lib/better_auth/crypto.rb +191 -0
- data/lib/better_auth/database_hooks.rb +114 -0
- data/lib/better_auth/endpoint.rb +326 -0
- data/lib/better_auth/error.rb +52 -0
- data/lib/better_auth/middleware/origin_check.rb +128 -0
- data/lib/better_auth/password.rb +120 -0
- data/lib/better_auth/plugin.rb +129 -0
- data/lib/better_auth/plugin_context.rb +16 -0
- data/lib/better_auth/plugin_registry.rb +67 -0
- data/lib/better_auth/plugins/access.rb +87 -0
- data/lib/better_auth/plugins/additional_fields.rb +29 -0
- data/lib/better_auth/plugins/admin/schema.rb +28 -0
- data/lib/better_auth/plugins/admin.rb +518 -0
- data/lib/better_auth/plugins/anonymous.rb +198 -0
- data/lib/better_auth/plugins/api_key.rb +16 -0
- data/lib/better_auth/plugins/bearer.rb +128 -0
- data/lib/better_auth/plugins/captcha.rb +159 -0
- data/lib/better_auth/plugins/custom_session.rb +84 -0
- data/lib/better_auth/plugins/device_authorization.rb +302 -0
- data/lib/better_auth/plugins/email_otp.rb +536 -0
- data/lib/better_auth/plugins/expo.rb +88 -0
- data/lib/better_auth/plugins/generic_oauth.rb +780 -0
- data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
- data/lib/better_auth/plugins/jwt.rb +482 -0
- data/lib/better_auth/plugins/last_login_method.rb +92 -0
- data/lib/better_auth/plugins/magic_link.rb +181 -0
- data/lib/better_auth/plugins/mcp.rb +342 -0
- data/lib/better_auth/plugins/multi_session.rb +173 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
- data/lib/better_auth/plugins/oauth_provider.rb +16 -0
- data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
- data/lib/better_auth/plugins/oidc_provider.rb +597 -0
- data/lib/better_auth/plugins/one_tap.rb +154 -0
- data/lib/better_auth/plugins/one_time_token.rb +106 -0
- data/lib/better_auth/plugins/open_api.rb +489 -0
- data/lib/better_auth/plugins/organization/schema.rb +106 -0
- data/lib/better_auth/plugins/organization.rb +990 -0
- data/lib/better_auth/plugins/passkey.rb +16 -0
- data/lib/better_auth/plugins/phone_number.rb +321 -0
- data/lib/better_auth/plugins/scim.rb +16 -0
- data/lib/better_auth/plugins/siwe.rb +242 -0
- data/lib/better_auth/plugins/sso.rb +16 -0
- data/lib/better_auth/plugins/stripe.rb +16 -0
- data/lib/better_auth/plugins/two_factor.rb +514 -0
- data/lib/better_auth/plugins/username.rb +278 -0
- data/lib/better_auth/plugins.rb +46 -0
- data/lib/better_auth/rate_limiter.rb +215 -0
- data/lib/better_auth/request_ip.rb +70 -0
- data/lib/better_auth/router.rb +365 -0
- data/lib/better_auth/routes/account.rb +211 -0
- data/lib/better_auth/routes/email_verification.rb +108 -0
- data/lib/better_auth/routes/error.rb +102 -0
- data/lib/better_auth/routes/ok.rb +15 -0
- data/lib/better_auth/routes/password.rb +164 -0
- data/lib/better_auth/routes/session.rb +137 -0
- data/lib/better_auth/routes/sign_in.rb +90 -0
- data/lib/better_auth/routes/sign_out.rb +15 -0
- data/lib/better_auth/routes/sign_up.rb +145 -0
- data/lib/better_auth/routes/social.rb +188 -0
- data/lib/better_auth/routes/user.rb +193 -0
- data/lib/better_auth/schema/sql.rb +191 -0
- data/lib/better_auth/schema.rb +275 -0
- data/lib/better_auth/session.rb +122 -0
- data/lib/better_auth/session_store.rb +91 -0
- data/lib/better_auth/social_providers/apple.rb +55 -0
- data/lib/better_auth/social_providers/base.rb +67 -0
- data/lib/better_auth/social_providers/discord.rb +59 -0
- data/lib/better_auth/social_providers/github.rb +59 -0
- data/lib/better_auth/social_providers/gitlab.rb +54 -0
- data/lib/better_auth/social_providers/google.rb +65 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
- data/lib/better_auth/social_providers.rb +9 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +87 -2
- metadata +218 -21
- data/.ruby-version +0 -1
- data/.standard.yml +0 -12
- data/.vscode/settings.json +0 -22
- data/AGENTS.md +0 -50
- data/CLAUDE.md +0 -1
- data/CODE_OF_CONDUCT.md +0 -173
- data/CONTRIBUTING.md +0 -187
- data/Gemfile +0 -12
- data/Makefile +0 -207
- data/Rakefile +0 -25
- data/SECURITY.md +0 -28
- data/docker-compose.yml +0 -63
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Plugins
|
|
7
|
+
ANONYMOUS_ERROR_CODES = {
|
|
8
|
+
"INVALID_EMAIL_FORMAT" => "Email was not generated in a valid format",
|
|
9
|
+
"FAILED_TO_CREATE_USER" => "Failed to create user",
|
|
10
|
+
"COULD_NOT_CREATE_SESSION" => "Could not create session",
|
|
11
|
+
"ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY" => "Anonymous users cannot sign in again anonymously",
|
|
12
|
+
"FAILED_TO_DELETE_ANONYMOUS_USER" => "Failed to delete anonymous user",
|
|
13
|
+
"USER_IS_NOT_ANONYMOUS" => "User is not anonymous",
|
|
14
|
+
"DELETE_ANONYMOUS_USER_DISABLED" => "Deleting anonymous users is disabled"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def anonymous(options = {})
|
|
20
|
+
config = normalize_hash(options)
|
|
21
|
+
|
|
22
|
+
Plugin.new(
|
|
23
|
+
id: "anonymous",
|
|
24
|
+
endpoints: {
|
|
25
|
+
sign_in_anonymous: sign_in_anonymous_endpoint(config),
|
|
26
|
+
delete_anonymous_user: delete_anonymous_user_endpoint(config)
|
|
27
|
+
},
|
|
28
|
+
hooks: {
|
|
29
|
+
after: [
|
|
30
|
+
{
|
|
31
|
+
matcher: ->(ctx) { anonymous_link_path?(ctx.path) },
|
|
32
|
+
handler: ->(ctx) { link_anonymous_user(ctx, config) }
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
schema: anonymous_schema(config),
|
|
37
|
+
error_codes: ANONYMOUS_ERROR_CODES,
|
|
38
|
+
options: config
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sign_in_anonymous_endpoint(config)
|
|
43
|
+
Endpoint.new(path: "/sign-in/anonymous", method: "POST") do |ctx|
|
|
44
|
+
existing_session = Session.find_current(ctx, disable_refresh: true)
|
|
45
|
+
if existing_session&.dig(:user, "isAnonymous")
|
|
46
|
+
raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
email = anonymous_email(config)
|
|
50
|
+
name = anonymous_name(ctx, config)
|
|
51
|
+
user = ctx.context.internal_adapter.create_user(
|
|
52
|
+
email: email,
|
|
53
|
+
emailVerified: false,
|
|
54
|
+
isAnonymous: true,
|
|
55
|
+
name: name,
|
|
56
|
+
createdAt: Time.now,
|
|
57
|
+
updatedAt: Time.now
|
|
58
|
+
)
|
|
59
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless user
|
|
60
|
+
|
|
61
|
+
session = ctx.context.internal_adapter.create_session(user["id"])
|
|
62
|
+
raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["COULD_NOT_CREATE_SESSION"]) unless session
|
|
63
|
+
|
|
64
|
+
Cookies.set_session_cookie(ctx, {session: session, user: user})
|
|
65
|
+
ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def delete_anonymous_user_endpoint(config)
|
|
70
|
+
Endpoint.new(path: "/delete-anonymous-user", method: "POST") do |ctx|
|
|
71
|
+
session = Routes.current_session(ctx, sensitive: true)
|
|
72
|
+
|
|
73
|
+
if config[:disable_delete_anonymous_user]
|
|
74
|
+
raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["DELETE_ANONYMOUS_USER_DISABLED"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless session[:user]["isAnonymous"]
|
|
78
|
+
raise APIError.new("FORBIDDEN", message: ANONYMOUS_ERROR_CODES["USER_IS_NOT_ANONYMOUS"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
ctx.context.internal_adapter.delete_user(session[:user]["id"])
|
|
83
|
+
rescue
|
|
84
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_DELETE_ANONYMOUS_USER"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Cookies.delete_session_cookie(ctx)
|
|
88
|
+
ctx.json({success: true})
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def anonymous_schema(config)
|
|
93
|
+
field_name = anonymous_schema_field_name(config) || "is_anonymous"
|
|
94
|
+
{
|
|
95
|
+
user: {
|
|
96
|
+
fields: {
|
|
97
|
+
isAnonymous: {
|
|
98
|
+
type: "boolean",
|
|
99
|
+
required: false,
|
|
100
|
+
input: false,
|
|
101
|
+
default_value: false,
|
|
102
|
+
field_name: field_name
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def anonymous_email(config)
|
|
110
|
+
generator = config[:generate_random_email]
|
|
111
|
+
email = generator.call if generator.respond_to?(:call)
|
|
112
|
+
if email && email != ""
|
|
113
|
+
unless email.is_a?(String) && !email.empty? && Routes::EMAIL_PATTERN.match?(email)
|
|
114
|
+
raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["INVALID_EMAIL_FORMAT"])
|
|
115
|
+
end
|
|
116
|
+
return email
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
id = SecureRandom.hex(16)
|
|
120
|
+
domain = config[:email_domain_name]
|
|
121
|
+
domain ? "temp-#{id}@#{domain}" : "temp@#{id}.com"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def anonymous_name(ctx, config)
|
|
125
|
+
generator = config[:generate_name]
|
|
126
|
+
name = generator.call(ctx) if generator.respond_to?(:call)
|
|
127
|
+
return name if present_string?(name)
|
|
128
|
+
|
|
129
|
+
"Anonymous"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def link_anonymous_user(ctx, config)
|
|
133
|
+
set_cookie = ctx.response_headers["set-cookie"].to_s
|
|
134
|
+
return if set_cookie.empty?
|
|
135
|
+
return unless set_cookie_value(set_cookie, ctx.context.auth_cookies[:session_token].name)
|
|
136
|
+
|
|
137
|
+
anonymous_session = Session.find_current(ctx, disable_refresh: true)
|
|
138
|
+
return unless anonymous_session&.dig(:user, "isAnonymous")
|
|
139
|
+
|
|
140
|
+
new_session = ctx.context.new_session
|
|
141
|
+
return unless new_session && new_session[:user] && new_session[:session]
|
|
142
|
+
|
|
143
|
+
on_link_account = config[:on_link_account]
|
|
144
|
+
if on_link_account.respond_to?(:call)
|
|
145
|
+
on_link_account.call(
|
|
146
|
+
anonymous_user: anonymous_session,
|
|
147
|
+
new_user: new_session,
|
|
148
|
+
ctx: ctx
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
new_user = new_session[:user]
|
|
153
|
+
return if config[:disable_delete_anonymous_user]
|
|
154
|
+
return if new_user["id"] == anonymous_session[:user]["id"]
|
|
155
|
+
return if new_user["isAnonymous"]
|
|
156
|
+
|
|
157
|
+
ctx.context.internal_adapter.delete_user(anonymous_session[:user]["id"])
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def set_cookie_value(set_cookie, name)
|
|
162
|
+
set_cookie.to_s.lines.each do |line|
|
|
163
|
+
cookie_pair = line.split(";", 2).first.to_s.strip
|
|
164
|
+
cookie_name, value = cookie_pair.split("=", 2)
|
|
165
|
+
return value if cookie_name == name && !value.nil?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def anonymous_link_path?(path)
|
|
172
|
+
path.to_s.start_with?(
|
|
173
|
+
"/sign-in",
|
|
174
|
+
"/sign-up",
|
|
175
|
+
"/callback",
|
|
176
|
+
"/oauth2/callback",
|
|
177
|
+
"/magic-link/verify",
|
|
178
|
+
"/email-otp/verify-email",
|
|
179
|
+
"/one-tap/callback",
|
|
180
|
+
"/passkey/verify-authentication",
|
|
181
|
+
"/phone-number/verify"
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def anonymous_schema_field_name(config)
|
|
186
|
+
fields = config.dig(:schema, :user, :fields) || {}
|
|
187
|
+
mapping = fields[:is_anonymous] || fields[:isAnonymous] || fields["isAnonymous"]
|
|
188
|
+
return mapping if mapping.is_a?(String)
|
|
189
|
+
return mapping[:field_name] || mapping[:fieldName] if mapping.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def present_string?(value)
|
|
195
|
+
value.is_a?(String) && !value.empty?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def api_key(*args)
|
|
8
|
+
Kernel.require "better_auth/api_key"
|
|
9
|
+
BetterAuth::Plugins.api_key(*args)
|
|
10
|
+
rescue LoadError => error
|
|
11
|
+
raise if error.path && error.path != "better_auth/api_key"
|
|
12
|
+
|
|
13
|
+
raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Plugins
|
|
7
|
+
BEARER_SCHEME = "bearer "
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def bearer(options = {})
|
|
12
|
+
config = normalize_hash(options)
|
|
13
|
+
|
|
14
|
+
Plugin.new(
|
|
15
|
+
id: "bearer",
|
|
16
|
+
hooks: {
|
|
17
|
+
before: [
|
|
18
|
+
{
|
|
19
|
+
matcher: ->(ctx) { authorization_header(ctx) },
|
|
20
|
+
handler: ->(ctx) { apply_bearer_token(ctx, config) }
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
after: [
|
|
24
|
+
{
|
|
25
|
+
matcher: ->(_ctx) { true },
|
|
26
|
+
handler: ->(ctx) { expose_auth_token(ctx) }
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
options: config
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def authorization_header(ctx)
|
|
35
|
+
ctx.headers["authorization"] || ctx.headers["Authorization"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def apply_bearer_token(ctx, config)
|
|
39
|
+
auth_header = authorization_header(ctx).to_s
|
|
40
|
+
return unless auth_header[0, BEARER_SCHEME.length].to_s.downcase == BEARER_SCHEME
|
|
41
|
+
|
|
42
|
+
token = auth_header[BEARER_SCHEME.length..].to_s.strip
|
|
43
|
+
return if token.empty?
|
|
44
|
+
|
|
45
|
+
signed_token = if token.include?(".")
|
|
46
|
+
normalize_signed_bearer_token(token)
|
|
47
|
+
else
|
|
48
|
+
sign_bearer_token(ctx, token, config)
|
|
49
|
+
end
|
|
50
|
+
return unless signed_token && valid_signed_token?(ctx, signed_token)
|
|
51
|
+
|
|
52
|
+
cookie_name = ctx.context.auth_cookies[:session_token].name
|
|
53
|
+
cookie = [ctx.headers["cookie"], "#{cookie_name}=#{signed_token}"].compact.reject(&:empty?).join("; ")
|
|
54
|
+
{context: {headers: ctx.headers.merge("cookie" => cookie)}}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def sign_bearer_token(ctx, token, config)
|
|
58
|
+
return if config[:require_signature]
|
|
59
|
+
|
|
60
|
+
signature = Crypto.hmac_signature(token, ctx.context.secret, encoding: :base64url)
|
|
61
|
+
"#{token}.#{signature}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_signed_token?(ctx, signed_token)
|
|
65
|
+
payload, signature = signed_token.rpartition(".").values_at(0, 2)
|
|
66
|
+
return false if payload.empty? || signature.empty?
|
|
67
|
+
|
|
68
|
+
Crypto.verify_hmac_signature(payload, signature, ctx.context.secret, encoding: :base64url)
|
|
69
|
+
rescue
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_signed_bearer_token(token)
|
|
74
|
+
token.include?("%") ? safe_decode_bearer_token(token) : safe_decode_bearer_token(safe_encode_bearer_token(token))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def safe_encode_bearer_token(token)
|
|
78
|
+
URI.encode_www_form_component(token.to_s).gsub("+", "%20")
|
|
79
|
+
rescue
|
|
80
|
+
token.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def safe_decode_bearer_token(token)
|
|
84
|
+
token.to_s.gsub(/%[0-9a-fA-F]{2}/) { |encoded| encoded[1, 2].to_i(16).chr }
|
|
85
|
+
rescue
|
|
86
|
+
token.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def bearer_session_cookie(line)
|
|
90
|
+
first, *attributes = line.to_s.split(";").map(&:strip)
|
|
91
|
+
name, value = first.split("=", 2)
|
|
92
|
+
return unless name && value
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: name,
|
|
96
|
+
value: value,
|
|
97
|
+
attributes: attributes.each_with_object({}) do |attribute, result|
|
|
98
|
+
key, attribute_value = attribute.split("=", 2)
|
|
99
|
+
result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def expired_bearer_cookie?(cookie)
|
|
105
|
+
max_age = cookie[:attributes]["max-age"]
|
|
106
|
+
max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def expose_auth_token(ctx)
|
|
110
|
+
set_cookie = ctx.response_headers["set-cookie"].to_s
|
|
111
|
+
token_name = ctx.context.auth_cookies[:session_token].name
|
|
112
|
+
token = set_cookie.lines.filter_map do |line|
|
|
113
|
+
cookie = bearer_session_cookie(line)
|
|
114
|
+
next unless cookie && cookie[:name] == token_name
|
|
115
|
+
next if cookie[:value].empty? || expired_bearer_cookie?(cookie)
|
|
116
|
+
|
|
117
|
+
cookie[:value]
|
|
118
|
+
end.first
|
|
119
|
+
return unless token
|
|
120
|
+
|
|
121
|
+
exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
122
|
+
exposed << "set-auth-token"
|
|
123
|
+
ctx.set_header("set-auth-token", token)
|
|
124
|
+
ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module Plugins
|
|
9
|
+
CAPTCHA_EXTERNAL_ERROR_CODES = {
|
|
10
|
+
"VERIFICATION_FAILED" => "Captcha verification failed",
|
|
11
|
+
"MISSING_RESPONSE" => "Missing CAPTCHA response",
|
|
12
|
+
"UNKNOWN_ERROR" => "Something went wrong"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
CAPTCHA_INTERNAL_ERROR_CODES = {
|
|
16
|
+
"MISSING_SECRET_KEY" => "Missing secret key",
|
|
17
|
+
"SERVICE_UNAVAILABLE" => "CAPTCHA service unavailable"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
CAPTCHA_DEFAULT_ENDPOINTS = [
|
|
21
|
+
"/sign-up/email",
|
|
22
|
+
"/sign-in/email",
|
|
23
|
+
"/request-password-reset"
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
CAPTCHA_SITE_VERIFY_URLS = {
|
|
27
|
+
"cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
28
|
+
"google-recaptcha" => "https://www.google.com/recaptcha/api/siteverify",
|
|
29
|
+
"hcaptcha" => "https://api.hcaptcha.com/siteverify",
|
|
30
|
+
"captchafox" => "https://api.captchafox.com/siteverify"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def captcha(options = {})
|
|
36
|
+
config = normalize_hash(options)
|
|
37
|
+
Plugin.new(
|
|
38
|
+
id: "captcha",
|
|
39
|
+
on_request: lambda do |request, context|
|
|
40
|
+
captcha_on_request(request, context, config)
|
|
41
|
+
end,
|
|
42
|
+
error_codes: CAPTCHA_EXTERNAL_ERROR_CODES,
|
|
43
|
+
options: config
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def captcha_on_request(request, context, config)
|
|
48
|
+
endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints])
|
|
49
|
+
return nil unless endpoints.any? { |endpoint| request.path_info.include?(endpoint.to_s) || request.url.include?(endpoint.to_s) }
|
|
50
|
+
|
|
51
|
+
raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty?
|
|
52
|
+
|
|
53
|
+
response_token = request.get_header("HTTP_X_CAPTCHA_RESPONSE")
|
|
54
|
+
if response_token.to_s.empty?
|
|
55
|
+
return {response: captcha_response(400, "MISSING_RESPONSE", CAPTCHA_EXTERNAL_ERROR_CODES["MISSING_RESPONSE"])}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result = captcha_verify(config, response_token, captcha_remote_ip(request, context))
|
|
59
|
+
return nil if captcha_success?(config, result)
|
|
60
|
+
|
|
61
|
+
{response: captcha_response(403, "VERIFICATION_FAILED", CAPTCHA_EXTERNAL_ERROR_CODES["VERIFICATION_FAILED"])}
|
|
62
|
+
rescue => error
|
|
63
|
+
captcha_log(context, error.message)
|
|
64
|
+
{response: captcha_response(500, "UNKNOWN_ERROR", CAPTCHA_EXTERNAL_ERROR_CODES["UNKNOWN_ERROR"])}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def captcha_verify(config, response_token, remote_ip)
|
|
68
|
+
provider = config[:provider].to_s
|
|
69
|
+
url = config[:site_verify_url_override] || CAPTCHA_SITE_VERIFY_URLS.fetch(provider)
|
|
70
|
+
params = {
|
|
71
|
+
site_verify_url: url,
|
|
72
|
+
secret_key: config[:secret_key],
|
|
73
|
+
captcha_response: response_token,
|
|
74
|
+
remote_ip: remote_ip,
|
|
75
|
+
site_key: config[:site_key],
|
|
76
|
+
min_score: config[:min_score],
|
|
77
|
+
provider: provider
|
|
78
|
+
}
|
|
79
|
+
return captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) if config[:verifier].respond_to?(:call)
|
|
80
|
+
|
|
81
|
+
captcha_http_verify(params)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def captcha_verifier_params(params)
|
|
85
|
+
provider = params.fetch(:provider)
|
|
86
|
+
payload = captcha_payload(provider, params)
|
|
87
|
+
content_type = (provider == "cloudflare-turnstile") ? "application/json" : "application/x-www-form-urlencoded"
|
|
88
|
+
{
|
|
89
|
+
url: params.fetch(:site_verify_url),
|
|
90
|
+
content_type: content_type,
|
|
91
|
+
payload: payload,
|
|
92
|
+
provider: provider
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def captcha_http_verify(params)
|
|
97
|
+
verifier = captcha_verifier_params(params)
|
|
98
|
+
uri = URI.parse(verifier[:url])
|
|
99
|
+
request = Net::HTTP::Post.new(uri)
|
|
100
|
+
request["Content-Type"] = verifier[:content_type]
|
|
101
|
+
request.body = if verifier[:content_type] == "application/json"
|
|
102
|
+
JSON.generate(verifier[:payload])
|
|
103
|
+
else
|
|
104
|
+
URI.encode_www_form(verifier[:payload])
|
|
105
|
+
end
|
|
106
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
107
|
+
raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)
|
|
108
|
+
|
|
109
|
+
JSON.parse(response.body.to_s)
|
|
110
|
+
rescue JSON::ParserError
|
|
111
|
+
raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def captcha_payload(provider, params)
|
|
115
|
+
payload = {
|
|
116
|
+
"secret" => params[:secret_key],
|
|
117
|
+
"response" => params[:captcha_response]
|
|
118
|
+
}
|
|
119
|
+
payload["sitekey"] = params[:site_key] if params[:site_key] && ["hcaptcha", "captchafox"].include?(provider)
|
|
120
|
+
if params[:remote_ip]
|
|
121
|
+
payload[(provider == "captchafox") ? "remoteIp" : "remoteip"] = params[:remote_ip]
|
|
122
|
+
end
|
|
123
|
+
payload
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def captcha_success?(config, result)
|
|
127
|
+
return false unless result && result["success"]
|
|
128
|
+
|
|
129
|
+
if config[:provider].to_s == "google-recaptcha" && result.key?("score")
|
|
130
|
+
return result["score"].to_f >= (config[:min_score] || 0.5).to_f
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def captcha_normalize_verifier_response(value)
|
|
137
|
+
return value.transform_keys(&:to_s) if value.is_a?(Hash)
|
|
138
|
+
|
|
139
|
+
{}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def captcha_response(status, code, message)
|
|
143
|
+
[status, {"content-type" => "application/json"}, [JSON.generate({code: code, message: message})]]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def captcha_remote_ip(request, context)
|
|
147
|
+
RequestIP.client_ip(request, context.options)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def captcha_log(context, message)
|
|
151
|
+
logger = context.logger
|
|
152
|
+
if logger.respond_to?(:call)
|
|
153
|
+
logger.call(:error, message)
|
|
154
|
+
elsif logger.respond_to?(:error)
|
|
155
|
+
logger.error(message)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def custom_session(resolver, options = nil, plugin_options = nil, **keywords)
|
|
8
|
+
config = normalize_hash(plugin_options || {})
|
|
9
|
+
config = config.merge(normalize_hash(options)) if options && !options.key?(:plugins)
|
|
10
|
+
config = config.merge(normalize_hash(keywords))
|
|
11
|
+
|
|
12
|
+
Plugin.new(
|
|
13
|
+
id: "custom-session",
|
|
14
|
+
endpoints: {
|
|
15
|
+
get_session: Endpoint.new(
|
|
16
|
+
path: "/get-session",
|
|
17
|
+
method: "GET",
|
|
18
|
+
query_schema: ->(query) { query || {} },
|
|
19
|
+
metadata: {
|
|
20
|
+
CUSTOM_SESSION: true,
|
|
21
|
+
openapi: {
|
|
22
|
+
description: "Get custom session data",
|
|
23
|
+
responses: {
|
|
24
|
+
"200" => {
|
|
25
|
+
description: "Success",
|
|
26
|
+
content: {
|
|
27
|
+
"application/json" => {
|
|
28
|
+
schema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
nullable: true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
) do |ctx|
|
|
39
|
+
session = Session.find_current(
|
|
40
|
+
ctx,
|
|
41
|
+
disable_cookie_cache: truthy_value?(fetch_value(ctx.query, "disableCookieCache")),
|
|
42
|
+
disable_refresh: truthy_value?(fetch_value(ctx.query, "disableRefresh"))
|
|
43
|
+
)
|
|
44
|
+
next ctx.json(nil) unless session
|
|
45
|
+
|
|
46
|
+
Cookies.set_session_cookie(ctx, session, false) if ctx.response_headers["set-cookie"].to_s.empty?
|
|
47
|
+
ctx.json(resolver.call(Routes.parsed_session_response(ctx, session), ctx))
|
|
48
|
+
end
|
|
49
|
+
},
|
|
50
|
+
hooks: {
|
|
51
|
+
after: [
|
|
52
|
+
{
|
|
53
|
+
matcher: ->(ctx) { ctx.path == "/multi-session/list-device-sessions" && config[:should_mutate_list_device_sessions_endpoint] },
|
|
54
|
+
handler: lambda do |ctx|
|
|
55
|
+
list = Array(ctx.returned)
|
|
56
|
+
ctx.json(list.map { |entry| resolver.call(symbolize_session(entry), ctx) })
|
|
57
|
+
end
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
options: config
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def truthy_value?(value)
|
|
66
|
+
value == true || value.to_s == "true"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def symbolize_session(entry)
|
|
70
|
+
data = stringify_keys(entry)
|
|
71
|
+
{
|
|
72
|
+
session: data["session"],
|
|
73
|
+
user: data["user"]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stringify_keys(value)
|
|
78
|
+
return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
|
|
79
|
+
return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|