mirror of
https://github.com/penpot/penpot.git
synced 2026-11-02 08:00:36 +00:00
Replace the ad-hoc `public-uri-with-path` helper with `build-public-uri` that uses the standard `u/ensure-path-slash` + `u/join` pattern already used throughout the backend and frontend. This makes OIDC redirect URI construction consistent with the rest of the codebase while correctly preserving subpath configuration. For hash routes (frontend URLs), use `u/append-query-param` to place query params inside the fragment, matching the expected URL structure. Add 6 new tests covering subpath edge cases: - Trailing slash in public-uri - Query params inside hash fragments - No-subpath fallback - Multiple redirect functions with subpaths AI-assisted-by: qwen3.7-plus AI-assisted-by: gpt-5.6-luna
914 lines
48 KiB
Clojure
914 lines
48 KiB
Clojure
;; 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 SUBSIDIARY SL
|
|
|
|
(ns backend-tests.auth-oidc-test
|
|
(:require
|
|
[app.auth.oidc :as oidc]
|
|
[app.common.data :as d]
|
|
[app.common.exceptions :as ex]
|
|
[app.common.time :as ct]
|
|
[app.common.uri :as u]
|
|
[app.config :as cf]
|
|
[app.http.session :as session]
|
|
[app.setup :as-alias setup]
|
|
[app.tokens :as tokens]
|
|
[clojure.test :as t]
|
|
[cuerdas.core :as str]
|
|
[mockery.core :refer [with-mocks]]
|
|
[yetti.response :as-alias yres]))
|
|
|
|
(def ^:private oidc-provider
|
|
{:id "oidc"
|
|
:type "oidc"})
|
|
|
|
(t/deftest parse-attr-path-supports-dot-and-double-underscore
|
|
(t/is
|
|
(= [:oidc/resource-access :penpot_roles :roles]
|
|
(#'oidc/parse-attr-path oidc-provider "resource_access__penpot_roles__roles")))
|
|
(t/is
|
|
(= [:oidc/ocs :data :email]
|
|
(#'oidc/parse-attr-path oidc-provider "ocs.data.email"))))
|
|
|
|
(t/deftest process-user-info-supports-dot-notation-nested-attrs
|
|
(let [provider (assoc oidc-provider
|
|
:email-attr "ocs.data.email"
|
|
:name-attr "ocs.data.display-name")
|
|
info (#'oidc/process-user-info provider
|
|
{}
|
|
{:email_verified true
|
|
:ocs {:data {:email "nextcloud@example.com"
|
|
:display-name "Nextcloud User"}}})]
|
|
(t/is (= "nextcloud@example.com" (:email info)))
|
|
(t/is (= "Nextcloud User" (:fullname info)))
|
|
(t/is (true? (:email-verified info)))))
|
|
|
|
;; The provider's `:user-info-source` value arrives as a string (enforced by
|
|
;; the malli schema in `app.config` and used as-is by the hard-coded Google /
|
|
;; GitHub provider maps), so the dispatch must interpret strings — not
|
|
;; keywords — to actually honour `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`.
|
|
(t/deftest select-user-info-source-interprets-config-strings
|
|
(t/testing "explicit string values map to keyword dispatch tokens"
|
|
(t/is (= :token (#'oidc/select-user-info-source "token")))
|
|
(t/is (= :userinfo (#'oidc/select-user-info-source "userinfo"))))
|
|
|
|
(t/testing "missing or explicit \"auto\" falls back to auto dispatch"
|
|
(t/is (= :auto (#'oidc/select-user-info-source "auto")))
|
|
(t/is (= :auto (#'oidc/select-user-info-source nil))))
|
|
|
|
(t/testing "unknown values fall back to auto dispatch safely"
|
|
(t/is (= :auto (#'oidc/select-user-info-source "unknown")))
|
|
;; Guards against the reverse regression — a stray keyword value must
|
|
;; not silently slip through as if it were the matching string.
|
|
(t/is (= :auto (#'oidc/select-user-info-source :token)))
|
|
(t/is (= :auto (#'oidc/select-user-info-source :userinfo)))))
|
|
|
|
(t/deftest token-endpoint-errors-detect-valid-client-credentials
|
|
(let [response {:status 403
|
|
:body "{\"error\":\"invalid_grant\",\"error_description\":\"Invalid authorization code\"}"}]
|
|
(t/is (#'oidc/token-endpoint-valid-client-error? response))
|
|
(t/is (not (#'oidc/token-endpoint-invalid-client-error? response)))))
|
|
|
|
(t/deftest token-endpoint-errors-detect-invalid-client-credentials
|
|
(t/is (#'oidc/token-endpoint-invalid-client-error?
|
|
{:status 401
|
|
:body "{\"error\":\"access_denied\",\"error_description\":\"Unauthorized\"}"}))
|
|
(t/is (#'oidc/token-endpoint-invalid-client-error?
|
|
{:status 400
|
|
:body "{\"error\":\"invalid_client\"}"}))
|
|
(t/is (not (#'oidc/token-endpoint-valid-client-error?
|
|
{:status 400
|
|
:body "{\"error\":\"invalid_client\"}"}))))
|
|
|
|
(t/deftest int-in-range-checks-range-correctly
|
|
(t/testing "values within range return true"
|
|
(t/is (#'oidc/int-in-range? 200 200 300))
|
|
(t/is (#'oidc/int-in-range? 250 200 300))
|
|
(t/is (#'oidc/int-in-range? 299 200 300)))
|
|
(t/testing "values outside range return false"
|
|
(t/is (not (#'oidc/int-in-range? 199 200 300)))
|
|
(t/is (not (#'oidc/int-in-range? 300 200 300)))))
|
|
|
|
(t/deftest redirect-response-builds-302-response
|
|
(let [result (#'oidc/redirect-response "https://example.com/path")]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (= "https://example.com/path" (get-in result [::yres/headers "location"])))))
|
|
|
|
(t/deftest valid-info-validates-info-map
|
|
(t/testing "valid info maps pass validation"
|
|
(t/is (#'oidc/valid-info?
|
|
{:backend "oidc" :email "user@example.com" :fullname "User"
|
|
:email-verified true :props {:foo 1}})))
|
|
(t/testing "incomplete maps fail validation"
|
|
(t/is (not (#'oidc/valid-info? nil)))
|
|
(t/is (not (#'oidc/valid-info? {})))
|
|
(t/is (not (#'oidc/valid-info? {:backend "oidc"})))
|
|
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com"})))
|
|
(t/is (not (#'oidc/valid-info? {:backend "oidc" :email "user@example.com" :fullname "User"})))
|
|
(t/is (not (#'oidc/valid-info?
|
|
{:backend "oidc" :email "user@example.com" :fullname "User" :email-verified true})))))
|
|
|
|
(t/deftest qualify-prop-key-qualifies-key
|
|
(let [provider {:type "github"}]
|
|
(t/is (= :github/email (#'oidc/qualify-prop-key provider :email)))
|
|
(t/is (= :github/full-name (#'oidc/qualify-prop-key provider :full_name)))
|
|
(t/is (= :github/my-key (#'oidc/qualify-prop-key provider :my-key)))))
|
|
|
|
(t/deftest qualify-props-qualifies-all-keys
|
|
(let [provider {:type "github"}
|
|
result (#'oidc/qualify-props provider {:email "u@e.com" :name "Test"})]
|
|
(t/is (= "u@e.com" (:github/email result)))
|
|
(t/is (= "Test" (:github/name result)))))
|
|
|
|
(t/deftest provider-has-email-verified-checks-email-verified
|
|
(let [provider {:type "github"}]
|
|
(t/testing "returns true when email_verified in props is true"
|
|
(t/is (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified true}})))
|
|
(t/testing "returns false when email_verified is false or missing"
|
|
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {}})))
|
|
(t/is (not (#'oidc/provider-has-email-verified? provider {:props {:github/email-verified false}}))))))
|
|
|
|
(t/deftest profile-has-provider-props-matches-provider
|
|
(t/testing "non-OIDC provider with string id checks for qualified email key"
|
|
(let [provider {:type "github" :id "github"}]
|
|
(t/is (#'oidc/profile-has-provider-props? provider {:props {:github/email "u@e.com"}}))
|
|
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {}})))
|
|
(t/is (not (#'oidc/profile-has-provider-props? provider {:props nil})))))
|
|
(t/testing "OIDC provider with UUID id checks oidc/provider-id"
|
|
(let [provider {:type "oidc" :id #uuid "00000000-0000-0000-0000-000000000001"}]
|
|
(t/is (#'oidc/profile-has-provider-props?
|
|
provider {:props {:oidc/provider-id "00000000-0000-0000-0000-000000000001"}}))
|
|
(t/is (not (#'oidc/profile-has-provider-props? provider {:props {:oidc/provider-id "other"}}))))))
|
|
|
|
(t/deftest redirect-with-error-builds-error-url
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(t/testing "with error and hint"
|
|
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (.contains loc "http://localhost:3449?screen=auth-login&"))
|
|
(t/is (.contains loc "error=auth-error"))
|
|
(t/is (.contains loc "hint=hint"))))
|
|
(t/testing "without hint omits hint param"
|
|
(let [result (#'oidc/redirect-with-error "auth-error")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "error=auth-error"))
|
|
(t/is (not (.contains loc "hint=")))))))
|
|
|
|
(t/deftest redirect-with-error-preserves-public-uri-subpath
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}]
|
|
(let [result (#'oidc/redirect-with-error "auth-error")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449/penpot?screen=auth-login&"))
|
|
(t/is (.contains loc "error=auth-error")))))
|
|
|
|
(t/deftest redirect-to-verify-token-builds-verify-url
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (.contains loc "http://localhost:3449?screen=auth-verify-token&"))
|
|
(t/is (.contains loc "token=test-token-value")))))
|
|
|
|
(t/deftest redirect-to-verify-token-preserves-public-uri-subpath
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}]
|
|
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449/penpot?screen=auth-verify-token&")))))
|
|
|
|
(t/deftest build-redirect-uri-constructs-redirect
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(t/is (= "http://localhost:3449/api/auth/oidc/callback"
|
|
(#'oidc/build-redirect-uri)))))
|
|
|
|
(t/deftest build-redirect-uri-preserves-public-uri-subpath
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}]
|
|
(t/is (= "http://localhost:3449/penpot/api/auth/oidc/callback"
|
|
(#'oidc/build-redirect-uri)))))
|
|
|
|
(t/deftest build-redirect-uri-with-subpath-and-trailing-slash
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot/"}]
|
|
(t/is (= "http://localhost:3449/penpot/api/auth/oidc/callback"
|
|
(#'oidc/build-redirect-uri)))))
|
|
|
|
(t/deftest redirect-with-error-subpath-screen-query
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}]
|
|
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449/penpot?screen=auth-login&"))
|
|
(t/is (.contains loc "error=auth-error"))
|
|
(t/is (.contains loc "hint=hint"))
|
|
;; Screen routing lives in the query string, not the fragment
|
|
(t/is (not (.contains loc "#"))))))
|
|
|
|
(t/deftest redirect-to-register-subpath
|
|
(let [test-key (byte-array (map byte (range 32)))
|
|
cfg {::setup/props {:tokens-key test-key}}]
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}]
|
|
(let [info {:email "u@e.com" :fullname "U" :backend "oidc"
|
|
:email-verified false :props {}}
|
|
provider {:type "oidc" :id "oidc"}
|
|
result (#'oidc/redirect-to-register cfg info provider)
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449/penpot?screen=auth-register-validate&"))
|
|
(t/is (.contains loc "token="))))))
|
|
|
|
(t/deftest redirect-to-register-no-subpath
|
|
(let [test-key (byte-array (map byte (range 32)))
|
|
cfg {::setup/props {:tokens-key test-key}}]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(let [info {:email "u@e.com" :fullname "U" :backend "oidc"
|
|
:email-verified false :props {}}
|
|
provider {:type "oidc" :id "oidc"}
|
|
result (#'oidc/redirect-to-register cfg info provider)
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449?screen=auth-register-validate&"))
|
|
(t/is (.contains loc "token="))))))
|
|
|
|
(t/deftest redirect-to-verify-token-subpath-no-trailing-slash
|
|
(binding [cf/config {:public-uri "http://localhost:3449/my-app"}]
|
|
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
|
loc (get-in result [::yres/headers "location"])]
|
|
(t/is (.contains loc "http://localhost:3449/my-app?screen=auth-verify-token&")))))
|
|
|
|
(t/deftest build-public-uri-no-subpath
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(t/is (= "http://localhost:3449/api/auth/oidc/callback"
|
|
(str (#'oidc/build-public-uri "api/auth/oidc/callback"))))))
|
|
|
|
(t/deftest fetch-user-info-returns-decoded-body-on-success
|
|
(let [cfg {}
|
|
provider {:user-uri "https://provider.example.com/userinfo"}
|
|
tdata {:token/access "test-access-token" :token/type "Bearer"}]
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 200
|
|
:body "{\"email\":\"user@example.com\",\"name\":\"Test User\"}"}}]
|
|
(let [result (#'oidc/fetch-user-info cfg provider tdata)]
|
|
(t/is (:called? @http-mock))
|
|
(t/is (= 1 (:call-count @http-mock)))
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "Test User" (:name result)))))))
|
|
|
|
(t/deftest fetch-user-info-throws-on-non-2xx
|
|
(let [cfg {}
|
|
provider {:user-uri "https://provider.example.com/userinfo"}
|
|
tdata {:token/access "test-at" :token/type "Bearer"}]
|
|
(t/testing "401 with Bad credentials"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 401
|
|
:body "Bad credentials"}}]
|
|
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo e))
|
|
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
|
|
(t/is (= 401 (:http-status (ex-data e))))
|
|
(t/is (= "Bad credentials" (:http-body (ex-data e)))))))
|
|
(t/testing "500 server error"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 500
|
|
:body "Internal Server Error"}}]
|
|
(let [e (try (#'oidc/fetch-user-info cfg provider tdata) (catch Throwable t t))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo e))
|
|
(t/is (= :unable-to-retrieve-user-info (:code (ex-data e))))
|
|
(t/is (= 500 (:http-status (ex-data e)))))))))
|
|
|
|
(t/deftest fetch-user-info-passes-correct-request
|
|
(let [cfg {}
|
|
provider {:user-uri "https://provider.example.com/userinfo" :skip-ssrf-check? true}
|
|
tdata {:token/access "secret-token" :token/type "Bearer"}]
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 200 :body "{}"}}]
|
|
(#'oidc/fetch-user-info cfg provider tdata)
|
|
(let [[_ req-opts opts] (-> @http-mock :call-args)]
|
|
(t/is (true? (:skip-ssrf-check? opts)))
|
|
(t/is (= "https://provider.example.com/userinfo" (:uri req-opts)))
|
|
(t/is (= "Bearer secret-token" (get-in req-opts [:headers "Authorization"])))
|
|
(t/is (= :get (:method req-opts)))))))
|
|
|
|
(t/deftest fetch-access-token-returns-token-data-on-success
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(let [cfg {}
|
|
provider {:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:token-uri "https://provider.example.com/token"}
|
|
code "auth-code-123"]
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 200
|
|
:body "{\"access_token\":\"at\",\"id_token\":\"it\",\"token_type\":\"Bearer\"}"}}]
|
|
(let [result (#'oidc/fetch-access-token cfg provider code)]
|
|
(t/is (:called? @http-mock))
|
|
(t/is (= 1 (:call-count @http-mock)))
|
|
(t/is (= "at" (:token/access result)))
|
|
(t/is (= "it" (:token/id result)))
|
|
(t/is (= "Bearer" (:token/type result))))))))
|
|
|
|
(t/deftest fetch-access-token-throws-on-error
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(let [cfg {}
|
|
provider {:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:token-uri "https://provider.example.com/token"}
|
|
code "auth-code-123"]
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 400 :body "{\"error\":\"invalid_grant\"}"}}]
|
|
(let [e (try (#'oidc/fetch-access-token cfg provider code) (catch Throwable t t))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo e))
|
|
(t/is (= :unable-to-fetch-access-token (:code (ex-data e)))))))))
|
|
|
|
;; Shared mock data for get-info tests
|
|
(def ^:private mock-tdata
|
|
{:token/access "mock-at" :token/id nil :token/type "Bearer"})
|
|
|
|
(def ^:private mock-claims
|
|
{:email "user@example.com" :name "User" :exp 1 :iss "test"})
|
|
|
|
(def ^:private mock-userinfo
|
|
{:email "user@example.com" :name "User"})
|
|
|
|
(t/deftest get-info-uses-token-source
|
|
(let [provider {:type "oidc" :user-info-source "token"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "User" (:fullname result)))
|
|
(t/is (= "oidc" (:backend result)))
|
|
(t/is (= false (:email-verified result)))))))
|
|
|
|
(t/deftest get-info-uses-userinfo-source
|
|
(let [provider {:type "oidc" :user-info-source "userinfo"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly nil)
|
|
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "User" (:fullname result)))
|
|
(t/is (= "oidc" (:backend result)))))))
|
|
|
|
(t/deftest get-info-auto-prefers-claims
|
|
(let [provider {:type "oidc" :user-info-source "auto"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly mock-claims)
|
|
app.auth.oidc/fetch-user-info (fn [& _] (throw (Exception. "should not call")))]
|
|
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "User" (:fullname result)))))))
|
|
|
|
(t/deftest get-info-auto-falls-back-to-userinfo
|
|
(let [provider {:type "oidc" :user-info-source "auto"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly nil)
|
|
app.auth.oidc/fetch-user-info (constantly mock-userinfo)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "User" (:fullname result)))))))
|
|
|
|
(t/deftest get-info-throws-on-incomplete-info
|
|
(let [provider {:type "oidc" :user-info-source "userinfo"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly nil)
|
|
app.auth.oidc/fetch-user-info (constantly {:no-email nil})]
|
|
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo e))
|
|
(t/is (= :incomplete-user-info (:code (ex-data e))))))))
|
|
|
|
(t/deftest get-info-checks-roles-satisfied
|
|
(let [provider {:type "oidc" :user-info-source "token" :roles #{"member"}}
|
|
state {}
|
|
code "code"
|
|
claims (assoc mock-claims :roles ["member" "admin"])]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly claims)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "user@example.com" (:email result)))
|
|
(t/is (= "oidc" (:backend result)))))))
|
|
|
|
(t/deftest get-info-throws-on-insufficient-roles
|
|
(let [provider {:type "oidc" :user-info-source "token" :roles #{"admin"}}
|
|
state {}
|
|
code "code"
|
|
claims (assoc mock-claims :roles ["member"])]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly claims)]
|
|
(let [e (try (#'oidc/get-info {} provider state code) (catch Throwable t t))]
|
|
(t/is (instance? clojure.lang.ExceptionInfo e))
|
|
(t/is (= :unable-to-auth (:code (ex-data e))))))))
|
|
|
|
(t/deftest get-info-merges-state-props
|
|
(let [provider {:type "oidc" :user-info-source "token"}
|
|
state {:invitation-token "inv-123"
|
|
:external-session-id "ext-456"
|
|
:props {:utm_source "twitter"}}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "inv-123" (:invitation-token result)))
|
|
(t/is (= "ext-456" (:external-session-id result)))
|
|
(t/is (= "twitter" (get-in result [:props :utm_source])))))))
|
|
|
|
(t/deftest get-info-adds-sso-session-id-from-claims
|
|
(let [provider {:type "oidc" :user-info-source "token"}
|
|
state {}
|
|
code "code"
|
|
claims (assoc mock-claims :sid "sso-sid")]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly claims)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= "sso-sid" (:sso-session-id result)))))))
|
|
|
|
(t/deftest get-info-adds-sso-provider-id-for-uuid-provider
|
|
(let [provider {:type "oidc" :user-info-source "token"
|
|
:id #uuid "00000000-0000-0000-0000-000000000001"}
|
|
state {}
|
|
code "code"]
|
|
(with-redefs [app.auth.oidc/fetch-access-token (constantly mock-tdata)
|
|
app.auth.oidc/get-id-token-claims (constantly mock-claims)]
|
|
(let [result (#'oidc/get-info {} provider state code)]
|
|
(t/is (= #uuid "00000000-0000-0000-0000-000000000001" (:sso-provider-id result)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; callback-handler tests
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private test-token-key
|
|
(byte-array (map byte (range 32))))
|
|
|
|
(def ^:private base-cfg
|
|
{::setup/props {:tokens-key test-token-key}
|
|
::session/manager (session/inmemory-manager)
|
|
:app.email/blacklist #{"banned.com"}
|
|
:app.email/whitelist #{"allowed.com"}})
|
|
|
|
(def ^:private test-profile-id
|
|
#uuid "11111111-1111-1111-1111-111111111111")
|
|
|
|
(def ^:private test-organization-id
|
|
#uuid "22222222-2222-2222-2222-222222222222")
|
|
|
|
(def ^:private test-profile
|
|
{:id test-profile-id
|
|
:is-active true
|
|
:is-blocked false
|
|
:auth-backend "oidc"
|
|
:email "user@example.com"
|
|
:props {}})
|
|
|
|
(defn- make-state-token
|
|
[cfg overrides]
|
|
(tokens/generate cfg (d/without-nils (merge {:iss "oidc" :provider "oidc"
|
|
:exp (ct/in-future {:hours 1})}
|
|
overrides))))
|
|
|
|
(defn- default-request
|
|
[cfg & {:keys [state] :or {state "dummy"}}]
|
|
{:params {:state state :code "test-code"}
|
|
:method :get
|
|
:path "/api/auth/oidc/callback"
|
|
:headers {"user-agent" "TestAgent"
|
|
"x-forwarded-for" "127.0.0.1"}
|
|
:remote-addr "127.0.0.1"})
|
|
|
|
(defn- redirect-location
|
|
"Extract the Location header from a handler response."
|
|
[result]
|
|
(get-in result [::yres/headers "location"]))
|
|
|
|
(t/deftest callback-param-error-redirects-to-login
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
request {:params {:error "access_denied"}}]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (.contains loc "error=unable-to-auth"))
|
|
(t/is (.contains loc "hint=access_denied"))))))
|
|
|
|
(t/deftest callback-no-profile-registration-disabled
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}
|
|
cf/flags #{}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly nil)]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "error=registration-disabled")))))))
|
|
|
|
(t/deftest callback-profile-blocked
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}
|
|
cf/flags #{:registration}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly (assoc test-profile :is-blocked true))]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "error=profile-blocked")))))))
|
|
|
|
(t/deftest callback-provider-mismatch
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}
|
|
cf/flags #{:registration}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly (assoc test-profile :auth-backend "gitlab"))]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "error=auth-provider-not-allowed")))))))
|
|
|
|
(t/deftest callback-profile-inactive-redirects-to-register
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449/penpot"}
|
|
cf/flags #{:registration}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly (assoc test-profile :is-active false))]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "http://localhost:3449/penpot?screen=auth-register-validate&"))
|
|
(t/is (.contains loc "token=")))))))
|
|
|
|
(t/deftest callback-success-flow
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}
|
|
cf/flags #{:registration}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly test-profile)
|
|
app.auth.oidc/update-profile-with-info (fn [cfg profile info] profile)
|
|
app.loggers.audit/submit (constantly nil)]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "http://localhost:3449?screen=auth-verify-token&"))
|
|
(t/is (.contains loc "token=")))))))
|
|
|
|
(t/deftest callback-gracefully-handles-unable-to-retrieve-user-info
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (fn [& _]
|
|
(ex/raise :type :internal
|
|
:code :unable-to-retrieve-user-info
|
|
:hint "unable to retrieve user info"
|
|
:http-status 401
|
|
:http-body "Bad credentials"))]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (.contains loc "error=unable-to-auth")))))))
|
|
|
|
(t/deftest organization-sso-callback-success-emits-succeeded
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
|
:organization-id test-organization-id})
|
|
request (default-request cfg :state state)
|
|
events (atom [])]
|
|
(with-redefs [app.nitrate/call (constantly {:active true})
|
|
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
|
app.auth.oidc/get-info (constantly {})
|
|
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
|
(let [result (#'oidc/callback-handler cfg request)]
|
|
(t/is (= "https://penpot.example.com/#/workspace" (redirect-location result)))
|
|
(t/is (= ["organization-sso-auth-succeeded"] (mapv :name @events)))
|
|
(t/is (= test-organization-id (get-in (first @events) [:props :organization-id])))))))
|
|
|
|
(t/deftest organization-sso-callback-error-emits-failed
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
|
:organization-id test-organization-id})
|
|
request (default-request cfg :state state)
|
|
events (atom [])]
|
|
(with-redefs [app.nitrate/call (fn [_cfg method _params]
|
|
(case method
|
|
:get-organization-sso {:active true}
|
|
:get-organization-summary {:name "Organization"}))
|
|
app.auth.oidc/prepare-organization-sso-provider (constantly {:type "oidc"})
|
|
app.auth.oidc/get-info (fn [& _]
|
|
(ex/raise :type :internal
|
|
:code :unable-to-retrieve-user-info))
|
|
app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
|
(#'oidc/callback-handler cfg request)
|
|
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
|
(t/is (= {:organization-id test-organization-id
|
|
:failure-reason "user-info-failed"}
|
|
(:props (first @events)))))))
|
|
|
|
(t/deftest organization-sso-oauth-error-emits-failed-without-changing-redirect
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {:dest-url "https://penpot.example.com/#/workspace"
|
|
:organization-id test-organization-id})
|
|
request (assoc-in (default-request cfg :state state) [:params :error] "access_denied")
|
|
events (atom [])]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"}]
|
|
(with-redefs [app.loggers.audit/submit (fn [_cfg event] (swap! events conj event))]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "error=unable-to-auth"))
|
|
(t/is (.contains loc "hint=access_denied"))
|
|
(t/is (= ["organization-sso-auth-failed"] (mapv :name @events)))
|
|
(t/is (= {:organization-id test-organization-id
|
|
:failure-reason "access-denied"}
|
|
(:props (first @events)))))))))
|
|
|
|
(t/deftest prepare-organization-sso-provider-does-not-skip-ssrf-check
|
|
(t/testing "organization SSO provider must use SSRF protection"
|
|
(let [captured-params (atom nil)]
|
|
(with-redefs [oidc/prepare-oidc-provider (fn [_cfg params]
|
|
(reset! captured-params params)
|
|
{:type "oidc" :id "test"})]
|
|
(#'oidc/prepare-organization-sso-provider {}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://idp.example.com"})
|
|
(t/is (not (true? (:skip-ssrf-check? @captured-params)))
|
|
"SSRF protection must be disabled for organization SSO")))))
|
|
|
|
(defn- ssl-handshake-failure
|
|
[]
|
|
(javax.net.ssl.SSLHandshakeException. "Remote host terminated the handshake"))
|
|
|
|
(t/deftest prepare-organization-sso-provider-raises-on-discovery-network-failure
|
|
(t/testing "SSL/network failures during OIDC discovery become controlled validation errors"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
|
|
(let [e (try
|
|
(#'oidc/prepare-organization-sso-provider
|
|
{}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://wrong-idp.example.com"})
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type (ex-data e))))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
|
|
|
|
(t/deftest prepare-organization-sso-provider-raises-on-discovery-non-200
|
|
(t/testing "non-200 OIDC discovery responses become controlled validation errors"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:return {:status 404 :body "not found"}}]
|
|
(let [e (try
|
|
(#'oidc/prepare-organization-sso-provider
|
|
{}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://idp.example.com"})
|
|
(catch Throwable t t))
|
|
data (ex-data e)]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type data)))
|
|
(t/is (= :invalid-sso-config (:code data)))
|
|
(t/is (= 404 (:response-status-code data)))
|
|
(t/is (= "unable to discover OIDC configuration" (ex-message e)))
|
|
(t/is (str/includes? (str (:discover-uri data)) "openid-configuration"))))))
|
|
|
|
(t/deftest prepare-organization-sso-provider-raises-on-ssrf-blocked-issuer
|
|
(t/testing "SSRF/DNS failures for the issuer URL become invalid-sso-config, not ssrf-blocked-target"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:side-effect (fn [& _]
|
|
(ex/raise :type :validation
|
|
:code :ssrf-blocked-target
|
|
:hint "uri host could not be resolved"))}]
|
|
(let [e (try
|
|
(#'oidc/prepare-organization-sso-provider
|
|
{}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://unresolvable.invalid"})
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type (ex-data e))))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
|
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
|
|
|
|
(t/deftest prepare-organization-sso-provider-raises-on-jwks-network-failure
|
|
(t/testing "SSL/network failures while fetching JWKs become controlled validation errors"
|
|
(let [discovery-body (str "{\"authorization_endpoint\":\"https://idp.example.com/auth\","
|
|
"\"token_endpoint\":\"https://idp.example.com/token\","
|
|
"\"userinfo_endpoint\":\"https://idp.example.com/userinfo\","
|
|
"\"jwks_uri\":\"https://idp.example.com/jwks\"}")]
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:side-effect (fn [_cfg request & _]
|
|
(if (str/includes? (str (:uri request)) "openid-configuration")
|
|
{:status 200 :body discovery-body}
|
|
(throw (ssl-handshake-failure))))}]
|
|
(let [e (try
|
|
(#'oidc/prepare-organization-sso-provider
|
|
{}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://idp.example.com"})
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type (ex-data e))))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e)))))))))
|
|
|
|
(t/deftest populate-jwks-strict-wraps-non-invalid-sso-config-errors
|
|
(t/testing "strict JWKS path wraps unrelated structured errors instead of rethrowing them"
|
|
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
|
|
:side-effect (fn [& _]
|
|
(ex/raise :type :validation
|
|
:code :ssrf-blocked-target
|
|
:hint "uri host could not be resolved"))}]
|
|
(let [e (try
|
|
(#'oidc/populate-jwks
|
|
{}
|
|
{:id "oidc"
|
|
:jwks-uri "https://idp.example.com/jwks"
|
|
:strict-jwks? true})
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type (ex-data e))))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
|
(t/is (= :ssrf-blocked-target (:code (ex-data (ex-cause e)))))))))
|
|
|
|
(t/deftest populate-jwks-strict-rethrows-invalid-sso-config
|
|
(t/testing "strict JWKS path rethrows an already-controlled invalid-sso-config"
|
|
(with-mocks [fetch-mock {:target 'app.auth.oidc/fetch-oidc-jwks
|
|
:side-effect (fn [& _]
|
|
(ex/raise :type :validation
|
|
:code :invalid-sso-config
|
|
:hint "unable to retrieve JWKs"
|
|
:jwks-uri "https://idp.example.com/jwks"))}]
|
|
(let [e (try
|
|
(#'oidc/populate-jwks
|
|
{}
|
|
{:id "oidc"
|
|
:jwks-uri "https://idp.example.com/jwks"
|
|
:strict-jwks? true})
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e))))
|
|
(t/is (= "unable to retrieve JWKs" (ex-message e)))
|
|
(t/is (= "https://idp.example.com/jwks" (:jwks-uri (ex-data e))))))))
|
|
|
|
(t/deftest build-organization-sso-auth-redirect-uri-raises-on-unreachable-provider
|
|
(t/testing "check-nitrate-sso path surfaces a controlled error when the issuer is unreachable"
|
|
(with-mocks [http-mock {:target 'app.http.client/req
|
|
:side-effect (fn [& _] (throw (ssl-handshake-failure)))}]
|
|
(let [e (try
|
|
(oidc/build-organization-sso-auth-redirect-uri
|
|
{}
|
|
{:client-id "test-client"
|
|
:client-secret "test-secret"
|
|
:issuer "https://wrong-idp.example.com"}
|
|
:dest-url "https://localhost:3449/#/dashboard"
|
|
:organization-id #uuid "00000000-0000-0000-0000-000000000001")
|
|
(catch Throwable t t))]
|
|
(t/is (ex/error? e))
|
|
(t/is (= :validation (:type (ex-data e))))
|
|
(t/is (= :invalid-sso-config (:code (ex-data e))))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; trusted alternate origins
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(def ^:private trusted-origin-provider
|
|
{:id "oidc"
|
|
:type "oidc"
|
|
:auth-uri "https://idp.example.com/auth"
|
|
:client-id "test-client"
|
|
:scopes ["openid"]})
|
|
|
|
(t/deftest auth-handler-carries-trusted-origin-in-state
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly trusted-origin-provider)
|
|
app.loggers.audit/extract-utm-params (constantly {})
|
|
app.loggers.audit/get-external-session-id (constantly nil)]
|
|
(t/testing "trusted origin is stored in the state and used for redirect_uri"
|
|
(let [request (assoc {:params {}} :app.http/trusted-origin "https://alt.example.com")
|
|
result (binding [cf/config {:public-uri "https://alt.example.com"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(#'oidc/auth-handler base-cfg request))
|
|
uri (get-in result [::yres/body :redirect-uri])
|
|
query (u/query-string->map (:query (u/uri uri)))
|
|
claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})]
|
|
(t/is (= "https://alt.example.com" (:origin claims)))
|
|
(t/is (= "https://alt.example.com/api/auth/oidc/callback" (:redirect_uri query)))))
|
|
|
|
(t/testing "without a trusted origin no :origin claim is stored"
|
|
(let [result (binding [cf/config {:public-uri "http://localhost:3449"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(#'oidc/auth-handler base-cfg {:params {}}))
|
|
uri (get-in result [::yres/body :redirect-uri])
|
|
query (u/query-string->map (:query (u/uri uri)))
|
|
claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})]
|
|
(t/is (not (contains? claims :origin)))
|
|
(t/is (= "http://localhost:3449/api/auth/oidc/callback" (:redirect_uri query)))))))
|
|
|
|
(t/deftest build-organization-sso-auth-redirect-uri-stores-trusted-origin
|
|
(let [sso {:organization-id test-organization-id
|
|
:issuer "https://idp.example.com"
|
|
:client-id "test-client"
|
|
:client-secret "test-secret"}
|
|
provider trusted-origin-provider
|
|
uri (binding [cf/config {:public-uri "https://alt.example.com"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(oidc/build-organization-sso-auth-redirect-uri
|
|
base-cfg sso
|
|
:dest-url "https://alt.example.com/#/dashboard"
|
|
:organization-id test-organization-id
|
|
:provider provider
|
|
:origin "https://alt.example.com"))
|
|
query (u/query-string->map (:query (u/uri uri)))
|
|
claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})]
|
|
(t/is (= "https://alt.example.com" (:origin claims)))
|
|
(t/is (= "https://alt.example.com/#/dashboard" (:dest-url claims)))
|
|
(t/is (= "https://alt.example.com/api/auth/oidc/callback" (:redirect_uri query)))))
|
|
|
|
(t/deftest callback-applies-stored-trusted-origin
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
seen (atom nil)
|
|
state (make-state-token cfg {:origin "https://alt.example.com"})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (fn [_ _ _ _]
|
|
(reset! seen (cf/get :public-uri))
|
|
{:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly test-profile)
|
|
app.auth.oidc/update-profile-with-info (fn [_ profile _] profile)
|
|
app.loggers.audit/submit (constantly nil)]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (= "https://alt.example.com" @seen))
|
|
(t/is (.contains loc "https://alt.example.com?screen=auth-verify-token&")))))))
|
|
|
|
(t/deftest callback-error-uses-stored-trusted-origin
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {:origin "https://alt.example.com"})
|
|
request (assoc (default-request cfg :state state)
|
|
:params {:state state :error "access_denied"})]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "https://alt.example.com?screen=auth-login&"))
|
|
(t/is (.contains loc "error=unable-to-auth"))))))
|
|
|
|
(t/deftest callback-honors-state-origin-even-when-not-in-config
|
|
;; The signed state origin was trusted when the flow started; it is used
|
|
;; as-is for the lifetime of the token, even if the operator removed it.
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
state (make-state-token cfg {:origin "https://alt.example.com"})
|
|
request (default-request cfg :state state)]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"
|
|
:trusted-origins #{}}]
|
|
(with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"})
|
|
app.auth.oidc/get-info (constantly {:email "u@e.com" :fullname "U"
|
|
:backend "oidc" :email-verified false
|
|
:props {}})
|
|
app.auth.oidc/get-profile (constantly test-profile)
|
|
app.auth.oidc/update-profile-with-info (fn [_ profile _] profile)
|
|
app.loggers.audit/submit (constantly nil)]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (.contains loc "https://alt.example.com?screen=auth-verify-token&")))))))
|
|
|
|
(t/deftest callback-without-state-redirects-to-canonical-error
|
|
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
|
|
request {:params {}}]
|
|
(binding [cf/config {:public-uri "http://localhost:3449"
|
|
:trusted-origins #{"https://alt.example.com"}}]
|
|
(let [result (#'oidc/callback-handler cfg request)
|
|
loc (redirect-location result)]
|
|
(t/is (= 302 (::yres/status result)))
|
|
(t/is (.contains loc "http://localhost:3449?screen=auth-login&"))
|
|
(t/is (.contains loc "error=unable-to-auth"))))))
|