better_auth-sso 0.6.2 → 0.8.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 +10 -0
- data/README.md +36 -2
- data/lib/better_auth/plugins/sso.rb +10 -1766
- data/lib/better_auth/sso/linking/org_assignment.rb +0 -3
- data/lib/better_auth/sso/plugin/core.rb +139 -0
- data/lib/better_auth/sso/plugin/endpoints.rb +151 -0
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +75 -0
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +420 -0
- data/lib/better_auth/sso/plugin/provider_utils.rb +216 -0
- data/lib/better_auth/sso/plugin/providers.rb +131 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +352 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +150 -0
- data/lib/better_auth/sso/plugin/saml_validation_and_state.rb +183 -0
- data/lib/better_auth/sso/plugin/sign_in_and_oidc_callbacks.rb +125 -0
- data/lib/better_auth/sso/routes/schemas.rb +14 -8
- data/lib/better_auth/sso/routes/sso.rb +1 -1
- data/lib/better_auth/sso/saml_state.rb +1 -1
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +27 -2
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def sso_verify_state(value, secret)
|
|
8
|
+
BetterAuth::Crypto.verify_jwt(value.to_s, secret)
|
|
9
|
+
rescue
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
|
|
14
|
+
config = normalize_hash(provider["oidcConfig"] || {})
|
|
15
|
+
endpoint = config[:authorization_endpoint] || config[:authorization_url]
|
|
16
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
|
|
17
|
+
|
|
18
|
+
scopes = Array(body[:scopes] || config[:scopes] || config[:scope] || ["openid", "email", "profile", "offline_access"])
|
|
19
|
+
query = {
|
|
20
|
+
client_id: config[:client_id],
|
|
21
|
+
response_type: "code",
|
|
22
|
+
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
23
|
+
scope: scopes.join(" "),
|
|
24
|
+
state: state
|
|
25
|
+
}.compact
|
|
26
|
+
nonce = sso_decode_state(state, ctx.context.secret)&.fetch("nonce", nil)
|
|
27
|
+
query[:nonce] = nonce if nonce && !nonce.to_s.empty?
|
|
28
|
+
login_hint = body[:login_hint] || body[:email]
|
|
29
|
+
query[:login_hint] = login_hint if login_hint
|
|
30
|
+
code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
|
|
31
|
+
if code_verifier
|
|
32
|
+
query[:code_challenge] = sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(code_verifier))
|
|
33
|
+
query[:code_challenge_method] = "S256"
|
|
34
|
+
end
|
|
35
|
+
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sso_saml_authorization_url(provider, relay_state, ctx = nil, config = {})
|
|
39
|
+
auth_request_url = config.dig(:saml, :auth_request_url)
|
|
40
|
+
if auth_request_url.respond_to?(:call)
|
|
41
|
+
return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
config = normalize_hash(provider["samlConfig"] || {})
|
|
45
|
+
metadata = sso_saml_idp_metadata(config)
|
|
46
|
+
entry_point = config[:entry_point] || normalize_hash(sso_saml_preferred_service(metadata[:single_sign_on_service]) || {})[:location]
|
|
47
|
+
query = {
|
|
48
|
+
SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
|
|
49
|
+
RelayState: relay_state
|
|
50
|
+
}
|
|
51
|
+
"#{entry_point}?#{URI.encode_www_form(query)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sso_store_saml_authn_request(ctx, provider, url, config)
|
|
55
|
+
return if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
56
|
+
|
|
57
|
+
request_id = sso_extract_saml_request_id(url)
|
|
58
|
+
return if request_id.to_s.empty?
|
|
59
|
+
|
|
60
|
+
ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
|
|
61
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
62
|
+
expires_at_ms = now_ms + ttl_ms
|
|
63
|
+
record = {
|
|
64
|
+
id: request_id,
|
|
65
|
+
providerId: provider.fetch("providerId"),
|
|
66
|
+
createdAt: now_ms,
|
|
67
|
+
expiresAt: expires_at_ms
|
|
68
|
+
}
|
|
69
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
70
|
+
identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
|
|
71
|
+
value: JSON.generate(record),
|
|
72
|
+
expiresAt: Time.at(expires_at_ms / 1000.0)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sso_extract_saml_request_id(url)
|
|
77
|
+
query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
|
|
78
|
+
encoded = query["SAMLRequest"]
|
|
79
|
+
return nil if encoded.to_s.empty?
|
|
80
|
+
|
|
81
|
+
xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
|
|
82
|
+
xml[/\bID=['"]([^'"]+)['"]/, 1]
|
|
83
|
+
rescue
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
88
|
+
return nil if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
89
|
+
|
|
90
|
+
in_response_to = sso_extract_saml_in_response_to(raw_response)
|
|
91
|
+
if in_response_to && !in_response_to.empty?
|
|
92
|
+
identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
|
|
93
|
+
verification = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
94
|
+
record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
|
|
95
|
+
if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
|
|
96
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if record["providerId"] != provider.fetch("providerId")
|
|
100
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
101
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
105
|
+
elsif config.dig(:saml, :allow_idp_initiated) == false
|
|
106
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sso_parse_saml_authn_request_record(value)
|
|
113
|
+
JSON.parse(value.to_s)
|
|
114
|
+
rescue
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def sso_saml_assertion_replay_expires_at(assertion, config = {})
|
|
119
|
+
timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
|
|
120
|
+
parsed = Time.parse(timestamp.to_s) if timestamp
|
|
121
|
+
clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
|
|
122
|
+
return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
|
|
123
|
+
|
|
124
|
+
ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
|
|
125
|
+
Time.now + (ttl_ms / 1000.0)
|
|
126
|
+
rescue
|
|
127
|
+
Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sso_extract_saml_in_response_to(raw_response)
|
|
131
|
+
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
132
|
+
xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
|
|
133
|
+
rescue
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sso_select_provider(ctx, body, config = {})
|
|
138
|
+
provider_id = body[:provider_id].to_s
|
|
139
|
+
issuer = body[:issuer].to_s
|
|
140
|
+
organization_slug = body[:organization_slug].to_s
|
|
141
|
+
domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
|
|
142
|
+
if config[:default_sso]
|
|
143
|
+
provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
|
|
144
|
+
return provider if provider
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
providers = ctx.context.adapter.find_many(model: "ssoProvider")
|
|
148
|
+
provider = if !provider_id.empty?
|
|
149
|
+
providers.find { |entry| entry["providerId"] == provider_id }
|
|
150
|
+
elsif !issuer.empty?
|
|
151
|
+
providers.find { |entry| entry["issuer"] == issuer }
|
|
152
|
+
elsif !organization_slug.empty?
|
|
153
|
+
organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
|
|
154
|
+
providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
|
|
155
|
+
elsif !domain.empty?
|
|
156
|
+
providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
|
|
157
|
+
providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
|
|
158
|
+
end
|
|
159
|
+
raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
|
|
160
|
+
|
|
161
|
+
provider
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def sso_callback_provider(ctx, config, provider_id)
|
|
165
|
+
if config[:default_sso]
|
|
166
|
+
provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
|
|
167
|
+
return provider if provider
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config)
|
|
174
|
+
token_callback = oidc_config[:get_token]
|
|
175
|
+
if token_callback.respond_to?(:call)
|
|
176
|
+
return normalize_hash(token_callback.call(
|
|
177
|
+
code: ctx.query[:code] || ctx.query["code"],
|
|
178
|
+
codeVerifier: state["codeVerifier"],
|
|
179
|
+
redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
180
|
+
provider: provider,
|
|
181
|
+
context: ctx
|
|
182
|
+
))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
token_endpoint = oidc_config[:token_endpoint]
|
|
186
|
+
return nil if token_endpoint.to_s.empty?
|
|
187
|
+
|
|
188
|
+
sso_exchange_oidc_code(
|
|
189
|
+
token_endpoint: token_endpoint,
|
|
190
|
+
code: ctx.query[:code] || ctx.query["code"],
|
|
191
|
+
code_verifier: state["codeVerifier"],
|
|
192
|
+
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
193
|
+
client_id: oidc_config[:client_id],
|
|
194
|
+
client_secret: oidc_config[:client_secret],
|
|
195
|
+
authentication: oidc_config[:token_endpoint_authentication]
|
|
196
|
+
)
|
|
197
|
+
rescue
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:)
|
|
202
|
+
uri = URI(token_endpoint.to_s)
|
|
203
|
+
request = Net::HTTP::Post.new(uri)
|
|
204
|
+
form = {
|
|
205
|
+
grant_type: "authorization_code",
|
|
206
|
+
code: code,
|
|
207
|
+
redirect_uri: redirect_uri,
|
|
208
|
+
client_id: client_id,
|
|
209
|
+
code_verifier: code_verifier
|
|
210
|
+
}.compact
|
|
211
|
+
if authentication.to_s == "client_secret_post"
|
|
212
|
+
form[:client_secret] = client_secret
|
|
213
|
+
elsif client_secret.to_s != ""
|
|
214
|
+
request.basic_auth(client_id.to_s, client_secret.to_s)
|
|
215
|
+
end
|
|
216
|
+
request.set_form_data(form)
|
|
217
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
218
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
219
|
+
|
|
220
|
+
normalize_hash(JSON.parse(response.body))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config, expected_nonce: nil)
|
|
224
|
+
user_callback = oidc_config[:get_user_info]
|
|
225
|
+
raw = if user_callback.respond_to?(:call)
|
|
226
|
+
user_callback.call(tokens)
|
|
227
|
+
elsif oidc_config[:user_info_endpoint]
|
|
228
|
+
sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token])
|
|
229
|
+
elsif tokens[:id_token]
|
|
230
|
+
return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
|
|
231
|
+
|
|
232
|
+
sso_validate_oidc_id_token(
|
|
233
|
+
tokens[:id_token],
|
|
234
|
+
jwks_endpoint: oidc_config[:jwks_endpoint],
|
|
235
|
+
audience: oidc_config[:client_id],
|
|
236
|
+
issuer: oidc_config[:issuer],
|
|
237
|
+
fetch: plugin_config[:oidc_jwks_fetch],
|
|
238
|
+
expected_nonce: expected_nonce
|
|
239
|
+
) || {_sso_error: "token_not_verified"}
|
|
240
|
+
else
|
|
241
|
+
{}
|
|
242
|
+
end
|
|
243
|
+
raw = normalize_hash(raw || {})
|
|
244
|
+
return raw if raw[:_sso_error]
|
|
245
|
+
|
|
246
|
+
mapping = normalize_hash(oidc_config[:mapping] || {})
|
|
247
|
+
extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
|
|
248
|
+
result[target] = raw[normalize_key(source)] || raw[source.to_s]
|
|
249
|
+
end
|
|
250
|
+
extra_fields.merge(
|
|
251
|
+
id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
|
|
252
|
+
email: raw[normalize_key(mapping[:email] || "email")],
|
|
253
|
+
email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
|
|
254
|
+
name: raw[normalize_key(mapping[:name] || "name")],
|
|
255
|
+
image: raw[normalize_key(mapping[:image] || "picture")]
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def sso_fetch_oidc_user_info(endpoint, access_token)
|
|
260
|
+
uri = URI(endpoint.to_s)
|
|
261
|
+
request = Net::HTTP::Get.new(uri)
|
|
262
|
+
request["authorization"] = "Bearer #{access_token}"
|
|
263
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
264
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
265
|
+
|
|
266
|
+
JSON.parse(response.body)
|
|
267
|
+
rescue
|
|
268
|
+
{}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil, expected_nonce: nil)
|
|
272
|
+
jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
|
|
273
|
+
payload, = ::JWT.decode(
|
|
274
|
+
token.to_s,
|
|
275
|
+
nil,
|
|
276
|
+
true,
|
|
277
|
+
algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
|
|
278
|
+
jwks: jwks,
|
|
279
|
+
aud: audience,
|
|
280
|
+
verify_aud: true,
|
|
281
|
+
iss: issuer,
|
|
282
|
+
verify_iss: true
|
|
283
|
+
)
|
|
284
|
+
if expected_nonce && !expected_nonce.to_s.empty?
|
|
285
|
+
token_nonce = payload["nonce"] || payload[:nonce]
|
|
286
|
+
return nil if token_nonce.to_s.empty?
|
|
287
|
+
return nil unless BetterAuth::Crypto.constant_time_compare(token_nonce.to_s, expected_nonce.to_s)
|
|
288
|
+
end
|
|
289
|
+
payload
|
|
290
|
+
rescue
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
|
|
295
|
+
if fetch.respond_to?(:call)
|
|
296
|
+
return normalize_hash(fetch.call(jwks_endpoint))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
uri = URI(jwks_endpoint.to_s)
|
|
300
|
+
response = Net::HTTP.get_response(uri)
|
|
301
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
302
|
+
|
|
303
|
+
normalize_hash(JSON.parse(response.body))
|
|
304
|
+
rescue
|
|
305
|
+
{}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def sso_decode_jwt_payload(token)
|
|
309
|
+
payload = token.to_s.split(".")[1]
|
|
310
|
+
return {} unless payload
|
|
311
|
+
|
|
312
|
+
JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
|
|
313
|
+
rescue
|
|
314
|
+
{}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def sso_append_error(url, error, description = nil)
|
|
318
|
+
separator = url.to_s.include?("?") ? "&" : "?"
|
|
319
|
+
query = {error: error, error_description: description}.compact
|
|
320
|
+
"#{url}#{separator}#{URI.encode_www_form(query)}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def sso_default_provider(config, provider_id:, domain:)
|
|
324
|
+
Array(config[:default_sso]).each do |raw_provider|
|
|
325
|
+
default_provider = normalize_hash(raw_provider)
|
|
326
|
+
next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
|
|
327
|
+
next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
|
|
328
|
+
|
|
329
|
+
oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
|
|
330
|
+
saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
|
|
331
|
+
return {
|
|
332
|
+
"issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
|
|
333
|
+
"providerId" => default_provider.fetch(:provider_id),
|
|
334
|
+
"userId" => "default",
|
|
335
|
+
"domain" => default_provider[:domain],
|
|
336
|
+
"domainVerified" => true,
|
|
337
|
+
"oidcConfig" => oidc_config,
|
|
338
|
+
"samlConfig" => saml_config
|
|
339
|
+
}.compact
|
|
340
|
+
end
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def sso_oidc_pkce_state(provider)
|
|
345
|
+
return {} unless normalize_hash(provider["oidcConfig"] || {})[:pkce]
|
|
346
|
+
|
|
347
|
+
{codeVerifier: BetterAuth::Crypto.random_string(128)}
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def sso_decode_state(state, secret)
|
|
351
|
+
BetterAuth::Crypto.verify_jwt(state.to_s, secret)
|
|
352
|
+
rescue
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def sso_base64_urlsafe(value)
|
|
357
|
+
Base64.strict_encode64(value).tr("+/", "-_").delete("=")
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def sso_storage_config(config)
|
|
361
|
+
normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
|
|
362
|
+
result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def sso_provider_limit(user, config)
|
|
367
|
+
limit = config[:providers_limit]
|
|
368
|
+
limit = 10 if limit.nil?
|
|
369
|
+
limit.respond_to?(:call) ? limit.call(user) : limit
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def sso_validate_url!(value, message)
|
|
373
|
+
uri = URI(value.to_s)
|
|
374
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
375
|
+
raise APIError.new("BAD_REQUEST", message: message)
|
|
376
|
+
end
|
|
377
|
+
rescue URI::InvalidURIError
|
|
378
|
+
raise APIError.new("BAD_REQUEST", message: message)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def sso_validate_organization_membership!(ctx, user_id, organization_id)
|
|
382
|
+
member = ctx.context.adapter.find_one(
|
|
383
|
+
model: "member",
|
|
384
|
+
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
385
|
+
)
|
|
386
|
+
raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
|
|
390
|
+
existing = oidc_config.merge(issuer: issuer)
|
|
391
|
+
discovered = sso_discover_oidc_config(
|
|
392
|
+
issuer: issuer,
|
|
393
|
+
existing_config: existing,
|
|
394
|
+
fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
|
|
395
|
+
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
396
|
+
)
|
|
397
|
+
existing.merge(discovered)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def sso_oidc_needs_runtime_discovery?(oidc_config)
|
|
401
|
+
config = normalize_hash(oidc_config || {})
|
|
402
|
+
config[:authorization_endpoint].to_s.empty? ||
|
|
403
|
+
config[:token_endpoint].to_s.empty?
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
|
|
407
|
+
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
408
|
+
needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
|
|
409
|
+
return provider if !needs_discovery
|
|
410
|
+
|
|
411
|
+
discovered = sso_discover_oidc_config(
|
|
412
|
+
issuer: provider.fetch("issuer"),
|
|
413
|
+
existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
|
|
414
|
+
fetch: plugin_config[:oidc_discovery_fetch],
|
|
415
|
+
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
416
|
+
)
|
|
417
|
+
provider.merge("oidcConfig" => oidc_config.merge(discovered))
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def sso_oidc_redirect_uri(context, provider_id)
|
|
8
|
+
redirect_uri = context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:redirect_uri, nil)
|
|
9
|
+
if redirect_uri && !redirect_uri.to_s.strip.empty?
|
|
10
|
+
value = redirect_uri.to_s
|
|
11
|
+
return value if URI(value).absolute?
|
|
12
|
+
|
|
13
|
+
path = value.start_with?("/") ? value : "/#{value}"
|
|
14
|
+
return "#{context.base_url}#{path}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
18
|
+
rescue URI::InvalidURIError
|
|
19
|
+
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sso_email_domain_matches?(email_domain, provider_domain)
|
|
23
|
+
email_domain = email_domain.to_s.strip.downcase
|
|
24
|
+
email_domain = email_domain.split("@", 2).last if email_domain.include?("@")
|
|
25
|
+
return false if email_domain.to_s.empty?
|
|
26
|
+
|
|
27
|
+
provider_domain.to_s.split(",").map { |value| value.strip.downcase }.reject(&:empty?).any? do |domain|
|
|
28
|
+
email_domain == domain || email_domain.end_with?(".#{domain}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sso_find_provider!(ctx, provider_id)
|
|
33
|
+
provider = ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
34
|
+
raise APIError.new("NOT_FOUND", message: "Provider not found", code: "PROVIDER_NOT_FOUND") unless provider
|
|
35
|
+
|
|
36
|
+
provider
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sso_provider_access?(provider, user_id, ctx)
|
|
40
|
+
organization_id = provider["organizationId"]
|
|
41
|
+
return provider["userId"] == user_id if organization_id.to_s.empty?
|
|
42
|
+
return provider["userId"] == user_id unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
|
|
43
|
+
|
|
44
|
+
member = ctx.context.adapter.find_one(
|
|
45
|
+
model: "member",
|
|
46
|
+
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
47
|
+
)
|
|
48
|
+
Array(member&.fetch("role", nil).to_s.split(",")).map(&:strip).any? { |role| %w[owner admin].include?(role) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sso_authorize_domain_verification!(ctx, provider, user_id)
|
|
52
|
+
organization_id = provider["organizationId"]
|
|
53
|
+
is_org_member = true
|
|
54
|
+
if organization_id
|
|
55
|
+
is_org_member = !!ctx.context.adapter.find_one(
|
|
56
|
+
model: "member",
|
|
57
|
+
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
return if provider["userId"] == user_id && is_org_member
|
|
61
|
+
|
|
62
|
+
raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "INSUFFICIENT_ACCESS")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sso_txt_record_exact_match?(records, expected)
|
|
66
|
+
Array(records).flatten.any? { |record| record.to_s.strip == expected.to_s }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sso_domain_verification_identifier(config, provider_id)
|
|
70
|
+
prefix = config.dig(:domain_verification, :token_prefix) || "better-auth-token"
|
|
71
|
+
"_#{prefix}-#{provider_id}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sso_future_time?(value)
|
|
75
|
+
time = value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
76
|
+
time > Time.now
|
|
77
|
+
rescue
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sso_hostname_from_domain(domain)
|
|
82
|
+
value = domain.to_s.strip
|
|
83
|
+
return nil if value.empty?
|
|
84
|
+
|
|
85
|
+
uri = URI(value.include?("://") ? value : "https://#{value}")
|
|
86
|
+
uri.host
|
|
87
|
+
rescue URI::InvalidURIError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sso_resolve_txt_records(hostname, config)
|
|
92
|
+
resolver = config.dig(:domain_verification, :dns_txt_resolver)
|
|
93
|
+
return Array(resolver.call(hostname)) if resolver.respond_to?(:call)
|
|
94
|
+
|
|
95
|
+
Resolv::DNS.open do |dns|
|
|
96
|
+
dns.getresources(hostname, Resolv::DNS::Resource::IN::TXT).map { |record| record.strings }
|
|
97
|
+
end
|
|
98
|
+
rescue
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sso_sanitize_provider(provider, context)
|
|
103
|
+
data = provider.dup
|
|
104
|
+
oidc_config = sso_provider_config_hash(data["oidcConfig"])
|
|
105
|
+
saml_config = sso_provider_config_hash(data["samlConfig"])
|
|
106
|
+
data["type"] = saml_config.empty? ? "oidc" : "saml"
|
|
107
|
+
data["organizationId"] ||= nil
|
|
108
|
+
data["domainVerified"] = !!data["domainVerified"]
|
|
109
|
+
data.delete("domainVerified") unless sso_context_domain_verification_enabled?(context)
|
|
110
|
+
data["oidcConfig"] = oidc_config.empty? ? nil : sso_sanitize_oidc_config(oidc_config)
|
|
111
|
+
data["samlConfig"] = saml_config.empty? ? nil : sso_sanitize_saml_config(saml_config)
|
|
112
|
+
data["spMetadataUrl"] = "#{context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(data.fetch("providerId"))}"
|
|
113
|
+
data.compact
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def sso_provider_config_hash(value)
|
|
117
|
+
return normalize_hash(value) if value.is_a?(Hash)
|
|
118
|
+
return {} if value.nil? || value.to_s.strip.empty?
|
|
119
|
+
|
|
120
|
+
parsed = JSON.parse(value.to_s)
|
|
121
|
+
normalize_hash(parsed)
|
|
122
|
+
rescue JSON::ParserError, TypeError
|
|
123
|
+
{}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def sso_context_domain_verification_enabled?(context)
|
|
127
|
+
context.options.plugins.any? do |plugin|
|
|
128
|
+
plugin.id == "sso" && plugin.options.dig(:domain_verification, :enabled)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def sso_sanitize_config(config)
|
|
133
|
+
data = normalize_hash(config || {})
|
|
134
|
+
data.delete(:client_secret)
|
|
135
|
+
data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value unless value.respond_to?(:call) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def sso_sanitize_oidc_config(config)
|
|
139
|
+
{
|
|
140
|
+
"clientIdLastFour" => sso_mask_client_id(config[:client_id]),
|
|
141
|
+
"authorizationEndpoint" => config[:authorization_endpoint],
|
|
142
|
+
"tokenEndpoint" => config[:token_endpoint],
|
|
143
|
+
"userInfoEndpoint" => config[:user_info_endpoint],
|
|
144
|
+
"jwksEndpoint" => config[:jwks_endpoint],
|
|
145
|
+
"scopes" => config[:scopes],
|
|
146
|
+
"tokenEndpointAuthentication" => config[:token_endpoint_authentication],
|
|
147
|
+
"pkce" => config[:pkce],
|
|
148
|
+
"discoveryEndpoint" => config[:discovery_endpoint],
|
|
149
|
+
"mapping" => config[:mapping] && sso_sanitize_config(config[:mapping])
|
|
150
|
+
}.compact
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def sso_sanitize_saml_config(config)
|
|
154
|
+
{
|
|
155
|
+
"entryPoint" => config[:entry_point],
|
|
156
|
+
"callbackUrl" => config[:callback_url],
|
|
157
|
+
"audience" => config[:audience],
|
|
158
|
+
"wantAssertionsSigned" => config[:want_assertions_signed],
|
|
159
|
+
"authnRequestsSigned" => config[:authn_requests_signed],
|
|
160
|
+
"identifierFormat" => config[:identifier_format],
|
|
161
|
+
"signatureAlgorithm" => config[:signature_algorithm],
|
|
162
|
+
"digestAlgorithm" => config[:digest_algorithm],
|
|
163
|
+
"certificate" => sso_parse_certificate(config[:cert]),
|
|
164
|
+
"idpMetadata" => sso_sanitize_saml_metadata_config(config[:idp_metadata]),
|
|
165
|
+
"spMetadata" => sso_sanitize_saml_metadata_config(config[:sp_metadata]),
|
|
166
|
+
"mapping" => config[:mapping] && sso_sanitize_config(config[:mapping])
|
|
167
|
+
}.compact
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def sso_sanitize_saml_metadata_config(metadata)
|
|
171
|
+
data = normalize_hash(metadata || {})
|
|
172
|
+
return nil if data.empty?
|
|
173
|
+
|
|
174
|
+
data.except(:private_key, :private_key_pass, :enc_private_key, :enc_private_key_pass, :decryption_pvk).each_with_object({}) do |(key, value), result|
|
|
175
|
+
result[(key == :entity_id) ? "entityID" : Schema.storage_key(key)] = value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def sso_mask_client_id(client_id)
|
|
180
|
+
value = client_id.to_s
|
|
181
|
+
return "****" if value.length <= 4
|
|
182
|
+
|
|
183
|
+
"****#{value[-4, 4]}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def sso_parse_certificate(cert)
|
|
187
|
+
OpenSSL::X509::Certificate.new(cert.to_s)
|
|
188
|
+
{subject: cert.to_s.lines.first.to_s.strip}
|
|
189
|
+
rescue
|
|
190
|
+
{error: "Failed to parse certificate"}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def sso_fetch(data, key)
|
|
194
|
+
return nil unless data.respond_to?(:[])
|
|
195
|
+
|
|
196
|
+
compact = key.to_s.delete("_").downcase
|
|
197
|
+
direct = data[key] ||
|
|
198
|
+
data[key.to_s] ||
|
|
199
|
+
data[Schema.storage_key(key)] ||
|
|
200
|
+
data[Schema.storage_key(key).to_sym] ||
|
|
201
|
+
data[compact] ||
|
|
202
|
+
data[compact.to_sym]
|
|
203
|
+
return direct unless direct.nil?
|
|
204
|
+
|
|
205
|
+
data.each do |candidate, value|
|
|
206
|
+
normalized = candidate.to_s.delete("_").downcase
|
|
207
|
+
return value if normalized == compact
|
|
208
|
+
end
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def sso_redirect(ctx, location)
|
|
213
|
+
[302, ctx.response_headers.merge("location" => location), [""]]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|