better_auth-sso 0.8.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/sso/plugin/core.rb +183 -0
- data/lib/better_auth/sso/plugin/endpoints.rb +6 -4
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +21 -60
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +104 -22
- data/lib/better_auth/sso/plugin/provider_utils.rb +27 -2
- data/lib/better_auth/sso/plugin/providers.rb +5 -3
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +37 -11
- data/lib/better_auth/sso/plugin/saml_response.rb +26 -9
- data/lib/better_auth/sso/plugin/sign_in_and_oidc_callbacks.rb +13 -8
- 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 +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a30af57534227b0842402e5c2b0f0e1dba463c9e162313b30a64bbbba4dbdc4
|
|
4
|
+
data.tar.gz: 49028a1009cb10b762a6518f3a451d8de7513234cf3f322943724ce9c78e1609
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2964e7ccda5662d364b0c87243b67e0d486f6bd7786692364c438eaa20feeb054c405d6a49daf70fde3649ce709279fd964f4ebc51a10ecf3c02c238d92b7c33
|
|
7
|
+
data.tar.gz: c72d4f2b51525461c1628c34dfbad25c0f56cf1a5d416eb30be317061d105be7db027d107e37993a596e7f30a393ab951101babd269ac86b5c526ff1a7c3a895
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.10.0 - 2026-05-21
|
|
6
|
+
|
|
7
|
+
- Hardened SSO redirect, OIDC, SAML metadata, logout, and response handling.
|
|
8
|
+
- Expanded adapter, Rack edge-case, and rate-limit coverage.
|
|
9
|
+
|
|
5
10
|
## 0.7.0 - 2026-05-05
|
|
6
11
|
|
|
7
12
|
- Fixed SAML config validation for `singleSignOnService` and added validation for `singleLogoutService`.
|
|
@@ -66,6 +66,9 @@ module BetterAuth
|
|
|
66
66
|
SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
|
|
67
67
|
SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
68
68
|
SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
|
|
69
|
+
SSO_DEFAULT_OIDC_HTTP_TIMEOUT = 10
|
|
70
|
+
SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE = 1024 * 1024
|
|
71
|
+
SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX = "oidc-pkce-verifier:"
|
|
69
72
|
|
|
70
73
|
def sso(options = {})
|
|
71
74
|
config = normalize_hash(options)
|
|
@@ -135,5 +138,185 @@ module BetterAuth
|
|
|
135
138
|
def sso_schema(config = {})
|
|
136
139
|
BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
|
|
137
140
|
end
|
|
141
|
+
|
|
142
|
+
def sso_openapi_for(route)
|
|
143
|
+
{
|
|
144
|
+
register_provider: sso_register_provider_openapi,
|
|
145
|
+
sign_in: sso_sign_in_openapi,
|
|
146
|
+
saml_callback: sso_saml_callback_openapi,
|
|
147
|
+
saml_acs: sso_saml_acs_openapi,
|
|
148
|
+
saml_slo: sso_saml_slo_openapi,
|
|
149
|
+
initiate_slo: sso_initiate_slo_openapi,
|
|
150
|
+
update_provider: sso_update_provider_openapi,
|
|
151
|
+
delete_provider: sso_delete_provider_openapi
|
|
152
|
+
}.fetch(route)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def sso_register_provider_openapi
|
|
156
|
+
{
|
|
157
|
+
openapi: {
|
|
158
|
+
description: "Register an SSO provider",
|
|
159
|
+
requestBody: OpenAPI.json_request_body(sso_provider_body_schema(required_fields: ["provider_id", "issuer", "domain"])),
|
|
160
|
+
responses: {
|
|
161
|
+
"200" => OpenAPI.json_response("SSO provider registered", sso_provider_response_schema)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def sso_sign_in_openapi
|
|
168
|
+
{
|
|
169
|
+
openapi: {
|
|
170
|
+
description: "Start an SSO sign-in flow",
|
|
171
|
+
requestBody: OpenAPI.json_request_body(
|
|
172
|
+
OpenAPI.object_schema(
|
|
173
|
+
{
|
|
174
|
+
provider_id: {type: "string", description: "SSO provider ID"},
|
|
175
|
+
domain: {type: "string", description: "Email domain used to select a provider"},
|
|
176
|
+
provider_type: {type: "string", enum: ["oidc", "saml"], description: "Preferred provider protocol"},
|
|
177
|
+
callback_url: {type: "string", description: "URL to redirect to after successful sign-in"},
|
|
178
|
+
error_callback_url: {type: "string", description: "URL to redirect to on sign-in error"},
|
|
179
|
+
new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
|
|
180
|
+
request_sign_up: {type: "boolean", description: "Whether the flow is requesting sign-up"}
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
),
|
|
184
|
+
responses: {
|
|
185
|
+
"200" => OpenAPI.json_response("SSO sign-in URL", OpenAPI.object_schema({url: {type: "string"}, redirect: {type: "boolean"}}, required: ["url", "redirect"]))
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sso_saml_callback_openapi
|
|
192
|
+
{
|
|
193
|
+
openapi: {
|
|
194
|
+
description: "Handle a SAML identity provider callback",
|
|
195
|
+
requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
|
|
196
|
+
responses: {
|
|
197
|
+
"200" => OpenAPI.json_response("SAML callback handled", {type: "object", additionalProperties: true})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def sso_saml_acs_openapi
|
|
204
|
+
{
|
|
205
|
+
openapi: {
|
|
206
|
+
description: "Handle a SAML assertion consumer service response",
|
|
207
|
+
requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
|
|
208
|
+
responses: {
|
|
209
|
+
"200" => OpenAPI.json_response("SAML response handled", {type: "object", additionalProperties: true})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def sso_saml_slo_openapi
|
|
216
|
+
{
|
|
217
|
+
openapi: {
|
|
218
|
+
description: "Handle SAML single logout",
|
|
219
|
+
requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
|
|
220
|
+
responses: {
|
|
221
|
+
"200" => OpenAPI.json_response("SAML single logout handled", {type: "object", additionalProperties: true})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def sso_initiate_slo_openapi
|
|
228
|
+
{
|
|
229
|
+
openapi: {
|
|
230
|
+
description: "Initiate SAML single logout",
|
|
231
|
+
requestBody: OpenAPI.json_request_body(
|
|
232
|
+
OpenAPI.object_schema(
|
|
233
|
+
{
|
|
234
|
+
callback_url: {type: "string", description: "URL to return to after logout"}
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
required: false
|
|
238
|
+
),
|
|
239
|
+
responses: {
|
|
240
|
+
"200" => OpenAPI.json_response("SAML logout initiated", {type: "object", additionalProperties: true})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def sso_update_provider_openapi
|
|
247
|
+
{
|
|
248
|
+
openapi: {
|
|
249
|
+
description: "Update an SSO provider",
|
|
250
|
+
requestBody: OpenAPI.json_request_body(sso_provider_body_schema(required_fields: [])),
|
|
251
|
+
responses: {
|
|
252
|
+
"200" => OpenAPI.json_response("SSO provider updated", sso_provider_response_schema)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def sso_delete_provider_openapi
|
|
259
|
+
{
|
|
260
|
+
openapi: {
|
|
261
|
+
description: "Delete an SSO provider",
|
|
262
|
+
requestBody: OpenAPI.json_request_body(
|
|
263
|
+
OpenAPI.object_schema(
|
|
264
|
+
{
|
|
265
|
+
provider_id: {type: "string", description: "SSO provider ID"}
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
),
|
|
269
|
+
responses: {
|
|
270
|
+
"200" => OpenAPI.json_response("SSO provider deleted", OpenAPI.success_response_schema)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def sso_provider_body_schema(required_fields:)
|
|
277
|
+
OpenAPI.object_schema(
|
|
278
|
+
{
|
|
279
|
+
provider_id: {type: "string", description: "SSO provider ID"},
|
|
280
|
+
issuer: {type: "string", description: "SSO provider issuer URL"},
|
|
281
|
+
domain: {type: "string", description: "Email domain for the provider"},
|
|
282
|
+
oidc_config: {type: "object", additionalProperties: true, description: "OIDC provider configuration"},
|
|
283
|
+
saml_config: {type: "object", additionalProperties: true, description: "SAML provider configuration"},
|
|
284
|
+
organization_id: {type: "string", description: "Organization ID for this provider"},
|
|
285
|
+
override_user_info: {type: "boolean", description: "Whether to override OIDC user info with ID token claims"}
|
|
286
|
+
},
|
|
287
|
+
required: required_fields
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def sso_provider_response_schema
|
|
292
|
+
OpenAPI.object_schema(
|
|
293
|
+
{
|
|
294
|
+
id: {type: "string"},
|
|
295
|
+
providerId: {type: "string"},
|
|
296
|
+
issuer: {type: "string"},
|
|
297
|
+
domain: {type: "string"},
|
|
298
|
+
oidcConfig: {type: ["object", "null"], additionalProperties: true},
|
|
299
|
+
samlConfig: {type: ["object", "null"], additionalProperties: true},
|
|
300
|
+
userId: {type: "string"},
|
|
301
|
+
organizationId: {type: ["string", "null"]},
|
|
302
|
+
domainVerified: {type: "boolean"},
|
|
303
|
+
redirectURI: {type: "string"},
|
|
304
|
+
domainVerificationToken: {type: "string"}
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def sso_saml_message_schema
|
|
310
|
+
OpenAPI.object_schema(
|
|
311
|
+
{
|
|
312
|
+
SAMLResponse: {type: "string", description: "SAML response"},
|
|
313
|
+
SAMLRequest: {type: "string", description: "SAML logout request"},
|
|
314
|
+
RelayState: {type: "string", description: "SAML relay state"},
|
|
315
|
+
saml_response: {type: "string", description: "SAML response"},
|
|
316
|
+
saml_request: {type: "string", description: "SAML logout request"},
|
|
317
|
+
relay_state: {type: "string", description: "SAML relay state"}
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
end
|
|
138
321
|
end
|
|
139
322
|
end
|
|
@@ -5,13 +5,13 @@ module BetterAuth
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def sso_saml_callback_endpoint(config)
|
|
8
|
-
Endpoint.new(path: "/sso/saml2/callback/:providerId", method: ["GET", "POST"], metadata:
|
|
8
|
+
Endpoint.new(path: "/sso/saml2/callback/:providerId", method: ["GET", "POST"], metadata: sso_openapi_for(:saml_callback).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
|
|
9
9
|
sso_handle_saml_response(ctx, config)
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def sso_saml_acs_endpoint(config)
|
|
14
|
-
Endpoint.new(path: "/sso/saml2/sp/acs/:providerId", method: "POST", metadata:
|
|
14
|
+
Endpoint.new(path: "/sso/saml2/sp/acs/:providerId", method: "POST", metadata: sso_openapi_for(:saml_acs).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
|
|
15
15
|
sso_handle_saml_response(ctx, config)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -30,7 +30,7 @@ module BetterAuth
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def sso_saml_slo_endpoint(config = {})
|
|
33
|
-
Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata:
|
|
33
|
+
Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata: sso_openapi_for(:saml_slo).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
|
|
34
34
|
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
35
35
|
|
|
36
36
|
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
@@ -44,6 +44,8 @@ module BetterAuth
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
raw_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
|
|
47
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid LogoutRequest") if raw_request.to_s.empty?
|
|
48
|
+
|
|
47
49
|
sso_validate_saml_slo_signature!(ctx, raw_request, "Invalid LogoutRequest") if config.dig(:saml, :want_logout_request_signed)
|
|
48
50
|
logout_request_data = sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
49
51
|
in_response_to = logout_request_data[:id].to_s.empty? ? "" : " InResponseTo=\"#{CGI.escapeHTML(logout_request_data[:id].to_s)}\""
|
|
@@ -59,7 +61,7 @@ module BetterAuth
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
def sso_initiate_slo_endpoint(config = {})
|
|
62
|
-
Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST") do |ctx|
|
|
64
|
+
Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST", metadata: sso_openapi_for(:initiate_slo)) do |ctx|
|
|
63
65
|
raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
|
|
64
66
|
|
|
65
67
|
session = Routes.current_session(ctx)
|
|
@@ -5,71 +5,32 @@ module BetterAuth
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def sso_discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, timeout: nil)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
valid = document[:issuer].to_s.sub(%r{/+\z}, "") == issuer.to_s.sub(%r{/+\z}, "") &&
|
|
21
|
-
!document[:authorization_endpoint].to_s.empty? &&
|
|
22
|
-
!document[:token_endpoint].to_s.empty? &&
|
|
23
|
-
!document[:jwks_uri].to_s.empty?
|
|
24
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document") unless valid
|
|
8
|
+
wrapped_fetch = sso_oidc_discovery_fetcher(fetch)
|
|
9
|
+
BetterAuth::SSO::OIDC::Discovery.discover_oidc_config(
|
|
10
|
+
issuer: issuer,
|
|
11
|
+
fetch: wrapped_fetch,
|
|
12
|
+
existing_config: existing_config,
|
|
13
|
+
discovery_endpoint: discovery_endpoint,
|
|
14
|
+
trusted_origin: trusted_origin,
|
|
15
|
+
timeout: timeout || SSO_DEFAULT_OIDC_HTTP_TIMEOUT
|
|
16
|
+
)
|
|
17
|
+
rescue BetterAuth::SSO::OIDC::DiscoveryError => error
|
|
18
|
+
raise BetterAuth::SSO::OIDC::Errors.api_error(error)
|
|
19
|
+
end
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
jwks_endpoint = sso_normalize_discovery_url(document[:jwks_uri], issuer, trusted_origin)
|
|
29
|
-
user_info_endpoint = document[:userinfo_endpoint] && sso_normalize_discovery_url(document[:userinfo_endpoint], issuer, trusted_origin)
|
|
30
|
-
auth_methods = Array(document[:token_endpoint_auth_methods_supported])
|
|
31
|
-
token_endpoint_authentication = if existing[:token_endpoint_authentication]
|
|
32
|
-
existing[:token_endpoint_authentication]
|
|
33
|
-
elsif auth_methods.include?("client_secret_post") && !auth_methods.include?("client_secret_basic")
|
|
34
|
-
"client_secret_post"
|
|
35
|
-
else
|
|
36
|
-
"client_secret_basic"
|
|
37
|
-
end
|
|
21
|
+
def sso_oidc_discovery_fetcher(fetch)
|
|
22
|
+
return nil unless fetch
|
|
38
23
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
authorization_endpoint: existing[:authorization_endpoint] || authorization_endpoint,
|
|
44
|
-
token_endpoint: existing[:token_endpoint] || token_endpoint,
|
|
45
|
-
jwks_endpoint: existing[:jwks_endpoint] || jwks_endpoint,
|
|
46
|
-
user_info_endpoint: existing[:user_info_endpoint] || user_info_endpoint,
|
|
47
|
-
token_endpoint_authentication: token_endpoint_authentication,
|
|
48
|
-
scopes_supported: existing[:scopes_supported] || document[:scopes_supported]
|
|
49
|
-
}.compact
|
|
50
|
-
rescue APIError
|
|
51
|
-
raise
|
|
52
|
-
rescue
|
|
53
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
|
|
24
|
+
->(url, timeout: nil) do
|
|
25
|
+
accepts_keywords = fetch.parameters.any? { |kind, name| kind == :keyrest || (kind == :key && name == :timeout) }
|
|
26
|
+
accepts_keywords ? fetch.call(url, timeout: timeout) : fetch.call(url)
|
|
27
|
+
end
|
|
54
28
|
end
|
|
55
29
|
|
|
56
30
|
def sso_normalize_discovery_url(value, issuer, trusted_origin)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
else
|
|
61
|
-
issuer_uri = URI(issuer.to_s)
|
|
62
|
-
issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
|
|
63
|
-
endpoint = value.to_s.sub(%r{\A/+}, "")
|
|
64
|
-
"#{issuer_base}/#{endpoint}"
|
|
65
|
-
end
|
|
66
|
-
if trusted_origin && !trusted_origin.call(normalized)
|
|
67
|
-
raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
normalized
|
|
71
|
-
rescue URI::InvalidURIError
|
|
72
|
-
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
|
|
31
|
+
BetterAuth::SSO::OIDC::Discovery.normalize_url("url", value, issuer, trusted_origin)
|
|
32
|
+
rescue BetterAuth::SSO::OIDC::DiscoveryError => error
|
|
33
|
+
raise BetterAuth::SSO::OIDC::Errors.api_error(error)
|
|
73
34
|
end
|
|
74
35
|
end
|
|
75
36
|
end
|
|
@@ -11,7 +11,7 @@ module BetterAuth
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
|
|
14
|
-
config =
|
|
14
|
+
config = sso_provider_config_hash(provider["oidcConfig"])
|
|
15
15
|
endpoint = config[:authorization_endpoint] || config[:authorization_url]
|
|
16
16
|
raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
|
|
17
17
|
|
|
@@ -23,13 +23,14 @@ module BetterAuth
|
|
|
23
23
|
scope: scopes.join(" "),
|
|
24
24
|
state: state
|
|
25
25
|
}.compact
|
|
26
|
-
|
|
26
|
+
decoded_state = sso_decode_state(state, ctx.context.secret)
|
|
27
|
+
nonce = decoded_state&.fetch("nonce", nil)
|
|
27
28
|
query[:nonce] = nonce if nonce && !nonce.to_s.empty?
|
|
28
29
|
login_hint = body[:login_hint] || body[:email]
|
|
29
30
|
query[:login_hint] = login_hint if login_hint
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
query[:code_challenge] =
|
|
31
|
+
code_challenge = decoded_state&.fetch("codeChallenge", nil)
|
|
32
|
+
if code_challenge
|
|
33
|
+
query[:code_challenge] = code_challenge
|
|
33
34
|
query[:code_challenge_method] = "S256"
|
|
34
35
|
end
|
|
35
36
|
"#{endpoint}?#{URI.encode_www_form(query)}"
|
|
@@ -41,7 +42,7 @@ module BetterAuth
|
|
|
41
42
|
return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
config =
|
|
45
|
+
config = sso_provider_config_hash(provider["samlConfig"])
|
|
45
46
|
metadata = sso_saml_idp_metadata(config)
|
|
46
47
|
entry_point = config[:entry_point] || normalize_hash(sso_saml_preferred_service(metadata[:single_sign_on_service]) || {})[:location]
|
|
47
48
|
query = {
|
|
@@ -101,7 +102,7 @@ module BetterAuth
|
|
|
101
102
|
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
|
|
102
103
|
end
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
return {identifier: identifier}
|
|
105
106
|
elsif config.dig(:saml, :allow_idp_initiated) == false
|
|
106
107
|
return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
|
|
107
108
|
end
|
|
@@ -109,6 +110,11 @@ module BetterAuth
|
|
|
109
110
|
nil
|
|
110
111
|
end
|
|
111
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
|
+
|
|
112
118
|
def sso_parse_saml_authn_request_record(value)
|
|
113
119
|
JSON.parse(value.to_s)
|
|
114
120
|
rescue
|
|
@@ -170,12 +176,13 @@ module BetterAuth
|
|
|
170
176
|
ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
171
177
|
end
|
|
172
178
|
|
|
173
|
-
def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config)
|
|
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])
|
|
174
181
|
token_callback = oidc_config[:get_token]
|
|
175
182
|
if token_callback.respond_to?(:call)
|
|
176
183
|
return normalize_hash(token_callback.call(
|
|
177
184
|
code: ctx.query[:code] || ctx.query["code"],
|
|
178
|
-
codeVerifier:
|
|
185
|
+
codeVerifier: code_verifier,
|
|
179
186
|
redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
180
187
|
provider: provider,
|
|
181
188
|
context: ctx
|
|
@@ -188,17 +195,19 @@ module BetterAuth
|
|
|
188
195
|
sso_exchange_oidc_code(
|
|
189
196
|
token_endpoint: token_endpoint,
|
|
190
197
|
code: ctx.query[:code] || ctx.query["code"],
|
|
191
|
-
code_verifier:
|
|
198
|
+
code_verifier: code_verifier,
|
|
192
199
|
redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
|
|
193
200
|
client_id: oidc_config[:client_id],
|
|
194
201
|
client_secret: oidc_config[:client_secret],
|
|
195
|
-
authentication: oidc_config[:token_endpoint_authentication]
|
|
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]
|
|
196
205
|
)
|
|
197
206
|
rescue
|
|
198
207
|
nil
|
|
199
208
|
end
|
|
200
209
|
|
|
201
|
-
def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:)
|
|
210
|
+
def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:, timeout: nil, max_body_size: nil)
|
|
202
211
|
uri = URI(token_endpoint.to_s)
|
|
203
212
|
request = Net::HTTP::Post.new(uri)
|
|
204
213
|
form = {
|
|
@@ -214,8 +223,15 @@ module BetterAuth
|
|
|
214
223
|
request.basic_auth(client_id.to_s, client_secret.to_s)
|
|
215
224
|
end
|
|
216
225
|
request.set_form_data(form)
|
|
217
|
-
response = Net::HTTP.start(
|
|
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) }
|
|
218
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)
|
|
219
235
|
|
|
220
236
|
normalize_hash(JSON.parse(response.body))
|
|
221
237
|
end
|
|
@@ -225,7 +241,7 @@ module BetterAuth
|
|
|
225
241
|
raw = if user_callback.respond_to?(:call)
|
|
226
242
|
user_callback.call(tokens)
|
|
227
243
|
elsif oidc_config[:user_info_endpoint]
|
|
228
|
-
sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token])
|
|
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])
|
|
229
245
|
elsif tokens[:id_token]
|
|
230
246
|
return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
|
|
231
247
|
|
|
@@ -256,12 +272,19 @@ module BetterAuth
|
|
|
256
272
|
)
|
|
257
273
|
end
|
|
258
274
|
|
|
259
|
-
def sso_fetch_oidc_user_info(endpoint, access_token)
|
|
275
|
+
def sso_fetch_oidc_user_info(endpoint, access_token, timeout: nil, max_body_size: nil)
|
|
260
276
|
uri = URI(endpoint.to_s)
|
|
261
277
|
request = Net::HTTP::Get.new(uri)
|
|
262
278
|
request["authorization"] = "Bearer #{access_token}"
|
|
263
|
-
response = Net::HTTP.start(
|
|
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) }
|
|
264
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)
|
|
265
288
|
|
|
266
289
|
JSON.parse(response.body)
|
|
267
290
|
rescue
|
|
@@ -297,8 +320,15 @@ module BetterAuth
|
|
|
297
320
|
end
|
|
298
321
|
|
|
299
322
|
uri = URI(jwks_endpoint.to_s)
|
|
300
|
-
response = Net::HTTP.
|
|
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) }
|
|
301
330
|
return {} unless response.is_a?(Net::HTTPSuccess)
|
|
331
|
+
return {} if response.body.to_s.bytesize > SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
|
|
302
332
|
|
|
303
333
|
normalize_hash(JSON.parse(response.body))
|
|
304
334
|
rescue
|
|
@@ -342,9 +372,40 @@ module BetterAuth
|
|
|
342
372
|
end
|
|
343
373
|
|
|
344
374
|
def sso_oidc_pkce_state(provider)
|
|
345
|
-
return {} unless
|
|
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
|
|
346
400
|
|
|
347
|
-
|
|
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
|
|
348
409
|
end
|
|
349
410
|
|
|
350
411
|
def sso_decode_state(state, secret)
|
|
@@ -392,7 +453,8 @@ module BetterAuth
|
|
|
392
453
|
issuer: issuer,
|
|
393
454
|
existing_config: existing,
|
|
394
455
|
fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
|
|
395
|
-
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
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)
|
|
396
458
|
)
|
|
397
459
|
existing.merge(discovered)
|
|
398
460
|
end
|
|
@@ -404,7 +466,7 @@ module BetterAuth
|
|
|
404
466
|
end
|
|
405
467
|
|
|
406
468
|
def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
|
|
407
|
-
oidc_config =
|
|
469
|
+
oidc_config = sso_provider_config_hash(provider["oidcConfig"])
|
|
408
470
|
needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
|
|
409
471
|
return provider if !needs_discovery
|
|
410
472
|
|
|
@@ -412,9 +474,29 @@ module BetterAuth
|
|
|
412
474
|
issuer: provider.fetch("issuer"),
|
|
413
475
|
existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
|
|
414
476
|
fetch: plugin_config[:oidc_discovery_fetch],
|
|
415
|
-
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) }
|
|
477
|
+
trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) },
|
|
478
|
+
timeout: plugin_config[:oidc_http_timeout]
|
|
416
479
|
)
|
|
417
480
|
provider.merge("oidcConfig" => oidc_config.merge(discovered))
|
|
418
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
|
|
419
501
|
end
|
|
420
502
|
end
|
|
@@ -14,9 +14,9 @@ module BetterAuth
|
|
|
14
14
|
return "#{context.base_url}#{path}"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
17
|
+
"#{context.base_url}/sso/callback/#{URI.encode_www_form_component(provider_id.to_s)}"
|
|
18
18
|
rescue URI::InvalidURIError
|
|
19
|
-
"#{context.base_url}/sso/callback/#{provider_id}"
|
|
19
|
+
"#{context.base_url}/sso/callback/#{URI.encode_www_form_component(provider_id.to_s)}"
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def sso_email_domain_matches?(email_domain, provider_domain)
|
|
@@ -36,6 +36,18 @@ module BetterAuth
|
|
|
36
36
|
provider
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def sso_find_saml_provider!(ctx, provider_id, config = {})
|
|
40
|
+
if config[:default_sso]
|
|
41
|
+
provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
|
|
42
|
+
return provider if provider && provider["samlConfig"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
provider = ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
|
|
46
|
+
raise APIError.new("NOT_FOUND", message: "Provider not found", code: "PROVIDER_NOT_FOUND") unless provider && provider["samlConfig"]
|
|
47
|
+
|
|
48
|
+
provider
|
|
49
|
+
end
|
|
50
|
+
|
|
39
51
|
def sso_provider_access?(provider, user_id, ctx)
|
|
40
52
|
organization_id = provider["organizationId"]
|
|
41
53
|
return provider["userId"] == user_id if organization_id.to_s.empty?
|
|
@@ -212,5 +224,18 @@ module BetterAuth
|
|
|
212
224
|
def sso_redirect(ctx, location)
|
|
213
225
|
[302, ctx.response_headers.merge("location" => location), [""]]
|
|
214
226
|
end
|
|
227
|
+
|
|
228
|
+
def sso_safe_oidc_redirect_url(ctx, url)
|
|
229
|
+
app_origin = ctx.context.base_url
|
|
230
|
+
value = url.to_s
|
|
231
|
+
return app_origin if value.empty?
|
|
232
|
+
|
|
233
|
+
return value if value.start_with?("/") && !value.start_with?("//")
|
|
234
|
+
return app_origin unless ctx.context.trusted_origin?(value, allow_relative_paths: false)
|
|
235
|
+
|
|
236
|
+
value
|
|
237
|
+
rescue
|
|
238
|
+
app_origin
|
|
239
|
+
end
|
|
215
240
|
end
|
|
216
241
|
end
|
|
@@ -5,7 +5,7 @@ module BetterAuth
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def sso_register_provider_endpoint(config = {})
|
|
8
|
-
Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
|
|
8
|
+
Endpoint.new(path: "/sso/register", method: "POST", metadata: sso_openapi_for(:register_provider)) do |ctx|
|
|
9
9
|
session = Routes.current_session(ctx)
|
|
10
10
|
body = normalize_hash(ctx.body)
|
|
11
11
|
provider_id = body[:provider_id].to_s
|
|
@@ -28,6 +28,7 @@ module BetterAuth
|
|
|
28
28
|
|
|
29
29
|
oidc_config = normalize_hash(body[:oidc_config] || {})
|
|
30
30
|
oidc_config = sso_hydrate_oidc_config(body[:issuer], oidc_config, ctx) if oidc_config.any? && !oidc_config[:skip_discovery]
|
|
31
|
+
sso_validate_oidc_endpoint_origins!(ctx, oidc_config) if oidc_config.any?
|
|
31
32
|
oidc_config[:override_user_info] = !!(body[:override_user_info] || config[:default_override_user_info]) if oidc_config.any?
|
|
32
33
|
saml_config = normalize_hash(body[:saml_config] || {})
|
|
33
34
|
sso_validate_saml_config!(saml_config, config) unless saml_config.empty?
|
|
@@ -82,7 +83,7 @@ module BetterAuth
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def sso_update_provider_endpoint(config = {})
|
|
85
|
-
Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
|
|
86
|
+
Endpoint.new(path: "/sso/update-provider", method: "POST", metadata: sso_openapi_for(:update_provider)) do |ctx|
|
|
86
87
|
session = Routes.current_session(ctx)
|
|
87
88
|
body = normalize_hash(ctx.body)
|
|
88
89
|
provider = sso_find_provider!(ctx, sso_fetch(body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
@@ -102,6 +103,7 @@ module BetterAuth
|
|
|
102
103
|
|
|
103
104
|
resolved_issuer = update[:issuer] || current[:issuer] || provider["issuer"]
|
|
104
105
|
update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config])).merge(issuer: resolved_issuer).compact
|
|
106
|
+
sso_validate_oidc_endpoint_origins!(ctx, update[:oidcConfig])
|
|
105
107
|
end
|
|
106
108
|
if body.key?(:saml_config)
|
|
107
109
|
current = sso_provider_config_hash(provider["samlConfig"])
|
|
@@ -118,7 +120,7 @@ module BetterAuth
|
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
def sso_delete_provider_endpoint
|
|
121
|
-
Endpoint.new(path: "/sso/delete-provider", method: "POST") do |ctx|
|
|
123
|
+
Endpoint.new(path: "/sso/delete-provider", method: "POST", metadata: sso_openapi_for(:delete_provider)) do |ctx|
|
|
122
124
|
session = Routes.current_session(ctx)
|
|
123
125
|
provider = sso_find_provider!(ctx, sso_fetch(ctx.body, :provider_id) || sso_fetch(ctx.params, :provider_id))
|
|
124
126
|
raise APIError.new("FORBIDDEN", message: "You don't have access to this provider") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
|
|
@@ -6,13 +6,16 @@ module BetterAuth
|
|
|
6
6
|
|
|
7
7
|
def sso_validate_saml_config!(saml_config, plugin_config = {})
|
|
8
8
|
metadata = saml_config[:idp_metadata] || saml_config[:metadata] || saml_config[:idp_metadata_xml]
|
|
9
|
+
idp_metadata = normalize_hash(saml_config[:idp_metadata] || {})
|
|
10
|
+
has_idp_metadata_xml = !idp_metadata[:metadata].to_s.empty? || !saml_config[:metadata].to_s.empty? || !saml_config[:idp_metadata_xml].to_s.empty?
|
|
11
|
+
has_idp_sso_service = !Array(idp_metadata[:single_sign_on_service] || saml_config[:single_sign_on_service]).empty?
|
|
9
12
|
max_metadata_size = plugin_config.dig(:saml, :max_metadata_size) || SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
10
13
|
if metadata.to_s.bytesize > max_metadata_size
|
|
11
14
|
raise APIError.new("BAD_REQUEST", message: "IdP metadata exceeds maximum allowed size (#{max_metadata_size} bytes)")
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
if saml_config[:entry_point].to_s.empty? &&
|
|
15
|
-
raise APIError.new("BAD_REQUEST", message: "SAML
|
|
17
|
+
if saml_config[:entry_point].to_s.empty? && !has_idp_sso_service && !has_idp_metadata_xml
|
|
18
|
+
raise APIError.new("BAD_REQUEST", message: "SAML configuration requires either idpMetadata.metadata, idpMetadata.singleSignOnService, or a valid entryPoint URL")
|
|
16
19
|
end
|
|
17
20
|
sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
|
|
18
21
|
unless saml_config[:single_sign_on_service].to_s.empty?
|
|
@@ -49,7 +52,7 @@ module BetterAuth
|
|
|
49
52
|
|
|
50
53
|
def sso_sp_metadata_xml(ctx, provider, config = {})
|
|
51
54
|
provider_id = provider.fetch("providerId")
|
|
52
|
-
saml_config =
|
|
55
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
53
56
|
explicit_metadata = saml_config.dig(:sp_metadata, :metadata)
|
|
54
57
|
return explicit_metadata unless explicit_metadata.to_s.empty?
|
|
55
58
|
|
|
@@ -57,19 +60,21 @@ module BetterAuth
|
|
|
57
60
|
acs_url = sso_saml_acs_url(ctx, provider)
|
|
58
61
|
authn_requests_signed = !!saml_config[:authn_requests_signed]
|
|
59
62
|
want_assertions_signed = saml_config.key?(:want_assertions_signed) ? !!saml_config[:want_assertions_signed] : true
|
|
60
|
-
|
|
63
|
+
escaped_entity_id = CGI.escapeHTML(entity_id.to_s)
|
|
64
|
+
escaped_acs_url = CGI.escapeHTML(acs_url.to_s)
|
|
65
|
+
name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{CGI.escapeHTML(saml_config[:identifier_format].to_s)}</NameIDFormat>"
|
|
61
66
|
slo = if config.dig(:saml, :enable_single_logout)
|
|
62
|
-
location = "#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}"
|
|
67
|
+
location = CGI.escapeHTML("#{ctx.context.base_url}/sso/saml2/sp/slo/#{URI.encode_www_form_component(provider_id)}")
|
|
63
68
|
"<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}\" />"
|
|
64
69
|
end
|
|
65
70
|
|
|
66
|
-
"<EntityDescriptor entityID=\"#{
|
|
71
|
+
"<EntityDescriptor entityID=\"#{escaped_entity_id}\"><SPSSODescriptor AuthnRequestsSigned=\"#{authn_requests_signed}\" WantAssertionsSigned=\"#{want_assertions_signed}\">#{slo}#{name_id_format}<AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"#{escaped_acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def sso_saml_acs_url(ctx, provider)
|
|
70
75
|
provider_id = provider.fetch("providerId")
|
|
71
76
|
base_url = ctx.context.base_url
|
|
72
|
-
configured =
|
|
77
|
+
configured = sso_provider_config_hash(provider["samlConfig"])[:callback_url].to_s
|
|
73
78
|
return configured if sso_saml_acs_url?(configured)
|
|
74
79
|
|
|
75
80
|
"#{base_url}/sso/saml2/sp/acs/#{URI.encode_www_form_component(provider_id)}"
|
|
@@ -154,12 +159,12 @@ module BetterAuth
|
|
|
154
159
|
end
|
|
155
160
|
|
|
156
161
|
def sso_saml_callback_url(provider)
|
|
157
|
-
saml_config =
|
|
162
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
158
163
|
saml_config[:callback_url]
|
|
159
164
|
end
|
|
160
165
|
|
|
161
166
|
def sso_saml_logout_destination(provider)
|
|
162
|
-
saml_config =
|
|
167
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
163
168
|
direct = saml_config[:single_logout_service] ||
|
|
164
169
|
saml_config[:single_logout_service_url] ||
|
|
165
170
|
saml_config[:idp_slo_service_url] ||
|
|
@@ -250,7 +255,13 @@ module BetterAuth
|
|
|
250
255
|
end
|
|
251
256
|
|
|
252
257
|
def sso_validate_saml_slo_signature!(ctx, raw_message, error_message)
|
|
253
|
-
|
|
258
|
+
signature = sso_fetch(ctx.body, :signature) || sso_fetch(ctx.query, :signature)
|
|
259
|
+
sig_alg = sso_fetch(ctx.body, :sig_alg) || sso_fetch(ctx.query, :sig_alg)
|
|
260
|
+
if !signature.to_s.empty? && !sig_alg.to_s.empty?
|
|
261
|
+
return true if sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)
|
|
262
|
+
|
|
263
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
264
|
+
end
|
|
254
265
|
|
|
255
266
|
xml = Base64.decode64(raw_message.to_s.gsub(/\s+/, ""))
|
|
256
267
|
return true if xml.include?("<Signature") || xml.include?(":Signature")
|
|
@@ -262,6 +273,21 @@ module BetterAuth
|
|
|
262
273
|
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
263
274
|
end
|
|
264
275
|
|
|
276
|
+
def sso_validate_saml_redirect_signature(ctx, raw_message, signature, sig_alg)
|
|
277
|
+
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
278
|
+
cert = sso_saml_idp_metadata(provider)[:cert]
|
|
279
|
+
certificate = OpenSSL::X509::Certificate.new(cert.to_s)
|
|
280
|
+
has_saml_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
|
|
281
|
+
saml_param = has_saml_request ? "SAMLRequest" : "SAMLResponse"
|
|
282
|
+
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
283
|
+
payload = [[saml_param, raw_message]]
|
|
284
|
+
payload << ["RelayState", relay_state] unless relay_state.to_s.empty?
|
|
285
|
+
payload << ["SigAlg", sig_alg]
|
|
286
|
+
certificate.public_key.verify(sso_saml_signature_digest(sig_alg), Base64.decode64(signature.to_s), URI.encode_www_form(payload))
|
|
287
|
+
rescue
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
|
|
265
291
|
def sso_parse_saml_logout_response(raw_response)
|
|
266
292
|
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
267
293
|
{
|
|
@@ -318,7 +344,7 @@ module BetterAuth
|
|
|
318
344
|
end
|
|
319
345
|
|
|
320
346
|
def sso_signed_saml_redirect_query(provider, query)
|
|
321
|
-
saml_config =
|
|
347
|
+
saml_config = sso_provider_config_hash(provider["samlConfig"])
|
|
322
348
|
private_key = saml_config.dig(:sp_metadata, :private_key) || saml_config[:private_key] || saml_config[:sp_private_key]
|
|
323
349
|
raise APIError.new("BAD_REQUEST", message: "SAML Redirect signing requires privateKey") if private_key.to_s.empty?
|
|
324
350
|
|
|
@@ -5,7 +5,7 @@ module BetterAuth
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def sso_handle_saml_response(ctx, config = {})
|
|
8
|
-
provider =
|
|
8
|
+
provider = sso_find_saml_provider!(ctx, sso_fetch(ctx.params, :provider_id), config)
|
|
9
9
|
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
10
10
|
state = sso_parse_saml_relay_state(ctx, relay_state) || {}
|
|
11
11
|
raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
@@ -21,19 +21,23 @@ module BetterAuth
|
|
|
21
21
|
if raw_response.to_s.bytesize > max_response_size
|
|
22
22
|
raise APIError.new("BAD_REQUEST", message: "SAML response exceeds maximum allowed size (#{max_response_size} bytes)")
|
|
23
23
|
end
|
|
24
|
-
|
|
25
|
-
return
|
|
24
|
+
in_response_to_result = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
|
|
25
|
+
return in_response_to_result if in_response_to_result.is_a?(Array)
|
|
26
26
|
|
|
27
27
|
assertion = sso_parse_saml_response(raw_response, config, provider, ctx)
|
|
28
28
|
assertion[:email_verified] = false unless config[:trust_email_verified]
|
|
29
29
|
sso_validate_saml_timestamp!(sso_saml_timestamp_conditions(assertion), config)
|
|
30
30
|
sso_validate_saml_response!(config, assertion, provider, ctx)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
sso_consume_saml_in_response_to(ctx, in_response_to_result)
|
|
32
|
+
assertion_id = assertion[:id] || assertion["id"]
|
|
33
|
+
unless assertion_id.to_s.empty?
|
|
34
|
+
replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
|
|
35
|
+
if ctx.context.internal_adapter.find_verification_value(replay_key)
|
|
36
|
+
callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
|
|
37
|
+
return sso_redirect(ctx, sso_append_error(callback_url, "replay_detected", "SAML assertion has already been used"))
|
|
38
|
+
end
|
|
39
|
+
ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
|
|
35
40
|
end
|
|
36
|
-
ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: sso_saml_assertion_replay_expires_at(assertion, config))
|
|
37
41
|
|
|
38
42
|
callback_url = sso_safe_saml_callback_url(ctx, state["callbackURL"] || sso_saml_callback_url(provider) || "/", provider.fetch("providerId"))
|
|
39
43
|
email = (assertion[:email] || assertion["email"]).to_s.downcase
|
|
@@ -77,6 +81,8 @@ module BetterAuth
|
|
|
77
81
|
end
|
|
78
82
|
if provider["samlConfig"]
|
|
79
83
|
return {error: "account_not_linked"} unless already_linked_provider || sso_saml_trusted_provider?(ctx, provider, email)
|
|
84
|
+
elsif !already_linked_provider && !sso_oidc_trusted_provider?(ctx, provider, email)
|
|
85
|
+
return {error: "account_not_linked"}
|
|
80
86
|
end
|
|
81
87
|
|
|
82
88
|
user = found[:user]
|
|
@@ -87,7 +93,7 @@ module BetterAuth
|
|
|
87
93
|
userId: user.fetch("id")
|
|
88
94
|
)
|
|
89
95
|
end
|
|
90
|
-
oidc_config =
|
|
96
|
+
oidc_config = sso_provider_config_hash(provider["oidcConfig"])
|
|
91
97
|
if oidc_config[:override_user_info] || config[:default_override_user_info]
|
|
92
98
|
update = {}
|
|
93
99
|
update[:name] = user_info[:name] if user_info.key?(:name)
|
|
@@ -124,6 +130,17 @@ module BetterAuth
|
|
|
124
130
|
trusted || (provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
|
|
125
131
|
end
|
|
126
132
|
|
|
133
|
+
def sso_oidc_trusted_provider?(ctx, provider, email)
|
|
134
|
+
provider_id = provider.fetch("providerId")
|
|
135
|
+
linking = ctx.context.options.account[:account_linking] || {}
|
|
136
|
+
return false if linking[:enabled] == false
|
|
137
|
+
|
|
138
|
+
trusted_providers = Array(linking[:trusted_providers]).map(&:to_s)
|
|
139
|
+
trusted_providers.include?(provider_id.to_s) ||
|
|
140
|
+
trusted_providers.include?("sso:#{provider_id}") ||
|
|
141
|
+
(provider["domainVerified"] && sso_email_domain_matches?(email, provider["domain"]))
|
|
142
|
+
end
|
|
143
|
+
|
|
127
144
|
def sso_assign_organization_membership(ctx, provider, user, config)
|
|
128
145
|
organization_id = provider["organizationId"]
|
|
129
146
|
return if organization_id.to_s.empty?
|
|
@@ -5,7 +5,7 @@ module BetterAuth
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def sso_sign_in_endpoint(config = {})
|
|
8
|
-
Endpoint.new(path: "/sign-in/sso", method: "POST") do |ctx|
|
|
8
|
+
Endpoint.new(path: "/sign-in/sso", method: "POST", metadata: sso_openapi_for(:sign_in)) do |ctx|
|
|
9
9
|
body = normalize_hash(ctx.body)
|
|
10
10
|
provider = sso_select_provider(ctx, body, config)
|
|
11
11
|
provider_type = body[:provider_type].to_s
|
|
@@ -29,11 +29,13 @@ module BetterAuth
|
|
|
29
29
|
|
|
30
30
|
if provider["oidcConfig"] && provider_type != "saml"
|
|
31
31
|
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
32
|
+
pkce = sso_oidc_pkce_state(provider)
|
|
32
33
|
state = BetterAuth::Crypto.sign_jwt(
|
|
33
|
-
state_data.merge({nonce: BetterAuth::Crypto.random_string(32)}).merge(
|
|
34
|
+
state_data.merge({nonce: BetterAuth::Crypto.random_string(32)}).merge(pkce.except(:codeVerifier)),
|
|
34
35
|
ctx.context.secret,
|
|
35
36
|
expires_in: 600
|
|
36
37
|
)
|
|
38
|
+
sso_store_oidc_pkce_verifier(ctx, state, pkce[:codeVerifier]) if pkce[:codeVerifier]
|
|
37
39
|
url = sso_oidc_authorization_url(provider, ctx, state, config, body)
|
|
38
40
|
elsif provider["samlConfig"]
|
|
39
41
|
relay_state = sso_generate_saml_relay_state(ctx, state_data)
|
|
@@ -65,8 +67,8 @@ module BetterAuth
|
|
|
65
67
|
state ||= sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
|
|
66
68
|
return ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
|
|
67
69
|
|
|
68
|
-
callback_url = state["callbackURL"] || "/"
|
|
69
|
-
error_url = state["errorURL"] || callback_url
|
|
70
|
+
callback_url = sso_safe_oidc_redirect_url(ctx, state["callbackURL"] || "/")
|
|
71
|
+
error_url = sso_safe_oidc_redirect_url(ctx, state["errorURL"] || callback_url)
|
|
70
72
|
if ctx.query[:error] || ctx.query["error"]
|
|
71
73
|
error = ctx.query[:error] || ctx.query["error"]
|
|
72
74
|
description = ctx.query[:error_description] || ctx.query["error_description"]
|
|
@@ -84,18 +86,19 @@ module BetterAuth
|
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
87
|
-
oidc_config =
|
|
89
|
+
oidc_config = sso_provider_config_hash(provider["oidcConfig"])
|
|
88
90
|
oidc_config[:issuer] ||= provider["issuer"]
|
|
89
91
|
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) if oidc_config.empty?
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
raw_state = ctx.query[:state] || ctx.query["state"]
|
|
94
|
+
tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config, raw_state: raw_state)
|
|
92
95
|
unless tokens
|
|
93
96
|
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "token_response_not_found"))
|
|
94
97
|
end
|
|
95
98
|
if oidc_config[:user_info_endpoint].to_s.empty? && tokens[:id_token] && oidc_config[:jwks_endpoint].to_s.empty?
|
|
96
99
|
begin
|
|
97
100
|
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config, require_jwks: true)
|
|
98
|
-
oidc_config =
|
|
101
|
+
oidc_config = sso_provider_config_hash(provider["oidcConfig"])
|
|
99
102
|
oidc_config[:issuer] ||= provider["issuer"]
|
|
100
103
|
rescue APIError
|
|
101
104
|
# Fall through to the upstream callback error when JWKS is still unavailable.
|
|
@@ -113,12 +116,14 @@ module BetterAuth
|
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
result = sso_find_or_create_user_result(ctx, provider, user_info, config)
|
|
119
|
+
return sso_redirect(ctx, sso_append_error(callback_url, result.fetch(:error))) if result[:error]
|
|
120
|
+
|
|
116
121
|
if config[:provision_user].respond_to?(:call) && (result.fetch(:created) || config[:provision_user_on_every_login])
|
|
117
122
|
config[:provision_user].call(user: result.fetch(:user), userInfo: user_info, token: tokens, provider: provider)
|
|
118
123
|
end
|
|
119
124
|
session = ctx.context.internal_adapter.create_session(result.fetch(:user).fetch("id"))
|
|
120
125
|
Cookies.set_session_cookie(ctx, {session: session, user: result.fetch(:user)})
|
|
121
|
-
redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? state["newUserURL"] : callback_url
|
|
126
|
+
redirect_to = (result.fetch(:created) && state["newUserURL"].to_s != "") ? sso_safe_oidc_redirect_url(ctx, state["newUserURL"]) : callback_url
|
|
122
127
|
sso_redirect(ctx, redirect_to || "/")
|
|
123
128
|
end
|
|
124
129
|
end
|
data/lib/better_auth/sso/saml.rb
CHANGED
|
@@ -66,7 +66,7 @@ module BetterAuth
|
|
|
66
66
|
name = [given_name, family_name].compact.join(" ").strip
|
|
67
67
|
name = mapped_attribute(attributes, mapping[:name]) || first_attribute(attributes, attribute_map.fetch(:name)) if name.empty?
|
|
68
68
|
extra_fields = mapped_extra_fields(attributes, mapping)
|
|
69
|
-
email_verified = mapping[:email_verified] ? mapped_attribute(attributes, mapping[:email_verified]) :
|
|
69
|
+
email_verified = mapping[:email_verified] ? mapped_attribute(attributes, mapping[:email_verified]) : false
|
|
70
70
|
extra_fields.merge(
|
|
71
71
|
email: email.to_s.downcase,
|
|
72
72
|
name: name.to_s.empty? ? email.to_s : name.to_s,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: better_auth-sso
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Sala
|
|
@@ -203,14 +203,14 @@ files:
|
|
|
203
203
|
- lib/better_auth/sso/types.rb
|
|
204
204
|
- lib/better_auth/sso/utils.rb
|
|
205
205
|
- lib/better_auth/sso/version.rb
|
|
206
|
-
homepage: https://github.com/sebasxsala/better-auth
|
|
206
|
+
homepage: https://github.com/sebasxsala/better-auth-rb
|
|
207
207
|
licenses:
|
|
208
208
|
- MIT
|
|
209
209
|
metadata:
|
|
210
|
-
homepage_uri: https://github.com/sebasxsala/better-auth
|
|
211
|
-
source_code_uri: https://github.com/sebasxsala/better-auth
|
|
212
|
-
changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-sso/CHANGELOG.md
|
|
213
|
-
bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
|
|
210
|
+
homepage_uri: https://github.com/sebasxsala/better-auth-rb
|
|
211
|
+
source_code_uri: https://github.com/sebasxsala/better-auth-rb
|
|
212
|
+
changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-sso/CHANGELOG.md
|
|
213
|
+
bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
|
|
214
214
|
rdoc_options: []
|
|
215
215
|
require_paths:
|
|
216
216
|
- lib
|