better_auth-sso 0.7.0 → 0.10.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,502 @@
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 = sso_provider_config_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
+ decoded_state = sso_decode_state(state, ctx.context.secret)
27
+ nonce = decoded_state&.fetch("nonce", nil)
28
+ query[:nonce] = nonce if nonce && !nonce.to_s.empty?
29
+ login_hint = body[:login_hint] || body[:email]
30
+ query[:login_hint] = login_hint if login_hint
31
+ code_challenge = decoded_state&.fetch("codeChallenge", nil)
32
+ if code_challenge
33
+ query[:code_challenge] = code_challenge
34
+ query[:code_challenge_method] = "S256"
35
+ end
36
+ "#{endpoint}?#{URI.encode_www_form(query)}"
37
+ end
38
+
39
+ def sso_saml_authorization_url(provider, relay_state, ctx = nil, config = {})
40
+ auth_request_url = config.dig(:saml, :auth_request_url)
41
+ if auth_request_url.respond_to?(:call)
42
+ return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
43
+ end
44
+
45
+ config = sso_provider_config_hash(provider["samlConfig"])
46
+ metadata = sso_saml_idp_metadata(config)
47
+ entry_point = config[:entry_point] || normalize_hash(sso_saml_preferred_service(metadata[:single_sign_on_service]) || {})[:location]
48
+ query = {
49
+ SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
50
+ RelayState: relay_state
51
+ }
52
+ "#{entry_point}?#{URI.encode_www_form(query)}"
53
+ end
54
+
55
+ def sso_store_saml_authn_request(ctx, provider, url, config)
56
+ return if config.dig(:saml, :enable_in_response_to_validation) == false
57
+
58
+ request_id = sso_extract_saml_request_id(url)
59
+ return if request_id.to_s.empty?
60
+
61
+ ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
62
+ now_ms = (Time.now.to_f * 1000).to_i
63
+ expires_at_ms = now_ms + ttl_ms
64
+ record = {
65
+ id: request_id,
66
+ providerId: provider.fetch("providerId"),
67
+ createdAt: now_ms,
68
+ expiresAt: expires_at_ms
69
+ }
70
+ ctx.context.internal_adapter.create_verification_value(
71
+ identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
72
+ value: JSON.generate(record),
73
+ expiresAt: Time.at(expires_at_ms / 1000.0)
74
+ )
75
+ end
76
+
77
+ def sso_extract_saml_request_id(url)
78
+ query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
79
+ encoded = query["SAMLRequest"]
80
+ return nil if encoded.to_s.empty?
81
+
82
+ xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
83
+ xml[/\bID=['"]([^'"]+)['"]/, 1]
84
+ rescue
85
+ nil
86
+ end
87
+
88
+ def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
89
+ return nil if config.dig(:saml, :enable_in_response_to_validation) == false
90
+
91
+ in_response_to = sso_extract_saml_in_response_to(raw_response)
92
+ if in_response_to && !in_response_to.empty?
93
+ identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
94
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
95
+ record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
96
+ if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
97
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
98
+ end
99
+
100
+ if record["providerId"] != provider.fetch("providerId")
101
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
102
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
103
+ end
104
+
105
+ return {identifier: identifier}
106
+ elsif config.dig(:saml, :allow_idp_initiated) == false
107
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
108
+ end
109
+
110
+ nil
111
+ end
112
+
113
+ def sso_consume_saml_in_response_to(ctx, result)
114
+ identifier = result.is_a?(Hash) ? result[:identifier] : nil
115
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier) unless identifier.to_s.empty?
116
+ end
117
+
118
+ def sso_parse_saml_authn_request_record(value)
119
+ JSON.parse(value.to_s)
120
+ rescue
121
+ nil
122
+ end
123
+
124
+ def sso_saml_assertion_replay_expires_at(assertion, config = {})
125
+ timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
126
+ parsed = Time.parse(timestamp.to_s) if timestamp
127
+ clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
128
+ return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
129
+
130
+ ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
131
+ Time.now + (ttl_ms / 1000.0)
132
+ rescue
133
+ Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
134
+ end
135
+
136
+ def sso_extract_saml_in_response_to(raw_response)
137
+ xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
138
+ xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
139
+ rescue
140
+ nil
141
+ end
142
+
143
+ def sso_select_provider(ctx, body, config = {})
144
+ provider_id = body[:provider_id].to_s
145
+ issuer = body[:issuer].to_s
146
+ organization_slug = body[:organization_slug].to_s
147
+ domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
148
+ if config[:default_sso]
149
+ provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
150
+ return provider if provider
151
+ end
152
+
153
+ providers = ctx.context.adapter.find_many(model: "ssoProvider")
154
+ provider = if !provider_id.empty?
155
+ providers.find { |entry| entry["providerId"] == provider_id }
156
+ elsif !issuer.empty?
157
+ providers.find { |entry| entry["issuer"] == issuer }
158
+ elsif !organization_slug.empty?
159
+ organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
160
+ providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
161
+ elsif !domain.empty?
162
+ providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
163
+ providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
164
+ end
165
+ raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
166
+
167
+ provider
168
+ end
169
+
170
+ def sso_callback_provider(ctx, config, provider_id)
171
+ if config[:default_sso]
172
+ provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
173
+ return provider if provider
174
+ end
175
+
176
+ ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
177
+ end
178
+
179
+ def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config, raw_state: nil)
180
+ code_verifier = sso_oidc_code_verifier(ctx, raw_state || state["state"] || state[:state])
181
+ token_callback = oidc_config[:get_token]
182
+ if token_callback.respond_to?(:call)
183
+ return normalize_hash(token_callback.call(
184
+ code: ctx.query[:code] || ctx.query["code"],
185
+ codeVerifier: code_verifier,
186
+ redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
187
+ provider: provider,
188
+ context: ctx
189
+ ))
190
+ end
191
+
192
+ token_endpoint = oidc_config[:token_endpoint]
193
+ return nil if token_endpoint.to_s.empty?
194
+
195
+ sso_exchange_oidc_code(
196
+ token_endpoint: token_endpoint,
197
+ code: ctx.query[:code] || ctx.query["code"],
198
+ code_verifier: code_verifier,
199
+ redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
200
+ client_id: oidc_config[:client_id],
201
+ client_secret: oidc_config[:client_secret],
202
+ authentication: oidc_config[:token_endpoint_authentication],
203
+ timeout: plugin_config[:oidc_http_timeout],
204
+ max_body_size: plugin_config[:oidc_http_max_body_size]
205
+ )
206
+ rescue
207
+ nil
208
+ end
209
+
210
+ def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:, timeout: nil, max_body_size: nil)
211
+ uri = URI(token_endpoint.to_s)
212
+ request = Net::HTTP::Post.new(uri)
213
+ form = {
214
+ grant_type: "authorization_code",
215
+ code: code,
216
+ redirect_uri: redirect_uri,
217
+ client_id: client_id,
218
+ code_verifier: code_verifier
219
+ }.compact
220
+ if authentication.to_s == "client_secret_post"
221
+ form[:client_secret] = client_secret
222
+ elsif client_secret.to_s != ""
223
+ request.basic_auth(client_id.to_s, client_secret.to_s)
224
+ end
225
+ request.set_form_data(form)
226
+ response = Net::HTTP.start(
227
+ uri.hostname,
228
+ uri.port,
229
+ use_ssl: uri.scheme == "https",
230
+ open_timeout: sso_oidc_http_timeout(timeout),
231
+ read_timeout: sso_oidc_http_timeout(timeout)
232
+ ) { |http| http.request(request) }
233
+ return nil unless response.is_a?(Net::HTTPSuccess)
234
+ return nil if response.body.to_s.bytesize > sso_oidc_http_max_body_size(max_body_size)
235
+
236
+ normalize_hash(JSON.parse(response.body))
237
+ end
238
+
239
+ def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config, expected_nonce: nil)
240
+ user_callback = oidc_config[:get_user_info]
241
+ raw = if user_callback.respond_to?(:call)
242
+ user_callback.call(tokens)
243
+ elsif oidc_config[:user_info_endpoint]
244
+ sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token], timeout: plugin_config[:oidc_http_timeout], max_body_size: plugin_config[:oidc_http_max_body_size])
245
+ elsif tokens[:id_token]
246
+ return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
247
+
248
+ sso_validate_oidc_id_token(
249
+ tokens[:id_token],
250
+ jwks_endpoint: oidc_config[:jwks_endpoint],
251
+ audience: oidc_config[:client_id],
252
+ issuer: oidc_config[:issuer],
253
+ fetch: plugin_config[:oidc_jwks_fetch],
254
+ expected_nonce: expected_nonce
255
+ ) || {_sso_error: "token_not_verified"}
256
+ else
257
+ {}
258
+ end
259
+ raw = normalize_hash(raw || {})
260
+ return raw if raw[:_sso_error]
261
+
262
+ mapping = normalize_hash(oidc_config[:mapping] || {})
263
+ extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
264
+ result[target] = raw[normalize_key(source)] || raw[source.to_s]
265
+ end
266
+ extra_fields.merge(
267
+ id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
268
+ email: raw[normalize_key(mapping[:email] || "email")],
269
+ email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
270
+ name: raw[normalize_key(mapping[:name] || "name")],
271
+ image: raw[normalize_key(mapping[:image] || "picture")]
272
+ )
273
+ end
274
+
275
+ def sso_fetch_oidc_user_info(endpoint, access_token, timeout: nil, max_body_size: nil)
276
+ uri = URI(endpoint.to_s)
277
+ request = Net::HTTP::Get.new(uri)
278
+ request["authorization"] = "Bearer #{access_token}"
279
+ response = Net::HTTP.start(
280
+ uri.hostname,
281
+ uri.port,
282
+ use_ssl: uri.scheme == "https",
283
+ open_timeout: sso_oidc_http_timeout(timeout),
284
+ read_timeout: sso_oidc_http_timeout(timeout)
285
+ ) { |http| http.request(request) }
286
+ return {} unless response.is_a?(Net::HTTPSuccess)
287
+ return {} if response.body.to_s.bytesize > sso_oidc_http_max_body_size(max_body_size)
288
+
289
+ JSON.parse(response.body)
290
+ rescue
291
+ {}
292
+ end
293
+
294
+ def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil, expected_nonce: nil)
295
+ jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
296
+ payload, = ::JWT.decode(
297
+ token.to_s,
298
+ nil,
299
+ true,
300
+ algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
301
+ jwks: jwks,
302
+ aud: audience,
303
+ verify_aud: true,
304
+ iss: issuer,
305
+ verify_iss: true
306
+ )
307
+ if expected_nonce && !expected_nonce.to_s.empty?
308
+ token_nonce = payload["nonce"] || payload[:nonce]
309
+ return nil if token_nonce.to_s.empty?
310
+ return nil unless BetterAuth::Crypto.constant_time_compare(token_nonce.to_s, expected_nonce.to_s)
311
+ end
312
+ payload
313
+ rescue
314
+ nil
315
+ end
316
+
317
+ def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
318
+ if fetch.respond_to?(:call)
319
+ return normalize_hash(fetch.call(jwks_endpoint))
320
+ end
321
+
322
+ uri = URI(jwks_endpoint.to_s)
323
+ response = Net::HTTP.start(
324
+ uri.hostname,
325
+ uri.port,
326
+ use_ssl: uri.scheme == "https",
327
+ open_timeout: SSO_DEFAULT_OIDC_HTTP_TIMEOUT,
328
+ read_timeout: SSO_DEFAULT_OIDC_HTTP_TIMEOUT
329
+ ) { |http| http.get(uri.request_uri) }
330
+ return {} unless response.is_a?(Net::HTTPSuccess)
331
+ return {} if response.body.to_s.bytesize > SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
332
+
333
+ normalize_hash(JSON.parse(response.body))
334
+ rescue
335
+ {}
336
+ end
337
+
338
+ def sso_decode_jwt_payload(token)
339
+ payload = token.to_s.split(".")[1]
340
+ return {} unless payload
341
+
342
+ JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
343
+ rescue
344
+ {}
345
+ end
346
+
347
+ def sso_append_error(url, error, description = nil)
348
+ separator = url.to_s.include?("?") ? "&" : "?"
349
+ query = {error: error, error_description: description}.compact
350
+ "#{url}#{separator}#{URI.encode_www_form(query)}"
351
+ end
352
+
353
+ def sso_default_provider(config, provider_id:, domain:)
354
+ Array(config[:default_sso]).each do |raw_provider|
355
+ default_provider = normalize_hash(raw_provider)
356
+ next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
357
+ next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
358
+
359
+ oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
360
+ saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
361
+ return {
362
+ "issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
363
+ "providerId" => default_provider.fetch(:provider_id),
364
+ "userId" => "default",
365
+ "domain" => default_provider[:domain],
366
+ "domainVerified" => true,
367
+ "oidcConfig" => oidc_config,
368
+ "samlConfig" => saml_config
369
+ }.compact
370
+ end
371
+ nil
372
+ end
373
+
374
+ def sso_oidc_pkce_state(provider)
375
+ return {} unless sso_provider_config_hash(provider["oidcConfig"])[:pkce]
376
+
377
+ verifier = BetterAuth::Crypto.random_string(128)
378
+ {
379
+ codeVerifier: verifier,
380
+ codeChallenge: sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(verifier))
381
+ }
382
+ end
383
+
384
+ def sso_store_oidc_pkce_verifier(ctx, state, verifier)
385
+ ctx.context.internal_adapter.create_verification_value(
386
+ identifier: "#{SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX}#{state}",
387
+ value: verifier,
388
+ expiresAt: Time.now + 600
389
+ )
390
+ end
391
+
392
+ def sso_oidc_code_verifier(ctx, state)
393
+ return nil if state.to_s.empty?
394
+
395
+ identifier = "#{SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX}#{state}"
396
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
397
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier) if verification
398
+ verification&.fetch("value", nil)
399
+ end
400
+
401
+ def sso_oidc_http_timeout(value)
402
+ timeout = value || SSO_DEFAULT_OIDC_HTTP_TIMEOUT
403
+ timeout.to_f.positive? ? timeout.to_f : SSO_DEFAULT_OIDC_HTTP_TIMEOUT
404
+ end
405
+
406
+ def sso_oidc_http_max_body_size(value)
407
+ size = value || SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
408
+ size.to_i.positive? ? size.to_i : SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
409
+ end
410
+
411
+ def sso_decode_state(state, secret)
412
+ BetterAuth::Crypto.verify_jwt(state.to_s, secret)
413
+ rescue
414
+ nil
415
+ end
416
+
417
+ def sso_base64_urlsafe(value)
418
+ Base64.strict_encode64(value).tr("+/", "-_").delete("=")
419
+ end
420
+
421
+ def sso_storage_config(config)
422
+ normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
423
+ result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
424
+ end
425
+ end
426
+
427
+ def sso_provider_limit(user, config)
428
+ limit = config[:providers_limit]
429
+ limit = 10 if limit.nil?
430
+ limit.respond_to?(:call) ? limit.call(user) : limit
431
+ end
432
+
433
+ def sso_validate_url!(value, message)
434
+ uri = URI(value.to_s)
435
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
436
+ raise APIError.new("BAD_REQUEST", message: message)
437
+ end
438
+ rescue URI::InvalidURIError
439
+ raise APIError.new("BAD_REQUEST", message: message)
440
+ end
441
+
442
+ def sso_validate_organization_membership!(ctx, user_id, organization_id)
443
+ member = ctx.context.adapter.find_one(
444
+ model: "member",
445
+ where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
446
+ )
447
+ raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
448
+ end
449
+
450
+ def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
451
+ existing = oidc_config.merge(issuer: issuer)
452
+ discovered = sso_discover_oidc_config(
453
+ issuer: issuer,
454
+ existing_config: existing,
455
+ fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
456
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) },
457
+ timeout: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_http_timeout, nil)
458
+ )
459
+ existing.merge(discovered)
460
+ end
461
+
462
+ def sso_oidc_needs_runtime_discovery?(oidc_config)
463
+ config = normalize_hash(oidc_config || {})
464
+ config[:authorization_endpoint].to_s.empty? ||
465
+ config[:token_endpoint].to_s.empty?
466
+ end
467
+
468
+ def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
469
+ oidc_config = sso_provider_config_hash(provider["oidcConfig"])
470
+ needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
471
+ return provider if !needs_discovery
472
+
473
+ discovered = sso_discover_oidc_config(
474
+ issuer: provider.fetch("issuer"),
475
+ existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
476
+ fetch: plugin_config[:oidc_discovery_fetch],
477
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) },
478
+ timeout: plugin_config[:oidc_http_timeout]
479
+ )
480
+ provider.merge("oidcConfig" => oidc_config.merge(discovered))
481
+ end
482
+
483
+ def sso_validate_oidc_endpoint_origins!(ctx, oidc_config)
484
+ return unless sso_oidc_trusted_origin_enforced?(ctx)
485
+
486
+ config = normalize_hash(oidc_config || {})
487
+ %i[authorization_endpoint token_endpoint jwks_endpoint user_info_endpoint discovery_endpoint].each do |field|
488
+ url = config[field]
489
+ next if url.to_s.empty?
490
+
491
+ sso_validate_url!(url, "OIDC #{Schema.storage_key(field)} must be a valid URL")
492
+ next if ctx.context.trusted_origin?(url.to_s, allow_relative_paths: false)
493
+
494
+ raise APIError.new("BAD_REQUEST", message: "OIDC #{Schema.storage_key(field)} is not trusted")
495
+ end
496
+ end
497
+
498
+ def sso_oidc_trusted_origin_enforced?(ctx)
499
+ Array(ctx.context.trusted_origins).map(&:to_s).uniq.length > 1
500
+ end
501
+ end
502
+ end