(ns metabase-enterprise.sso.integrations.jwt-test (:require [buddy.sign.jwt :as jwt] [buddy.sign.util :as buddy-util] [clojure.string :as str] [clojure.test :refer :all] [crypto.random :as crypto-random] [metabase-enterprise.sso.integrations.jwt :as mt.jwt] [metabase-enterprise.sso.integrations.saml-test :as saml-test] [metabase.models.permissions-group :refer [PermissionsGroup]] [metabase.models.permissions-group-membership :refer [PermissionsGroupMembership]] [metabase.models.user :refer [User]] [metabase.public-settings.premium-features-test :as premium-features-test] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] [metabase.util :as u] [toucan.db :as db] [toucan.util.test :as tt])) (use-fixtures :once (fixtures/initialize :test-users)) (defn- disable-other-sso-types [thunk] (mt/with-temporary-setting-values [ldap-enabled false saml-enabled false] (thunk))) (use-fixtures :each disable-other-sso-types) (def ^:private default-idp-uri "http://test.idp.metabase.com") (def ^:private default-redirect-uri "http://localhost:3000/test") (def ^:private default-jwt-secret (crypto-random/hex 32)) (deftest sso-prereqs-test (testing "SSO requests fail if JWT hasn't been enabled" (mt/with-temporary-setting-values [jwt-enabled false] (saml-test/with-valid-premium-features-token (is (= "SSO has not been enabled and/or configured" (saml-test/client :get 400 "/auth/sso")))) (testing "SSO requests fail if they don't have a valid premium-features token" (premium-features-test/with-premium-features nil (is (= "SSO requires a valid token" (saml-test/client :get 403 "/auth/sso"))))))) (testing "SSO requests fail if JWT is enabled but hasn't been configured" (saml-test/with-valid-premium-features-token (mt/with-temporary-setting-values [jwt-enabled true jwt-identity-provider-uri nil] (is (= "JWT SSO has not been enabled and/or configured" (saml-test/client :get 400 "/auth/sso")))))) (testing "The JWT Shared Secret must also be included for SSO to be configured" (saml-test/with-valid-premium-features-token (mt/with-temporary-setting-values [jwt-enabled true jwt-identity-provider-uri default-idp-uri jwt-shared-secret nil] (is (= "JWT SSO has not been enabled and/or configured" (saml-test/client :get 400 "/auth/sso"))))))) (defn- call-with-default-jwt-config [f] (mt/with-temporary-setting-values [jwt-enabled true jwt-identity-provider-uri default-idp-uri jwt-shared-secret default-jwt-secret site-url "http://localhost"] (f))) (defmacro ^:private with-jwt-default-setup [& body] `(disable-other-sso-types (fn [] (saml-test/with-valid-premium-features-token (saml-test/call-with-login-attributes-cleared! (fn [] (call-with-default-jwt-config (fn [] ~@body)))))))) (deftest redirect-test (testing "with JWT configured, a GET request should result in a redirect to the IdP" (with-jwt-default-setup (let [result (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) redirect-url (get-in result [:headers "Location"])] (is (str/starts-with? redirect-url default-idp-uri))))) (testing (str "JWT configured with a redirect-uri containing query params, " "a GET request should result in a redirect to the IdP as a correctly formatted URL (#13078)") (with-jwt-default-setup (mt/with-temporary-setting-values [jwt-identity-provider-uri "http://test.idp.metabase.com/login?some_param=yes"] (let [result (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) redirect-url (get-in result [:headers "Location"])] (is (str/includes? redirect-url "&return_to="))))))) (deftest happy-path-test (testing (str "Happy path login, valid JWT, checks to ensure the user was logged in successfully and the redirect to " "the right location") (with-jwt-default-setup (let [response (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "rasta@metabase.com" :first_name "Rasta" :last_name "Toucan" :extra "keypairs" :are "also present"} default-jwt-secret))] (is (saml-test/successful-login? response)) (testing "redirect URI" (is (= default-redirect-uri (get-in response [:headers "Location"])))) (testing "login attributes" (is (= {"extra" "keypairs", "are" "also present"} (db/select-one-field :login_attributes User :email "rasta@metabase.com")))))))) (deftest no-open-redirect-test (testing "Check a JWT with bad (open redirect)" (with-jwt-default-setup (is (= "SSO is trying to do an open redirect to an untrusted site" (saml-test/client :get 400 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to "https://evilsite.com" :jwt (jwt/sign {:email "rasta@metabase.com" :first_name "Rasta" :last_name "Toucan" :extra "keypairs" :are "also present"} default-jwt-secret))))))) (deftest expired-jwt-test (testing "Check an expired JWT" (with-jwt-default-setup (is (= "Token is older than max-age (180)" (:message (saml-test/client :get 401 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "test@metabase.com", :first_name "Test" :last_name "User" :iat (- (buddy-util/now) (u/minutes->seconds 5))} default-jwt-secret)))))))) (defmacro with-users-with-email-deleted {:style/indent 1} [user-email & body] `(try ~@body (finally (db/delete! User :%lower.email (u/lower-case-en ~user-email))))) (deftest create-new-account-test (testing "A new account will be created for a JWT user we haven't seen before" (with-jwt-default-setup (with-users-with-email-deleted "newuser@metabase.com" (letfn [(new-user-exists? [] (boolean (seq (db/select User :%lower.email "newuser@metabase.com"))))] (is (= false (new-user-exists?))) (let [response (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "newuser@metabase.com" :first_name "New" :last_name "User" :more "stuff" :for "the new user"} default-jwt-secret))] (is (saml-test/successful-login? response)) (testing "new user" (is (= [{:email "newuser@metabase.com" :first_name "New" :is_qbnewb true :is_superuser false :id true :last_name "User" :date_joined true :common_name "New User"}] (->> (mt/boolean-ids-and-timestamps (db/select User :email "newuser@metabase.com")) (map #(dissoc % :last_login)))))) (testing "attributes" (is (= {"more" "stuff" "for" "the new user"} (db/select-one-field :login_attributes User :email "newuser@metabase.com")))))))))) (deftest update-account-test (testing "A new account with 'Unknown' name will be created for a new JWT user without a first or last name." (with-jwt-default-setup (with-users-with-email-deleted "newuser@metabase.com" (letfn [(new-user-exists? [] (boolean (seq (db/select User :%lower.email "newuser@metabase.com"))))] (is (= false (new-user-exists?))) (let [response (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "newuser@metabase.com"} default-jwt-secret))] (is (saml-test/successful-login? response)) (testing "new user with no first or last name" (is (= [{:email "newuser@metabase.com" :first_name "Unknown" :is_qbnewb true :is_superuser false :id true :last_name "Unknown" :date_joined true :common_name "Unknown Unknown"}] (->> (mt/boolean-ids-and-timestamps (db/select User :email "newuser@metabase.com")) (map #(dissoc % :last_login))))))) (let [response (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "newuser@metabase.com" :first_name "New" :last_name "User"} default-jwt-secret))] (is (saml-test/successful-login? response)) (testing "update user first and last name" (is (= [{:email "newuser@metabase.com" :first_name "New" :is_qbnewb true :is_superuser false :id true :last_name "User" :date_joined true :common_name "New User"}] (->> (mt/boolean-ids-and-timestamps (db/select User :email "newuser@metabase.com")) (map #(dissoc % :last_login)))))))))))) (deftest group-mappings-test (testing "make sure our setting for mapping group names -> IDs works" (mt/with-temporary-setting-values [jwt-group-mappings {"group_1" [1 2 3] "group_2" [3 4] "group_3" [5]}] (testing "keyword group names" (is (= #{1 2 3 4} (#'mt.jwt/group-names->ids [:group_1 :group_2])))) (testing "string group names" (is (= #{3 4 5} (#'mt.jwt/group-names->ids ["group_2" "group_3"]))))))) (defn- group-memberships [user-or-id] (when-let [group-ids (seq (db/select-field :group_id PermissionsGroupMembership :user_id (u/the-id user-or-id)))] (db/select-field :name PermissionsGroup :id [:in group-ids]))) (deftest login-sync-group-memberships-test (testing "login should sync group memberships if enabled" (with-jwt-default-setup (tt/with-temp PermissionsGroup [my-group {:name (str ::my-group)}] (mt/with-temporary-setting-values [jwt-group-sync true jwt-group-mappings {"my_group" [(u/the-id my-group)]} jwt-attribute-groups "GrOuPs"] (with-users-with-email-deleted "newuser@metabase.com" (let [response (saml-test/client-full-response :get 302 "/auth/sso" {:request-options {:redirect-strategy :none}} :return_to default-redirect-uri :jwt (jwt/sign {:email "newuser@metabase.com" :first_name "New" :last_name "User" :more "stuff" :GrOuPs ["my_group"] :for "the new user"} default-jwt-secret))] (is (saml-test/successful-login? response)) (is (= #{"All Users" ":metabase-enterprise.sso.integrations.jwt-test/my-group"} (group-memberships (u/the-id (db/select-one-id User :email "newuser@metabase.com"))))))))))))