Skip to content

Commit c15d50d

Browse files
eld120claude
andcommitted
Carry the SAML transaction in RelayState instead of the session
The IdP returns the assertion as a cross-site POST, and a SameSite=Lax cookie is not sent on one - so the callback saw no session, found no saml_org_slug, and failed every login with "SAML session mismatch". This could never have worked against a real IdP; the specs missed it because Rack::Test carries the cookie regardless of SameSite. Saml::RequestStore keeps {org_slug, request_id} in Redis under a random token that travels in RelayState, signed alongside the AuthnRequest. Claiming reads and deletes in one operation, so a replayed assertion is rejected even inside the InResponseTo validity window - stronger than the session binding it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1e49578 commit c15d50d

4 files changed

Lines changed: 154 additions & 18 deletions

File tree

app/controllers/saml_controller.rb

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,25 @@ def metadata
2020
content_type: "application/samlmetadata+xml"
2121
end
2222

23-
# SP-initiated login: redirect to the IdP with a signed AuthnRequest, remembering the
24-
# request id (replay protection) and org slug (cross-tenant binding) for the callback.
23+
# SP-initiated login: redirect to the IdP with a signed AuthnRequest, parking the request id
24+
# (replay protection) and org slug (cross-tenant binding) in RelayState for the callback.
2525
def init
2626
settings = Saml::SettingsBuilder.build(configured_saml_configuration)
2727
auth_request = OneLogin::RubySaml::Authrequest.new
28-
redirect_url = auth_request.create(settings)
29-
session[:saml_request_id] = auth_request.request_id
30-
session[:saml_org_slug] = params[:org_slug]
31-
redirect_to redirect_url, allow_other_host: true
28+
relay_state = Saml::RequestStore.create(request_id: auth_request.request_id,
29+
org_slug: params[:org_slug])
30+
redirect_to auth_request.create(settings, RelayState: relay_state), allow_other_host: true
3231
end
3332

3433
# Assertion Consumer Service: validate the IdP's response and sign the user in.
3534
def callback
3635
saml_configuration = configured_saml_configuration
37-
request_id = session.delete(:saml_request_id)
38-
return saml_failure("SAML session mismatch") if session.delete(:saml_org_slug) != params[:org_slug]
36+
saml_request = Saml::RequestStore.claim(params[:RelayState])
37+
return saml_failure("this login has expired, please try again") if saml_request.blank?
38+
return saml_failure("SAML session mismatch") if saml_request[:org_slug] != params[:org_slug]
3939

4040
result = Saml::AssertionProcessor.call(saml_configuration:,
41-
raw_response: params[:SAMLResponse], request_id:)
41+
raw_response: params[:SAMLResponse], request_id: saml_request[:request_id])
4242
return saml_failure(result.error) unless result.success?
4343

4444
sign_in_and_redirect(result.user)

app/services/saml/request_store.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Carries the in-flight SAML transaction from the AuthnRequest to the assertion.
2+
#
3+
# The session can't hold it: the IdP returns the assertion as a cross-site POST, and a
4+
# SameSite=Lax cookie isn't sent on one, so the callback sees no session at all. The token
5+
# travels in RelayState (signed alongside the AuthnRequest) and the state stays here.
6+
module Saml
7+
module RequestStore
8+
extend Functionable
9+
10+
# Long enough for a password prompt and an MFA challenge, short enough that an
11+
# intercepted RelayState is worthless by the time it's used.
12+
TTL = 10.minutes
13+
14+
def create(request_id:, org_slug:)
15+
SecureRandom.urlsafe_base64(24).tap do |token|
16+
RedisPool.conn { |r| r.set(key(token), [org_slug, request_id].join(SEPARATOR), ex: TTL.to_i) }
17+
end
18+
end
19+
20+
# Reads and deletes in one operation, so an assertion replayed against a spent token
21+
# finds nothing even if it arrives inside the InResponseTo validity window.
22+
def claim(token)
23+
return nil if token.blank?
24+
25+
raw = RedisPool.conn { |r| r.getdel(key(token)) }
26+
return nil if raw.blank?
27+
28+
org_slug, request_id = raw.split(SEPARATOR, 2)
29+
{org_slug:, request_id:}
30+
end
31+
32+
#
33+
# private below here
34+
#
35+
SEPARATOR = "\n"
36+
37+
def key(token)
38+
"saml_request:#{token}"
39+
end
40+
41+
conceal :key
42+
end
43+
end

spec/requests/saml_callback_request_spec.rb

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
require "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.
56
RSpec.describe "SAML SSO login", :saml_env, type: :request do
67
let(:domain) { "example.edu" }
78
let(:organization) do
@@ -16,19 +17,21 @@
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
168219
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require "rails_helper"
2+
3+
RSpec.describe Saml::RequestStore do
4+
let(:request_id) { "_abc-123" }
5+
let(:org_slug) { "some-university" }
6+
7+
describe "create and claim" do
8+
it "round trips the transaction" do
9+
token = described_class.create(request_id:, org_slug:)
10+
expect(token).to be_present
11+
expect(described_class.claim(token)).to eq({org_slug:, request_id:})
12+
end
13+
14+
it "only claims once" do
15+
token = described_class.create(request_id:, org_slug:)
16+
described_class.claim(token)
17+
expect(described_class.claim(token)).to be_nil
18+
end
19+
20+
it "issues a distinct token per request" do
21+
tokens = Array.new(3) { described_class.create(request_id:, org_slug:) }
22+
expect(tokens.uniq.count).to eq 3
23+
end
24+
25+
it "expires" do
26+
token = described_class.create(request_id:, org_slug:)
27+
ttl = RedisPool.conn { |r| r.ttl("saml_request:#{token}") }
28+
expect(ttl).to be_within(5).of(described_class::TTL.to_i)
29+
end
30+
end
31+
32+
describe "claim" do
33+
it "is nil for a blank token" do
34+
expect(described_class.claim(nil)).to be_nil
35+
expect(described_class.claim("")).to be_nil
36+
end
37+
38+
it "is nil for an unknown token" do
39+
expect(described_class.claim("not-a-real-token")).to be_nil
40+
end
41+
end
42+
end

0 commit comments

Comments
 (0)