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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/lib/better_auth/plugins/sso.rb +10 -1877
- data/lib/better_auth/sso/plugin/core.rb +322 -0
- data/lib/better_auth/sso/plugin/endpoints.rb +153 -0
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +36 -0
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +502 -0
- data/lib/better_auth/sso/plugin/provider_utils.rb +241 -0
- data/lib/better_auth/sso/plugin/providers.rb +133 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +378 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +167 -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 +130 -0
- data/lib/better_auth/sso/routes/schemas.rb +1 -1
- data/lib/better_auth/sso/saml.rb +1 -1
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +18 -7
|
@@ -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
|