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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7aeb07725d9147d0ea71a45cd26ce3d5a8ea62668a6f20a3b52a85faa75b2a0c
4
- data.tar.gz: 33904ef6ca664b29892995aa1d8acc7d18e254a31f598a1c2890fb15e1410c8c
3
+ metadata.gz: 4a30af57534227b0842402e5c2b0f0e1dba463c9e162313b30a64bbbba4dbdc4
4
+ data.tar.gz: 49028a1009cb10b762a6518f3a451d8de7513234cf3f322943724ce9c78e1609
5
5
  SHA512:
6
- metadata.gz: 60cee7f073bf639c84fc8f966524416666d8e42d71a9c62b0491b69c0825c77bf779721a38ffe4fe514aabf097c4b07f80e6c797821a0fe3d6aa42e60f3c9b01
7
- data.tar.gz: 335191868b3b4d82a3dd77ea32ff3c38b37a0ff609036c402a3e1a5e2750712da8775f8f3193b105e65a2436422de94bf6708ec9ab5f049f0733124ea30d5639
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: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
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: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
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: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
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
- existing = normalize_hash(existing_config || {})
9
- discovery_url = discovery_endpoint || existing[:discovery_endpoint] || "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
10
- if trusted_origin && !trusted_origin.call(discovery_url)
11
- raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
12
- end
13
- document = if fetch
14
- fetch.call(discovery_url)
15
- else
16
- uri = URI(discovery_url)
17
- JSON.parse(Net::HTTP.get(uri))
18
- end
19
- document = normalize_hash(document)
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
- authorization_endpoint = sso_normalize_discovery_url(document[:authorization_endpoint], issuer, trusted_origin)
27
- token_endpoint = sso_normalize_discovery_url(document[:token_endpoint], issuer, trusted_origin)
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
- issuer: existing[:issuer] || document[:issuer],
41
- discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
42
- client_id: existing[:client_id],
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
- uri = URI(value.to_s)
58
- normalized = if uri.absolute?
59
- uri.to_s
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 = normalize_hash(provider["oidcConfig"] || {})
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
- nonce = sso_decode_state(state, ctx.context.secret)&.fetch("nonce", nil)
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
- code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
31
- if code_verifier
32
- query[:code_challenge] = sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(code_verifier))
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 = normalize_hash(provider["samlConfig"] || {})
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
- ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
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: state["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: state["codeVerifier"],
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(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
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(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
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.get_response(uri)
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 normalize_hash(provider["oidcConfig"] || {})[:pkce]
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
- {codeVerifier: BetterAuth::Crypto.random_string(128)}
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 = normalize_hash(provider["oidcConfig"] || {})
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? && saml_config[:single_sign_on_service].to_s.empty? && metadata.to_s.empty?
15
- raise APIError.new("BAD_REQUEST", message: "SAML config must include entryPoint, singleSignOnService, or IdP metadata")
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 = normalize_hash(provider["samlConfig"] || {})
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
- name_id_format = saml_config[:identifier_format].to_s.empty? ? "" : "<NameIDFormat>#{saml_config[:identifier_format]}</NameIDFormat>"
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=\"#{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=\"#{acs_url}\" index=\"0\" /></SPSSODescriptor></EntityDescriptor>"
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 = normalize_hash(provider["samlConfig"] || {})[:callback_url].to_s
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 = normalize_hash(provider["samlConfig"] || {})
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 = normalize_hash(provider["samlConfig"] || {})
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
- return true if !sso_fetch(ctx.body, :signature).to_s.empty? || !sso_fetch(ctx.query, :signature).to_s.empty?
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 = normalize_hash(provider["samlConfig"] || {})
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 = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
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
- in_response_to_error = sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
25
- return in_response_to_error if in_response_to_error
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
- assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
32
- replay_key = "#{SSO_SAML_USED_ASSERTION_KEY_PREFIX}#{assertion_id}"
33
- if ctx.context.internal_adapter.find_verification_value(replay_key)
34
- raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
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 = normalize_hash(provider["oidcConfig"] || {})
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(sso_oidc_pkce_state(provider)),
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 = normalize_hash(provider["oidcConfig"] || {})
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
- tokens = sso_oidc_tokens(ctx, provider, oidc_config, state, config)
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 = normalize_hash(provider["oidcConfig"] || {})
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
@@ -59,7 +59,7 @@ module BetterAuth
59
59
  end
60
60
  {
61
61
  ssoProvider: {
62
- model_name: normalized[:model_name] || "ssoProviders",
62
+ model_name: normalized[:model_name] || "sso_providers",
63
63
  fields: fields
64
64
  }
65
65
  }
@@ -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]) : true
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,
@@ -2,7 +2,7 @@
2
2
 
3
3
  module BetterAuth
4
4
  module SSO
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  PACKAGE_VERSION = VERSION
7
7
  end
8
8
  end
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.8.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