better_auth-sso 0.6.2 → 0.7.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 +10 -0
- data/README.md +36 -2
- data/lib/better_auth/plugins/sso.rb +128 -17
- data/lib/better_auth/sso/linking/org_assignment.rb +0 -3
- data/lib/better_auth/sso/routes/schemas.rb +14 -8
- data/lib/better_auth/sso/routes/sso.rb +1 -1
- data/lib/better_auth/sso/saml_state.rb +1 -1
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4f762319ea61412d5e0eb1ac83d359c9b533f761ba2f655d166544ad2d21b83
|
|
4
|
+
data.tar.gz: aa5768114552b9cdb59dd3ae6ac5b394172d0ae859193f4d6ac3495e8ef00f6a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 147d540a80e7584227916a79c3fa2e34508de75c39de90332c2a1fd4c37913d00b31285830a18b95e27bc0d8679953565506e4f6f506692c5fb83f24273e4dff
|
|
7
|
+
data.tar.gz: 31807bffa4c57fa065295b2bc2ff27f5f346990597c314e63b86d531d44cf13a4466b6717efae71626ea51a44a8708e4f71fef2d6ee97ea306f77ecdbdd7d7b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.7.0 - 2026-05-05
|
|
6
|
+
|
|
7
|
+
- Fixed SAML config validation for `singleSignOnService` and added validation for `singleLogoutService`.
|
|
8
|
+
- Hardened OIDC callbacks by binding signed state `providerId` to the callback route and verifying `nonce` on JWKS-backed ID tokens.
|
|
9
|
+
- Changed SSO domain verification to require exact TXT record matches and corrected the insufficient access error code to `INSUFFICIENT_ACCESS`.
|
|
10
|
+
- Declared `jwt` as a direct runtime dependency for the SSO gem.
|
|
11
|
+
- Added regression coverage for SAML SP metadata XML responses.
|
|
12
|
+
|
|
3
13
|
## 0.2.0 - 2026-04-29
|
|
4
14
|
|
|
5
15
|
- Improved SSO upstream parity for OIDC and SAML provider flows, organization handling, callback behavior, metadata parsing, account linking, and response/error shapes.
|
data/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
External SSO plugin package for `better_auth`.
|
|
4
4
|
|
|
5
|
-
SSO is the app-facing feature. It supports OIDC SSO
|
|
5
|
+
SSO is the app-facing feature. It supports OIDC SSO, SAML SSO, provider management,
|
|
6
|
+
domain verification, SAML replay protection, runtime OIDC discovery, organization
|
|
7
|
+
assignment, and SAML Single Logout. SAML is not the same thing as SSO; SAML is
|
|
8
|
+
one protocol used by SSO.
|
|
6
9
|
|
|
7
10
|
```ruby
|
|
8
11
|
require "better_auth"
|
|
@@ -15,7 +18,11 @@ BetterAuth.auth(
|
|
|
15
18
|
)
|
|
16
19
|
```
|
|
17
20
|
|
|
18
|
-
SAML XML validation is included in this package and backed by `ruby-saml
|
|
21
|
+
SAML XML validation is included in this package and backed by `ruby-saml`.
|
|
22
|
+
Production XML SAML deployments should configure `BetterAuth::SSO::SAML.sso_options`
|
|
23
|
+
or compatible SAML hooks so AuthnRequest generation and SAMLResponse parsing use
|
|
24
|
+
the real XML/SAML boundary instead of the lightweight JSON/base64 fallback used by
|
|
25
|
+
local tests:
|
|
19
26
|
|
|
20
27
|
```ruby
|
|
21
28
|
require "better_auth/sso"
|
|
@@ -43,3 +50,30 @@ SAML SLO follows upstream route shapes when `saml.enableSingleLogout` is enabled
|
|
|
43
50
|
Ruby keeps the lightweight JSON/base64 fallback used by the local SAML test adapter, and real XML deployments should configure `BetterAuth::SSO::SAML.sso_options` or compatible SAML hooks.
|
|
44
51
|
|
|
45
52
|
SCIM is a separate provisioning feature and lives in `better_auth-scim`.
|
|
53
|
+
|
|
54
|
+
## Organization Assignment
|
|
55
|
+
|
|
56
|
+
When the organization plugin is installed, SSO can add users to an organization
|
|
57
|
+
linked to an SSO provider. SSO login flows assign from the matched provider.
|
|
58
|
+
Generic OAuth callbacks under `/callback/:provider` also assign by verified SSO
|
|
59
|
+
email domain when domain verification is enabled, matching upstream behavior for
|
|
60
|
+
users who sign in through non-SSO OAuth but share an enterprise domain.
|
|
61
|
+
|
|
62
|
+
## Schema Compatibility
|
|
63
|
+
|
|
64
|
+
The Ruby package intentionally keeps the historical default SSO provider model
|
|
65
|
+
name `ssoProviders` for backward compatibility. Upstream Better Auth defaults to
|
|
66
|
+
`ssoProvider`; configure `model_name:` if your application needs a different
|
|
67
|
+
storage model name.
|
|
68
|
+
|
|
69
|
+
Field mapping options are supported through `fields:` for the SSO provider
|
|
70
|
+
schema, including `issuer`, `oidcConfig`, `samlConfig`, `userId`, `providerId`,
|
|
71
|
+
`organizationId`, `domain`, and `domainVerified`.
|
|
72
|
+
|
|
73
|
+
## Scope and Non-Goals
|
|
74
|
+
|
|
75
|
+
This package does not currently imply support for advanced enterprise features
|
|
76
|
+
such as `private_key_jwt`, mTLS client authentication, every SAML XML edge case,
|
|
77
|
+
or large internal SSO refactors. Those items are tracked in the
|
|
78
|
+
[upstream and product alignment backlog](../../.docs/backlog/upstream-product-alignment.md)
|
|
79
|
+
until they have explicit product scope and upstream parity decisions.
|
|
@@ -91,11 +91,60 @@ module BetterAuth
|
|
|
91
91
|
init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
|
|
92
92
|
schema: BetterAuth::SSO::Routes::Schemas.plugin_schema(config),
|
|
93
93
|
endpoints: endpoints,
|
|
94
|
+
hooks: sso_hooks(config),
|
|
94
95
|
error_codes: SSO_ERROR_CODES,
|
|
95
96
|
options: config
|
|
96
97
|
)
|
|
97
98
|
end
|
|
98
99
|
|
|
100
|
+
def sso_hooks(config = {})
|
|
101
|
+
{
|
|
102
|
+
before: [
|
|
103
|
+
{
|
|
104
|
+
matcher: ->(ctx) { ctx.path == "/sign-out" },
|
|
105
|
+
handler: ->(ctx) { sso_before_sign_out(ctx, config) }
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
after: [
|
|
109
|
+
{
|
|
110
|
+
matcher: ->(ctx) { ctx.path.to_s.match?(%r{\A/callback/[^/]+\z}) },
|
|
111
|
+
handler: ->(ctx) { sso_after_generic_callback(ctx, config) }
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def sso_before_sign_out(ctx, config = {})
|
|
118
|
+
return unless config.dig(:saml, :enable_single_logout)
|
|
119
|
+
|
|
120
|
+
token_cookie = ctx.context.auth_cookies[:session_token]
|
|
121
|
+
session_token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
|
|
122
|
+
return if session_token.to_s.empty?
|
|
123
|
+
|
|
124
|
+
lookup_key = "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}"
|
|
125
|
+
session_lookup = ctx.context.internal_adapter.find_verification_value(lookup_key)
|
|
126
|
+
saml_session_key = session_lookup&.fetch("value", nil)
|
|
127
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
|
|
128
|
+
ctx.context.internal_adapter.delete_verification_by_identifier(lookup_key)
|
|
129
|
+
nil
|
|
130
|
+
rescue
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def sso_after_generic_callback(ctx, config = {})
|
|
135
|
+
new_session = ctx.context.new_session if ctx.context.respond_to?(:new_session)
|
|
136
|
+
return unless new_session && new_session[:user]
|
|
137
|
+
return unless defined?(BetterAuth::SSO::Linking::OrgAssignment)
|
|
138
|
+
|
|
139
|
+
BetterAuth::SSO::Linking::OrgAssignment.assign_organization_by_domain(
|
|
140
|
+
ctx,
|
|
141
|
+
user: new_session.fetch(:user),
|
|
142
|
+
provisioning_options: config[:organization_provisioning],
|
|
143
|
+
domain_verification: config[:domain_verification]
|
|
144
|
+
)
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
99
148
|
def sso_schema(config = {})
|
|
100
149
|
BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
|
|
101
150
|
end
|
|
@@ -245,7 +294,7 @@ module BetterAuth
|
|
|
245
294
|
end
|
|
246
295
|
end
|
|
247
296
|
|
|
248
|
-
def sso_update_provider_endpoint
|
|
297
|
+
def sso_update_provider_endpoint(config = {})
|
|
249
298
|
Endpoint.new(path: "/sso/update-provider", method: "POST") do |ctx|
|
|
250
299
|
session = Routes.current_session(ctx)
|
|
251
300
|
body = normalize_hash(ctx.body)
|
|
@@ -264,13 +313,17 @@ module BetterAuth
|
|
|
264
313
|
current = sso_provider_config_hash(provider["oidcConfig"])
|
|
265
314
|
raise APIError.new("BAD_REQUEST", message: "Cannot update OIDC config for a provider that doesn't have OIDC configured") if current.empty?
|
|
266
315
|
|
|
267
|
-
update[:
|
|
316
|
+
resolved_issuer = update[:issuer] || current[:issuer] || provider["issuer"]
|
|
317
|
+
update[:oidcConfig] = current.merge(normalize_hash(body[:oidc_config])).merge(issuer: resolved_issuer).compact
|
|
268
318
|
end
|
|
269
319
|
if body.key?(:saml_config)
|
|
270
320
|
current = sso_provider_config_hash(provider["samlConfig"])
|
|
271
321
|
raise APIError.new("BAD_REQUEST", message: "Cannot update SAML config for a provider that doesn't have SAML configured") if current.empty?
|
|
272
322
|
|
|
273
|
-
update[:
|
|
323
|
+
resolved_issuer = update[:issuer] || current[:issuer] || provider["issuer"]
|
|
324
|
+
merged_saml_config = current.merge(normalize_hash(body[:saml_config])).merge(issuer: resolved_issuer).compact
|
|
325
|
+
sso_validate_saml_config!(merged_saml_config, config)
|
|
326
|
+
update[:samlConfig] = merged_saml_config
|
|
274
327
|
end
|
|
275
328
|
updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
|
|
276
329
|
ctx.json(sso_sanitize_provider(updated, ctx.context))
|
|
@@ -313,7 +366,11 @@ module BetterAuth
|
|
|
313
366
|
|
|
314
367
|
if provider["oidcConfig"] && provider_type != "saml"
|
|
315
368
|
provider = sso_ensure_runtime_oidc_provider(ctx, provider, config)
|
|
316
|
-
state = BetterAuth::Crypto.sign_jwt(
|
|
369
|
+
state = BetterAuth::Crypto.sign_jwt(
|
|
370
|
+
state_data.merge({nonce: BetterAuth::Crypto.random_string(32)}).merge(sso_oidc_pkce_state(provider)),
|
|
371
|
+
ctx.context.secret,
|
|
372
|
+
expires_in: 600
|
|
373
|
+
)
|
|
317
374
|
url = sso_oidc_authorization_url(provider, ctx, state, config, body)
|
|
318
375
|
elsif provider["samlConfig"]
|
|
319
376
|
relay_state = sso_generate_saml_relay_state(ctx, state_data)
|
|
@@ -352,6 +409,10 @@ module BetterAuth
|
|
|
352
409
|
description = ctx.query[:error_description] || ctx.query["error_description"]
|
|
353
410
|
return sso_redirect(ctx, sso_append_error(error_url, error, description))
|
|
354
411
|
end
|
|
412
|
+
state_provider_id = state["providerId"] || state[:providerId]
|
|
413
|
+
if state_provider_id.to_s != provider_id.to_s
|
|
414
|
+
return sso_redirect(ctx, sso_append_error(error_url, "invalid_state", "provider mismatch"))
|
|
415
|
+
end
|
|
355
416
|
|
|
356
417
|
provider = sso_callback_provider(ctx, config, provider_id)
|
|
357
418
|
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", "provider not found")) unless provider
|
|
@@ -377,7 +438,7 @@ module BetterAuth
|
|
|
377
438
|
# Fall through to the upstream callback error when JWKS is still unavailable.
|
|
378
439
|
end
|
|
379
440
|
end
|
|
380
|
-
user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config)
|
|
441
|
+
user_info = sso_oidc_user_info(ctx, oidc_config, tokens, config, expected_nonce: state["nonce"] || state[:nonce])
|
|
381
442
|
if user_info[:_sso_error]
|
|
382
443
|
return sso_redirect(ctx, sso_append_error(error_url, "invalid_provider", user_info[:_sso_error]))
|
|
383
444
|
end
|
|
@@ -430,13 +491,18 @@ module BetterAuth
|
|
|
430
491
|
provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
|
|
431
492
|
relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
|
|
432
493
|
if sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
433
|
-
|
|
494
|
+
raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
|
|
495
|
+
sso_validate_saml_slo_signature!(ctx, raw_response, "Invalid LogoutResponse") if config.dig(:saml, :want_logout_response_signed)
|
|
496
|
+
sso_process_saml_logout_response(ctx, raw_response)
|
|
434
497
|
Cookies.delete_session_cookie(ctx)
|
|
435
498
|
next sso_redirect(ctx, sso_safe_slo_redirect_url(ctx, relay_state, provider.fetch("providerId")))
|
|
436
499
|
end
|
|
437
500
|
|
|
438
|
-
|
|
439
|
-
|
|
501
|
+
raw_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
|
|
502
|
+
sso_validate_saml_slo_signature!(ctx, raw_request, "Invalid LogoutRequest") if config.dig(:saml, :want_logout_request_signed)
|
|
503
|
+
logout_request_data = sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
504
|
+
in_response_to = logout_request_data[:id].to_s.empty? ? "" : " InResponseTo=\"#{CGI.escapeHTML(logout_request_data[:id].to_s)}\""
|
|
505
|
+
response = Base64.strict_encode64("<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_#{BetterAuth::Crypto.random_string(32)}\"#{in_response_to} Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{sso_saml_logout_destination(provider)}\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status></samlp:LogoutResponse>")
|
|
440
506
|
if sso_fetch(ctx.body, :saml_request)
|
|
441
507
|
next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
|
|
442
508
|
end
|
|
@@ -526,7 +592,7 @@ module BetterAuth
|
|
|
526
592
|
|
|
527
593
|
records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
|
|
528
594
|
expected = "#{identifier}=#{active.fetch("value")}"
|
|
529
|
-
unless records
|
|
595
|
+
unless sso_txt_record_exact_match?(records, expected)
|
|
530
596
|
raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
|
|
531
597
|
end
|
|
532
598
|
|
|
@@ -677,9 +743,27 @@ module BetterAuth
|
|
|
677
743
|
end
|
|
678
744
|
sso_validate_url!(saml_config[:entry_point], "SAML entryPoint must be a valid URL") unless saml_config[:entry_point].to_s.empty?
|
|
679
745
|
unless saml_config[:single_sign_on_service].to_s.empty?
|
|
680
|
-
sso_validate_url!(saml_config[:single_sign_on_service], "SAML
|
|
746
|
+
sso_validate_url!(saml_config[:single_sign_on_service], "SAML singleSignOnService must be a valid URL")
|
|
747
|
+
end
|
|
748
|
+
unless saml_config[:single_logout_service].to_s.empty?
|
|
749
|
+
sso_validate_url!(saml_config[:single_logout_service], "SAML singleLogoutService must be a valid URL")
|
|
681
750
|
end
|
|
682
751
|
|
|
752
|
+
config_algorithm_xml = +""
|
|
753
|
+
unless saml_config[:signature_algorithm].to_s.empty?
|
|
754
|
+
config_algorithm_xml << "<ds:SignatureMethod Algorithm=\"#{saml_config[:signature_algorithm]}\"/>"
|
|
755
|
+
end
|
|
756
|
+
unless saml_config[:digest_algorithm].to_s.empty?
|
|
757
|
+
config_algorithm_xml << "<ds:DigestMethod Algorithm=\"#{saml_config[:digest_algorithm]}\"/>"
|
|
758
|
+
end
|
|
759
|
+
sso_validate_saml_algorithms!(
|
|
760
|
+
config_algorithm_xml,
|
|
761
|
+
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
762
|
+
allowed_signature_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_signature_algorithms) || saml_config[:allowed_signature_algorithms],
|
|
763
|
+
allowed_digest_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_digest_algorithms) || saml_config[:allowed_digest_algorithms],
|
|
764
|
+
allowed_key_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_key_encryption_algorithms) || saml_config[:allowed_key_encryption_algorithms],
|
|
765
|
+
allowed_data_encryption_algorithms: plugin_config.dig(:saml, :algorithms, :allowed_data_encryption_algorithms) || saml_config[:allowed_data_encryption_algorithms]
|
|
766
|
+
)
|
|
683
767
|
sso_validate_saml_algorithms!(
|
|
684
768
|
metadata.to_s,
|
|
685
769
|
on_deprecated: plugin_config.dig(:saml, :algorithms, :on_deprecated) || saml_config[:on_deprecated_algorithm] || "warn",
|
|
@@ -842,11 +926,11 @@ module BetterAuth
|
|
|
842
926
|
|
|
843
927
|
def sso_process_saml_logout_request(ctx, provider, raw_request)
|
|
844
928
|
data = sso_parse_saml_logout_request(raw_request)
|
|
845
|
-
return if data[:name_id].to_s.empty?
|
|
929
|
+
return data if data[:name_id].to_s.empty?
|
|
846
930
|
|
|
847
931
|
session_identifier = "#{SSO_SAML_SESSION_KEY_PREFIX}#{provider.fetch("providerId")}:#{data[:name_id]}"
|
|
848
932
|
verification = ctx.context.internal_adapter.find_verification_value(session_identifier)
|
|
849
|
-
return unless verification
|
|
933
|
+
return data unless verification
|
|
850
934
|
|
|
851
935
|
record = JSON.parse(verification.fetch("value"))
|
|
852
936
|
session_token = record["sessionToken"]
|
|
@@ -854,8 +938,9 @@ module BetterAuth
|
|
|
854
938
|
ctx.context.internal_adapter.delete_session(session_token) if session_token && session_index_matches
|
|
855
939
|
ctx.context.internal_adapter.delete_verification_by_identifier(session_identifier)
|
|
856
940
|
ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}") if session_token
|
|
941
|
+
data
|
|
857
942
|
rescue
|
|
858
|
-
|
|
943
|
+
{}
|
|
859
944
|
end
|
|
860
945
|
|
|
861
946
|
def sso_store_saml_logout_request(ctx, provider, request_id, config)
|
|
@@ -883,6 +968,7 @@ module BetterAuth
|
|
|
883
968
|
def sso_parse_saml_logout_request(raw_request)
|
|
884
969
|
xml = Base64.decode64(raw_request.to_s.gsub(/\s+/, ""))
|
|
885
970
|
{
|
|
971
|
+
id: xml[/\bID=['"]([^'"]+)['"]/, 1],
|
|
886
972
|
name_id: xml[%r{<(?:\w+:)?NameID[^>]*>([^<]+)</(?:\w+:)?NameID>}, 1],
|
|
887
973
|
session_index: xml[%r{<(?:\w+:)?SessionIndex[^>]*>([^<]+)</(?:\w+:)?SessionIndex>}, 1]
|
|
888
974
|
}
|
|
@@ -890,6 +976,19 @@ module BetterAuth
|
|
|
890
976
|
{}
|
|
891
977
|
end
|
|
892
978
|
|
|
979
|
+
def sso_validate_saml_slo_signature!(ctx, raw_message, error_message)
|
|
980
|
+
return true if !sso_fetch(ctx.body, :signature).to_s.empty? || !sso_fetch(ctx.query, :signature).to_s.empty?
|
|
981
|
+
|
|
982
|
+
xml = Base64.decode64(raw_message.to_s.gsub(/\s+/, ""))
|
|
983
|
+
return true if xml.include?("<Signature") || xml.include?(":Signature")
|
|
984
|
+
|
|
985
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
986
|
+
rescue APIError
|
|
987
|
+
raise
|
|
988
|
+
rescue
|
|
989
|
+
raise APIError.new("BAD_REQUEST", message: error_message)
|
|
990
|
+
end
|
|
991
|
+
|
|
893
992
|
def sso_parse_saml_logout_response(raw_response)
|
|
894
993
|
xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
|
|
895
994
|
{
|
|
@@ -1187,6 +1286,8 @@ module BetterAuth
|
|
|
1187
1286
|
scope: scopes.join(" "),
|
|
1188
1287
|
state: state
|
|
1189
1288
|
}.compact
|
|
1289
|
+
nonce = sso_decode_state(state, ctx.context.secret)&.fetch("nonce", nil)
|
|
1290
|
+
query[:nonce] = nonce if nonce && !nonce.to_s.empty?
|
|
1190
1291
|
login_hint = body[:login_hint] || body[:email]
|
|
1191
1292
|
query[:login_hint] = login_hint if login_hint
|
|
1192
1293
|
code_verifier = sso_decode_state(state, ctx.context.secret)&.fetch("codeVerifier", nil)
|
|
@@ -1382,7 +1483,7 @@ module BetterAuth
|
|
|
1382
1483
|
normalize_hash(JSON.parse(response.body))
|
|
1383
1484
|
end
|
|
1384
1485
|
|
|
1385
|
-
def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config)
|
|
1486
|
+
def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config, expected_nonce: nil)
|
|
1386
1487
|
user_callback = oidc_config[:get_user_info]
|
|
1387
1488
|
raw = if user_callback.respond_to?(:call)
|
|
1388
1489
|
user_callback.call(tokens)
|
|
@@ -1396,7 +1497,8 @@ module BetterAuth
|
|
|
1396
1497
|
jwks_endpoint: oidc_config[:jwks_endpoint],
|
|
1397
1498
|
audience: oidc_config[:client_id],
|
|
1398
1499
|
issuer: oidc_config[:issuer],
|
|
1399
|
-
fetch: plugin_config[:oidc_jwks_fetch]
|
|
1500
|
+
fetch: plugin_config[:oidc_jwks_fetch],
|
|
1501
|
+
expected_nonce: expected_nonce
|
|
1400
1502
|
) || {_sso_error: "token_not_verified"}
|
|
1401
1503
|
else
|
|
1402
1504
|
{}
|
|
@@ -1429,7 +1531,7 @@ module BetterAuth
|
|
|
1429
1531
|
{}
|
|
1430
1532
|
end
|
|
1431
1533
|
|
|
1432
|
-
def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil)
|
|
1534
|
+
def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil, expected_nonce: nil)
|
|
1433
1535
|
jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
|
|
1434
1536
|
payload, = ::JWT.decode(
|
|
1435
1537
|
token.to_s,
|
|
@@ -1442,6 +1544,11 @@ module BetterAuth
|
|
|
1442
1544
|
iss: issuer,
|
|
1443
1545
|
verify_iss: true
|
|
1444
1546
|
)
|
|
1547
|
+
if expected_nonce && !expected_nonce.to_s.empty?
|
|
1548
|
+
token_nonce = payload["nonce"] || payload[:nonce]
|
|
1549
|
+
return nil if token_nonce.to_s.empty?
|
|
1550
|
+
return nil unless BetterAuth::Crypto.constant_time_compare(token_nonce.to_s, expected_nonce.to_s)
|
|
1551
|
+
end
|
|
1445
1552
|
payload
|
|
1446
1553
|
rescue
|
|
1447
1554
|
nil
|
|
@@ -1628,7 +1735,11 @@ module BetterAuth
|
|
|
1628
1735
|
end
|
|
1629
1736
|
return if provider["userId"] == user_id && is_org_member
|
|
1630
1737
|
|
|
1631
|
-
raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "
|
|
1738
|
+
raise APIError.new("FORBIDDEN", message: "User must be owner of or belong to the SSO provider organization", code: "INSUFFICIENT_ACCESS")
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1741
|
+
def sso_txt_record_exact_match?(records, expected)
|
|
1742
|
+
Array(records).flatten.any? { |record| record.to_s.strip == expected.to_s }
|
|
1632
1743
|
end
|
|
1633
1744
|
|
|
1634
1745
|
def sso_domain_verification_identifier(config, provider_id)
|
|
@@ -66,9 +66,6 @@ module BetterAuth
|
|
|
66
66
|
|
|
67
67
|
def organization_plugin?(ctx)
|
|
68
68
|
context = ctx.context
|
|
69
|
-
return context.hasPlugin("organization") if context.respond_to?(:hasPlugin)
|
|
70
|
-
return context.has_plugin?("organization") if context.respond_to?(:has_plugin?)
|
|
71
|
-
|
|
72
69
|
plugins = context.options.respond_to?(:plugins) ? context.options.plugins : []
|
|
73
70
|
plugins.any? { |plugin| plugin.respond_to?(:id) && plugin.id == "organization" }
|
|
74
71
|
end
|
|
@@ -44,17 +44,18 @@ module BetterAuth
|
|
|
44
44
|
|
|
45
45
|
def plugin_schema(config = {})
|
|
46
46
|
normalized = BetterAuth::Plugins.normalize_hash(config || {})
|
|
47
|
+
field_mappings = BetterAuth::Plugins.normalize_hash(normalized[:fields] || {})
|
|
47
48
|
fields = {
|
|
48
|
-
issuer: {type: "string", required: true},
|
|
49
|
-
oidcConfig: {type: "string", required: false},
|
|
50
|
-
samlConfig: {type: "string", required: false},
|
|
51
|
-
userId: {type: "string", required: true},
|
|
52
|
-
providerId: {type: "string", required: true, unique: true},
|
|
53
|
-
domain: {type: "string", required: true},
|
|
54
|
-
organizationId: {type: "string", required: false}
|
|
49
|
+
issuer: field("issuer", {type: "string", required: true}, field_mappings),
|
|
50
|
+
oidcConfig: field("oidcConfig", {type: "string", required: false}, field_mappings),
|
|
51
|
+
samlConfig: field("samlConfig", {type: "string", required: false}, field_mappings),
|
|
52
|
+
userId: field("userId", {type: "string", required: true, references: {model: "user", field: "id"}}, field_mappings),
|
|
53
|
+
providerId: field("providerId", {type: "string", required: true, unique: true}, field_mappings),
|
|
54
|
+
domain: field("domain", {type: "string", required: true}, field_mappings),
|
|
55
|
+
organizationId: field("organizationId", {type: "string", required: false}, field_mappings)
|
|
55
56
|
}
|
|
56
57
|
if normalized.dig(:domain_verification, :enabled)
|
|
57
|
-
fields[:domainVerified] = {type: "boolean", required: false, default_value: false}
|
|
58
|
+
fields[:domainVerified] = field("domainVerified", {type: "boolean", required: false, default_value: false}, field_mappings)
|
|
58
59
|
end
|
|
59
60
|
{
|
|
60
61
|
ssoProvider: {
|
|
@@ -71,6 +72,11 @@ module BetterAuth
|
|
|
71
72
|
def saml_config_key?(key)
|
|
72
73
|
SAML_CONFIG_KEYS.include?(BetterAuth::Plugins.normalize_key(key))
|
|
73
74
|
end
|
|
75
|
+
|
|
76
|
+
def field(logical_name, attributes, mappings)
|
|
77
|
+
mapping = mappings[BetterAuth::Plugins.normalize_key(logical_name)]
|
|
78
|
+
mapping ? attributes.merge(field_name: mapping) : attributes
|
|
79
|
+
end
|
|
74
80
|
end
|
|
75
81
|
end
|
|
76
82
|
end
|
|
@@ -20,7 +20,7 @@ module BetterAuth
|
|
|
20
20
|
initiate_slo: BetterAuth::Plugins.sso_initiate_slo_endpoint(normalized),
|
|
21
21
|
list_sso_providers: BetterAuth::Plugins.sso_list_providers_endpoint,
|
|
22
22
|
get_sso_provider: BetterAuth::Plugins.sso_get_provider_endpoint,
|
|
23
|
-
update_sso_provider: BetterAuth::Plugins.sso_update_provider_endpoint,
|
|
23
|
+
update_sso_provider: BetterAuth::Plugins.sso_update_provider_endpoint(normalized),
|
|
24
24
|
delete_sso_provider: BetterAuth::Plugins.sso_delete_provider_endpoint
|
|
25
25
|
}
|
|
26
26
|
if normalized.dig(:domain_verification, :enabled)
|
|
@@ -7,7 +7,7 @@ module BetterAuth
|
|
|
7
7
|
|
|
8
8
|
def generate_relay_state(ctx, link = nil, additional_data = {})
|
|
9
9
|
callback_url = BetterAuth::Plugins.sso_fetch(ctx.body, :callback_url)
|
|
10
|
-
raise APIError.new("BAD_REQUEST", message: "callbackURL is required") if callback_url.to_s.empty?
|
|
10
|
+
raise BetterAuth::APIError.new("BAD_REQUEST", message: "callbackURL is required") if callback_url.to_s.empty?
|
|
11
11
|
|
|
12
12
|
extra = (additional_data == false) ? {} : (additional_data || {})
|
|
13
13
|
BetterAuth::Plugins.sso_generate_saml_relay_state(
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Sala
|
|
@@ -43,6 +43,20 @@ dependencies:
|
|
|
43
43
|
- - "<"
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '1.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: jwt
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '2.8'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2.8'
|
|
46
60
|
- !ruby/object:Gem::Dependency
|
|
47
61
|
name: logger
|
|
48
62
|
requirement: !ruby/object:Gem::Requirement
|