diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md
index dd0b09edde..0423a838a6 100644
--- a/.serena/memories/backend/auth-permissions-product-domains.md
+++ b/.serena/memories/backend/auth-permissions-product-domains.md
@@ -12,6 +12,7 @@
- Lockout and RPC rate limits share the `app.http.errors/handle-error :rate-limit` HTTP path: status 429, body `{:type :rate-limit :code ... :hint ... :ttl ...}`, and any supplied `::http/headers` preserved. Account lockout raises `:code :account-locked` and, when it carries a non-nil `:ttl` (seconds), the handler adds `retry-after`. The RPC limiter (`app.rpc.rlimit`) raises `:code :request-blocked` and sets `retry-after` itself in `::http/headers` (seconds until the longest rejecting limit resets), alongside `x-rate-limit-remaining`/`x-rate-limit-reset`. CORS (`app.http.middleware/with-cors-headers`) exposes `content-type`, `retry-after`, and both `x-rate-limit-*` headers. External flags: `enable-account-lockout`, `enable-rpc-rlimit`.
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
+- When public registration is disabled, invitation-based password registration also requires a matching, non-expired `team_invitation` row; registration revalidates it under a row lock before creating the profile.
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
## Permission model
diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj
index ff2e0c0ae3..a838eff3be 100644
--- a/backend/src/app/rpc/commands/auth.clj
+++ b/backend/src/app/rpc/commands/auth.clj
@@ -31,6 +31,7 @@
[app.rpc.climit :as-alias climit]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
+ [app.rpc.commands.teams-invitations :as teams-invitations]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.setup :as-alias setup]
@@ -225,23 +226,51 @@
;; ---- COMMAND: Prepare Register
+(defn- validate-registration-flags!
+ ([cfg invitation]
+ (validate-registration-flags! cfg invitation false))
+ ([cfg invitation lock-invitation?]
+ (when-not (contains? cf/flags :login-with-password)
+ (ex/raise :type :restriction
+ :code :registration-disabled
+ :hint "registration disabled"))
+
+ (when-not (contains? cf/flags :registration)
+ (if (nil? invitation)
+ (ex/raise :type :restriction
+ :code :registration-disabled
+ :hint "registration disabled")
+ (let [opts (when lock-invitation? {::db/for-update true})]
+ (when (nil? (teams-invitations/active-invitation cfg invitation opts))
+ (ex/raise :type :validation
+ :code :invalid-token
+ :hint "no active invitation associated with the token")))))))
+
(defn- validate-register-attempt!
[cfg params]
-
- (when (or (not (contains? cf/flags :registration))
- (not (contains? cf/flags :login-with-password)))
- (ex/raise :type :restriction
- :code :registration-disabled
- :hint "registration disabled"))
-
- (when (contains? params :invitation-token)
- (let [invitation (tokens/verify cfg
+ (let [invitation? (contains? params :invitation-token)
+ invitation (when invitation?
+ (tokens/verify cfg
{:token (:invitation-token params)
- :iss :team-invitation})]
- (when-not (= (:email params) (:member-email invitation))
- (ex/raise :type :restriction
- :code :email-does-not-match-invitation
- :hint "email should match the invitation"))))
+ :iss :team-invitation}))]
+
+ (when (or (not (contains? cf/flags :login-with-password))
+ (and (not (contains? cf/flags :registration))
+ (not invitation?)))
+ (ex/raise :type :restriction
+ :code :registration-disabled
+ :hint "registration disabled"))
+
+ (when (and invitation?
+ (not= (profile/clean-email (:email params))
+ (profile/clean-email (:member-email invitation))))
+ (ex/raise :type :restriction
+ :code :email-does-not-match-invitation
+ :hint "email should match the invitation"))
+
+ (when (and invitation?
+ (not (contains? cf/flags :registration)))
+ (validate-registration-flags! cfg invitation)))
(when (and (email.blacklist/enabled? cfg)
(email.blacklist/contains? cfg (:email params)))
@@ -467,6 +496,9 @@
(defn register-profile
[{:keys [::db/conn] :as cfg} {:keys [token] :as params}]
(let [claims (tokens/verify cfg {:token token :iss :prepared-register})
+ invitation (when-let [token (:invitation-token claims)]
+ (tokens/verify cfg {:token token :iss :team-invitation}))
+ _ (validate-registration-flags! cfg invitation true)
params (cond-> claims
(:accept-newsletter-updates params)
(update :props assoc :newsletter-updates true))
@@ -484,9 +516,6 @@
created? (-> profile meta :created true?)
- invitation (when-let [token (:invitation-token params)]
- (tokens/verify cfg {:token token :iss :team-invitation}))
-
props (-> (audit/profile->props profile)
(assoc :from-invitation (some? invitation)))]
diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj
index f78ef08c70..fe8abd5b24 100644
--- a/backend/src/app/rpc/commands/teams_invitations.clj
+++ b/backend/src/app/rpc/commands/teams_invitations.clj
@@ -70,6 +70,24 @@
[sql:check-recent-invitation team-id email])]
(some? (db/exec-one! conn query))))
+(defn active-invitation
+ ([cfg claims]
+ (active-invitation cfg claims {}))
+ ([cfg {:keys [team-id organization-id member-email]} opts]
+ (let [email (profile/clean-email member-email)
+ invitation (cond
+ organization-id (db/get* cfg :team-invitation
+ {:email-to email
+ :org-id organization-id}
+ opts)
+ team-id (db/get* cfg :team-invitation
+ {:email-to email
+ :team-id team-id}
+ opts))]
+ (when (and invitation
+ (ct/is-after? (:valid-until invitation) (ct/now)))
+ invitation))))
+
(defn- create-invitation-token
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
(tokens/generate cfg
diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj
index 4a7d8a8439..ac0875cb56 100644
--- a/backend/src/app/rpc/commands/verify_token.clj
+++ b/backend/src/app/rpc/commands/verify_token.clj
@@ -21,6 +21,7 @@
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.commands.teams :as teams]
+ [app.rpc.commands.teams-invitations :as teams-invitations]
[app.rpc.doc :as-alias doc]
[app.rpc.helpers :as rph]
[app.rpc.quotes :as quotes]
@@ -102,12 +103,6 @@
;; --- Team Invitation
-(def ^:private sql:get-organization-invitation
- "SELECT *
- FROM team_invitation
- WHERE email_to = ?
- AND org_id = ?")
-
(def ^:private sql:delete-organization-invitation
"DELETE FROM team_invitation
WHERE email_to = ?
@@ -198,17 +193,16 @@
:code :invalid-invitation-token
:hint "invitation token contains unexpected data"))
- (let [invitation (if organization-id
- (db/exec-one! conn [sql:get-organization-invitation member-email organization-id])
- (db/get* conn :team-invitation
- {:email-to member-email
- :team-id team-id}))
+ (let [member-email (profile/clean-email member-email)
+ claims (assoc claims :member-email member-email)
+ invitation (teams-invitations/active-invitation
+ cfg
+ claims
+ {::db/for-update true})
profile (db/get* conn :profile
{:id profile-id}
{:columns [:id :email :default-team-id]})
- registration-disabled? (not (contains? cf/flags :registration))
-
- organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
+ organization-invitation? (and (contains? cf/flags :admin-console) organization-id)]
(if profile
(do
@@ -346,11 +340,12 @@
"no invitation associated with the token")))
;; If we have not logged-in user, and invitation comes with member-id we
- ;; redirect user to login, if no member-id is present and in the invitation
- ;; token and registration is enabled, we redirect user the the register page.
+ ;; redirect user to login. If no member-id is present this is an invitation
+ ;; for a new user — send them to the register page. Invitations bypass
+ ;; the disable-registration flag per documentation.
{:invitation-token token
:iss :team-invitation
- :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register)
+ :redirect-to (if member-id :auth-login :auth-register)
:state :pending}))))
;; --- Default
diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj
index 5f5b803c4e..ec54555701 100644
--- a/backend/test/backend_tests/rpc_profile_test.clj
+++ b/backend/test/backend_tests/rpc_profile_test.clj
@@ -16,9 +16,11 @@
[app.nitrate :as nitrate]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
+ [app.rpc.commands.teams-invitations :as teams-invitations]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.java.io :as io]
+ [clojure.string :as cstring]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
@@ -664,6 +666,172 @@
(let [profile (th/db-get :profile {:email "hello@example.com"})]
(t/is (false? (:is-active profile)))))))))
+(defn- create-invitation-fixture
+ [email valid-until]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})
+ invitation (th/db-insert! :team-invitation
+ {:id (uuid/random)
+ :team-id (:id team)
+ :email-to email
+ :created-by (:id owner)
+ :role "editor"
+ :valid-until valid-until})]
+ {:owner owner
+ :team team
+ :invitation invitation}))
+
+(defn- create-test-invitation-token
+ ([{:keys [owner team]} email]
+ (create-test-invitation-token {:owner owner
+ :team team}
+ email
+ {}))
+ ([{:keys [owner team]} email claims]
+ (tokens/generate th/*system*
+ (merge {:iss :team-invitation
+ :exp (ct/in-future "48h")
+ :profile-id (:id owner)
+ :role :editor
+ :team-id (:id team)
+ :member-email email}
+ claims))))
+
+(t/deftest prepare-register-with-invitation-and-disabled-registration
+ (with-redefs [app.config/flags #{:login-with-password :email-verification}]
+ (with-mocks [mock {:target 'app.email/send! :return nil}]
+ (let [email "invited@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))
+ itoken (create-test-invitation-token fixture email)
+ data {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"}
+ out (th/command! data)]
+
+ (t/is (th/success? out))
+ (t/is (string? (get-in out [:result :token])))
+
+ (let [register-out (th/command! {::th/type :register-profile
+ :token (get-in out [:result :token])})]
+ (t/is (th/success? register-out))
+ (t/is (= 1 (:call-count @mock))))))))
+
+(t/deftest prepare-register-normalizes-invitation-email
+ (with-redefs [app.config/flags #{:login-with-password :email-verification}]
+ (let [email "mixed-case@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))
+ itoken (create-test-invitation-token
+ fixture
+ email
+ {:member-email (cstring/upper-case email)})
+ out (th/command! {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"})]
+ (t/is (th/success? out)))))
+
+(t/deftest prepare-register-with-canceled-invitation-is-rejected
+ (with-redefs [app.config/flags #{:login-with-password :email-verification}]
+ (let [email "canceled@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))
+ itoken (create-test-invitation-token fixture email)
+ data {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"}]
+ (t/is (th/success? (th/command! data)))
+ (th/db-delete! :team-invitation
+ {:team-id (:id (:team fixture))
+ :email-to email})
+ (let [out (th/command! data)]
+ (t/is (not (th/success? out)))
+ (t/is (= :validation (-> out :error ex-data :type)))
+ (t/is (= :invalid-token (-> out :error ex-data :code)))
+ (t/is (nil? (th/db-get :profile {:email email})))))))
+
+(t/deftest prepare-register-with-expired-invitation-is-rejected
+ (with-redefs [app.config/flags #{:login-with-password :email-verification}]
+ (let [email "expired@example.com"
+ fixture (create-invitation-fixture email (ct/in-past "48h"))
+ itoken (create-test-invitation-token fixture email)
+ out (th/command! {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"})]
+ (t/is (not (th/success? out)))
+ (t/is (= :validation (-> out :error ex-data :type)))
+ (t/is (= :invalid-token (-> out :error ex-data :code)))
+ (t/is (nil? (th/db-get :profile {:email email}))))))
+
+(t/deftest active-invitation-locks-row-during-transaction
+ (let [email "locked@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))
+ claims {:team-id (:id (:team fixture))
+ :member-email email}
+ locked (promise)
+ release (promise)
+ holder (future
+ (db/tx-run! th/*system*
+ (fn [cfg]
+ (let [invitation (teams-invitations/active-invitation
+ cfg
+ claims
+ {::db/for-update true})]
+ (deliver locked invitation)
+ (deref release 10000 ::timeout)
+ invitation))))
+ invitation (deref locked 10000 nil)
+ cancellation (when invitation
+ (future
+ (try
+ {:result
+ (db/tx-run! th/*system*
+ (fn [cfg]
+ (let [conn (::db/conn cfg)]
+ (db/exec-one! conn ["SET LOCAL lock_timeout = '100ms'"])
+ (db/delete! conn :team-invitation
+ {:team-id (:id (:team fixture))
+ :email-to email}))))}
+ (catch Throwable cause
+ {:error cause}))))
+ cancellation-result (when cancellation
+ (deref cancellation 10000 nil))
+ _ (deliver release true)
+ holder-result (deref holder 10000 nil)
+ _ (when cancellation
+ (deref cancellation 10000 nil))]
+
+ (t/is (some? invitation))
+ (t/is (contains? cancellation-result :error))
+ (t/is (some? holder-result))))
+
+(t/deftest register-profile-revalidates-invitation-before-creating-profile
+ (with-redefs [app.config/flags #{:login-with-password :email-verification}]
+ (let [email "canceled-between@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))
+ itoken (create-test-invitation-token fixture email)
+ prep (th/command! {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"})
+ rtoken (-> prep :result :token)]
+ (t/is (th/success? prep))
+ (th/db-delete! :team-invitation
+ {:team-id (:id (:team fixture))
+ :email-to email})
+ (let [out (th/command! {::th/type :register-profile
+ :token rtoken})]
+ (t/is (not (th/success? out)))
+ (t/is (= :validation (-> out :error ex-data :type)))
+ (t/is (= :invalid-token (-> out :error ex-data :code)))
+ (t/is (nil? (th/db-get :profile {:email email})))))))
+
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
;; With email-verification ENABLED (the default), a brand-new
;; profile created via the invitation flow is NOT active yet, so
@@ -769,28 +937,8 @@
(t/is (= :restriction (:type edata)))
(t/is (= :email-does-not-match-invitation (:code edata))))))
-(t/deftest prepare-and-register-with-invitation-and-disabled-registration-1
- (with-redefs [app.config/flags [:disable-registration]]
- (let [itoken (tokens/generate th/*system*
- {:iss :team-invitation
- :exp (ct/in-future "48h")
- :role :editor
- :team-id uuid/zero
- :member-email "user@example.com"})
- data {::th/type :prepare-register-profile
- :invitation-token itoken
- :fullname "foobar"
- :email "user@example.com"
- :password "Foobar12!"}
- out (th/command! data)]
-
- (t/is (not (th/success? out)))
- (let [edata (-> out :error ex-data)]
- (t/is (= :restriction (:type edata)))
- (t/is (= :registration-disabled (:code edata)))))))
-
-(t/deftest prepare-and-register-with-invitation-and-disabled-registration-2
- (with-redefs [app.config/flags [:disable-registration]]
+(t/deftest prepare-register-with-invitation-and-disabled-registration-and-wrong-email
+ (with-redefs [app.config/flags #{:login-with-password}]
(let [itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
@@ -808,10 +956,10 @@
(t/is (not (th/success? out)))
(let [edata (-> out :error ex-data)]
(t/is (= :restriction (:type edata)))
- (t/is (= :registration-disabled (:code edata)))))))
+ (t/is (= :email-does-not-match-invitation (:code edata)))))))
-(t/deftest prepare-and-register-with-invitation-and-disabled-login-with-password
- (with-redefs [app.config/flags [:disable-login-with-password]]
+(t/deftest prepare-register-with-invitation-and-disabled-login-with-password
+ (with-redefs [app.config/flags #{:registration}]
(let [itoken (tokens/generate th/*system*
{:iss :team-invitation
:exp (ct/in-future "48h")
@@ -831,6 +979,62 @@
(t/is (= :restriction (:type edata)))
(t/is (= :registration-disabled (:code edata)))))))
+(t/deftest register-profile-rejects-disabled-login-with-password
+ (let [email "login-disabled@example.com"
+ fixture (create-invitation-fixture email (ct/in-future "48h"))]
+ (with-redefs [app.config/flags #{:login-with-password :registration}]
+ (let [itoken (create-test-invitation-token fixture email)
+ prep (th/command! {::th/type :prepare-register-profile
+ :invitation-token itoken
+ :fullname "foobar"
+ :email email
+ :password "Foobar12!"})]
+ (t/is (th/success? prep))
+ (with-redefs [app.config/flags #{:registration}]
+ (let [out (th/command! {::th/type :register-profile
+ :token (-> prep :result :token)})]
+ (t/is (not (th/success? out)))
+ (t/is (= :registration-disabled (-> out :error ex-data :code)))
+ (t/is (nil? (th/db-get :profile {:email email})))))))))
+
+(t/deftest verify-token-redirects-invitation-based-on-member-id
+ (with-redefs [app.config/flags #{:login-with-password}]
+ (let [owner (th/create-profile* 1 {:is-active true})
+ team (th/create-team* 1 {:profile-id (:id owner)})
+ email "invited@example.com"
+ token (fn [claims]
+ (tokens/generate th/*system*
+ (merge {:iss :team-invitation
+ :exp (ct/in-future "48h")
+ :role :editor
+ :profile-id (:id owner)
+ :team-id (:id team)
+ :member-email email}
+ claims)))]
+ (th/db-insert! :team-invitation
+ {:id (uuid/random)
+ :team-id (:id team)
+ :email-to email
+ :created-by (:id owner)
+ :role "editor"
+ :valid-until (ct/in-future "48h")})
+
+ (let [new-user-token (token {})
+ existing-user-token (token {:member-id (uuid/random)})
+ new-user-out (th/command! {::th/type :verify-token
+ :token new-user-token})
+ existing-user-out (th/command! {::th/type :verify-token
+ :token existing-user-token})]
+ (t/is (th/success? new-user-out))
+ (t/is (= :auth-register (get-in new-user-out [:result :redirect-to])))
+ (t/is (= :pending (get-in new-user-out [:result :state])))
+ (t/is (= new-user-token (get-in new-user-out [:result :invitation-token])))
+
+ (t/is (th/success? existing-user-out))
+ (t/is (= :auth-login (get-in existing-user-out [:result :redirect-to])))
+ (t/is (= :pending (get-in existing-user-out [:result :state])))
+ (t/is (= existing-user-token (get-in existing-user-out [:result :invitation-token])))))))
+
(t/deftest prepare-register-with-registration-disabled
(with-redefs [app.config/flags #{}]
(let [data {::th/type :prepare-register-profile
diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md
index cb5a4465c6..9fd0282761 100644
--- a/docs/technical-guide/configuration.md
+++ b/docs/technical-guide/configuration.md
@@ -96,13 +96,25 @@ enabled with enable-email-whitelist flag. For
autoenable it when PENPOT_REGISTRATION_DOMAIN_WHITELIST is set with
not-empty content.
-Penpot also comes with an option to completely disable the registration process;
-for this, use the following flag:
+Penpot also comes with an option to disable public registration. Users with a
+valid and active team invitation can still register an account when password
+login is enabled. The invitation must still exist in the database and its
+validity period must not have ended. To disable public registration, use the
+following flag:
```bash
PENPOT_FLAGS: [...] disable-registration
```
+For invitation-based registration, keep the password login flag enabled:
+
+```bash
+PENPOT_FLAGS: [...] disable-registration enable-login-with-password
+```
+
+The `disable-login-with-password` flag still disables password-based login and
+registration, including password-based registration through an invitation.
+
This option is only recommended for demo instances, not for production environments.
### Authentication Providers
@@ -433,7 +445,8 @@ This is an example of a demo configuration:
PENPOT_FLAGS: disable-registration enable-demo-users enable-demo-warning
```
-**disable-registration** prevents any user from registering in the platform.
+**disable-registration** prevents public registration in the platform, while
+valid team invitations can still create accounts when password login is enabled.
**enable-demo-users** creates users with a default expiration time of 7 days, and
once expired they are completely deleted with all the generated content.
From the registration page, there is a link with a `Create demo account` which creates one of these
diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md
index 66d7a311ba..92c8df6ca0 100644
--- a/docs/technical-guide/getting-started/docker.md
+++ b/docs/technical-guide/getting-started/docker.md
@@ -83,7 +83,10 @@ Penpot provides a script (`manage.py`) with some administrative tasks to perform
flag set in the docker-compose.yaml file. For older versions of docker-compose.yaml file,
this flag is set in the backend service.
-For instance, if the registration is disabled, the only way to create a new user is with this script:
+For users who do not have a team invitation, if public registration is disabled, the
+way to create a new user is with this script. Users with a valid and active team
+invitation can register through the invitation link when password login is enabled.
+The invitation must still exist and must not have expired.
```bash
docker exec -ti penpot-penpot-backend-1 python3 manage.py create-profile
diff --git a/frontend/test/frontend_tests/data/nitrate_test.cljs b/frontend/test/frontend_tests/data/nitrate_test.cljs
index 3937fb7afa..19ed782d0d 100644
--- a/frontend/test/frontend_tests/data/nitrate_test.cljs
+++ b/frontend/test/frontend_tests/data/nitrate_test.cljs
@@ -122,6 +122,24 @@
(t/is (contains? event :days-since-member-added))
(t/is (nil? (:days-since-member-added event)))))))
+(t/deftest pending-team-invitation-redirects-to-register
+ (let [emitted (atom [])
+ invitation-token "invitation-123"]
+ (with-redefs [st/emit! (fn
+ ([event]
+ (swap! emitted conj event))
+ ([event & events]
+ (swap! emitted into (cons event events))))]
+ (verify-token/handle-token
+ {:iss :team-invitation
+ :state :pending
+ :redirect-to :auth-register
+ :invitation-token invitation-token})
+
+ (let [event @(first @emitted)]
+ (t/is (= :auth-register (:id event)))
+ (t/is (= invitation-token (get-in event [:params :invitation-token])))))))
+
(t/deftest accept-organization-invitation-audit-event-test
(let [emitted (atom [])]
(with-redefs [st/emit! (fn