;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC

(ns app.auth.oidc
  "OIDC client implementation."
  (:require
   [app.auth :as auth]
   [app.auth.oidc.providers :as-alias providers]
   [app.common.data :as d]
   [app.common.data.macros :as dm]
   [app.common.exceptions :as ex]
   [app.common.logging :as l]
   [app.common.spec :as us]
   [app.common.uri :as u]
   [app.config :as cf]
   [app.db :as db]
   [app.http.client :as http]
   [app.http.session :as session]
   [app.loggers.audit :as audit]
   [app.rpc.commands.profile :as profile]
   [app.setup :as-alias setup]
   [app.tokens :as tokens]
   [app.util.json :as json]
   [app.util.time :as dt]
   [buddy.sign.jwk :as jwk]
   [buddy.sign.jwt :as jwt]
   [clojure.set :as set]
   [clojure.spec.alpha :as s]
   [cuerdas.core :as str]
   [integrant.core :as ig]
   [ring.response :as-alias rres]))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn obfuscate-string
  [s]
  (if (< (count s) 10)
    (apply str (take (count s) (repeat "*")))
    (str (subs s 0 5)
         (apply str (take (- (count s) 5) (repeat "*"))))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; OIDC PROVIDER (GENERIC)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- discover-oidc-config
  [cfg {:keys [base-uri] :as opts}]
  (let [uri (dm/str (u/join base-uri ".well-known/openid-configuration"))
        rsp (http/req! cfg {:method :get :uri uri} {:sync? true})]
    (if (= 200 (:status rsp))
      (let [data      (-> rsp :body json/decode)
            token-uri (get data :token_endpoint)
            auth-uri  (get data :authorization_endpoint)
            user-uri  (get data :userinfo_endpoint)
            jwks-uri  (get data :jwks_uri)]

        (l/debug :hint "oidc uris discovered"
                 :token-uri token-uri
                 :auth-uri auth-uri
                 :user-uri user-uri
                 :jwks-uri jwks-uri)

        {:token-uri token-uri
         :auth-uri  auth-uri
         :user-uri  user-uri
         :jwks-uri jwks-uri})
      (do
        (l/warn :hint "unable to discover OIDC configuration"
                :discover-uri uri
                :http-status (:status rsp))
        nil))))

(defn- prepare-oidc-opts
  [cfg]
  (let [opts {:base-uri      (cf/get :oidc-base-uri)
              :client-id     (cf/get :oidc-client-id)
              :client-secret (cf/get :oidc-client-secret)
              :token-uri     (cf/get :oidc-token-uri)
              :auth-uri      (cf/get :oidc-auth-uri)
              :user-uri      (cf/get :oidc-user-uri)
              :jwks-uri      (cf/get :oidc-jwks-uri)
              :scopes        (cf/get :oidc-scopes #{"openid" "profile" "email"})
              :roles-attr    (cf/get :oidc-roles-attr)
              :roles         (cf/get :oidc-roles)
              :name          "oidc"}

        opts (d/without-nils opts)]

    (when (and (string? (:base-uri opts))
               (string? (:client-id opts))
               (string? (:client-secret opts)))
      (if (and (string? (:token-uri opts))
               (string? (:user-uri opts))
               (string? (:auth-uri opts)))
        opts
        (try
          (-> (discover-oidc-config cfg opts)
              (merge opts {:discover? true}))
          (catch Throwable cause
            (l/warn :hint "unable to discover OIDC configuration"
                    :cause cause)))))))

(defn- process-oidc-jwks
  [keys]
  (reduce (fn [result {:keys [kid] :as kdata}]
            (let [pkey (ex/try! (jwk/public-key kdata))]
              (if (ex/exception? pkey)
                (do
                  (l/warn :hint "unable to create public key"
                          :kid (:kid kdata)
                          :cause pkey)
                  result)
                (assoc result kid pkey))))
          {}
          keys))

(defn- fetch-oidc-jwks
  [cfg {:keys [jwks-uri]}]
  (when jwks-uri
    (try
      (let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri} {:sync? true})]
        (if (= 200 status)
          (-> body json/decode :keys process-oidc-jwks)
          (do
            (l/warn :hint "unable to retrieve JWKs (unexpected response status code)"
                    :http-status status
                    :http-body  body)
            nil)))
      (catch Throwable cause
        (l/warn :hint "unable to retrieve JWKs (unexpected exception)"
                :cause cause)))))

(defmethod ig/pre-init-spec ::providers/generic [_]
  (s/keys :req [::http/client]))

(defmethod ig/init-key ::providers/generic
  [_ cfg]
  (when (contains? cf/flags :login-with-oidc)
    (if-let [opts (prepare-oidc-opts cfg)]
      (let [jwks (fetch-oidc-jwks cfg opts)]
        (l/info :hint "provider initialized"
                :provider "oidc"
                :method (if (:discover? opts) "discover" "manual")
                :client-id (:client-id opts)
                :client-secret (obfuscate-string (:client-secret opts))
                :scopes     (str/join "," (:scopes opts))
                :auth-uri   (:auth-uri opts)
                :user-uri   (:user-uri opts)
                :token-uri  (:token-uri opts)
                :roles-attr (:roles-attr opts)
                :roles      (:roles opts)
                :keys       (str/join "," (map str (keys jwks))))
        (assoc opts :jwks jwks))
      (do
        (l/warn :hint "unable to initialize auth provider, missing configuration" :provider "oidc")
        nil))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GOOGLE AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defmethod ig/init-key ::providers/google
  [_ _]
  (let [opts {:client-id     (cf/get :google-client-id)
              :client-secret (cf/get :google-client-secret)
              :scopes        #{"openid" "email" "profile"}
              :auth-uri      "https://accounts.google.com/o/oauth2/v2/auth"
              :token-uri     "https://oauth2.googleapis.com/token"
              :user-uri      "https://openidconnect.googleapis.com/v1/userinfo"
              :name          "google"}]

    (when (contains? cf/flags :login-with-google)
      (if (and (string? (:client-id opts))
               (string? (:client-secret opts)))
        (do
          (l/info :hint "provider initialized"
                  :provider "google"
                  :client-id (:client-id opts)
                  :client-secret (obfuscate-string (:client-secret opts)))
          opts)

        (do
          (l/warn :hint "unable to initialize auth provider, missing configuration" :provider "google")
          nil)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GITHUB AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- retrieve-github-email
  [cfg tdata props]
  (or (some-> props :github/email)
      (let [params {:uri "https://api.github.com/user/emails"
                    :headers {"Authorization" (dm/str (:token/type tdata) " " (:token/access tdata))}
                    :timeout 6000
                    :method :get}

            {:keys [status body]} (http/req! cfg params {:sync? true})]

        (when-not (s/int-in-range? 200 300 status)
          (ex/raise :type :internal
                    :code :unable-to-retrieve-github-emails
                    :hint "unable to retrieve github emails"
                    :http-status status
                    :http-body body))

        (->> body json/decode (filter :primary) first :email))))

(defmethod ig/pre-init-spec ::providers/github [_]
  (s/keys :req [::http/client]))

(defmethod ig/init-key ::providers/github
  [_ cfg]
  (let [opts {:client-id     (cf/get :github-client-id)
              :client-secret (cf/get :github-client-secret)
              :scopes        #{"read:user" "user:email"}
              :auth-uri      "https://github.com/login/oauth/authorize"
              :token-uri     "https://github.com/login/oauth/access_token"
              :user-uri      "https://api.github.com/user"
              :name          "github"

              ;; Additional hooks for provider specific way of
              ;; retrieve emails.
              :get-email-fn  (partial retrieve-github-email cfg)}]

    (when (contains? cf/flags :login-with-github)
      (if (and (string? (:client-id opts))
               (string? (:client-secret opts)))
        (do
          (l/info :hint "provider initialized"
                  :provider "github"
                  :client-id (:client-id opts)
                  :client-secret (obfuscate-string (:client-secret opts)))
          opts)

        (do
          (l/warn :hint "unable to initialize auth provider, missing configuration" :provider "github")
          nil)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; GITLAB AUTH PROVIDER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defmethod ig/init-key ::providers/gitlab
  [_ _]
  (let [base (cf/get :gitlab-base-uri "https://gitlab.com")
        opts {:base-uri      base
              :client-id     (cf/get :gitlab-client-id)
              :client-secret (cf/get :gitlab-client-secret)
              :scopes        #{"openid" "profile" "email"}
              :auth-uri      (str base "/oauth/authorize")
              :token-uri     (str base "/oauth/token")
              :user-uri      (str base "/oauth/userinfo")
              :name          "gitlab"}]
    (when (contains? cf/flags :login-with-gitlab)
      (if (and (string? (:client-id opts))
               (string? (:client-secret opts)))
        (do
          (l/info :hint "provider initialized"
                  :provider "gitlab"
                  :base-uri base
                  :client-id (:client-id opts)
                  :client-secret (obfuscate-string (:client-secret opts)))
          opts)

        (do
          (l/warn :hint "unable to initialize auth provider, missing configuration" :provider "gitlab")
          nil)))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; HANDLERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- parse-attr-path
  [provider path]
  (let [[fitem & items] (str/split path "__")]
    (into [(keyword (:name provider) fitem)] (map keyword) items)))

(defn- build-redirect-uri
  [{:keys [::provider] :as cfg}]
  (let [public (u/uri (cf/get :public-uri))]
    (str (assoc public :path (str "/api/auth/oauth/" (:name provider) "/callback")))))

(defn- build-auth-uri
  [{:keys [::provider] :as cfg} state]
  (let [params {:client_id (:client-id provider)
                :redirect_uri (build-redirect-uri cfg)
                :response_type "code"
                :state state
                :scope (str/join " " (:scopes provider []))}
        query  (u/map->query-string params)]
    (-> (u/uri (:auth-uri provider))
        (assoc :query query)
        (str))))

(defn- qualify-prop-key
  [provider k]
  (keyword (:name provider) (name k)))

(defn- qualify-props
  [provider props]
  (reduce-kv (fn [result k v]
               (assoc result (qualify-prop-key provider k) v))
             {}
             props))

(defn- fetch-access-token
  [{:keys [::provider] :as cfg} code]
  (let [params {:client_id (:client-id provider)
                :client_secret (:client-secret provider)
                :code code
                :grant_type "authorization_code"
                :redirect_uri (build-redirect-uri cfg)}
        req    {:method :post
                :headers {"content-type" "application/x-www-form-urlencoded"
                          "accept" "application/json"}
                :uri (:token-uri provider)
                :body (u/map->query-string params)}]

    (l/trace :hint "request access token"
             :provider (:name provider)
             :client-id (:client-id provider)
             :client-secret (obfuscate-string (:client-secret provider))
             :grant-type (:grant_type params)
             :redirect-uri (:redirect_uri params))

    (let [{:keys [status body]} (http/req! cfg req {:sync? true})]
      (l/trace :hint "access token response" :status status :body body)
      (if (= status 200)
        (let [data (json/decode body)]
          {:token/access (get data :access_token)
           :token/id     (get data :id_token)
           :token/type   (get data :token_type)})

        (ex/raise :type :internal
                  :code :unable-to-retrieve-token
                  :hint "unable to retrieve token"
                  :http-status status
                  :http-body body)))))

(defn- process-user-info
  [provider tdata info]
  (letfn [(get-email [props]
            ;; Allow providers hook into this for custom email
            ;; retrieval method.
            (if-let [get-email-fn (:get-email-fn provider)]
              (get-email-fn tdata props)
              (let [attr-kw (cf/get :oidc-email-attr "email")
                    attr-ph (parse-attr-path provider attr-kw)]
                (get-in props attr-ph))))

          (get-name [props]
            (let [attr-kw (cf/get :oidc-name-attr "name")
                  attr-ph (parse-attr-path provider attr-kw)]
              (get-in props attr-ph)))]

    (let [props (qualify-props provider info)
          email (get-email props)]
      {:backend  (:name provider)
       :fullname (or (get-name props) email)
       :email    email
       :props    props})))

(defn- fetch-user-info
  [{:keys [::provider] :as cfg} tdata]
  (l/trace :hint "fetch user info"
           :uri (:user-uri provider)
           :token (obfuscate-string (:token/access tdata)))

  (let [params   {:uri (:user-uri provider)
                  :headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
                  :timeout 6000
                  :method :get}
        response (http/req! cfg params {:sync? true})]

    (l/trace :hint "user info response"
             :status (:status response)
             :body   (:body response))

    (when-not (s/int-in-range? 200 300 (:status response))
      (ex/raise :type :internal
                :code :unable-to-retrieve-user-info
                :hint "unable to retrieve user info"
                :http-status (:status response)
                :http-body (:body response)))

    (-> response :body json/decode)))

(defn- get-user-info
  [{:keys [::provider]} tdata]
  (try
    (when (:token/id tdata)
      (let [{:keys [kid alg] :as theader} (jwt/decode-header (:token/id tdata))]
        (when-let [key (if (str/starts-with? (name alg) "hs")
                         (:client-secret provider)
                         (get-in provider [:jwks kid]))]

          (let [claims (jwt/unsign (:token/id tdata) key {:alg alg})]
            (dissoc claims :exp :iss :iat :sid :aud :sub)))))
    (catch Throwable cause
      (l/warn :hint "unable to get user info from JWT token (unexpected exception)"
              :cause cause))))

(s/def ::backend ::us/not-empty-string)
(s/def ::email ::us/not-empty-string)
(s/def ::fullname ::us/not-empty-string)
(s/def ::props (s/map-of ::us/keyword any?))
(s/def ::info
  (s/keys :req-un [::backend
                   ::email
                   ::fullname
                   ::props]))

(defn- get-info
  [{:keys [::provider ::setup/props] :as cfg} {:keys [params] :as request}]
  (when-let [error (get params :error)]
    (ex/raise :type :internal
              :code :error-on-retrieving-code
              :error-id error
              :error-desc (get params :error_description)))

  (let [state  (get params :state)
        code   (get params :code)
        state  (tokens/verify props {:token state :iss :oauth})
        tdata  (fetch-access-token cfg code)
        info   (case (cf/get :oidc-user-info-source)
                 :token (get-user-info cfg tdata)
                 :userinfo (fetch-user-info cfg tdata)
                 (or (get-user-info cfg tdata)
                     (fetch-user-info cfg tdata)))

        info   (process-user-info provider tdata info)]

    (l/trace :hint "user info" :info info)

    (when-not (s/valid? ::info info)
      (l/warn :hint "received incomplete profile info object (please set correct scopes)" :info info)
      (ex/raise :type :internal
                :code :incomplete-user-info
                :hint "inconmplete user info"
                :info info))

    ;; If the provider is OIDC, we can proceed to check
    ;; roles if they are defined.
    (when (and (= "oidc" (:name provider))
               (seq (:roles provider)))

      (let [expected-roles (into #{} (:roles provider))
            current-roles  (let [roles-kw (cf/get :oidc-roles-attr "roles")
                                 roles-ph (parse-attr-path provider roles-kw)
                                 roles    (get-in (:props info) roles-ph)]
                             (cond
                               (string? roles) (into #{} (str/words roles))
                               (vector? roles) (into #{} roles)
                               :else #{}))]

        ;; check if profile has a configured set of roles
        (when-not (set/subset? expected-roles current-roles)
          (ex/raise :type :internal
                    :code :unable-to-auth
                    :hint "not enough permissions"))))

    (cond-> info
      (some? (:invitation-token state))
      (assoc :invitation-token (:invitation-token state))

      ;; If state token comes with props, merge them. The state token
      ;; props can contain pm_ and utm_ prefixed query params.
      (map? (:props state))
      (update :props merge (:props state)))))

(defn- get-profile
  [cfg info]
  (db/run! cfg (fn [{:keys [::db/conn]}]
                 (some->> (:email info)
                          (profile/clean-email)
                          (profile/get-profile-by-email conn)))))

(defn- redirect-response
  [uri]
  {::rres/status 302
   ::rres/headers {"location" (str uri)}})

(defn- redirect-with-error
  ([error] (redirect-with-error error nil))
  ([error hint]
   (let [params {:error error :hint hint}
         params (d/without-nils params)
         uri    (-> (u/uri (cf/get :public-uri))
                    (assoc :path "/#/auth/login")
                    (assoc :query (u/map->query-string params)))]
     (redirect-response uri))))

(defn- redirect-to-register
  [cfg info request]
  (let [info   (assoc info
                      :iss :prepared-register
                      :exp (dt/in-future {:hours 48}))

        params {:token (tokens/generate (::setup/props cfg) info)
                :provider (:provider (:path-params request))
                :fullname (:fullname info)}
        params (d/without-nils params)]

    (redirect-response
     (-> (u/uri (cf/get :public-uri))
         (assoc :path "/#/auth/register/validate")
         (assoc :query (u/map->query-string params))))))

(defn- redirect-to-verify-token
  [token]
  (let [params {:token token}
        uri    (-> (u/uri (cf/get :public-uri))
                   (assoc :path "/#/auth/verify-token")
                   (assoc :query (u/map->query-string params)))]

    (redirect-response uri)))

(defn- provider-has-email-verified?
  [{:keys [::provider] :as cfg} {:keys [props] :as info}]
  (let [prop (qualify-prop-key provider :email_verified)]
    (true? (get props prop))))

(defn- profile-has-provider-props?
  [{:keys [::provider] :as cfg} profile]
  (let [prop (qualify-prop-key provider :email)]
    (contains? (:props profile) prop)))

(defn- provider-matches-profile?
  [{:keys [::provider] :as cfg} profile info]
  (or (= (:auth-backend profile) (:name provider))
      (profile-has-provider-props? cfg profile)
      (provider-has-email-verified? cfg info)))

(defn- process-callback
  [cfg request info profile]
  (cond
    (some? profile)
    (cond
      (:is-blocked profile)
      (redirect-with-error "profile-blocked")

      (not (provider-matches-profile? cfg profile info))
      (redirect-with-error "auth-provider-not-allowed")

      (not (:is-active profile))
      (let [info (assoc info :profile-id (:id profile))]
        (redirect-to-register cfg info request))

      :else
      (let [sxf   (session/create-fn cfg (:id profile))
            token (or (:invitation-token info)
                      (tokens/generate (::setup/props cfg)
                                       {:iss :auth
                                        :exp (dt/in-future "15m")
                                        :props (:props info)
                                        :profile-id (:id profile)}))]

        (audit/submit! cfg {::audit/type "command"
                            ::audit/name "login-with-oidc"
                            ::audit/profile-id (:id profile)
                            ::audit/ip-addr (audit/parse-client-ip request)
                            ::audit/props (audit/profile->props profile)})

        (->> (redirect-to-verify-token token)
             (sxf request))))

    (not (auth/email-domain-in-whitelist? (:email info)))
    (redirect-with-error "email-domain-not-allowed")

    :else
    (let [info (assoc info :is-active (provider-has-email-verified? cfg info))]
      (if (contains? cf/flags :registration)
        (redirect-to-register cfg info request)
        (redirect-with-error "registration-disabled")))))

(defn- auth-handler
  [cfg {:keys [params] :as request}]
  (let [props (audit/extract-utm-params params)
        state (tokens/generate (::setup/props cfg)
                               {:iss :oauth
                                :invitation-token (:invitation-token params)
                                :props props
                                :exp (dt/in-future "4h")})
        uri   (build-auth-uri cfg state)]
    {::rres/status 200
     ::rres/body {:redirect-uri uri}}))

(defn- callback-handler
  [cfg request]
  (try
    (let [info    (get-info cfg request)
          profile (get-profile cfg info)]
      (process-callback cfg request info profile))
    (catch Throwable cause
      (l/err :hint "error on oauth process" :cause cause)
      (redirect-with-error "unable-to-auth" (ex-message cause)))))

(def provider-lookup
  {:compile
   (fn [& _]
     (fn [handler {:keys [::providers] :as cfg}]
       (fn [request]
         (let [provider (some-> request :path-params :provider keyword)]
           (if-let [provider (get providers provider)]
             (handler (assoc cfg ::provider provider) request)
             (ex/raise :type :restriction
                       :code :provider-not-configured
                       :provider provider
                       :hint "provider not configured"))))))})

(s/def ::client-id ::cf/oidc-client-id)
(s/def ::client-secret ::cf/oidc-client-secret)
(s/def ::base-uri ::cf/oidc-base-uri)
(s/def ::token-uri ::cf/oidc-token-uri)
(s/def ::auth-uri ::cf/oidc-auth-uri)
(s/def ::user-uri ::cf/oidc-user-uri)
(s/def ::scopes ::cf/oidc-scopes)
(s/def ::roles ::cf/oidc-roles)
(s/def ::roles-attr ::cf/oidc-roles-attr)
(s/def ::email-attr ::cf/oidc-email-attr)
(s/def ::name-attr ::cf/oidc-name-attr)

(s/def ::provider
  (s/keys :req-un [::client-id
                   ::client-secret]
          :opt-un [::base-uri
                   ::token-uri
                   ::auth-uri
                   ::user-uri
                   ::scopes
                   ::roles
                   ::roles-attr
                   ::email-attr
                   ::name-attr]))

(s/def ::providers (s/map-of ::us/keyword (s/nilable ::provider)))

(s/def ::routes vector?)

(defmethod ig/pre-init-spec ::routes
  [_]
  (s/keys :req [::session/manager
                ::http/client
                ::setup/props
                ::db/pool
                ::providers]))

(defmethod ig/init-key ::routes
  [_ cfg]
  (let [cfg (update cfg :provider d/without-nils)]
    ["" {:middleware [[session/authz cfg]
                      [provider-lookup cfg]]}
     ["/auth/oauth"
      ["/:provider"
       {:handler auth-handler
        :allowed-methods #{:post}}]
      ["/:provider/callback"
       {:handler callback-handler
        :allowed-methods #{:get}}]]]))
