Skip to content
Snippets Groups Projects
  • adam-james's avatar
    174afe58
    Adjust JWT and SAML fetch-and-update user to save new attributes (#23005) · 174afe58
    adam-james authored
    * Adjust JWT and SAML fetch-and-update user to save new attributes
    
    Before this change, JWT/SAML logins would attempt to update attributes, but never considered the first-name or
    last-name attributes.
    
    * Attempts to fix tests to prevent pulluting test users with "Unknown"
    
    * No deleting users.
    
    * Unit tests checking that first/last names are updated for SSO users
    
    When an SSO user is first logged in, they might not have first_name and/or last_name keys. This is allowed, but the
    names will be "Unknown" in the app-db. Subsequently, a User may log in again with SSO but have fisrt/last name
    attributes, which should update the Metabase user data in the app-db.
    
    These unit tests set up such a scenario to check that the :first_name and :last_name keys are indeed updated.
    
    * Adjust Enterprise LDAP to also use SSO-UTILS
    
    Trying to unify the LDAP implementation with JWT/SAML a bit here.
    
    * Lint error
    
    * Reverting LDAP ns changes to get the PR unstuck
    
    This is to keep the ball rolling on SSO fixes. I'll add LDAP as an item in the Epic to address this separately.
    Adjust JWT and SAML fetch-and-update user to save new attributes (#23005)
    adam-james authored
    * Adjust JWT and SAML fetch-and-update user to save new attributes
    
    Before this change, JWT/SAML logins would attempt to update attributes, but never considered the first-name or
    last-name attributes.
    
    * Attempts to fix tests to prevent pulluting test users with "Unknown"
    
    * No deleting users.
    
    * Unit tests checking that first/last names are updated for SSO users
    
    When an SSO user is first logged in, they might not have first_name and/or last_name keys. This is allowed, but the
    names will be "Unknown" in the app-db. Subsequently, a User may log in again with SSO but have fisrt/last name
    attributes, which should update the Metabase user data in the app-db.
    
    These unit tests set up such a scenario to check that the :first_name and :last_name keys are indeed updated.
    
    * Adjust Enterprise LDAP to also use SSO-UTILS
    
    Trying to unify the LDAP implementation with JWT/SAML a bit here.
    
    * Lint error
    
    * Reverting LDAP ns changes to get the PR unstuck
    
    This is to keep the ball rolling on SSO fixes. I'll add LDAP as an item in the Epic to address this separately.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
jwt.clj 5.10 KiB
(ns metabase-enterprise.sso.integrations.jwt
  "Implementation of the JWT backend for sso"
  (:require [buddy.sign.jwt :as jwt]
            [clojure.string :as str]
            [metabase-enterprise.sso.api.interface :as sso.i]
            [metabase-enterprise.sso.integrations.sso-settings :as sso-settings]
            [metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
            [metabase.api.common :as api]
            [metabase.api.session :as api.session]
            [metabase.integrations.common :as integrations.common]
            [metabase.server.middleware.session :as mw.session]
            [metabase.server.request.util :as request.u]
            [metabase.util.i18n :refer [trs tru]]
            [ring.util.response :as response])
  (:import java.net.URLEncoder))

(defn fetch-or-create-user!
  "Returns a session map for the given `email`. Will create the user if needed."
  [first-name last-name email user-attributes]
  (when-not (sso-settings/jwt-configured?)
    (throw (IllegalArgumentException. (str (tru "Can't create new JWT user when JWT is not configured")))))
  (let [user {:first_name       first-name
              :last_name        last-name
              :email            email
              :sso_source       "jwt"
              :login_attributes user-attributes}]
    (or (sso-utils/fetch-and-update-login-attributes! user)
        (sso-utils/create-new-sso-user! (merge user
                                               (when-not first-name {:first_name (trs "Unknown")})
                                               (when-not last-name {:last_name (trs "Unknown")}))))))

(def ^:private ^{:arglists '([])} jwt-attribute-email     (comp keyword sso-settings/jwt-attribute-email))
(def ^:private ^{:arglists '([])} jwt-attribute-firstname (comp keyword sso-settings/jwt-attribute-firstname))
(def ^:private ^{:arglists '([])} jwt-attribute-lastname  (comp keyword sso-settings/jwt-attribute-lastname))
(def ^:private ^{:arglists '([])} jwt-attribute-groups    (comp keyword sso-settings/jwt-attribute-groups))

(defn- jwt-data->login-attributes [jwt-data]
  (dissoc jwt-data
          (jwt-attribute-email)
          (jwt-attribute-firstname)
          (jwt-attribute-lastname)
          :iat
          :max_age))

;; JWTs use seconds since Epoch, not milliseconds since Epoch for the `iat` and `max_age` time. 3 minutes is the time
;; used by Zendesk for their JWT SSO, so it seemed like a good place for us to start
(def ^:private ^:const three-minutes-in-seconds 180)

(defn- group-names->ids
  "Translate a user's group names to a set of MB group IDs using the configured mappings"
  [group-names]
  (set (mapcat (sso-settings/jwt-group-mappings)
               (map keyword group-names))))

(defn- all-mapped-group-ids
  "Returns the set of all MB group IDs that have configured mappings"
  []
  (-> (sso-settings/jwt-group-mappings)
      vals
      flatten
      set))

(defn- sync-groups!
  "Sync a user's groups based on mappings configured in the JWT settings"
  [user jwt-data]
  (when (sso-settings/jwt-group-sync)
    (when-let [groups-attribute (jwt-attribute-groups)]
      (when-let [group-names (get jwt-data groups-attribute)]
        (integrations.common/sync-group-memberships! user
                                                     (group-names->ids group-names)
                                                     (all-mapped-group-ids))))))

(defn- login-jwt-user
  [jwt {{redirect :return_to} :params, :as request}]
  (let [redirect-url (or redirect (URLEncoder/encode "/"))]
    (sso-utils/check-sso-redirect redirect-url)
    (let [jwt-data     (try
                         (jwt/unsign jwt (sso-settings/jwt-shared-secret)
                                     {:max-age three-minutes-in-seconds})
                         (catch Throwable e
                           (throw (ex-info (ex-message e)
                                           (assoc (ex-data e) :status-code 401)
                                           e))))
          login-attrs  (jwt-data->login-attributes jwt-data)
          email        (get jwt-data (jwt-attribute-email))
          first-name   (get jwt-data (jwt-attribute-firstname))
          last-name    (get jwt-data (jwt-attribute-lastname))
          user         (fetch-or-create-user! first-name last-name email login-attrs)
          session      (api.session/create-session! :sso user (request.u/device-info request))]
      (sync-groups! user jwt-data)
      (mw.session/set-session-cookie request (response/redirect redirect-url) session))))

(defn- check-jwt-enabled []
  (api/check (sso-settings/jwt-configured?)
    [400 (tru "JWT SSO has not been enabled and/or configured")]))

(defmethod sso.i/sso-get :jwt
  [{{:keys [jwt redirect]} :params, :as request}]
  (check-jwt-enabled)
  (if jwt
    (login-jwt-user jwt request)
    (let [idp (sso-settings/jwt-identity-provider-uri)
          return-to-param (if (str/includes? idp "?") "&return_to=" "?return_to=")]
      (response/redirect (str idp (when redirect
                                (str return-to-param redirect)))))))

(defmethod sso.i/sso-post :jwt
  [_]
  (throw (ex-info "POST not valid for JWT SSO requests" {:status-code 400})))