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,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module Plugins
|
|
9
|
+
HAVE_I_BEEN_PWNED_ERROR_CODES = {
|
|
10
|
+
"PASSWORD_COMPROMISED" => "The password you entered has been compromised. Please choose a different password."
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
HAVE_I_BEEN_PWNED_DEFAULT_PATHS = [
|
|
14
|
+
"/sign-up/email",
|
|
15
|
+
"/change-password",
|
|
16
|
+
"/reset-password"
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def have_i_been_pwned(options = {})
|
|
22
|
+
config = normalize_hash(options)
|
|
23
|
+
config[:paths] = Array(config[:paths]).empty? ? HAVE_I_BEEN_PWNED_DEFAULT_PATHS : Array(config[:paths])
|
|
24
|
+
|
|
25
|
+
Plugin.new(
|
|
26
|
+
id: "have-i-been-pwned",
|
|
27
|
+
init: ->(context) { have_i_been_pwned_wrap_password_hasher!(context, config) },
|
|
28
|
+
error_codes: HAVE_I_BEEN_PWNED_ERROR_CODES,
|
|
29
|
+
options: config
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def have_i_been_pwned_wrap_password_hasher!(context, config)
|
|
34
|
+
email_config = context.options.email_and_password
|
|
35
|
+
password_config = email_config[:password] ||= {}
|
|
36
|
+
original_hasher = password_config[:hash]
|
|
37
|
+
algorithm = context.options.password_hasher
|
|
38
|
+
password_config[:hash] = lambda do |password, hash_ctx = nil|
|
|
39
|
+
if config[:enabled] != false && hash_ctx && config[:paths].include?(hash_ctx.path)
|
|
40
|
+
have_i_been_pwned_check_password!(password, config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if original_hasher.respond_to?(:call)
|
|
44
|
+
arity = original_hasher.arity
|
|
45
|
+
return original_hasher.call(password, hash_ctx) if arity != 1 && arity != -1
|
|
46
|
+
|
|
47
|
+
return original_hasher.call(password)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Password.hash(password, algorithm: algorithm)
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def have_i_been_pwned_check_password!(password, config)
|
|
56
|
+
return if password.to_s.empty?
|
|
57
|
+
|
|
58
|
+
hash = OpenSSL::Digest.hexdigest("SHA1", password.to_s).upcase
|
|
59
|
+
prefix = hash[0, 5]
|
|
60
|
+
suffix = hash[5..]
|
|
61
|
+
data = if config[:range_lookup].respond_to?(:call)
|
|
62
|
+
config[:range_lookup].call(prefix)
|
|
63
|
+
else
|
|
64
|
+
have_i_been_pwned_range_lookup(prefix)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
found = data.to_s.lines.any? { |line| line.split(":").first.to_s.upcase == suffix }
|
|
68
|
+
return unless found
|
|
69
|
+
|
|
70
|
+
raise APIError.new(
|
|
71
|
+
"BAD_REQUEST",
|
|
72
|
+
message: config[:custom_password_compromised_message] || HAVE_I_BEEN_PWNED_ERROR_CODES["PASSWORD_COMPROMISED"],
|
|
73
|
+
code: "PASSWORD_COMPROMISED"
|
|
74
|
+
)
|
|
75
|
+
rescue APIError
|
|
76
|
+
raise
|
|
77
|
+
rescue
|
|
78
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Please try again later.")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def have_i_been_pwned_range_lookup(prefix)
|
|
82
|
+
uri = URI.parse("https://api.pwnedpasswords.com/range/#{prefix}")
|
|
83
|
+
request = Net::HTTP::Get.new(uri)
|
|
84
|
+
request["Add-Padding"] = "true"
|
|
85
|
+
request["User-Agent"] = "BetterAuth Password Checker"
|
|
86
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
87
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
response.body.to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module Plugins
|
|
9
|
+
module JWT
|
|
10
|
+
SUPPORTED_ALGORITHMS = %w[EdDSA RS256 PS256 ES256 ES512].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def public_key(jwk)
|
|
15
|
+
data = stringify_jwk(jwk)
|
|
16
|
+
return OpenSSL::PKey.read(data["pem"] || data["publicKey"]) if data["pem"] || data["publicKey"]
|
|
17
|
+
|
|
18
|
+
if data["kty"] == "RSA" && data["n"] && data["e"]
|
|
19
|
+
rsa_from_components(data["n"], data["e"])
|
|
20
|
+
elsif data["kty"] == "OKP" && data["crv"] == "Ed25519" && data["x"]
|
|
21
|
+
OpenSSL::PKey.new_raw_public_key("ED25519", Crypto.base64url_decode(data["x"]))
|
|
22
|
+
else
|
|
23
|
+
raise OpenSSL::PKey::PKeyError, "Unsupported JWK"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rsa_from_components(n, e)
|
|
28
|
+
sequence = OpenSSL::ASN1::Sequence([
|
|
29
|
+
OpenSSL::ASN1::Integer(OpenSSL::BN.new(Crypto.base64url_decode(n).unpack1("H*"), 16)),
|
|
30
|
+
OpenSSL::ASN1::Integer(OpenSSL::BN.new(Crypto.base64url_decode(e).unpack1("H*"), 16))
|
|
31
|
+
])
|
|
32
|
+
OpenSSL::PKey::RSA.new(sequence.to_der)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stringify_jwk(value)
|
|
36
|
+
value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = object_value } if value.is_a?(Hash)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def jwt(options = {})
|
|
43
|
+
config = normalize_hash(options)
|
|
44
|
+
validate_jwt_options!(config)
|
|
45
|
+
jwks_path = config.dig(:jwks, :jwks_path) || "/jwks"
|
|
46
|
+
|
|
47
|
+
Plugin.new(
|
|
48
|
+
id: "jwt",
|
|
49
|
+
endpoints: {
|
|
50
|
+
get_jwks: get_jwks_endpoint(config, jwks_path),
|
|
51
|
+
get_token: get_token_endpoint(config),
|
|
52
|
+
sign_jwt: sign_jwt_endpoint(config),
|
|
53
|
+
verify_jwt: verify_jwt_endpoint(config)
|
|
54
|
+
},
|
|
55
|
+
hooks: {
|
|
56
|
+
after: [
|
|
57
|
+
{
|
|
58
|
+
matcher: ->(ctx) { ctx.path == "/get-session" },
|
|
59
|
+
handler: ->(ctx) { set_jwt_header(ctx, config) }
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
schema: {
|
|
64
|
+
jwks: {
|
|
65
|
+
fields: {
|
|
66
|
+
publicKey: {type: "string", required: true},
|
|
67
|
+
privateKey: {type: "string", required: true},
|
|
68
|
+
createdAt: {type: "date", required: true},
|
|
69
|
+
expiresAt: {type: "date", required: false},
|
|
70
|
+
alg: {type: "string", required: false},
|
|
71
|
+
kty: {type: "string", required: false},
|
|
72
|
+
crv: {type: "string", required: false},
|
|
73
|
+
x: {type: "string", required: false},
|
|
74
|
+
y: {type: "string", required: false},
|
|
75
|
+
pem: {type: "string", required: false},
|
|
76
|
+
n: {type: "string", required: false},
|
|
77
|
+
e: {type: "string", required: false}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
options: config
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_jwt_options!(config)
|
|
86
|
+
alg = config.dig(:jwks, :key_pair_config, :alg)
|
|
87
|
+
if alg && !JWT::SUPPORTED_ALGORITHMS.include?(alg.to_s)
|
|
88
|
+
raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server. Supported algorithms: #{JWT::SUPPORTED_ALGORITHMS.join(", ")}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if config.dig(:jwt, :sign) && !config.dig(:jwks, :remote_url)
|
|
92
|
+
raise Error, "options.jwks.remoteUrl must be set when using options.jwt.sign"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if config.dig(:jwks, :remote_url) && !config.dig(:jwks, :key_pair_config, :alg)
|
|
96
|
+
raise Error, "options.jwks.keyPairConfig.alg must be specified when using the oidc plugin with options.jwks.remoteUrl"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
path = config.dig(:jwks, :jwks_path)
|
|
100
|
+
if path && (!path.is_a?(String) || path.empty? || !path.start_with?("/") || path.include?(".."))
|
|
101
|
+
raise Error, "options.jwks.jwksPath must be a non-empty string starting with '/' and not contain '.."
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def get_jwks_endpoint(config, path)
|
|
106
|
+
Endpoint.new(path: path, method: "GET") do |ctx|
|
|
107
|
+
raise APIError.new("NOT_FOUND") if config.dig(:jwks, :remote_url)
|
|
108
|
+
|
|
109
|
+
create_jwk(ctx, config) if all_jwks(ctx, config).empty?
|
|
110
|
+
ctx.json({keys: public_jwks(ctx, config).map { |key| public_jwk(key, config) }})
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def get_token_endpoint(config)
|
|
115
|
+
Endpoint.new(path: "/token", method: "GET") do |ctx|
|
|
116
|
+
session = Session.find_current(ctx)
|
|
117
|
+
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"]) unless session
|
|
118
|
+
|
|
119
|
+
ctx.json({token: jwt_token(ctx, session, config)})
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def sign_jwt_endpoint(config)
|
|
124
|
+
Endpoint.new(path: nil, method: "POST") do |ctx|
|
|
125
|
+
payload = fetch_value(ctx.body, "payload") || {}
|
|
126
|
+
override = normalize_hash(fetch_value(ctx.body, "overrideOptions") || {})
|
|
127
|
+
ctx.json({token: sign_jwt_payload(ctx, stringify_payload(payload), deep_merge(config, override))})
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def verify_jwt_endpoint(config)
|
|
132
|
+
Endpoint.new(path: nil, method: "POST") do |ctx|
|
|
133
|
+
token = fetch_value(ctx.body, "token")
|
|
134
|
+
issuer = fetch_value(ctx.body, "issuer")
|
|
135
|
+
verify_options = issuer ? deep_merge(config, jwt: {issuer: issuer}) : config
|
|
136
|
+
ctx.json({payload: verify_jwt_token(ctx, token, verify_options)})
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def set_jwt_header(ctx, config)
|
|
141
|
+
return if config[:disable_setting_jwt_header]
|
|
142
|
+
|
|
143
|
+
session = ctx.context.current_session || ctx.context.new_session
|
|
144
|
+
return unless session && session[:session]
|
|
145
|
+
|
|
146
|
+
token = jwt_token(ctx, session, config)
|
|
147
|
+
exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
148
|
+
exposed << "set-auth-jwt"
|
|
149
|
+
ctx.set_header("set-auth-jwt", token)
|
|
150
|
+
ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def jwt_token(ctx, session, config)
|
|
155
|
+
jwt_config = config[:jwt] || {}
|
|
156
|
+
payload = if jwt_config[:define_payload].respond_to?(:call)
|
|
157
|
+
jwt_config[:define_payload].call(session)
|
|
158
|
+
else
|
|
159
|
+
session[:user]
|
|
160
|
+
end
|
|
161
|
+
subject = if jwt_config[:get_subject].respond_to?(:call)
|
|
162
|
+
jwt_config[:get_subject].call(session)
|
|
163
|
+
else
|
|
164
|
+
session[:user]["id"]
|
|
165
|
+
end
|
|
166
|
+
sign_jwt_payload(ctx, stringify_payload(payload).merge("sub" => subject), config)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def sign_jwt_payload(ctx, payload, config)
|
|
170
|
+
jwt_config = config[:jwt] || {}
|
|
171
|
+
now = Time.now.to_i
|
|
172
|
+
payload = stringify_payload(payload).dup
|
|
173
|
+
payload["iat"] ||= now
|
|
174
|
+
payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
|
|
175
|
+
payload["iss"] ||= jwt_config[:issuer] || ctx.context.base_url
|
|
176
|
+
payload["aud"] ||= jwt_config[:audience] || ctx.context.base_url
|
|
177
|
+
|
|
178
|
+
return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
|
|
179
|
+
|
|
180
|
+
key = signing_jwk(ctx, config)
|
|
181
|
+
private_key = OpenSSL::PKey.read(jwk_private_key_value(ctx, key, config))
|
|
182
|
+
alg = key["alg"] || "RS256"
|
|
183
|
+
return encode_eddsa_jwt(payload, private_key, key["id"]) if alg == "EdDSA"
|
|
184
|
+
|
|
185
|
+
::JWT.encode(payload, private_key, alg, kid: key["id"])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def verify_jwt_token(ctx, token, config)
|
|
189
|
+
header = ::JWT.decode(token.to_s, nil, false).last
|
|
190
|
+
key = verification_jwks(ctx, config).find { |entry| entry["id"] == header["kid"] || entry["kid"] == header["kid"] }
|
|
191
|
+
return nil unless key
|
|
192
|
+
return verify_eddsa_jwt(ctx, token.to_s, key, config) if (key["alg"] || header["alg"]) == "EdDSA"
|
|
193
|
+
|
|
194
|
+
options = {
|
|
195
|
+
algorithm: key["alg"] || "RS256",
|
|
196
|
+
iss: config.dig(:jwt, :issuer) || ctx.context.base_url,
|
|
197
|
+
verify_iss: true,
|
|
198
|
+
aud: config.dig(:jwt, :audience) || ctx.context.base_url,
|
|
199
|
+
verify_aud: true
|
|
200
|
+
}
|
|
201
|
+
decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
|
|
202
|
+
jwt_payload_valid?(decoded) ? decoded : nil
|
|
203
|
+
rescue ::JWT::DecodeError, OpenSSL::PKey::PKeyError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def latest_jwk(ctx, config)
|
|
208
|
+
all_jwks(ctx, config).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def signing_jwk(ctx, config)
|
|
212
|
+
key = latest_jwk(ctx, config)
|
|
213
|
+
return key if key && !jwk_expired?(key)
|
|
214
|
+
|
|
215
|
+
create_jwk(ctx, config)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def public_jwks(ctx, config)
|
|
219
|
+
now = Time.now
|
|
220
|
+
grace_period = config.dig(:jwks, :grace_period) || 60 * 60 * 24 * 30
|
|
221
|
+
all_jwks(ctx, config).select do |key|
|
|
222
|
+
expires_at = normalize_time(key["expiresAt"])
|
|
223
|
+
!expires_at || expires_at + grace_period.to_i > now
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def all_jwks(ctx, config)
|
|
228
|
+
adapter = config[:adapter]
|
|
229
|
+
if adapter && adapter[:get_jwks].respond_to?(:call)
|
|
230
|
+
return Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
ctx.context.adapter.find_many(model: "jwks")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def verification_jwks(ctx, config)
|
|
237
|
+
local = all_jwks(ctx, config)
|
|
238
|
+
return local unless config.dig(:jwks, :remote_url)
|
|
239
|
+
|
|
240
|
+
local + remote_jwks(ctx, config)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def remote_jwks(ctx, config)
|
|
244
|
+
url = config.dig(:jwks, :remote_url)
|
|
245
|
+
fetcher = config.dig(:jwks, :fetch) || config.dig(:jwks, :fetcher)
|
|
246
|
+
payload = if fetcher.respond_to?(:call)
|
|
247
|
+
fetcher.call(url)
|
|
248
|
+
else
|
|
249
|
+
uri = URI.parse(url.to_s)
|
|
250
|
+
response = Net::HTTP.get_response(uri)
|
|
251
|
+
response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
|
|
252
|
+
end
|
|
253
|
+
keys = fetch_value(payload, "keys")
|
|
254
|
+
Array(keys).map { |entry| normalize_remote_jwk(entry) }
|
|
255
|
+
rescue JSON::ParserError, URI::InvalidURIError, SocketError, SystemCallError
|
|
256
|
+
[]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def normalize_remote_jwk(entry)
|
|
260
|
+
data = stringify_payload(entry || {})
|
|
261
|
+
data["id"] ||= data["kid"]
|
|
262
|
+
data["publicKey"] ||= data["pem"]
|
|
263
|
+
data
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def create_jwk(ctx, config)
|
|
267
|
+
adapter = config[:adapter]
|
|
268
|
+
alg = (config.dig(:jwks, :key_pair_config, :alg) || "EdDSA").to_s
|
|
269
|
+
pair = generate_key_pair(alg)
|
|
270
|
+
public_key = public_key_for(pair)
|
|
271
|
+
public_pem = public_key_pem(public_key)
|
|
272
|
+
data = {
|
|
273
|
+
"id" => Crypto.uuid,
|
|
274
|
+
"publicKey" => public_pem,
|
|
275
|
+
"privateKey" => jwk_private_key_for_storage(ctx, private_key_pem(pair), config),
|
|
276
|
+
"createdAt" => Time.now,
|
|
277
|
+
"alg" => alg,
|
|
278
|
+
"pem" => public_pem
|
|
279
|
+
}
|
|
280
|
+
data.merge!(public_key_jwk_fields(public_key, alg))
|
|
281
|
+
data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
|
|
282
|
+
|
|
283
|
+
if adapter && adapter[:create_jwk].respond_to?(:call)
|
|
284
|
+
return stringify_payload(adapter[:create_jwk].call(data, ctx))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def public_jwk(key, _config)
|
|
291
|
+
data = {
|
|
292
|
+
kid: key["id"],
|
|
293
|
+
kty: key["kty"] || key_type_for_alg(key["alg"] || "RS256"),
|
|
294
|
+
alg: key["alg"] || "EdDSA",
|
|
295
|
+
use: "sig",
|
|
296
|
+
pem: key["pem"] || key["publicKey"]
|
|
297
|
+
}
|
|
298
|
+
data[:n] = key["n"] if key["n"]
|
|
299
|
+
data[:e] = key["e"] if key["e"]
|
|
300
|
+
data[:crv] = key["crv"] if key["crv"]
|
|
301
|
+
data[:x] = key["x"] if key["x"]
|
|
302
|
+
data[:y] = key["y"] if key["y"]
|
|
303
|
+
data
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def jwk_private_key_for_storage(ctx, private_key, config)
|
|
307
|
+
return private_key if config.dig(:jwks, :disable_private_key_encryption)
|
|
308
|
+
|
|
309
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret, data: private_key)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def jwk_private_key_value(ctx, key, _config)
|
|
313
|
+
value = key["privateKey"]
|
|
314
|
+
Crypto.symmetric_decrypt(key: ctx.context.secret, data: value) || value
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def jwt_payload_valid?(payload)
|
|
318
|
+
return false if payload["sub"].to_s.empty?
|
|
319
|
+
|
|
320
|
+
audience = payload["aud"]
|
|
321
|
+
return false if audience.nil?
|
|
322
|
+
return false if audience.respond_to?(:empty?) && audience.empty?
|
|
323
|
+
|
|
324
|
+
true
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def generate_key_pair(alg)
|
|
328
|
+
case alg
|
|
329
|
+
when "EdDSA"
|
|
330
|
+
OpenSSL::PKey.generate_key("ED25519")
|
|
331
|
+
when "RS256", "PS256"
|
|
332
|
+
OpenSSL::PKey::RSA.generate(2048)
|
|
333
|
+
when "ES256"
|
|
334
|
+
OpenSSL::PKey::EC.generate("prime256v1")
|
|
335
|
+
when "ES512"
|
|
336
|
+
OpenSSL::PKey::EC.generate("secp521r1")
|
|
337
|
+
else
|
|
338
|
+
raise Error, "JWT/JWKS algorithm #{alg} is not supported by the Ruby server"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def public_key_for(pair)
|
|
343
|
+
OpenSSL::PKey.read(pair.public_to_pem)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def private_key_pem(pair)
|
|
347
|
+
pair.respond_to?(:private_to_pem) ? pair.private_to_pem : pair.to_pem
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def public_key_pem(pair)
|
|
351
|
+
pair.respond_to?(:public_to_pem) ? pair.public_to_pem : pair.to_pem
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def public_key_jwk_fields(public_key, alg)
|
|
355
|
+
if public_key.is_a?(OpenSSL::PKey::RSA)
|
|
356
|
+
{
|
|
357
|
+
"kty" => "RSA",
|
|
358
|
+
"n" => base64url_bn(public_key.n),
|
|
359
|
+
"e" => base64url_bn(public_key.e)
|
|
360
|
+
}
|
|
361
|
+
elsif alg == "EdDSA"
|
|
362
|
+
{
|
|
363
|
+
"kty" => "OKP",
|
|
364
|
+
"crv" => "Ed25519",
|
|
365
|
+
"x" => Crypto.base64url_encode(public_key.raw_public_key)
|
|
366
|
+
}
|
|
367
|
+
else
|
|
368
|
+
point = public_key.public_key.to_octet_string(:uncompressed).bytes
|
|
369
|
+
length = (point.length - 1) / 2
|
|
370
|
+
{
|
|
371
|
+
"kty" => "EC",
|
|
372
|
+
"crv" => ec_curve_for_alg(alg),
|
|
373
|
+
"x" => Crypto.base64url_encode(point[1, length].pack("C*")),
|
|
374
|
+
"y" => Crypto.base64url_encode(point[(1 + length), length].pack("C*"))
|
|
375
|
+
}
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def key_type_for_alg(alg)
|
|
380
|
+
return "OKP" if alg == "EdDSA"
|
|
381
|
+
|
|
382
|
+
alg.to_s.start_with?("ES") ? "EC" : "RSA"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def ec_curve_for_alg(alg)
|
|
386
|
+
(alg == "ES512") ? "P-521" : "P-256"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def encode_eddsa_jwt(payload, private_key, kid)
|
|
390
|
+
header = {"alg" => "EdDSA", "kid" => kid}
|
|
391
|
+
signing_input = [
|
|
392
|
+
Crypto.base64url_encode(JSON.generate(header)),
|
|
393
|
+
Crypto.base64url_encode(JSON.generate(payload))
|
|
394
|
+
].join(".")
|
|
395
|
+
signature = private_key.sign(nil, signing_input)
|
|
396
|
+
"#{signing_input}.#{Crypto.base64url_encode(signature)}"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def verify_eddsa_jwt(ctx, token, key, config)
|
|
400
|
+
header_segment, payload_segment, signature_segment = token.split(".", 3)
|
|
401
|
+
return nil unless header_segment && payload_segment && signature_segment
|
|
402
|
+
|
|
403
|
+
public_key = JWT.public_key(key)
|
|
404
|
+
signing_input = "#{header_segment}.#{payload_segment}"
|
|
405
|
+
signature = Crypto.base64url_decode(signature_segment)
|
|
406
|
+
return nil unless public_key.verify(nil, signature, signing_input)
|
|
407
|
+
|
|
408
|
+
payload = JSON.parse(Crypto.base64url_decode(payload_segment))
|
|
409
|
+
now = Time.now.to_i
|
|
410
|
+
return nil if payload["exp"] && payload["exp"].to_i <= now
|
|
411
|
+
issuer = config.dig(:jwt, :issuer) || ctx.context.base_url
|
|
412
|
+
audience = config.dig(:jwt, :audience) || ctx.context.base_url
|
|
413
|
+
return nil if issuer && payload["iss"] != issuer
|
|
414
|
+
return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
|
|
415
|
+
return nil unless jwt_payload_valid?(payload)
|
|
416
|
+
|
|
417
|
+
payload
|
|
418
|
+
rescue JSON::ParserError, OpenSSL::PKey::PKeyError, ArgumentError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def jwk_expired?(key)
|
|
423
|
+
expires_at = normalize_time(key["expiresAt"])
|
|
424
|
+
expires_at && expires_at < Time.now
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def jwt_expiration(value, iat)
|
|
428
|
+
return value.to_i if value.is_a?(Integer)
|
|
429
|
+
return value.to_i if value.is_a?(Time)
|
|
430
|
+
|
|
431
|
+
iat.to_i + parse_duration(value.to_s)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def parse_duration(value)
|
|
435
|
+
match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i)
|
|
436
|
+
raise TypeError, "Invalid time string" unless match
|
|
437
|
+
|
|
438
|
+
amount = match[1].to_i
|
|
439
|
+
amount = -amount if value.include?("ago")
|
|
440
|
+
unit = match[2].downcase
|
|
441
|
+
multiplier = case unit
|
|
442
|
+
when "s", "sec", "secs", "second", "seconds" then 1
|
|
443
|
+
when "m", "min", "mins", "minute", "minutes" then 60
|
|
444
|
+
when "h", "hr", "hrs", "hour", "hours" then 3600
|
|
445
|
+
when "d", "day", "days" then 86_400
|
|
446
|
+
when "w", "week", "weeks" then 604_800
|
|
447
|
+
else 31_557_600
|
|
448
|
+
end
|
|
449
|
+
amount * multiplier
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def base64url_bn(number)
|
|
453
|
+
hex = number.to_s(16)
|
|
454
|
+
hex = "0#{hex}" if hex.length.odd?
|
|
455
|
+
Crypto.base64url_encode([hex].pack("H*"))
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def deep_merge(base, override)
|
|
459
|
+
normalize_hash(base || {}).merge(normalize_hash(override || {})) do |_key, old_value, new_value|
|
|
460
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
461
|
+
deep_merge(old_value, new_value)
|
|
462
|
+
else
|
|
463
|
+
new_value
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def stringify_payload(value)
|
|
469
|
+
return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_payload(object_value) } if value.is_a?(Hash)
|
|
470
|
+
return value.map { |entry| stringify_payload(entry) } if value.is_a?(Array)
|
|
471
|
+
|
|
472
|
+
value
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def normalize_time(value)
|
|
476
|
+
return value if value.is_a?(Time)
|
|
477
|
+
return nil if value.nil?
|
|
478
|
+
|
|
479
|
+
Time.parse(value.to_s)
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def last_login_method(options = {})
|
|
8
|
+
config = {
|
|
9
|
+
cookie_name: "better-auth.last_used_login_method",
|
|
10
|
+
max_age: 60 * 60 * 24 * 30
|
|
11
|
+
}.merge(normalize_hash(options))
|
|
12
|
+
|
|
13
|
+
Plugin.new(
|
|
14
|
+
id: "last-login-method",
|
|
15
|
+
schema: last_login_method_schema(config),
|
|
16
|
+
hooks: {
|
|
17
|
+
after: [
|
|
18
|
+
{
|
|
19
|
+
matcher: ->(_ctx) { true },
|
|
20
|
+
handler: ->(ctx) { apply_last_login_method(ctx, config) }
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
options: config
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def last_login_method_schema(config)
|
|
29
|
+
return {} unless config[:store_in_database]
|
|
30
|
+
|
|
31
|
+
field_name = config.dig(:schema, :user, :last_login_method) || "lastLoginMethod"
|
|
32
|
+
{
|
|
33
|
+
user: {
|
|
34
|
+
fields: {
|
|
35
|
+
lastLoginMethod: {
|
|
36
|
+
type: "string",
|
|
37
|
+
input: false,
|
|
38
|
+
required: false,
|
|
39
|
+
field_name: field_name
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_last_login_method(ctx, config)
|
|
47
|
+
method = resolve_login_method(ctx, config)
|
|
48
|
+
return unless method
|
|
49
|
+
|
|
50
|
+
set_cookie = ctx.response_headers["set-cookie"].to_s
|
|
51
|
+
return unless set_cookie.include?(ctx.context.auth_cookies[:session_token].name)
|
|
52
|
+
|
|
53
|
+
attributes = ctx.context.auth_cookies[:session_token].attributes.merge(max_age: config[:max_age], http_only: false)
|
|
54
|
+
ctx.set_cookie(config[:cookie_name], method, attributes)
|
|
55
|
+
|
|
56
|
+
if config[:store_in_database] && ctx.context.new_session&.dig(:user, "id")
|
|
57
|
+
updated = ctx.context.internal_adapter.update_user(ctx.context.new_session[:user]["id"], lastLoginMethod: method)
|
|
58
|
+
ctx.context.new_session[:user].merge!(updated) if updated
|
|
59
|
+
end
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_login_method(ctx, config)
|
|
64
|
+
custom = config[:custom_resolve_method]
|
|
65
|
+
resolve_context = ctx
|
|
66
|
+
unless ctx.path
|
|
67
|
+
resolve_context = ctx.dup
|
|
68
|
+
resolve_context.path = ""
|
|
69
|
+
end
|
|
70
|
+
resolved = custom.call(resolve_context) if custom.respond_to?(:call)
|
|
71
|
+
return resolved if resolved
|
|
72
|
+
|
|
73
|
+
path = resolve_context.path.to_s
|
|
74
|
+
case path
|
|
75
|
+
when "/sign-in/email", "/sign-up/email"
|
|
76
|
+
"email"
|
|
77
|
+
when "/callback/:providerId"
|
|
78
|
+
fetch_value(ctx.params, "providerId")
|
|
79
|
+
when "/oauth2/callback/:providerId"
|
|
80
|
+
fetch_value(ctx.params, "providerId")
|
|
81
|
+
else
|
|
82
|
+
return Regexp.last_match(1) if path =~ %r{\A/callback/([^/]+)\z}
|
|
83
|
+
return Regexp.last_match(1) if path =~ %r{\A/oauth2/callback/([^/]+)\z}
|
|
84
|
+
return "siwe" if path.include?("siwe")
|
|
85
|
+
return "passkey" if path.include?("/passkey/verify-authentication")
|
|
86
|
+
return "magic-link" if path.start_with?("/magic-link/verify")
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|