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.
@@ -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