User authentication #

Users in Penpot may register via several different methods (if enabled in the configuration of the Penpot instance). We have implemented this as a series of "authentication backends" in our code:

  • penpot: internal registration with email and password.
  • ldap: authentication over an external LDAP directory.
  • oidc, google, github, gitlab: authentication over an external service using the OpenID Connect protocol. We have a generic handler, and other ones already preconfigured for popular services.

The main logic resides in the following files:

backend/src/app/rpc/mutations/profile.clj
backend/src/app/rpc/mutations/ldap.clj
backend/src/app/rpc/mutations/verify-token.clj
backend/src/app/http/oauth.clj
backend/src/app/http/session.clj
frontend/src/app/main/ui/auth/verify-token.cljs

We store in the user profiles in the database the auth backend used to register first time (mainly for audit). A user may login with other methods later, if the email is the same.

Register and login #

The code is organized to try to reuse functions and unify processes as much as possible for the different auth systems.

Penpot backend #

When a user types an email and password in the basic Penpot registration page, frontend calls :prepare-register-profile method. It generates a "register token", a temporary JWT token that includes the login data.

This is used in the second registration page, that finally calls :register-profile with the token and the rest of profile data. This function is reused in all the registration methods, and it's responsible of creating the user profile in the database. Then, it sends the confirmation email if using penpot backend, or directly opens a session (see below) for othe methods or if the user has been invited from a team.

The confirmation email has a link to /auth/verify-token, that has a handler in frontend, that is a hub for different kinds of tokens (registration email, email change and invitation link). This view uses :verify-token RPC call and redirects to the corresponding page with the result.

To login with the penpot backend, the user simply types the email and password and they are sent to :login method to check and open session.

OIDC backend #

When the user press one of the "Log in with XXX" button, frontend calls /auth/oauth/:provider (provider is google, github or gitlab). The handler generates a request token and redirects the user to the service provider to authenticate in it.

If succesful, the provider redirects to the /auth/oauth/:provider/callback. This verifies the call with the request token, extracts another access token from the auth response, and uses it to request the email and full name from the service provider.

Then checks if this is an already registered profile or not. In the first case it opens a session, and in the second one calls :register-profile to create a new user in the sytem.

For the known service providers, the addresses of the protocol endpoints are hardcoded. But for a generic OIDC service, there is a discovery protocol to ask the provider for them, or the system administrator may set them via configuration variables.

LDAP #

Registration is not possible by LDAP (we use an external user directory managed outside of Penpot). Typically when LDAP registration is enabled, the plain user & password login is disabled.

When the user types their user & password and presses "Login with LDAP" button, the :login-with-ldap method is called. It connects with the LDAP service to validate credentials and retrieve email and full name.

Similarly as the OIDC backend, it checks if the profile exists, and calls :login or :register-profile as needed.

Sessions #

User sessions are created when a user logs in via any one of the backends. A session token is generated (a JWT token that does not currently contain any data) and returned to frontend as a cookie.

Normally the session is stored in a DB table with the information of the user profile and the session expiration. But if a frontend connects to the backend in "read only" mode (for example, to debug something in production with the local devenv), sessions are stored in memory (may be lost if the backend restarts).

Team invitations #

The invitation link has a call to /auth/verify-token frontend view (explained above) with a token that includes the invited email.

When a user follows it, the token is verified and then the corresponding process is routed, depending if the email corresponds to an existing account or not. The :register-profile or :login services are used, and the invitation token is attached so that the profile is linked to the team at the end.

Handling unfinished registrations and bouncing users #

All tokens have an expiration date, and when they are put in a permanent storage, a garbage colector task visits it periodically to cleand old items.

Also our email sever registers email bounces and spam complaint reportings (see backend/src/app/emails.clj). When the email of one profile receives too many notifications, it becames blocked. From this on, the user cannot login or register with this email, and no message will be sent to it. If it recovers later, it needs to be unlocked manually in the database.

How to test in devenv #

To test all normal registration process you can use the devenv Mail catcher utility.

To test OIDC, you need to register an application in one of the providers:

The URL of the app will be the devenv frontend: http://localhost:3449.

And then put the credentials in backend/scripts/repl and frontend/resources/public/js/config.js.

Finally, to test LDAP, in the devenv we include a test LDAP server, that is already configured, and only needs to be enabled in frontend config.js:

var penpotFlags = "enable-login-with-ldap";