Implementation of the JWT backend for sso

(ns metabase-enterprise.sso.integrations.jwt
  (:require
   [buddy.sign.jwt :as jwt]
   [clojure.string :as str]
   [java-time.api :as t]
   [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.embed.settings :as embed.settings]
   [metabase.premium-features.core :as premium-features]
   [metabase.request.core :as request]
   [metabase.session.models.session :as session]
   [metabase.sso.core :as sso]
   [metabase.util.i18n :refer [tru]]
   [ring.util.response :as response])
  (:import
   (java.net URLEncoder)))
(set! *warn-on-reflection* true)

Returns a session map for the given email. Will create the user if needed.

(defn fetch-or-create-user!
  [first-name last-name email user-attributes]
  (when-not (sso-settings/jwt-enabled)
    (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/check-user-provisioning :jwt)
        (sso-utils/create-new-sso-user! user))))
(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))

Registered claims in the JWT standard which we should not interpret as login attributes

(def ^:private registered-claims
  [:iss :iat :sub :aud :exp :nbf :jti])
(defn- jwt-data->login-attributes [jwt-data]
  (apply dissoc
         jwt-data
         (jwt-attribute-email)
         (jwt-attribute-firstname)
         (jwt-attribute-lastname)
         registered-claims))

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)

Translate a user's group names to a set of MB group IDs using the configured mappings

(defn- group-names->ids
  [group-names]
  (set
   (mapcat (sso-settings/jwt-group-mappings)
           (map keyword group-names))))

Returns the set of all MB group IDs that have configured mappings

(defn- all-mapped-group-ids
  []
  (-> (sso-settings/jwt-group-mappings)
      vals
      flatten
      set))

Sync a user's groups based on mappings configured in the JWT settings

(defn- sync-groups!
  [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)]
        (sso/sync-group-memberships! user
                                     (group-names->ids group-names)
                                     (all-mapped-group-ids))))))
(defn- session-data
  [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)
                                     {:status      "error-jwt-bad-unsigning"
                                      :status-code 401}))))
          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      (session/create-session! :sso user (request/device-info request))]
      (sync-groups! user jwt-data)
      {:session session, :redirect-url redirect-url, :jwt-data jwt-data})))
(defn- check-jwt-enabled []
  (when-not (sso-settings/jwt-configured)
    (throw
     (ex-info (tru "JWT SSO has not been configured")
              {:status      "error-sso-jwt-not-configured"
               :status-code 402})))
  (when-not (sso-settings/jwt-enabled)
    (throw
     (ex-info (tru "JWT SSO has not been enabled")
              {:status      "error-sso-jwt-disabled"
               :status-code 402})))
  true)
(defn ^:private generate-response-token
  [session jwt-data]
  (if-not (embed.settings/enable-embedding-sdk)
    (throw
     (ex-info (tru "SDK Embedding is disabled. Enable it in the Embedding settings.")
              {:status      "error-embedding-sdk-disabled"
               :status-code 402}))
    (response/response
     {:status :ok
      :id     (:id session)
      :exp    (:exp jwt-data)
      :iat    (:iat jwt-data)})))
(defn ^:private redirect-to-idp
  [idp redirect]
  (let [return-to-param (if (str/includes? idp "?") "&return_to=" "?return_to=")]
    (response/redirect
     (str idp
          (when redirect
            (str return-to-param redirect))))))
(defn ^:private handle-jwt-authentication
  [{:keys [session redirect-url jwt-data]} token request]
  (if token
    (generate-response-token session jwt-data)
    (request/set-session-cookies request (response/redirect redirect-url) session (t/zoned-date-time (t/zone-id "GMT")))))
(defmethod sso.i/sso-get :jwt
  [{{:keys [jwt redirect token] :or {token nil}} :params, :as request}]
  (premium-features/assert-has-feature :sso-jwt (tru "JWT-based authentication"))
  (check-jwt-enabled)
  (if jwt
    (handle-jwt-authentication (session-data jwt request) token request)
    (redirect-to-idp (sso-settings/jwt-identity-provider-uri) redirect)))
(defmethod sso.i/sso-post :jwt
  [_]
  (throw
   (ex-info (tru "POST not valid for JWT SSO requests")
            {:status "error-post-jwt-not-valid" :status-code 501})))