better_auth-sso 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +10 -0
- data/lib/better_auth/plugins/sso.rb +1004 -104
- data/lib/better_auth/sso/saml.rb +52 -13
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +1 -1
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
+
require "cgi"
|
|
4
5
|
require "json"
|
|
6
|
+
require "jwt"
|
|
5
7
|
require "net/http"
|
|
6
8
|
require "openssl"
|
|
9
|
+
require "resolv"
|
|
7
10
|
require "securerandom"
|
|
11
|
+
require "time"
|
|
8
12
|
require "uri"
|
|
13
|
+
require "zlib"
|
|
9
14
|
|
|
10
15
|
module BetterAuth
|
|
11
16
|
module Plugins
|
|
@@ -17,7 +22,12 @@ module BetterAuth
|
|
|
17
22
|
SSO_ERROR_CODES = {
|
|
18
23
|
"PROVIDER_NOT_FOUND" => "No provider found",
|
|
19
24
|
"INVALID_STATE" => "Invalid state",
|
|
20
|
-
"SAML_RESPONSE_REPLAYED" => "SAML response has already been used"
|
|
25
|
+
"SAML_RESPONSE_REPLAYED" => "SAML response has already been used",
|
|
26
|
+
"SINGLE_LOGOUT_NOT_ENABLED" => "Single Logout is not enabled",
|
|
27
|
+
"INVALID_LOGOUT_REQUEST" => "Invalid LogoutRequest",
|
|
28
|
+
"INVALID_LOGOUT_RESPONSE" => "Invalid LogoutResponse",
|
|
29
|
+
"LOGOUT_FAILED_AT_IDP" => "Logout failed at IdP",
|
|
30
|
+
"IDP_SLO_NOT_SUPPORTED" => "IdP does not support Single Logout Service"
|
|
21
31
|
}.freeze
|
|
22
32
|
|
|
23
33
|
SSO_SAML_SIGNATURE_ALGORITHMS = {
|
|
@@ -55,47 +65,70 @@ module BetterAuth
|
|
|
55
65
|
http://www.w3.org/2009/xmlenc11#aes192-gcm
|
|
56
66
|
http://www.w3.org/2009/xmlenc11#aes256-gcm
|
|
57
67
|
].freeze
|
|
68
|
+
SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
|
|
69
|
+
SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
|
|
70
|
+
SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
|
|
71
|
+
SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
72
|
+
SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
|
|
73
|
+
SSO_DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000
|
|
74
|
+
SSO_DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
75
|
+
SSO_SAML_SESSION_KEY_PREFIX = "saml-session:"
|
|
76
|
+
SSO_SAML_SESSION_BY_ID_KEY_PREFIX = "saml-session-by-id:"
|
|
77
|
+
SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
|
|
78
|
+
SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
79
|
+
SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
58
80
|
|
|
59
81
|
def sso(options = {})
|
|
60
82
|
config = normalize_hash(options)
|
|
83
|
+
if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
|
|
84
|
+
config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
|
|
85
|
+
end
|
|
86
|
+
endpoints = {
|
|
87
|
+
sp_metadata: sso_sp_metadata_endpoint(config),
|
|
88
|
+
register_sso_provider: sso_register_provider_endpoint(config),
|
|
89
|
+
sign_in_sso: sso_sign_in_endpoint(config),
|
|
90
|
+
callback_sso: sso_oidc_callback_endpoint(config),
|
|
91
|
+
callback_sso_shared: sso_oidc_shared_callback_endpoint(config),
|
|
92
|
+
callback_sso_saml: sso_saml_callback_endpoint(config),
|
|
93
|
+
acs_endpoint: sso_saml_acs_endpoint(config),
|
|
94
|
+
slo_endpoint: sso_saml_slo_endpoint(config),
|
|
95
|
+
initiate_slo: sso_initiate_slo_endpoint(config),
|
|
96
|
+
list_sso_providers: sso_list_providers_endpoint,
|
|
97
|
+
get_sso_provider: sso_get_provider_endpoint,
|
|
98
|
+
update_sso_provider: sso_update_provider_endpoint,
|
|
99
|
+
delete_sso_provider: sso_delete_provider_endpoint
|
|
100
|
+
}
|
|
101
|
+
if config.dig(:domain_verification, :enabled)
|
|
102
|
+
endpoints[:request_domain_verification] = sso_request_domain_verification_endpoint(config)
|
|
103
|
+
endpoints[:verify_domain] = sso_verify_domain_endpoint(config)
|
|
104
|
+
end
|
|
61
105
|
Plugin.new(
|
|
62
106
|
id: "sso",
|
|
63
|
-
init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs"]}}} },
|
|
107
|
+
init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
|
|
64
108
|
schema: sso_schema(config),
|
|
65
|
-
endpoints:
|
|
66
|
-
sp_metadata: sso_sp_metadata_endpoint,
|
|
67
|
-
register_sso_provider: sso_register_provider_endpoint,
|
|
68
|
-
sign_in_sso: sso_sign_in_endpoint(config),
|
|
69
|
-
callback_sso: sso_oidc_callback_endpoint,
|
|
70
|
-
callback_sso_saml: sso_saml_callback_endpoint(config),
|
|
71
|
-
acs_endpoint: sso_saml_acs_endpoint(config),
|
|
72
|
-
list_sso_providers: sso_list_providers_endpoint,
|
|
73
|
-
get_sso_provider: sso_get_provider_endpoint,
|
|
74
|
-
update_sso_provider: sso_update_provider_endpoint,
|
|
75
|
-
delete_sso_provider: sso_delete_provider_endpoint,
|
|
76
|
-
request_domain_verification: sso_request_domain_verification_endpoint(config),
|
|
77
|
-
verify_domain: sso_verify_domain_endpoint(config)
|
|
78
|
-
},
|
|
109
|
+
endpoints: endpoints,
|
|
79
110
|
error_codes: SSO_ERROR_CODES,
|
|
80
111
|
options: config
|
|
81
112
|
)
|
|
82
113
|
end
|
|
83
114
|
|
|
84
115
|
def sso_schema(config = {})
|
|
116
|
+
fields = {
|
|
117
|
+
issuer: {type: "string", required: true},
|
|
118
|
+
oidcConfig: {type: "string", required: false},
|
|
119
|
+
samlConfig: {type: "string", required: false},
|
|
120
|
+
userId: {type: "string", required: true},
|
|
121
|
+
providerId: {type: "string", required: true, unique: true},
|
|
122
|
+
domain: {type: "string", required: true},
|
|
123
|
+
organizationId: {type: "string", required: false}
|
|
124
|
+
}
|
|
125
|
+
if config.dig(:domain_verification, :enabled)
|
|
126
|
+
fields[:domainVerified] = {type: "boolean", required: false, default_value: false}
|
|
127
|
+
end
|
|
85
128
|
{
|
|
86
129
|
ssoProvider: {
|
|
87
130
|
model_name: config[:model_name] || "ssoProviders",
|
|
88
|
-
fields:
|
|
89
|
-
issuer: {type: "string", required: true},
|
|
90
|
-
oidcConfig: {type: "string", required: false},
|
|
91
|
-
samlConfig: {type: "string", required: false},
|
|
92
|
-
userId: {type: "string", required: true},
|
|
93
|
-
providerId: {type: "string", required: true, unique: true},
|
|
94
|
-
domain: {type: "string", required: true},
|
|
95
|
-
domainVerified: {type: "boolean", required: false, default_value: false},
|
|
96
|
-
domainVerificationToken: {type: "string", required: false},
|
|
97
|
-
organizationId: {type: "string", required: false}
|
|
98
|
-
}
|
|
131
|
+
fields: fields
|
|
99
132
|
}
|
|
100
133
|
}
|
|
101
134
|
end
|
|
@@ -155,7 +188,9 @@ module BetterAuth
|
|
|
155
188
|
uri.to_s
|
|
156
189
|
else
|
|
157
190
|
issuer_uri = URI(issuer.to_s)
|
|
158
|
-
|
|
191
|
+
issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
|
|
192
|
+
endpoint = value.to_s.sub(%r{\A/+}, "")
|
|
193
|
+
"#{issuer_base}/#{endpoint}"
|
|
159
194
|
end
|
|
160
195
|
if trusted_origin && !trusted_origin.call(normalized)
|
|
161
196
|
raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
|
|
@@ -166,30 +201,50 @@ module BetterAuth
|
|
|
166
201
|
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
|
|
167
202
|
end
|
|
168
203
|
|
|
169
|
-
def sso_register_provider_endpoint
|
|
204
|
+
def sso_register_provider_endpoint(config = {})
|
|
170
205
|
Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
|
|
171
206
|
session = Routes.current_session(ctx)
|
|
172
207
|
body = normalize_hash(ctx.body)
|
|
173
208
|
provider_id = body[:provider_id].to_s
|
|
174
209
|
raise APIError.new("BAD_REQUEST", message: "providerId is required") if provider_id.empty?
|
|
210
|
+
|
|
211
|
+
limit = sso_provider_limit(session.fetch(:user), config)
|
|
212
|
+
if limit.to_i.zero?
|
|
213
|
+
raise APIError.new("FORBIDDEN", message: "SSO provider registration is disabled")
|
|
214
|
+
end
|
|
215
|
+
providers = ctx.context.adapter.find_many(model: "ssoProvider", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
|
|
216
|
+
if providers.length >= limit.to_i
|
|
217
|
+
raise APIError.new("FORBIDDEN", message: "You have reached the maximum number of SSO providers")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL")
|
|
221
|
+
sso_validate_organization_membership!(ctx, session.fetch(:user).fetch("id"), body[:organization_id]) if body[:organization_id]
|
|
175
222
|
if ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id}])
|
|
176
|
-
raise APIError.new("
|
|
223
|
+
raise APIError.new("UNPROCESSABLE_ENTITY", message: "SSO provider with this providerId already exists")
|
|
177
224
|
end
|
|
178
225
|
|
|
226
|
+
oidc_config = normalize_hash(body[:oidc_config] || {})
|
|
227
|
+
oidc_config = sso_hydrate_oidc_config(body[:issuer], oidc_config, ctx) if oidc_config.any? && !oidc_config[:skip_discovery]
|
|
228
|
+
oidc_config[:override_user_info] = !!(body[:override_user_info] || config[:default_override_user_info]) if oidc_config.any?
|
|
229
|
+
saml_config = normalize_hash(body[:saml_config] || {})
|
|
230
|
+
sso_validate_saml_config!(saml_config, config) unless saml_config.empty?
|
|
231
|
+
|
|
179
232
|
provider = ctx.context.adapter.create(
|
|
180
233
|
model: "ssoProvider",
|
|
181
234
|
data: {
|
|
182
235
|
providerId: provider_id,
|
|
183
236
|
issuer: body[:issuer].to_s,
|
|
184
237
|
domain: body[:domain].to_s.downcase,
|
|
185
|
-
oidcConfig:
|
|
186
|
-
samlConfig:
|
|
238
|
+
oidcConfig: oidc_config.empty? ? nil : oidc_config,
|
|
239
|
+
samlConfig: saml_config.empty? ? nil : saml_config,
|
|
187
240
|
userId: session.fetch(:user).fetch("id"),
|
|
188
241
|
organizationId: body[:organization_id],
|
|
189
|
-
domainVerified:
|
|
242
|
+
domainVerified: false
|
|
190
243
|
}
|
|
191
244
|
)
|
|
192
|
-
|
|
245
|
+
response = sso_sanitize_provider(provider, ctx.context)
|
|
246
|
+
response[:redirectURI] = sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId"))
|
|
247
|
+
ctx.json(response)
|
|
193
248
|
end
|
|
194
249
|
end
|
|
195
250
|
|
|
@@ -204,38 +259,52 @@ module BetterAuth
|
|
|
204
259
|
end
|
|
205
260
|
|
|
206
261
|
def sso_get_provider_endpoint
|
|
207
|
-
Endpoint.new(path: "/sso/
|
|
262
|
+
Endpoint.new(path: "/sso/get-provider", method: "GET") do |ctx|
|
|
208
263
|
session = Routes.current_session(ctx)
|
|
209
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
210
|
-
raise APIError.new("FORBIDDEN", message: "
|
|
264
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
265
|
+
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
211
266
|
|
|
212
267
|
ctx.json(sso_sanitize_provider(provider, ctx.context))
|
|
213
268
|
end
|
|
214
269
|
end
|
|
215
270
|
|
|
216
271
|
def sso_update_provider_endpoint
|
|
217
|
-
Endpoint.new(path: "/sso/
|
|
272
|
+
Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
|
|
218
273
|
session = Routes.current_session(ctx)
|
|
219
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
220
|
-
raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
221
|
-
|
|
222
274
|
body = normalize_hash(ctx.body)
|
|
275
|
+
provider = sso_find_provider!(ctx, sso_fetch(body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
276
|
+
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
277
|
+
|
|
278
|
+
if !body.key?(:issuer) && !body.key?(:domain) && !body.key?(:oidc_config) && !body.key?(:saml_config)
|
|
279
|
+
raise APIError.new("BAD_REQUEST", message: "No fields provided for update")
|
|
280
|
+
end
|
|
281
|
+
sso_validate_url!(body[:issuer], "Invalid issuer. Must be a valid URL") if body.key?(:issuer)
|
|
223
282
|
update = {}
|
|
224
283
|
update[:issuer] = body[:issuer] if body.key?(:issuer)
|
|
225
284
|
update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
|
|
226
|
-
update[:domainVerified] = false if body.key?(:domain)
|
|
227
|
-
|
|
228
|
-
|
|
285
|
+
update[:domainVerified] = false if body.key?(:domain) && body[:domain].to_s.downcase != provider["domain"].to_s
|
|
286
|
+
if body.key?(:oidc_config)
|
|
287
|
+
current = normalize_hash(provider["oidcConfig"] || {})
|
|
288
|
+
raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
|
|
289
|
+
|
|
290
|
+
update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config]))
|
|
291
|
+
end
|
|
292
|
+
if body.key?(:saml_config)
|
|
293
|
+
current = normalize_hash(provider["samlConfig"] || {})
|
|
294
|
+
raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
|
|
295
|
+
|
|
296
|
+
update[:samlConfig] = current.merge(normalize_hash(body[:saml_config]))
|
|
297
|
+
end
|
|
229
298
|
updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
|
|
230
299
|
ctx.json(sso_sanitize_provider(updated, ctx.context))
|
|
231
300
|
end
|
|
232
301
|
end
|
|
233
302
|
|
|
234
303
|
def sso_delete_provider_endpoint
|
|
235
|
-
Endpoint.new(path: "/sso/
|
|
304
|
+
Endpoint.new(path: "/sso/delete-provider", method: "POST") do |ctx|
|
|
236
305
|
session = Routes.current_session(ctx)
|
|
237
|
-
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
238
|
-
raise APIError.new("FORBIDDEN", message: "
|
|
306
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
307
|
+
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
239
308
|
|
|
240
309
|
ctx.context.adapter.delete(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}])
|
|
241
310
|
ctx.json({success: true})
|
|
@@ -245,7 +314,18 @@ module BetterAuth
|
|
|
245
314
|
def sso_sign_in_endpoint(config = {})
|
|
246
315
|
Endpoint.new(path: "/sign-in/sso", method: "POST") do |ctx|
|
|
247
316
|
body = normalize_hash(ctx.body)
|
|
248
|
-
provider = sso_select_provider(ctx, body)
|
|
317
|
+
provider = sso_select_provider(ctx, body, config)
|
|
318
|
+
provider_type = body[:provider_type].to_s
|
|
319
|
+
if provider_type == "oidc" && !provider["oidcConfig"]
|
|
320
|
+
raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
|
|
321
|
+
end
|
|
322
|
+
if provider_type == "saml" && !provider["samlConfig"]
|
|
323
|
+
raise APIError.new("BAD_REQUEST", message: "SAML provider is not configured")
|
|
324
|
+
end
|
|
325
|
+
if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
|
|
326
|
+
raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
|
|
327
|
+
end
|
|
328
|
+
|
|
249
329
|
state_data = {
|
|
250
330
|
providerId: provider.fetch("providerId"),
|
|
251
331
|
callbackURL: body[:callback_url] || "/",
|
|
@@ -254,34 +334,91 @@ module BetterAuth
|
|
|
254
334
|
requestSignUp: body[:request_sign_up]
|
|
255
335
|
}
|
|
256
336
|
|
|
257
|
-
if provider["
|
|
337
|
+
if provider["oidcConfig"] && provider_type != "saml"
|
|
338
|
+
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
339
|
+
state = BetterAuth::Crypto.sign_jwt(state_data.merge(sso_oidc_pkce_state(provider)), ctx.context.secret, expires_in: 600)
|
|
340
|
+
url = sso_oidc_authorization_url(provider, ctx, state, config, body)
|
|
341
|
+
elsif provider["samlConfig"]
|
|
258
342
|
relay_state = BetterAuth::Crypto.sign_jwt(state_data.merge(nonce: SecureRandom.hex(8)), ctx.context.secret, expires_in: 600)
|
|
259
343
|
url = sso_saml_authorization_url(provider, relay_state, ctx, config)
|
|
344
|
+
sso_store_saml_authn_request(ctx, provider, url, config)
|
|
260
345
|
else
|
|
261
|
-
|
|
262
|
-
url = sso_oidc_authorization_url(provider, ctx, state)
|
|
346
|
+
raise APIError.new("BAD_REQUEST", message: "OIDC provider is not configured")
|
|
263
347
|
end
|
|
264
348
|
ctx.json({url: url, redirect: true})
|
|
265
349
|
end
|
|
266
350
|
end
|
|
267
351
|
|
|
268
|
-
def sso_oidc_callback_endpoint
|
|
352
|
+
def sso_oidc_callback_endpoint(config = {})
|
|
269
353
|
Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
|
|
354
|
+
sso_handle_oidc_callback(ctx, config, sso_fetch(ctx.params, :provider_id))
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def sso_oidc_shared_callback_endpoint(config = {})
|
|
359
|
+
Endpoint.new(path: "/sso/callback", method: "GET") do |ctx|
|
|
270
360
|
state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
|
|
271
361
|
next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
|
|
272
362
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
363
|
+
sso_handle_oidc_callback(ctx, config, state["providerId"], state: state)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def sso_handle_oidc_callback(ctx, config, provider_id, state: nil)
|
|
368
|
+
state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
|
|
369
|
+
return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
|
|
370
|
+
|
|
371
|
+
callback_url = state["callbackURL"] || "/"
|
|
372
|
+
error_url = state["errorURL"] || callback_url
|
|
373
|
+
if ctx.query[:error] || ctx.query["error"]
|
|
374
|
+
error = ctx.query[:error] || ctx.query["error"]
|
|
375
|
+
description = ctx.query[:error_description] || ctx.query["error_description"]
|
|
376
|
+
return sso_redirect(ctx, sso_append_error(error_url, error, description))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
provider = sso_callback_provider(ctx, config, provider_id)
|
|
380
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
|
|
381
|
+
if config.dig(:domain_verification, :enabled) && !(provider.key?("domainVerified") && provider["domainVerified"])
|
|
382
|
+
raise APIError.new("UNAUTHORIZED", message: "Provider domain has not been verified")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
386
|
+
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
387
|
+
oidc_config[:issuer] ||= provider["issuer"]
|
|
388
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
|
|
389
|
+
|
|
390
|
+
tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config)
|
|
391
|
+
unless tokens
|
|
392
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
|
|
393
|
+
end
|
|
394
|
+
if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
|
|
395
|
+
begin
|
|
396
|
+
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
|
|
397
|
+
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
398
|
+
oidc_config[:issuer] ||= provider["issuer"]
|
|
399
|
+
rescue APIError
|
|
400
|
+
# Fall through to the upstream callback error when JWKS is still unavailable.
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config)
|
|
404
|
+
if user_info[:_sso_error]
|
|
405
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
|
|
406
|
+
end
|
|
407
|
+
if user_info[:email].to_s.empty? || user_info[:id].to_s.empty?
|
|
408
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "missing_user_info"))
|
|
284
409
|
end
|
|
410
|
+
if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(user_info[:email].to_s.downcase)
|
|
411
|
+
return sso_redirect(ctx, sso_append_error(error_url, "signup disabled"))
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
result = sso_find_or_create_user_result(ctx, provider, user_info, config)
|
|
415
|
+
if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
|
|
416
|
+
config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
|
|
417
|
+
end
|
|
418
|
+
session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
|
|
419
|
+
Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
|
|
420
|
+
redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? state["newUserURL"] : callback_url
|
|
421
|
+
sso_redirect(ctx, redirect_to || "/")
|
|
285
422
|
end
|
|
286
423
|
|
|
287
424
|
def sso_saml_callback_endpoint(config)
|
|
@@ -296,10 +433,10 @@ module BetterAuth
|
|
|
296
433
|
end
|
|
297
434
|
end
|
|
298
435
|
|
|
299
|
-
def sso_sp_metadata_endpoint
|
|
436
|
+
def sso_sp_metadata_endpoint(config = {})
|
|
300
437
|
Endpoint.new(path: "/sso/saml2/sp/metadata", method: "GET") do |ctx|
|
|
301
438
|
provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id))
|
|
302
|
-
metadata =
|
|
439
|
+
metadata = sso_sp_metadata_xml(ctx, provider, config)
|
|
303
440
|
if (ctx.query[:format] || ctx.query["format"]) == "json"
|
|
304
441
|
ctx.json({providerId: provider.fetch("providerId"), metadata: metadata})
|
|
305
442
|
else
|
|
@@ -309,28 +446,113 @@ module BetterAuth
|
|
|
309
446
|
end
|
|
310
447
|
end
|
|
311
448
|
|
|
449
|
+
def sso_saml_slo_endpoint(config = {})
|
|
450
|
+
Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
|
|
451
|
+
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
452
|
+
|
|
453
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
454
|
+
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
455
|
+
if sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
456
|
+
sso_process_saml_logout_response(ctx, sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response))
|
|
457
|
+
Cookies.delete_session_cookie(ctx)
|
|
458
|
+
next sso_redirect(ctx, sso_safe_slo_redirect_url(ctx, relay_state, provider.fetch("providerId")))
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
sso_process_saml_logout_request(ctx, provider, sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request))
|
|
462
|
+
response = Base64.strict_encode64("<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_#{SecureRandom.hex(16)}\" Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{sso_saml_logout_destination(provider)}\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status></samlp:LogoutResponse>")
|
|
463
|
+
if sso_fetch(ctx.body, :saml_request)
|
|
464
|
+
next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(SAMLResponse: response, RelayState: relay_state)}")
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def sso_initiate_slo_endpoint(config = {})
|
|
472
|
+
Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST") do |ctx|
|
|
473
|
+
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
474
|
+
|
|
475
|
+
session = Routes.current_session(ctx)
|
|
476
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
477
|
+
destination = sso_saml_logout_destination(provider)
|
|
478
|
+
if destination.to_s.empty?
|
|
479
|
+
raise APIError.new("BAD_REQUEST", message: "IdP does not support Single Logout Service")
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
relay_state = sso_fetch(ctx.body, :callback_url) || ctx.context.base_url
|
|
483
|
+
session_token = session.fetch(:session).fetch("token")
|
|
484
|
+
user_email = session.fetch(:user).fetch("email")
|
|
485
|
+
saml_session_key = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")&.fetch("value")
|
|
486
|
+
saml_session = saml_session_key && ctx.context.internal_adapter.find_verification_value(saml_session_key)
|
|
487
|
+
saml_record = saml_session ? JSON.parse(saml_session.fetch("value")) : {}
|
|
488
|
+
name_id = saml_record["nameId"] || user_email
|
|
489
|
+
session_index = saml_record["sessionIndex"]
|
|
490
|
+
|
|
491
|
+
request_id = "_#{SecureRandom.hex(16)}"
|
|
492
|
+
session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
|
|
493
|
+
request = Base64.strict_encode64("<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"#{request_id}\" Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{CGI.escapeHTML(destination.to_s)}\"><saml:NameID>#{CGI.escapeHTML(name_id.to_s)}</saml:NameID>#{session_index_xml}</samlp:LogoutRequest>")
|
|
494
|
+
sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
495
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
|
|
496
|
+
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
|
|
497
|
+
ctx.context.internal_adapter.delete_session(session_token)
|
|
498
|
+
Cookies.delete_session_cookie(ctx)
|
|
499
|
+
sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(SAMLRequest: request, RelayState: relay_state)}")
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
312
503
|
def sso_request_domain_verification_endpoint(config)
|
|
313
504
|
Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
|
|
314
|
-
Routes.current_session(ctx)
|
|
505
|
+
session = Routes.current_session(ctx)
|
|
315
506
|
provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
507
|
+
sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
|
|
508
|
+
if provider.key?("domainVerified") && provider["domainVerified"]
|
|
509
|
+
raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
|
|
513
|
+
active = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
514
|
+
if active && sso_future_time?(active.fetch("expiresAt"))
|
|
515
|
+
next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
token = SecureRandom.alphanumeric(24)
|
|
519
|
+
ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
|
|
520
|
+
config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
|
|
521
|
+
ctx.json({domainVerificationToken: token}, status: 201)
|
|
320
522
|
end
|
|
321
523
|
end
|
|
322
524
|
|
|
323
525
|
def sso_verify_domain_endpoint(config)
|
|
324
526
|
Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
|
|
325
|
-
Routes.current_session(ctx)
|
|
527
|
+
session = Routes.current_session(ctx)
|
|
326
528
|
provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
529
|
+
sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
|
|
530
|
+
if provider.key?("domainVerified") && provider["domainVerified"]
|
|
531
|
+
raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
|
|
532
|
+
end
|
|
331
533
|
|
|
332
|
-
|
|
333
|
-
|
|
534
|
+
identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
|
|
535
|
+
if identifier.length > 63
|
|
536
|
+
raise APIError.new("BAD_REQUEST", message: "Verification identifier exceeds the DNS label limit of 63 characters", code: "IDENTIFIER_TOO_LONG")
|
|
537
|
+
end
|
|
538
|
+
active = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
539
|
+
if !active || !sso_future_time?(active.fetch("expiresAt"))
|
|
540
|
+
raise APIError.new("NOT_FOUND", message: "No pending domain verification exists", code: "NO_PENDING_VERIFICATION")
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
hostname = sso_hostname_from_domain(provider.fetch("domain"))
|
|
544
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid domain", code: "INVALID_DOMAIN") if hostname.to_s.empty?
|
|
545
|
+
|
|
546
|
+
records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
|
|
547
|
+
expected = "#{identifier}=#{active.fetch("value")}"
|
|
548
|
+
unless records.flatten.any? { |record| record.to_s.include?(expected) }
|
|
549
|
+
raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true})
|
|
553
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
554
|
+
ctx.set_status(204)
|
|
555
|
+
nil
|
|
334
556
|
end
|
|
335
557
|
end
|
|
336
558
|
|
|
@@ -338,34 +560,67 @@ module BetterAuth
|
|
|
338
560
|
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
339
561
|
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
340
562
|
state = sso_verify_state(relay_state, ctx.context.secret) || {}
|
|
341
|
-
|
|
563
|
+
raw_response = sso_fetch(ctx.body, :saml_response)
|
|
564
|
+
max_response_size = config.dig(:saml, :max_response_size) || SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
|
|
565
|
+
if raw_response.to_s.bytesize > max_response_size
|
|
566
|
+
raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
|
|
567
|
+
end
|
|
568
|
+
in_response_to_error = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
569
|
+
return in_response_to_error if in_response_to_error
|
|
570
|
+
|
|
571
|
+
assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
|
|
572
|
+
assertion[:email_verified] = false unless config[:trust_email_verified]
|
|
573
|
+
sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
|
|
342
574
|
sso_validate_saml_response!(config, assertion, provider, ctx)
|
|
343
575
|
assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
|
|
344
|
-
replay_key = "
|
|
576
|
+
replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
|
|
345
577
|
if ctx.context.internal_adapter.find_verification_value(replay_key)
|
|
346
578
|
raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
|
|
347
579
|
end
|
|
348
|
-
ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt:
|
|
580
|
+
ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
|
|
349
581
|
|
|
350
|
-
user = sso_find_or_create_user(ctx, provider, assertion, config)
|
|
351
|
-
session = ctx.context.internal_adapter.create_session(user.fetch("id"))
|
|
352
|
-
Cookies.set_session_cookie(ctx, {session: session, user: user})
|
|
353
582
|
callback_url = state["callbackURL"] || "/"
|
|
354
583
|
callback_url = "/" unless ctx.context.trusted_origin?(callback_url, allow_relative_paths: true)
|
|
584
|
+
email = (assertion[:email] || assertion["email"]).to_s.downcase
|
|
585
|
+
if config[:disable_implicit_sign_up] && !state["requestSignUp"] && !ctx.context.internal_adapter.find_user_by_email(email)
|
|
586
|
+
return sso_redirect(ctx, sso_append_error(callback_url, "signup disabled"))
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
result = sso_find_or_create_user_result(ctx, provider, assertion, config)
|
|
590
|
+
user = result.fetch(:user)
|
|
591
|
+
if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
|
|
592
|
+
config[:provision_user].call(user: user, userInfo: assertion, provider: provider)
|
|
593
|
+
end
|
|
594
|
+
session = ctx.context.internal_adapter.create_session(user.fetch("id"))
|
|
595
|
+
sso_store_saml_session(ctx, provider, assertion, session) if config.dig(:saml, :enable_single_logout)
|
|
596
|
+
Cookies.set_session_cookie(ctx, {session: session, user: user})
|
|
355
597
|
sso_redirect(ctx, callback_url)
|
|
356
598
|
end
|
|
357
599
|
|
|
358
600
|
def sso_find_or_create_user(ctx, provider, user_info, config = {})
|
|
601
|
+
sso_find_or_create_user_result(ctx, provider, user_info, config).fetch(:user)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def sso_find_or_create_user_result(ctx, provider, user_info, config = {})
|
|
359
605
|
user_info = normalize_hash(user_info)
|
|
360
606
|
email = user_info[:email].to_s.downcase
|
|
361
607
|
found = ctx.context.internal_adapter.find_user_by_email(email)
|
|
362
|
-
|
|
363
|
-
found[:user]
|
|
608
|
+
if found
|
|
609
|
+
user = found[:user]
|
|
610
|
+
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
611
|
+
if oidc_config[:override_user_info] || config[:default_override_user_info]
|
|
612
|
+
update = {}
|
|
613
|
+
update[:name] = user_info[:name] if user_info.key?(:name)
|
|
614
|
+
update[:image] = user_info[:image] if user_info.key?(:image)
|
|
615
|
+
update[:emailVerified] = !!user_info[:email_verified] if user_info.key?(:email_verified)
|
|
616
|
+
user = ctx.context.internal_adapter.update_user(user.fetch("id"), update) if update.any?
|
|
617
|
+
end
|
|
618
|
+
created = false
|
|
364
619
|
else
|
|
365
620
|
created = ctx.context.internal_adapter.create_user(
|
|
366
621
|
email: email,
|
|
367
622
|
name: user_info[:name] || email,
|
|
368
|
-
emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] :
|
|
623
|
+
emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : false,
|
|
369
624
|
image: user_info[:image]
|
|
370
625
|
)
|
|
371
626
|
ctx.context.internal_adapter.create_account(
|
|
@@ -373,10 +628,11 @@ module BetterAuth
|
|
|
373
628
|
providerId: "sso:#{provider.fetch("providerId")}",
|
|
374
629
|
userId: created.fetch("id")
|
|
375
630
|
)
|
|
376
|
-
created
|
|
631
|
+
user = created
|
|
632
|
+
created = true
|
|
377
633
|
end
|
|
378
634
|
sso_assign_organization_membership(ctx, provider, user, config)
|
|
379
|
-
user
|
|
635
|
+
{user: user, created: created}
|
|
380
636
|
end
|
|
381
637
|
|
|
382
638
|
def sso_validate_saml_response!(config, assertion, provider, ctx)
|
|
@@ -387,26 +643,201 @@ module BetterAuth
|
|
|
387
643
|
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
388
644
|
end
|
|
389
645
|
|
|
646
|
+
def sso_validate_saml_config!(saml_config, plugin_config = {})
|
|
647
|
+
metadata = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
648
|
+
max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
649
|
+
if metadata.to_s.bytesize > max_metadata_size
|
|
650
|
+
raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
if saml_config[:entry_point].to_s.empty? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
|
|
654
|
+
raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
sso_validate_saml_algorithms!(
|
|
658
|
+
metadata.to_s,
|
|
659
|
+
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
660
|
+
allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
|
|
661
|
+
allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
|
|
662
|
+
allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
|
|
663
|
+
allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
|
|
664
|
+
)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def sso_sp_metadata_xml(ctx, provider, config = {})
|
|
668
|
+
provider_id = provider.fetch("providerId")
|
|
669
|
+
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
670
|
+
entity_id = saml_config.dig(:sp_metadata, :entity_id) || saml_config[:audience] || "#{ctx.context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(provider_id)}"
|
|
671
|
+
acs_url = saml_config[:callback_url] || "#{ctx.context.base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
|
|
672
|
+
authn_requests_signed = !!saml_config[:authn_requests_signed]
|
|
673
|
+
want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
|
|
674
|
+
slo = if config.dig(:saml, :enable_single_logout)
|
|
675
|
+
location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
|
|
676
|
+
"<SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{location}\" /><SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"#{location}\" />"
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
"<EntityDescriptor entityID=\"#{entity_id}\"><SPSSODescriptor AuthnRequestsSigned=\"#{authn_requests_signed}\" WantAssertionsSigned=\"#{want_assertions_signed}\">#{slo}<AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def sso_saml_logout_destination(provider)
|
|
683
|
+
saml_config = normalize_hash(provider["samlConfig"] || {})
|
|
684
|
+
direct = saml_config[:single_logout_service] ||
|
|
685
|
+
saml_config[:single_logout_service_url] ||
|
|
686
|
+
saml_config[:idp_slo_service_url] ||
|
|
687
|
+
saml_config[:logout_url]
|
|
688
|
+
return direct unless direct.to_s.empty?
|
|
689
|
+
|
|
690
|
+
idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
|
|
691
|
+
structured = idp_metadata[:single_logout_service] || saml_config[:single_logout_service]
|
|
692
|
+
structured = structured.first if structured.is_a?(Array)
|
|
693
|
+
structured = normalize_hash(structured) if structured.is_a?(Hash)
|
|
694
|
+
return structured[:location] if structured.is_a?(Hash) && !structured[:location].to_s.empty?
|
|
695
|
+
|
|
696
|
+
metadata = idp_metadata[:metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
697
|
+
metadata.to_s[/<[^>]*SingleLogoutService\b[^>]*\bLocation=['"]([^'"]+)['"]/, 1]
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def sso_store_saml_session(ctx, provider, assertion, session)
|
|
701
|
+
name_id = assertion[:name_id] || assertion[:nameid] || assertion[:email]
|
|
702
|
+
session_index = assertion[:session_index] || assertion[:sessionindex] || assertion[:id]
|
|
703
|
+
return if name_id.to_s.empty? || session_index.to_s.empty?
|
|
704
|
+
|
|
705
|
+
record = {
|
|
706
|
+
providerId: provider.fetch("providerId"),
|
|
707
|
+
sessionToken: session.fetch("token"),
|
|
708
|
+
userId: session.fetch("userId"),
|
|
709
|
+
nameId: name_id.to_s,
|
|
710
|
+
sessionIndex: session_index.to_s
|
|
711
|
+
}
|
|
712
|
+
expires_at = session["expiresAt"] || Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
713
|
+
value = JSON.generate(record)
|
|
714
|
+
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{name_id}"
|
|
715
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
716
|
+
identifier: session_identifier,
|
|
717
|
+
value: value,
|
|
718
|
+
expiresAt: expires_at
|
|
719
|
+
)
|
|
720
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
721
|
+
identifier: "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session.fetch("token")}",
|
|
722
|
+
value: session_identifier,
|
|
723
|
+
expiresAt: expires_at
|
|
724
|
+
)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
728
|
+
data = sso_parse_saml_logout_request(raw_request)
|
|
729
|
+
return if data[:name_id].to_s.empty?
|
|
730
|
+
|
|
731
|
+
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
|
|
732
|
+
verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
|
|
733
|
+
return unless verification
|
|
734
|
+
|
|
735
|
+
record = JSON.parse(verification.fetch("value"))
|
|
736
|
+
session_token = record["sessionToken"]
|
|
737
|
+
session_index_matches = data[:session_index].to_s.empty? || record["sessionIndex"].to_s.empty? || data[:session_index].to_s == record["sessionIndex"].to_s
|
|
738
|
+
ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
|
|
739
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
|
|
740
|
+
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
|
|
741
|
+
rescue
|
|
742
|
+
nil
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
746
|
+
ttl_ms = (config.dig(:saml, :logout_request_ttl) || SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS).to_i
|
|
747
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
748
|
+
identifier: "#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{request_id}",
|
|
749
|
+
value: provider.fetch("providerId"),
|
|
750
|
+
expiresAt: Time.now + (ttl_ms / 1000.0)
|
|
751
|
+
)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def sso_process_saml_logout_response(ctx, raw_response)
|
|
755
|
+
data = sso_parse_saml_logout_response(raw_response)
|
|
756
|
+
status_code = data[:status_code]
|
|
757
|
+
if status_code && status_code != SSO_SAML_STATUS_SUCCESS
|
|
758
|
+
raise APIError.new("BAD_REQUEST", message: "Logout failed at IdP")
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
in_response_to = data[:in_response_to]
|
|
762
|
+
return if in_response_to.to_s.empty?
|
|
763
|
+
|
|
764
|
+
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX}#{in_response_to}")
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def sso_parse_saml_logout_request(raw_request)
|
|
768
|
+
xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
|
|
769
|
+
{
|
|
770
|
+
name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
|
|
771
|
+
session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
|
|
772
|
+
}
|
|
773
|
+
rescue
|
|
774
|
+
{}
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def sso_parse_saml_logout_response(raw_response)
|
|
778
|
+
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
779
|
+
{
|
|
780
|
+
in_response_to: xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1],
|
|
781
|
+
status_code: xml[/<(?:\w+:)?StatusCode\b[^>]*\bValue=['"]([^'"]+)['"]/, 1]
|
|
782
|
+
}
|
|
783
|
+
rescue
|
|
784
|
+
{}
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def sso_safe_slo_redirect_url(ctx, url, provider_id)
|
|
788
|
+
app_origin = ctx.context.base_url
|
|
789
|
+
callback_path = URI.parse("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}").path
|
|
790
|
+
value = url.to_s
|
|
791
|
+
return app_origin if value.empty?
|
|
792
|
+
|
|
793
|
+
if value.start_with?("/") && !value.start_with?("//")
|
|
794
|
+
parsed = URI.parse(value)
|
|
795
|
+
return app_origin if parsed.path == callback_path
|
|
796
|
+
return value
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
800
|
+
|
|
801
|
+
parsed = URI.parse(value)
|
|
802
|
+
return app_origin if parsed.path == callback_path
|
|
803
|
+
|
|
804
|
+
value
|
|
805
|
+
rescue
|
|
806
|
+
app_origin
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def sso_saml_post_form(action, saml_param, saml_value, relay_state = nil)
|
|
810
|
+
relay_input = relay_state.to_s.empty? ? "" : "<input type=\"hidden\" name=\"RelayState\" value=\"#{CGI.escapeHTML(relay_state.to_s)}\" />"
|
|
811
|
+
html = "<!DOCTYPE html><html><body onload=\"document.forms[0].submit();\"><form method=\"POST\" action=\"#{CGI.escapeHTML(action.to_s)}\"><input type=\"hidden\" name=\"#{CGI.escapeHTML(saml_param.to_s)}\" value=\"#{CGI.escapeHTML(saml_value.to_s)}\" />#{relay_input}<noscript><input type=\"submit\" value=\"Continue\" /></noscript></form></body></html>"
|
|
812
|
+
[200, {"content-type" => "text/html"}, [html]]
|
|
813
|
+
end
|
|
814
|
+
|
|
390
815
|
def sso_assign_organization_membership(ctx, provider, user, config)
|
|
391
816
|
organization_id = provider["organizationId"]
|
|
392
817
|
return if organization_id.to_s.empty?
|
|
393
|
-
return
|
|
394
|
-
return unless sso_email_domain_matches?(user["email"].to_s.split("@").last.to_s.downcase, provider["domain"])
|
|
818
|
+
return if config.dig(:organization_provisioning, :disabled)
|
|
395
819
|
return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
|
|
396
820
|
return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
|
|
397
821
|
|
|
398
|
-
role = config.dig(:organization_provisioning, :
|
|
822
|
+
role = if config.dig(:organization_provisioning, :get_role).respond_to?(:call)
|
|
823
|
+
config.dig(:organization_provisioning, :get_role).call(user: user, userInfo: {}, provider: provider)
|
|
824
|
+
else
|
|
825
|
+
config.dig(:organization_provisioning, :default_role) || config.dig(:organization_provisioning, :role) || "member"
|
|
826
|
+
end
|
|
399
827
|
ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
|
|
400
828
|
end
|
|
401
829
|
|
|
402
830
|
def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
|
|
403
831
|
parser = config.dig(:saml, :parse_response)
|
|
404
832
|
if parser.respond_to?(:call)
|
|
833
|
+
sso_validate_single_saml_assertion!(value) if sso_base64_xml?(value)
|
|
405
834
|
parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
|
|
406
835
|
return normalize_hash(parsed)
|
|
407
836
|
end
|
|
408
837
|
|
|
409
838
|
JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
|
|
839
|
+
rescue APIError
|
|
840
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
410
841
|
rescue
|
|
411
842
|
raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
|
|
412
843
|
end
|
|
@@ -430,6 +861,46 @@ module BetterAuth
|
|
|
430
861
|
raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
|
|
431
862
|
end
|
|
432
863
|
|
|
864
|
+
def sso_validate_saml_timestamp!(conditions, config = {}, now: Time.now.utc)
|
|
865
|
+
conditions = normalize_hash(conditions || {})
|
|
866
|
+
not_before = conditions[:not_before] || conditions[:notBefore]
|
|
867
|
+
not_on_or_after = conditions[:not_on_or_after] || conditions[:notOnOrAfter]
|
|
868
|
+
if not_before.to_s.empty? && not_on_or_after.to_s.empty?
|
|
869
|
+
raise APIError.new("BAD_REQUEST", message: "SAML assertion missing required timestamp conditions") if config.dig(:saml, :require_timestamps)
|
|
870
|
+
|
|
871
|
+
return true
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
|
|
875
|
+
parsed_not_before = sso_parse_saml_timestamp(not_before, "SAML assertion has invalid NotBefore timestamp") unless not_before.to_s.empty?
|
|
876
|
+
parsed_not_on_or_after = sso_parse_saml_timestamp(not_on_or_after, "SAML assertion has invalid NotOnOrAfter timestamp") unless not_on_or_after.to_s.empty?
|
|
877
|
+
|
|
878
|
+
raise APIError.new("BAD_REQUEST", message: "SAML assertion is not yet valid") if parsed_not_before && now < (parsed_not_before - clock_skew_seconds)
|
|
879
|
+
raise APIError.new("BAD_REQUEST", message: "SAML assertion has expired") if parsed_not_on_or_after && now > (parsed_not_on_or_after + clock_skew_seconds)
|
|
880
|
+
|
|
881
|
+
true
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def sso_parse_saml_timestamp(value, error_message)
|
|
885
|
+
Time.parse(value.to_s).utc
|
|
886
|
+
rescue
|
|
887
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def sso_saml_timestamp_conditions(assertion)
|
|
891
|
+
assertion = normalize_hash(assertion || {})
|
|
892
|
+
conditions = normalize_hash(assertion[:conditions] || {})
|
|
893
|
+
conditions[:not_before] ||= assertion[:not_before] || assertion[:notBefore]
|
|
894
|
+
conditions[:not_on_or_after] ||= assertion[:not_on_or_after] || assertion[:notOnOrAfter]
|
|
895
|
+
conditions
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def sso_base64_xml?(value)
|
|
899
|
+
Base64.decode64(value.to_s).lstrip.start_with?("<")
|
|
900
|
+
rescue
|
|
901
|
+
false
|
|
902
|
+
end
|
|
903
|
+
|
|
433
904
|
def sso_validate_saml_algorithms!(xml, options = {})
|
|
434
905
|
on_deprecated = (options[:on_deprecated] || "warn").to_s
|
|
435
906
|
signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
|
|
@@ -505,16 +976,26 @@ module BetterAuth
|
|
|
505
976
|
nil
|
|
506
977
|
end
|
|
507
978
|
|
|
508
|
-
def sso_oidc_authorization_url(provider, ctx, state)
|
|
979
|
+
def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
|
|
509
980
|
config = normalize_hash(provider["oidcConfig"] || {})
|
|
510
981
|
endpoint = config[:authorization_endpoint] || config[:authorization_url]
|
|
982
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
|
|
983
|
+
|
|
984
|
+
scopes = Array(body[:scopes] || config[:scopes] || config[:scope] || ["openid", "email", "profile", "offline_access"])
|
|
511
985
|
query = {
|
|
512
986
|
client_id: config[:client_id],
|
|
513
987
|
response_type: "code",
|
|
514
|
-
redirect_uri:
|
|
515
|
-
scope:
|
|
988
|
+
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
989
|
+
scope: scopes.join(" "),
|
|
516
990
|
state: state
|
|
517
|
-
}
|
|
991
|
+
}.compact
|
|
992
|
+
login_hint = body[:login_hint] || body[:email]
|
|
993
|
+
query[:login_hint] = login_hint if login_hint
|
|
994
|
+
code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
|
|
995
|
+
if code_verifier
|
|
996
|
+
query[:code_challenge] = sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(code_verifier))
|
|
997
|
+
query[:code_challenge_method] = "S256"
|
|
998
|
+
end
|
|
518
999
|
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
519
1000
|
end
|
|
520
1001
|
|
|
@@ -532,22 +1013,386 @@ module BetterAuth
|
|
|
532
1013
|
"#{config[:entry_point]}?#{URI.encode_www_form(query)}"
|
|
533
1014
|
end
|
|
534
1015
|
|
|
535
|
-
def
|
|
1016
|
+
def sso_store_saml_authn_request(ctx, provider, url, config)
|
|
1017
|
+
return if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
1018
|
+
|
|
1019
|
+
request_id = sso_extract_saml_request_id(url)
|
|
1020
|
+
return if request_id.to_s.empty?
|
|
1021
|
+
|
|
1022
|
+
ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
|
|
1023
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
1024
|
+
expires_at_ms = now_ms + ttl_ms
|
|
1025
|
+
record = {
|
|
1026
|
+
id: request_id,
|
|
1027
|
+
providerId: provider.fetch("providerId"),
|
|
1028
|
+
createdAt: now_ms,
|
|
1029
|
+
expiresAt: expires_at_ms
|
|
1030
|
+
}
|
|
1031
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
1032
|
+
identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
|
|
1033
|
+
value: JSON.generate(record),
|
|
1034
|
+
expiresAt: Time.at(expires_at_ms / 1000.0)
|
|
1035
|
+
)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
def sso_extract_saml_request_id(url)
|
|
1039
|
+
query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
|
|
1040
|
+
encoded = query["SAMLRequest"]
|
|
1041
|
+
return nil if encoded.to_s.empty?
|
|
1042
|
+
|
|
1043
|
+
xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
|
|
1044
|
+
xml[/\bID=['"]([^'"]+)['"]/, 1]
|
|
1045
|
+
rescue
|
|
1046
|
+
nil
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
1050
|
+
return nil if config.dig(:saml, :enable_in_response_to_validation) == false
|
|
1051
|
+
|
|
1052
|
+
in_response_to = sso_extract_saml_in_response_to(raw_response)
|
|
1053
|
+
if in_response_to && !in_response_to.empty?
|
|
1054
|
+
identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
|
|
1055
|
+
verification = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
1056
|
+
record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
|
|
1057
|
+
if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
|
|
1058
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
if record["providerId"] != provider.fetch("providerId")
|
|
1062
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
1063
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
|
|
1067
|
+
elsif config.dig(:saml, :allow_idp_initiated) == false
|
|
1068
|
+
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
nil
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def sso_parse_saml_authn_request_record(value)
|
|
1075
|
+
JSON.parse(value.to_s)
|
|
1076
|
+
rescue
|
|
1077
|
+
nil
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
def sso_saml_assertion_replay_expires_at(assertion, config = {})
|
|
1081
|
+
timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
|
|
1082
|
+
parsed = Time.parse(timestamp.to_s) if timestamp
|
|
1083
|
+
clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
|
|
1084
|
+
return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
|
|
1085
|
+
|
|
1086
|
+
ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
|
|
1087
|
+
Time.now + (ttl_ms / 1000.0)
|
|
1088
|
+
rescue
|
|
1089
|
+
Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def sso_extract_saml_in_response_to(raw_response)
|
|
1093
|
+
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
1094
|
+
xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
|
|
1095
|
+
rescue
|
|
1096
|
+
nil
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def sso_select_provider(ctx, body, config = {})
|
|
1100
|
+
provider_id = body[:provider_id].to_s
|
|
1101
|
+
issuer = body[:issuer].to_s
|
|
1102
|
+
organization_slug = body[:organization_slug].to_s
|
|
1103
|
+
domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
|
|
1104
|
+
if config[:default_sso]
|
|
1105
|
+
provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
|
|
1106
|
+
return provider if provider
|
|
1107
|
+
end
|
|
1108
|
+
|
|
536
1109
|
providers = ctx.context.adapter.find_many(model: "ssoProvider")
|
|
537
|
-
provider = if
|
|
538
|
-
providers.find { |entry| entry["providerId"] ==
|
|
539
|
-
elsif
|
|
540
|
-
providers.find { |entry| entry["issuer"] ==
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
providers.find { |entry|
|
|
1110
|
+
provider = if !provider_id.empty?
|
|
1111
|
+
providers.find { |entry| entry["providerId"] == provider_id }
|
|
1112
|
+
elsif !issuer.empty?
|
|
1113
|
+
providers.find { |entry| entry["issuer"] == issuer }
|
|
1114
|
+
elsif !organization_slug.empty?
|
|
1115
|
+
organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
|
|
1116
|
+
providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
|
|
1117
|
+
elsif !domain.empty?
|
|
1118
|
+
providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
|
|
1119
|
+
providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
|
|
544
1120
|
end
|
|
545
1121
|
raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
|
|
546
1122
|
|
|
547
1123
|
provider
|
|
548
1124
|
end
|
|
549
1125
|
|
|
1126
|
+
def sso_callback_provider(ctx, config, provider_id)
|
|
1127
|
+
if config[:default_sso]
|
|
1128
|
+
provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
|
|
1129
|
+
return provider if provider
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config)
|
|
1136
|
+
token_callback = oidc_config[:get_token]
|
|
1137
|
+
if token_callback.respond_to?(:call)
|
|
1138
|
+
return normalize_hash(token_callback.call(
|
|
1139
|
+
code: ctx.query[:code] || ctx.query["code"],
|
|
1140
|
+
codeVerifier: state["codeVerifier"],
|
|
1141
|
+
redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
1142
|
+
provider: provider,
|
|
1143
|
+
context: ctx
|
|
1144
|
+
))
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
token_endpoint = oidc_config[:token_endpoint]
|
|
1148
|
+
return nil if token_endpoint.to_s.empty?
|
|
1149
|
+
|
|
1150
|
+
sso_exchange_oidc_code(
|
|
1151
|
+
token_endpoint: token_endpoint,
|
|
1152
|
+
code: ctx.query[:code] || ctx.query["code"],
|
|
1153
|
+
code_verifier: state["codeVerifier"],
|
|
1154
|
+
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
1155
|
+
client_id: oidc_config[:client_id],
|
|
1156
|
+
client_secret: oidc_config[:client_secret],
|
|
1157
|
+
authentication: oidc_config[:token_endpoint_authentication]
|
|
1158
|
+
)
|
|
1159
|
+
rescue
|
|
1160
|
+
nil
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:)
|
|
1164
|
+
uri = URI(token_endpoint.to_s)
|
|
1165
|
+
request = Net::HTTP::Post.new(uri)
|
|
1166
|
+
form = {
|
|
1167
|
+
grant_type: "authorization_code",
|
|
1168
|
+
code: code,
|
|
1169
|
+
redirect_uri: redirect_uri,
|
|
1170
|
+
client_id: client_id,
|
|
1171
|
+
code_verifier: code_verifier
|
|
1172
|
+
}.compact
|
|
1173
|
+
if authentication.to_s == "client_secret_post"
|
|
1174
|
+
form[:client_secret] = client_secret
|
|
1175
|
+
elsif client_secret.to_s != ""
|
|
1176
|
+
request.basic_auth(client_id.to_s, client_secret.to_s)
|
|
1177
|
+
end
|
|
1178
|
+
request.set_form_data(form)
|
|
1179
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
1180
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
1181
|
+
|
|
1182
|
+
normalize_hash(JSON.parse(response.body))
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config)
|
|
1186
|
+
user_callback = oidc_config[:get_user_info]
|
|
1187
|
+
raw = if user_callback.respond_to?(:call)
|
|
1188
|
+
user_callback.call(tokens)
|
|
1189
|
+
elsif oidc_config[:user_info_endpoint]
|
|
1190
|
+
sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token])
|
|
1191
|
+
elsif tokens[:id_token]
|
|
1192
|
+
return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
|
|
1193
|
+
|
|
1194
|
+
sso_validate_oidc_id_token(
|
|
1195
|
+
tokens[:id_token],
|
|
1196
|
+
jwks_endpoint: oidc_config[:jwks_endpoint],
|
|
1197
|
+
audience: oidc_config[:client_id],
|
|
1198
|
+
issuer: oidc_config[:issuer],
|
|
1199
|
+
fetch: plugin_config[:oidc_jwks_fetch]
|
|
1200
|
+
) || {_sso_error: "token_not_verified"}
|
|
1201
|
+
else
|
|
1202
|
+
{}
|
|
1203
|
+
end
|
|
1204
|
+
raw = normalize_hash(raw || {})
|
|
1205
|
+
return raw if raw[:_sso_error]
|
|
1206
|
+
|
|
1207
|
+
mapping = normalize_hash(oidc_config[:mapping] || {})
|
|
1208
|
+
extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
|
|
1209
|
+
result[target] = raw[normalize_key(source)] || raw[source.to_s]
|
|
1210
|
+
end
|
|
1211
|
+
extra_fields.merge(
|
|
1212
|
+
id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
|
|
1213
|
+
email: raw[normalize_key(mapping[:email] || "email")],
|
|
1214
|
+
email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
|
|
1215
|
+
name: raw[normalize_key(mapping[:name] || "name")],
|
|
1216
|
+
image: raw[normalize_key(mapping[:image] || "picture")]
|
|
1217
|
+
)
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def sso_fetch_oidc_user_info(endpoint, access_token)
|
|
1221
|
+
uri = URI(endpoint.to_s)
|
|
1222
|
+
request = Net::HTTP::Get.new(uri)
|
|
1223
|
+
request["authorization"] = "Bearer #{access_token}"
|
|
1224
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
1225
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
1226
|
+
|
|
1227
|
+
JSON.parse(response.body)
|
|
1228
|
+
rescue
|
|
1229
|
+
{}
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil)
|
|
1233
|
+
jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
|
|
1234
|
+
payload, = ::JWT.decode(
|
|
1235
|
+
token.to_s,
|
|
1236
|
+
nil,
|
|
1237
|
+
true,
|
|
1238
|
+
algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
|
|
1239
|
+
jwks: jwks,
|
|
1240
|
+
aud: audience,
|
|
1241
|
+
verify_aud: true,
|
|
1242
|
+
iss: issuer,
|
|
1243
|
+
verify_iss: true
|
|
1244
|
+
)
|
|
1245
|
+
payload
|
|
1246
|
+
rescue
|
|
1247
|
+
nil
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
|
|
1251
|
+
if fetch.respond_to?(:call)
|
|
1252
|
+
return normalize_hash(fetch.call(jwks_endpoint))
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
uri = URI(jwks_endpoint.to_s)
|
|
1256
|
+
response = Net::HTTP.get_response(uri)
|
|
1257
|
+
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
1258
|
+
|
|
1259
|
+
normalize_hash(JSON.parse(response.body))
|
|
1260
|
+
rescue
|
|
1261
|
+
{}
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def sso_decode_jwt_payload(token)
|
|
1265
|
+
payload = token.to_s.split(".")[1]
|
|
1266
|
+
return {} unless payload
|
|
1267
|
+
|
|
1268
|
+
JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
|
|
1269
|
+
rescue
|
|
1270
|
+
{}
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
def sso_append_error(url, error, description = nil)
|
|
1274
|
+
separator = url.to_s.include?("?") ? "&" : "?"
|
|
1275
|
+
query = {error: error, error_description: description}.compact
|
|
1276
|
+
"#{url}#{separator}#{URI.encode_www_form(query)}"
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def sso_default_provider(config, provider_id:, domain:)
|
|
1280
|
+
Array(config[:default_sso]).each do |raw_provider|
|
|
1281
|
+
default_provider = normalize_hash(raw_provider)
|
|
1282
|
+
next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
|
|
1283
|
+
next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
|
|
1284
|
+
|
|
1285
|
+
oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
|
|
1286
|
+
saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
|
|
1287
|
+
return {
|
|
1288
|
+
"issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
|
|
1289
|
+
"providerId" => default_provider.fetch(:provider_id),
|
|
1290
|
+
"userId" => "default",
|
|
1291
|
+
"domain" => default_provider[:domain],
|
|
1292
|
+
"domainVerified" => true,
|
|
1293
|
+
"oidcConfig" => oidc_config,
|
|
1294
|
+
"samlConfig" => saml_config
|
|
1295
|
+
}.compact
|
|
1296
|
+
end
|
|
1297
|
+
nil
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def sso_oidc_pkce_state(provider)
|
|
1301
|
+
return {} unless normalize_hash(provider["oidcConfig"] || {})[:pkce]
|
|
1302
|
+
|
|
1303
|
+
{codeVerifier: SecureRandom.urlsafe_base64(48)}
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def sso_decode_state(state, secret)
|
|
1307
|
+
BetterAuth::Crypto.verify_jwt(state.to_s, secret)
|
|
1308
|
+
rescue
|
|
1309
|
+
nil
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
def sso_base64_urlsafe(value)
|
|
1313
|
+
Base64.strict_encode64(value).tr("+/", "-_").delete("=")
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def sso_storage_config(config)
|
|
1317
|
+
normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
|
|
1318
|
+
result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
|
|
1319
|
+
end
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def sso_provider_limit(user, config)
|
|
1323
|
+
limit = config[:providers_limit]
|
|
1324
|
+
limit = 10 if limit.nil?
|
|
1325
|
+
limit.respond_to?(:call) ? limit.call(user) : limit
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
def sso_validate_url!(value, message)
|
|
1329
|
+
uri = URI(value.to_s)
|
|
1330
|
+
unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
1331
|
+
raise APIError.new("BAD_REQUEST", message: message)
|
|
1332
|
+
end
|
|
1333
|
+
rescue URI::InvalidURIError
|
|
1334
|
+
raise APIError.new("BAD_REQUEST", message: message)
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
def sso_validate_organization_membership!(ctx, user_id, organization_id)
|
|
1338
|
+
member = ctx.context.adapter.find_one(
|
|
1339
|
+
model: "member",
|
|
1340
|
+
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
1341
|
+
)
|
|
1342
|
+
raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
|
|
1343
|
+
end
|
|
1344
|
+
|
|
1345
|
+
def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
|
|
1346
|
+
existing = oidc_config.merge(issuer: issuer)
|
|
1347
|
+
discovered = sso_discover_oidc_config(
|
|
1348
|
+
issuer: issuer,
|
|
1349
|
+
existing_config: existing,
|
|
1350
|
+
fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
|
|
1351
|
+
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
1352
|
+
)
|
|
1353
|
+
existing.merge(discovered)
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def sso_oidc_needs_runtime_discovery?(oidc_config)
|
|
1357
|
+
config = normalize_hash(oidc_config || {})
|
|
1358
|
+
config[:authorization_endpoint].to_s.empty? ||
|
|
1359
|
+
config[:token_endpoint].to_s.empty?
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
|
|
1363
|
+
oidc_config = normalize_hash(provider["oidcConfig"] || {})
|
|
1364
|
+
needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
|
|
1365
|
+
return provider if !needs_discovery
|
|
1366
|
+
|
|
1367
|
+
discovered = sso_discover_oidc_config(
|
|
1368
|
+
issuer: provider.fetch("issuer"),
|
|
1369
|
+
existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
|
|
1370
|
+
fetch: plugin_config[:oidc_discovery_fetch],
|
|
1371
|
+
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
1372
|
+
)
|
|
1373
|
+
provider.merge("oidcConfig" => oidc_config.merge(discovered))
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
def sso_oidc_redirect_uri(context, provider_id)
|
|
1377
|
+
redirect_uri = context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:redirect_uri, nil)
|
|
1378
|
+
if redirect_uri && !redirect_uri.to_s.strip.empty?
|
|
1379
|
+
value = redirect_uri.to_s
|
|
1380
|
+
return value if URI(value).absolute?
|
|
1381
|
+
|
|
1382
|
+
path = value.start_with?("/") ? value : "/#{value}"
|
|
1383
|
+
return "#{context.base_url}#{path}"
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
1387
|
+
rescue URI::InvalidURIError
|
|
1388
|
+
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
1389
|
+
end
|
|
1390
|
+
|
|
550
1391
|
def sso_email_domain_matches?(email_domain, provider_domain)
|
|
1392
|
+
email_domain = email_domain.to_s.strip.downcase
|
|
1393
|
+
email_domain = email_domain.split("@", 2).last if email_domain.include?("@")
|
|
1394
|
+
return false if email_domain.to_s.empty?
|
|
1395
|
+
|
|
551
1396
|
provider_domain.to_s.split(",").map { |value| value.strip.downcase }.reject(&:empty?).any? do |domain|
|
|
552
1397
|
email_domain == domain || email_domain.end_with?(".#{domain}")
|
|
553
1398
|
end
|
|
@@ -563,7 +1408,7 @@ module BetterAuth
|
|
|
563
1408
|
def sso_provider_access?(provider, user_id, ctx)
|
|
564
1409
|
organization_id = provider["organizationId"]
|
|
565
1410
|
return provider["userId"] == user_id if organization_id.to_s.empty?
|
|
566
|
-
return
|
|
1411
|
+
return provider["userId"] == user_id unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
|
|
567
1412
|
|
|
568
1413
|
member = ctx.context.adapter.find_one(
|
|
569
1414
|
model: "member",
|
|
@@ -572,6 +1417,53 @@ module BetterAuth
|
|
|
572
1417
|
Array(member&.fetch("role", nil).to_s.split(",")).map(&:strip).any? { |role| %w[owner admin].include?(role) }
|
|
573
1418
|
end
|
|
574
1419
|
|
|
1420
|
+
def sso_authorize_domain_verification!(ctx, provider, user_id)
|
|
1421
|
+
organization_id = provider["organizationId"]
|
|
1422
|
+
is_org_member = true
|
|
1423
|
+
if organization_id
|
|
1424
|
+
is_org_member = !!ctx.context.adapter.find_one(
|
|
1425
|
+
model: "member",
|
|
1426
|
+
where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
|
|
1427
|
+
)
|
|
1428
|
+
end
|
|
1429
|
+
return if provider["userId"] == user_id && is_org_member
|
|
1430
|
+
|
|
1431
|
+
raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "INSUFICCIENT_ACCESS")
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
def sso_domain_verification_identifier(config, provider_id)
|
|
1435
|
+
prefix = config.dig(:domain_verification, :token_prefix) || "better-auth-token"
|
|
1436
|
+
"_#{prefix}-#{provider_id}"
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
def sso_future_time?(value)
|
|
1440
|
+
time = value.is_a?(Time) ? value : Time.parse(value.to_s)
|
|
1441
|
+
time > Time.now
|
|
1442
|
+
rescue
|
|
1443
|
+
false
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
def sso_hostname_from_domain(domain)
|
|
1447
|
+
value = domain.to_s.strip
|
|
1448
|
+
return nil if value.empty?
|
|
1449
|
+
|
|
1450
|
+
uri = URI(value.include?("://") ? value : "https://#{value}")
|
|
1451
|
+
uri.host
|
|
1452
|
+
rescue URI::InvalidURIError
|
|
1453
|
+
nil
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
def sso_resolve_txt_records(hostname, config)
|
|
1457
|
+
resolver = config.dig(:domain_verification, :dns_txt_resolver)
|
|
1458
|
+
return Array(resolver.call(hostname)) if resolver.respond_to?(:call)
|
|
1459
|
+
|
|
1460
|
+
Resolv::DNS.open do |dns|
|
|
1461
|
+
dns.getresources(hostname, Resolv::DNS::Resource::IN::TXT).map { |record| record.strings }
|
|
1462
|
+
end
|
|
1463
|
+
rescue
|
|
1464
|
+
[]
|
|
1465
|
+
end
|
|
1466
|
+
|
|
575
1467
|
def sso_sanitize_provider(provider, context)
|
|
576
1468
|
data = provider.dup
|
|
577
1469
|
oidc_config = normalize_hash(data["oidcConfig"] || {})
|
|
@@ -579,12 +1471,19 @@ module BetterAuth
|
|
|
579
1471
|
data["type"] = saml_config.empty? ? "oidc" : "saml"
|
|
580
1472
|
data["organizationId"] ||= nil
|
|
581
1473
|
data["domainVerified"] = !!data["domainVerified"]
|
|
1474
|
+
data.delete("domainVerified") unless sso_context_domain_verification_enabled?(context)
|
|
582
1475
|
data["oidcConfig"] = oidc_config.empty? ? nil : sso_sanitize_oidc_config(oidc_config)
|
|
583
1476
|
data["samlConfig"] = saml_config.empty? ? nil : sso_sanitize_saml_config(saml_config)
|
|
584
1477
|
data["spMetadataUrl"] = "#{context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(data.fetch("providerId"))}"
|
|
585
1478
|
data.compact
|
|
586
1479
|
end
|
|
587
1480
|
|
|
1481
|
+
def sso_context_domain_verification_enabled?(context)
|
|
1482
|
+
context.options.plugins.any? do |plugin|
|
|
1483
|
+
plugin.id == "sso" && plugin.options.dig(:domain_verification, :enabled)
|
|
1484
|
+
end
|
|
1485
|
+
end
|
|
1486
|
+
|
|
588
1487
|
def sso_sanitize_config(config)
|
|
589
1488
|
data = normalize_hash(config || {})
|
|
590
1489
|
data.delete(:client_secret)
|
|
@@ -611,6 +1510,7 @@ module BetterAuth
|
|
|
611
1510
|
"callbackUrl" => config[:callback_url],
|
|
612
1511
|
"audience" => config[:audience],
|
|
613
1512
|
"wantAssertionsSigned" => config[:want_assertions_signed],
|
|
1513
|
+
"authnRequestsSigned" => config[:authn_requests_signed],
|
|
614
1514
|
"identifierFormat" => config[:identifier_format],
|
|
615
1515
|
"signatureAlgorithm" => config[:signature_algorithm],
|
|
616
1516
|
"digestAlgorithm" => config[:digest_algorithm],
|