11require "rails_helper"
22
3- # End-to-end SP-initiated login: a real /init populates the session, then a signed
4- # assertion (minted in-process, see spec/support/saml_helpers.rb) is POSTed to the ACS.
3+ # End-to-end SP-initiated login: a real /init stores the transaction and hands back a
4+ # RelayState, then a signed assertion (minted in-process, see spec/support/saml_helpers.rb)
5+ # is POSTed to the ACS along with it.
56RSpec . describe "SAML SSO login" , :saml_env , type : :request do
67 let ( :domain ) { "example.edu" }
78 let ( :organization ) do
1617
1718 before { saml_configuration } # ensure the config exists before /init
1819
19- # Drive a real /init so the session carries saml_request_id + saml_org_slug;
20- # return the AuthnRequest id to echo back as InResponseTo .
20+ # Drive a real /init; return the AuthnRequest id to echo back as InResponseTo, plus the
21+ # RelayState the IdP would hand back to us .
2122 def initiate_login
2223 get "/sso/#{ slug } /init"
2324 expect ( response ) . to have_http_status ( :found )
24- saml_request_id_from_redirect ( response . headers [ "Location" ] )
25+ location = response . headers [ "Location" ]
26+ [ saml_request_id_from_redirect ( location ) , Rack ::Utils . parse_query ( URI ( location ) . query ) [ "RelayState" ] ]
2527 end
2628
27- def post_callback ( **overrides )
28- request_id = initiate_login
29+ def post_callback ( relay_state : :from_init , **overrides )
30+ request_id , initiated_relay_state = initiate_login
2931 params = { audience : settings . sp_entity_id , recipient : settings . assertion_consumer_service_url ,
3032 in_response_to : request_id , issuer : saml_configuration . idp_entity_id , email :} . merge ( overrides )
31- post "/sso/#{ slug } /callback" , params : { SAMLResponse : signed_saml_response ( **params ) }
33+ post "/sso/#{ slug } /callback" , params : { SAMLResponse : signed_saml_response ( **params ) ,
34+ RelayState : ( relay_state == :from_init ) ? initiated_relay_state : relay_state }
3235 end
3336
3437 def signed_in?
@@ -155,7 +158,7 @@ def signed_in?
155158 end
156159
157160 context "unsolicited response (no prior init)" do
158- it "is rejected (no session binding )" do
161+ it "is rejected (no RelayState to bind it to )" do
159162 saml_response = signed_saml_response ( audience : settings . sp_entity_id ,
160163 recipient : settings . assertion_consumer_service_url , in_response_to : "_unsolicited" ,
161164 issuer : saml_configuration . idp_entity_id , email :)
@@ -164,5 +167,53 @@ def signed_in?
164167 expect ( signed_in? ) . to be false
165168 end
166169 end
170+
171+ # The IdP returns the assertion as a cross-site POST, which a SameSite=Lax cookie isn't
172+ # sent on - so the callback has to work with no session at all.
173+ context "no session cookie on the callback" do
174+ it "signs in anyway" do
175+ request_id , relay_state = initiate_login
176+ reset! # a fresh browser: no cookie of any kind on the POST
177+ post "/sso/#{ slug } /callback" , params : {
178+ SAMLResponse : signed_saml_response ( audience : settings . sp_entity_id ,
179+ recipient : settings . assertion_consumer_service_url , in_response_to : request_id ,
180+ issuer : saml_configuration . idp_entity_id , email :) ,
181+ RelayState : relay_state
182+ }
183+ expect ( response ) . to have_http_status ( :found )
184+ expect ( signed_in? ) . to be true
185+ end
186+ end
187+
188+ context "replayed assertion" do
189+ it "is rejected the second time, the token being single use" do
190+ request_id , relay_state = initiate_login
191+ saml_response = signed_saml_response ( audience : settings . sp_entity_id ,
192+ recipient : settings . assertion_consumer_service_url , in_response_to : request_id ,
193+ issuer : saml_configuration . idp_entity_id , email :)
194+
195+ post "/sso/#{ slug } /callback" , params : { SAMLResponse : saml_response , RelayState : relay_state }
196+ expect ( signed_in? ) . to be true
197+
198+ reset! # a fresh browser: no cookie of any kind on the POST
199+ post "/sso/#{ slug } /callback" , params : { SAMLResponse : saml_response , RelayState : relay_state }
200+ expect ( response ) . to redirect_to ( new_session_path )
201+ expect ( signed_in? ) . to be false
202+ end
203+ end
204+
205+ context "RelayState for a different organization" do
206+ let ( :other_organization ) do
207+ FactoryBot . create ( :organization_with_organization_features ,
208+ enabled_feature_slugs : "saml_sso" , user_email_domain : "other.edu" )
209+ end
210+ it "is rejected" do
211+ foreign_relay_state = Saml ::RequestStore . create ( request_id : "_whatever" ,
212+ org_slug : other_organization . to_param )
213+ expect { post_callback ( relay_state : foreign_relay_state ) } . not_to change ( User , :count )
214+ expect ( response ) . to redirect_to ( new_session_path )
215+ expect ( signed_in? ) . to be false
216+ end
217+ end
167218 end
168219end
0 commit comments