penpot/backend/test/backend_tests/auth_oidc_test.clj
Andrey Antukh 0ea6d166f0
✨ Add a session origin model to auth events (#12856)
* 🐛 Return the full profile from the LDAP invitation branch

The invitation branch responded with only {:invitation-token token},
so the session start received a map without id or auth-backend. With
the new auth-origin vocabulary that map fails validation and the login
breaks in development and tests; in production the event would be
emitted without a channel.

Return the profile plus the token, like the regular branch, and cover
both branches with RPC-level tests.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Mint an auth token in the OIDC callback with the invitation inside

The callback used the invitation JWE as the session token, so the
frontend landed directly on token verification and never ran the
regular session start: no signin event, no teams, no host hook.

Always mint an :auth token and carry the invitation in its claims.
Token verification returns the claims verbatim, so login-from-token
attaches the invitation to the profile and logged-in does the rest,
the same pattern the verify-email token already uses.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* ✨ Keep the session origin in the telemetry shadow rows

The anonymisation filter drops every string unless it is on a short
allowlist. Add source and activation next to lang and auth-backend so
the shadow copy keeps them; the email and the full name are still
dropped and the email domain is still derived from the email.

The filter keeps its two bindings: the transducer discards everything
that is not a UUID, a boolean or a number, so the allowlist is applied
on the filtered map, never on the raw props.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* ✨ Describe the signup outcome with activation, not with action

The three success branches of register-profile labelled a new account
as "login", "email-verification" or "accept-invitation", so the event
contradicted its own name and the value only lived in context, where
the analytics consumers never read it.

Add activation to the event props and drop action from those branches:
the name already says it is a signup, and a constant action would only
add noise. The retry branches keep their action in context because
there it distinguishes four different outcomes.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* ✨ Describe the session origin in the signin event

The event carried the profile but never said why the session started:
the only field that tried was frontend metadata that never leaves the
browser, and several flows filled it with the wrong value.

Add a small closed vocabulary (source, auth-backend, activation,
from-invitation) and compose it from the profile when signin is
emitted. The assertion fails in development and tests and is elided in
the release build, so a bad value cannot break a real login.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* ✨ Emit the session origin from every login entry point

The four emitters stamped a free-form string as metadata, so the
session event could not rely on it: two flows that were signups wrote
the same value as a login.

Route them through origin-meta: password and LDAP logins declare
source login, register declares source register with activation
immediate, and login-from-token derives both from the token issuer:
:auth for a login and :verify-email for a signup that needed the
email confirmation.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Send the invitation signups through the regular session start

Registering from a team invitation link navigated by hand to token
verification, so the session start never ran: no signin event, no
teams, no host hook. The same navigation was copied in four places.

Teach login-from-register to carry an optional invitation token,
extract the profile decoration into with-register-origin, and call it
from the four sites instead of the hand-written navigation.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Resolve the invitation without waiting for the teams

Registering from an invitation link now goes through the session
start, and that start only navigates after the teams request
completes. fetch-teams has no error handling, so a network failure
there would leave the invitation unaccepted and the user on the signup
screen with a session already minted.

Move the invitation navigation out of the teams path: it emits as soon
as the profile carries a token, and the teams path emits nothing for
an invited profile so it cannot move the user away from the token
verification screen.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* 📚 Document the session origin vocabulary and the invitation flow

Add a focused memory with the four fields, where each value comes from,
the table of flows, the LDAP exception and the invitation path through
logged-in, and link it from the frontend routing.

Update the audit-log memory: source and activation are now on the
telemetry allowlist, activation lives in props and action only exists
on the retry rows.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash

* 📎 Cover the invitation session start and fix the auth memories

The invitation flows and the token decoration in login-from-token were
only covered by helper tests, so removing the guard that keeps the
teams path from navigating, or the token attach, broke the behaviour
silently.

Run the events through ptk/watch with a teams-fetched event as input:
an invited profile navigates to the token verification screen and
never to the dashboard, and the signin keeps from-invitation. Add the
login-from-token cases with and without the token, and fix the memory
statements about action, the flow table and from-invitation.

Closes #12849

AI-assisted-by: deepseek-v4.1-flash
2026-11-09 10:11:54 +01:00

777 lines
41 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.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-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 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 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"]))
(defn- location-token
"Extract the `token` query param from a redirect location."
[location]
(second (re-find #"[?&]token=([^&]+)" 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"}
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?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-with-invitation-mints-auth-token-with-invitation-claim
(let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist)
invitation (tokens/generate cfg {:iss :team-invitation
:exp (ct/in-future {:hours 1})
:member-id test-profile-id
:member-email "u@e.com"})
state (make-state-token cfg {:invitation-token invitation})
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
:invitation-token invitation
: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)
claims (tokens/decode cfg (location-token loc))]
(t/is (= :auth (:iss claims))
"the session token must be an :auth token, not the invitation one")
(t/is (= invitation (:invitation-token claims))
"the invitation token must travel inside the :auth claims")
(t/is (= test-profile-id (:profile-id claims))))))))
(t/deftest callback-without-invitation-mints-plain-auth-token
(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)
claims (tokens/decode cfg (location-token loc))]
(t/is (= :auth (:iss claims)))
(t/is (not (contains? claims :invitation-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))))))))