Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/controllers/concerns/saml_idp_logout_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ def handle_valid_sp_logout_request
sign_out if user_signed_in?
end

def handle_valid_sp_remote_logout_request(user_id)
# Remotely invalidate the user's current session, see config/initializers/session_limitable.rb
User.find(user_id).update!(unique_session_id: nil)

render_template_for(
Base64.strict_encode64(logout_response),
saml_request.response_url,
'SAMLResponse',
)
end

def find_user_from_session_index
uuid = saml_request.session_index
issuer = saml_request.issuer
agency_id = ServiceProvider.find_by(issuer: issuer).agency_id
AgencyIdentity.find_by(agency_id: agency_id, uuid: uuid)&.user_id
end

def valid_remote_logout_user_id?(user_id)
return false unless user_id.present?
ServiceProviderIdentity.where(
user_id: user_id,
service_provider: saml_request.issuer,
).count.positive?
end

def logout_response
encode_response(
current_user,
Expand All @@ -38,6 +64,14 @@ def track_logout_event
)
end

def track_remote_logout_event
analytics.track_event(
Analytics::REMOTE_LOGOUT_INITIATED,
service_provider: saml_request&.issuer,
saml_request_valid: valid_saml_request?,
)
end

def saml_response_signature_options
endpoint = SamlEndpoint.new(request)
{
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/saml_idp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ def logout
handle_valid_sp_logout_request
end

def remotelogout
raw_saml_request = params[:SAMLRequest]
return head(:bad_request) if raw_saml_request.nil?

decode_request(raw_saml_request)

track_remote_logout_event

return head(:bad_request) unless valid_saml_request?

user_id = find_user_from_session_index

return head(:bad_request) unless valid_remote_logout_user_id?(user_id)

handle_valid_sp_remote_logout_request(user_id)
end

private

def confirm_user_is_authenticated_with_fresh_mfa
Expand Down
1 change: 1 addition & 0 deletions app/services/analytics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def browser_attributes
RATE_LIMIT_TRIGGERED = 'Rate Limit Triggered'.freeze
RESPONSE_TIMED_OUT = 'Response Timed Out'.freeze
REMEMBERED_DEVICE_USED_FOR_AUTH = 'Remembered device used for authentication'.freeze
REMOTE_LOGOUT_INITIATED = 'Remote Logout initiated'.freeze
RETURN_TO_SP_CANCEL = 'Return to SP: Cancelled'.freeze
RETURN_TO_SP_FAILURE_TO_PROOF = 'Return to SP: Failed to proof'.freeze
RULES_OF_USE_VISIT = 'Rules Of Use Visited'.freeze
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SamlEndpoint.suffixes.each do |suffix|
get "/api/saml/metadata#{suffix}" => 'saml_idp#metadata', format: false
match "/api/saml/logout#{suffix}" => 'saml_idp#logout', via: %i[get post delete]
match "/api/saml/remotelogout#{suffix}" => 'saml_idp#remotelogout', via: %i[get post delete]
# JS-driven POST redirect route to preserve existing session
post "/api/saml/auth#{suffix}" => 'saml_post#auth'
# actual SAML handling POST route
Expand Down
178 changes: 178 additions & 0 deletions spec/controllers/saml_idp_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,184 @@
end
end

describe '/api/saml/remotelogout' do
it 'tracks the event when the saml request is invalid' do
stub_analytics

result = { service_provider: nil, saml_request_valid: false }
expect(@analytics).to receive(:track_event).with(Analytics::REMOTE_LOGOUT_INITIATED, result)

delete :remotelogout, params: { SAMLRequest: 'foo' }
end

let(:service_provider) do
create(
:service_provider,
certs: ['sp_sinatra_demo', 'saml_test_sp'],
active: true,
assertion_consumer_logout_service_url: 'https://example.com',
)
end

let(:user) { create(:user, :signed_up, unique_session_id: 'abc123') }
let!(:identity) do
ServiceProviderIdentity.create(
service_provider: service_provider.issuer,
user: user,
)
end
let!(:agency_identity) do
AgencyIdentity.create!(
agency: service_provider.agency,
user: user,
uuid: identity.uuid,
)
end

let(:right_cert_settings) do
saml_settings(
overrides: {
issuer: service_provider.issuer,
assertion_consumer_logout_service_url: 'https://example.com',
sessionindex: agency_identity.uuid,
},
)
end

let(:right_cert_no_session_settings) do
saml_settings(
overrides: {
issuer: service_provider.issuer,
assertion_consumer_logout_service_url: 'https://example.com',
},
)
end

let(:right_cert_bad_session_settings) do
saml_settings(
overrides: {
issuer: service_provider.issuer,
assertion_consumer_logout_service_url: 'https://example.com',
sessionindex: 'abc123',
},
)
end

let(:wrong_cert_settings) do
saml_settings(
overrides: {
issuer: service_provider.issuer,
certificate: File.read(Rails.root.join('certs', 'sp', 'saml_test_sp2.crt')),
private_key: OpenSSL::PKey::RSA.new(
File.read(Rails.root + 'keys/saml_test_sp2.key'),
).to_pem,
},
)
end

it 'accepts requests from a correct cert and correct session index' do
saml_request = UriService.params(
OneLogin::RubySaml::Logoutrequest.new.create(right_cert_settings),
)[:SAMLRequest]

payload = [
['SAMLRequest', saml_request],
['RelayState', 'aaa'],
['SigAlg', 'SHA256'],
]
canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&')

private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key)
signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string)

certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate)

# This is the same verification process we expect the SAML gem will run
expect(
certificate.public_key.verify(
OpenSSL::Digest.new('SHA256'),
signature,
canon_string,
),
).to eq(true)

delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature))

expect(response).to be_ok
expect(User.find(user.id).unique_session_id).to be_nil
end

it 'rejects requests from a correct cert but no session index' do
saml_request = UriService.params(
OneLogin::RubySaml::Logoutrequest.new.create(right_cert_no_session_settings),
)[:SAMLRequest]

payload = [
['SAMLRequest', saml_request],
['RelayState', 'aaa'],
['SigAlg', 'SHA256'],
]
canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&')

private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key)
signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string)

certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate)

# This is the same verification process we expect the SAML gem will run
expect(
certificate.public_key.verify(
OpenSSL::Digest.new('SHA256'),
signature,
canon_string,
),
).to eq(true)

delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature))

expect(response).to be_bad_request
end

it 'rejects requests from a correct cert but bad session index' do
saml_request = UriService.params(
OneLogin::RubySaml::Logoutrequest.new.create(right_cert_bad_session_settings),
)[:SAMLRequest]

payload = [
['SAMLRequest', saml_request],
['RelayState', 'aaa'],
['SigAlg', 'SHA256'],
]
canon_string = payload.map { |k, v| "#{k}=#{CGI.escape(v)}" }.join('&')

private_sp_key = OpenSSL::PKey::RSA.new(right_cert_settings.private_key)
signature = private_sp_key.sign(OpenSSL::Digest.new('SHA256'), canon_string)

certificate = OpenSSL::X509::Certificate.new(right_cert_settings.certificate)

# This is the same verification process we expect the SAML gem will run
expect(
certificate.public_key.verify(
OpenSSL::Digest.new('SHA256'),
signature,
canon_string,
),
).to eq(true)

delete :remotelogout, params: payload.to_h.merge(Signature: Base64.encode64(signature))

expect(response).to be_bad_request
end

it 'rejects requests from a wrong cert' do
delete :remotelogout, params: UriService.params(
OneLogin::RubySaml::Logoutrequest.new.create(wrong_cert_settings),
)

expect(response).to be_bad_request
end
end

describe '/api/saml/metadata' do
before do
get :metadata
Expand Down