defmodule PlausibleWeb.SSOControllerSyncTest do use PlausibleWeb.ConnCase @moduletag :ee_only on_ee do alias Plausible.Auth alias Plausible.Auth.SSO alias Plausible.Repo @cert_pem """ -----BEGIN CERTIFICATE----- MIICmjCCAYICCQDX5sKPsYV3+jANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0 ZXN0MB4XDTE5MTIyMzA5MDI1MVoXDTIwMDEyMjA5MDI1MVowDzENMAsGA1UEAwwE dGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMdtDJ278DQTp84O 5Nq5F8s5YOR34GFOGI2Swb/3pU7X7918lVljiKv7WVM65S59nJSyXV+fa15qoXLf sdRnq3yw0hTSTs2YDX+jl98kK3ksk3rROfYh1LIgByj4/4NeNpExgeB6rQk5Ay7Y S+ARmMzEjXa0favHxu5BOdB2y6WvRQyjPS2lirT/PKWBZc04QZepsZ56+W7bd557 tdedcYdY/nKI1qmSQClG2qgslzgqFOv1KCOw43a3mcK/TiiD8IXyLMJNC6OFW3xT L/BG6SOZ3dQ9rjQOBga+6GIaQsDjC4Xp7Kx+FkSvgaw0sJV8gt1mlZy+27Sza6d+ hHD2pWECAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAm2fk1+gd08FQxK7TL04O8EK1 f0bzaGGUxWzlh98a3Dm8+OPhVQRi/KLsFHliLC86lsZQKunYdDB+qd0KUk2oqDG6 tstG/htmRYD/S/jNmt8gyPAVi11dHUqW3IvQgJLwxZtoAv6PNs188hvT1WK3VWJ4 YgFKYi5XQYnR5sv69Vsr91lYAxyrIlMKahjSW1jTD3ByRfAQghsSLk6fV0OyJHyh uF1TxOVBVf8XOdaqfmvD90JGIPGtfMLPUX4m35qaGAU48PwCL7L3cRHYs9wZWc0i fXZcBENLtHYCLi5txR8c5lyHB9d3AQHzKHMFNjLswn5HsckKg83RH7+eVqHqGw== -----END CERTIFICATE----- """ @other_cert_pem """ -----BEGIN CERTIFICATE----- MIIFdTCCA12gAwIBAgIUNcATm3CidmlEMMsZa9KBZpWYCVcwDQYJKoZIhvcNAQEL BQAwYzELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEcMBoGA1UEAwwTc29tZWlkcC5leGFt cGxlLmNvbTAeFw0yNTA1MjExMjI5MzVaFw0yNjA1MjExMjI5MzVaMGMxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQxHDAaBgNVBAMME3NvbWVpZHAuZXhhbXBsZS5jb20wggIi MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1N6Drbjed+lFXpOYvE6Efgndy W7kYiO8LqQTr4UwVrp9ArxgYuK4TrcNRh2rhS08xAzNTo+NqnJOm95baG97ADYk1 TqVIKxzaFurv+L/Na0wVXyeNUtxIVKF59uElsg2YLm5eQhL9fmN8jVINCvwDPzxc Ihm6mQOaL/i/0DGINOqwHG9MGMZ11AeOM0wKMuXJ2+aKjHOCedhMYVuOaHZgLkcX Zzgiv7itm3+JpCjL474MMfibiqKHR0e3QRNcsEC13f/LD8BAGOwsKLznFC8Uctms 48EDNbxxLG01jVbnJSxRrcDN3RUDjtCdHyaTCCFJAgmldHKKua3VQEynOwJIkFMC fpL1LpLvATzIt0cT1ESb1RHIlgacmESVn/TW2QjO5tp4FAu7GJK+5xY7jPvI6saG oUHsk0zo9obLK8WYneF19ln+Ea5ZCl9PcTi559AKGpYzpL/9uxoPT1zxxTn6c2lt 4xkxkuHtYqi/ENHGdo4CLBL93GDZEilSVmZjD/9N9990yWbPXXQ0eNoFckYSZuls HaWz8W5c046/ob8mASI6wzAUCkO9Zz4WbIj9A+mNZB32hMZbMA02gU//ffvNkFjL DGlNbROCg2DX64rvGs/RuqhuDVCnVfid9B36Cgs76GWI8dCInEfyZMtiqUb7E8Oe BPVwtTscz1StlF/0cQIDAQABoyEwHzAdBgNVHQ4EFgQU9lvXH4X04v99rrwKNzsw pNQP/dUwDQYJKoZIhvcNAQELBQADggIBAJD0MD+OK58vlP2HEoKLQYAKYM/4NsBz vSK1PtZsEj0fqiuu66ceH0wlKlGquRad4Z+LXMptu1DzMNmJsf0zSQfleGFks3xI 86hgkQ7f0qjs+YJzjJUxF9H8zX4jJk5poOqOJwStHBCDLsUmIxnT7/il3jlT0Nj4 cVs4946pCg7rP1kR9jojFD5yvzKoRBJG3/qvFnzAi8cDv9CRjSgoDTZyzZmwdCgu NioW7YeFCtvYxvY7HDXinwq/w8Gn3n8zdISoAqSpYrt5Y5ygJGiEYVDWdA50a6PC gq5xt8RCizz1L7a5BUJFMCQ0pyAUuODTndPUGLT8i7jFgzhamFPD72zFMk2+IabE Dutyt2GFeTQ75wL8QvfsKm29Vd5EjAsdfmup3hCpLGqF3g8Sh0aXDrj8KPqIecuS gkL69M9iXfnwZhTo23zUuFjBNoAIPXkNKXiJS7p9IEpYRVnlPYLToSEnnzptoPPQ zMBb8x/UMMtNYkyehSLhuIPrRLvv3eth7Hq3hA7tOCRyyf78tReVm+VoRx6AK68v 5ufxMKBFRTNoLIN3sD+DmSUNY+CaHxRMDhSESy0Ac/95J2yKi+Y1Kml2GV53pSlT 6FPm8B0R9YXM7lHhTLyL7DYqnvklkLh2bUqCLyBowynPyGqdYV4DbFSiST14fGXR mNEYF78kg0IA -----END CERTIFICATE----- """ describe "saml_signin/2 (real SAML)" do setup do patch_env(:sso_saml_adapter, PlausibleWeb.SSO.RealSAMLAdapter) team = new_site().team |> Plausible.Teams.complete_setup() integration = SSO.initiate_saml_integration(team) {:ok, integration} = SSO.update_integration(integration, %{ idp_signin_url: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", idp_entity_id: "http://localhost:8080/simplesaml/saml2/idp/metadata.php", idp_cert_pem: @cert_pem }) domain = "example-#{Enum.random(1..10_000)}.com" {:ok, sso_domain} = SSO.Domains.add(integration, domain) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, team: team, integration: integration, domain: domain, sso_domain: sso_domain} end test "redirects to IdP", %{conn: conn, domain: domain, integration: integration} do email = "paul@" <> domain conn = get( conn, Routes.sso_path(conn, :saml_signin, integration.identifier, email: email, return_to: "/sites" ) ) session = fetch_cookies(conn, encrypted: ["session_saml"]).cookies["session_saml"] assert String.length(session.relay_state) > 0 assert session.return_to == "/sites" assert url = redirected_to(conn, 302) assert {:ok, uri} = URI.new(url) assert uri.host == "localhost" assert uri.port == 8080 assert uri.scheme == "http" assert %{ "RelayState" => relay_state, "SAMLEncoding" => "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE", "SAMLRequest" => saml_request, "login_hint" => ^email } = URI.decode_query(uri.query) assert relay_state == session.relay_state xml = saml_request |> Base.decode64!() |> :zlib.unzip() assert {:ok, root_node} = SimpleXml.parse(xml) assert {:ok, issuer_node} = SimpleXml.XmlNode.first_child(root_node, "saml:Issuer") assert {:ok, acs_url} = SimpleXml.XmlNode.text(issuer_node) assert acs_url == SSO.SAMLConfig.entity_id(integration) end test "redirects to login if integration not found", %{conn: conn, domain: domain} do email = "paul@" <> domain conn = get( conn, Routes.sso_path(conn, :saml_signin, Ecto.UUID.generate(), email: email, return_to: "/sites" ) ) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "We couldn't find a Single Sign-On account for that email." end end @assertion File.read!("extra/fixture/assertion.xml") @assertion_missing_email File.read!("extra/fixture/assertion_missing_email.xml") @assertion_invalid_email File.read!("extra/fixture/assertion_invalid_email.xml") @assertion_missing_name File.read!("extra/fixture/assertion_missing_name.xml") describe "saml_consume/2 (real SAML)" do setup %{conn: conn} do patch_env(:sso_saml_adapter, PlausibleWeb.SSO.RealSAMLAdapter) team = new_site().team |> Plausible.Teams.complete_setup() integration = SSO.initiate_saml_integration(team) {:ok, integration} = SSO.update_integration(integration, %{ idp_signin_url: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", idp_entity_id: "http://localhost:8080/simplesaml/saml2/idp/metadata.php", idp_cert_pem: @cert_pem }) domain = "plausible.test" {:ok, sso_domain} = SSO.Domains.add(integration, domain) sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) {:ok, root_node} = @assertion |> SimpleXml.parse() {:ok, assertion_node} = SimpleXml.XmlNode.first_child(root_node, "*:Assertion") {:ok, subject_node} = SimpleXml.XmlNode.first_child(assertion_node, "*:Subject") {:ok, name_id_node} = SimpleXml.XmlNode.first_child(subject_node, "*:NameID") {:ok, name_id} = SimpleXml.XmlNode.text(name_id_node) email = "user@plausible.test" conn = get( conn, Routes.sso_path(conn, :saml_signin, integration.identifier, email: email, return_to: "/sites" ) ) saml_session = fetch_cookies(conn, encrypted: ["session_saml"]).cookies["session_saml"] relay_state = saml_session.relay_state conn = conn |> recycle() |> prepare_conn() {:ok, team: team, integration: integration, domain: domain, sso_domain: sso_domain, email: email, conn: conn, name_id: name_id, relay_state: relay_state} end test "provisions identity and logs in", %{ conn: conn, team: team, integration: integration, sso_domain: sso_domain, relay_state: relay_state, name_id: name_id } do params = %{ "SAMLResponse" => Base.encode64(@assertion), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == "/sites" session = get_session(conn) assert session["current_team_id"] == team.identifier assert {:ok, user_session} = Auth.UserSessions.get_by_token(session["user_token"]) assert user_session.user.email == "user@plausible.test" assert user_session.user.type == :sso assert user_session.user.name == "Jane Smith" assert user_session.user.sso_integration_id == integration.id assert user_session.user.sso_domain_id == sso_domain.id assert user_session.user.sso_identity_id == name_id timeout_minutes = team.policy.sso_session_timeout_minutes timeout_threshold_lower = NaiveDateTime.add(NaiveDateTime.utc_now(:second), timeout_minutes - 2, :minute) timeout_threshold_upper = NaiveDateTime.add(NaiveDateTime.utc_now(:second), timeout_minutes + 2, :minute) assert NaiveDateTime.compare(user_session.timeout_at, timeout_threshold_lower) == :gt assert NaiveDateTime.compare(user_session.timeout_at, timeout_threshold_upper) == :lt assert entry = audited_entry("sso_login_success", team_id: integration.team_id) assert entry.change["email"] == "user@plausible.test" assert entry.change["name"] == "Jane Smith" assert entry.change["integration_id"] == integration.identifier end test "redirects with error when no matching integration found", %{ conn: conn, relay_state: relay_state } do params = %{ "SAMLResponse" => Base.encode64(@assertion), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "We couldn't find a Single Sign-On account for that email." end test "redirects with error on mismatch of RelayState", %{ conn: conn, integration: integration } do params = %{ "SAMLResponse" => Base.encode64(@assertion), "RelayState" => Ecto.UUID.generate() } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :invalid_relay_state)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":invalid_relay_state" } ) end test "redirects with error on missing relay state", %{ conn: conn, integration: integration } do params = %{"SAMLResponse" => Base.encode64(@assertion)} conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :invalid_relay_state)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":invalid_relay_state" } ) end test "redirects with error on malformed assertion", %{ conn: conn, integration: integration, relay_state: relay_state } do params = %{ "SAMLResponse" => "malformed", "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :base64_decoding_failed)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":base64_decoding_failed" } ) end test "redirects with error on malformed certificate in config (should not happen)", %{ conn: conn, integration: integration, relay_state: relay_state } do Repo.query!( """ UPDATE sso_integrations SET config = jsonb_set(config, '{idp_cert_pem}', '"invalid"') WHERE id = $1 """, [integration.id] ) params = %{ "SAMLResponse" => Base.encode64(@assertion), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :malformed_certificate)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":malformed_certificate" } ) end test "redirects with error on mismatched certificate in config", %{ conn: conn, integration: integration, relay_state: relay_state } do {:ok, integration} = SSO.update_integration(integration, %{idp_cert_pem: @other_cert_pem}) params = %{ "SAMLResponse" => Base.encode64(@assertion), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :digest_verification_failed)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":digest_verification_failed" } ) end test "redirects with error on missing email attribute in assertion", %{ conn: conn, integration: integration, relay_state: relay_state } do params = %{ "SAMLResponse" => Base.encode64(@assertion_missing_email), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :missing_email_attribute)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":missing_email_attribute" } ) end test "redirects with error on invalid email attribute in assertion", %{ conn: conn, integration: integration, relay_state: relay_state } do params = %{ "SAMLResponse" => Base.encode64(@assertion_invalid_email), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :invalid_email_attribute)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":invalid_email_attribute" } ) end test "redirects with error on missing name attributes in assertion", %{ conn: conn, integration: integration, relay_state: relay_state } do params = %{ "SAMLResponse" => Base.encode64(@assertion_missing_name), "RelayState" => relay_state } conn = post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), params) assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites") assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Authentication failed (reason: :missing_name_attributes)" assert audited_entry("sso_login_failure", team_id: integration.team_id, change: %{ "error" => ":missing_name_attributes" } ) end end end end