Skip to content

feat(auth): add OIDC login for the web UI#684

Open
pgbezerra wants to merge 3 commits into
axllent:developfrom
pgbezerra:feat/oidc-ui-auth
Open

feat(auth): add OIDC login for the web UI#684
pgbezerra wants to merge 3 commits into
axllent:developfrom
pgbezerra:feat/oidc-ui-auth

Conversation

@pgbezerra
Copy link
Copy Markdown

@pgbezerra pgbezerra commented May 12, 2026

Demo

https://youtu.be/hwBq5VjvEx0

Summary

Adds optional OIDC (Authorization Code + PKCE) login for the web UI, while keeping HTTP Basic Auth working for API clients and integrations.

Closes #683.

Why

Sharing one Basic Auth password across a team is awkward — every time someone leaves, the secret has to be rotated and redistributed. Putting the UI behind a corporate OIDC IdP (Keycloak, Okta, Google, Auth0, …) lets organisations gate Mailpit behind SSO without losing the simple Basic Auth path their automations rely on.

How to turn it on

Set two env vars (or the equivalent flags):

MP_UI_OIDC_ISSUER=https://your-idp.example.com/realms/something
MP_UI_OIDC_CLIENT_ID=mailpit

When unset, behaviour is byte-for-byte identical to today.

Design notes

  • Stateless server. No cookies, no sessions, no HMAC secrets. The server only knows the issuer URL and client ID.
  • Public client, PKCE. The SPA drives the OIDC dance directly with the IdP via oidc-client-ts. No client secret on the server.
  • Bearer JWT transport. Every request carries Authorization: Bearer <id_token>. The server verifies it against the IdP's JWKS (fetched once on boot, cached for 24h).
  • Refresh-token silent renew with offline_access. Users don't get bounced to the IdP mid-session.
  • Basic Auth coexists. Any Authorization: Basic … keeps being checked against the htpasswd store — Send API, scripts, scrapers don't change.
  • Tokens in localStorage, so new tabs in the same browser inherit the session.
  • Conditional bundle. oidc-client-ts lives in its own entry point (dist/oidc-entry.js) that the HTML template only includes when OIDC is enabled — default deployments ship zero extra bytes.

What's tested

  • 13 unit tests in internal/auth/oidc_test.go covering: disabled / missing client ID / unreachable issuer / JWKS preload on boot / happy-path verify / Bearer-prefix stripping / wrong audience / wrong issuer / expired / bad signature / empty + disabled / JWKS cached across verifies / JWKS refresh after TTL.
  • Integration tests in server/server_test.go covering: valid Bearer → 200, expired / tampered Bearer → 401, Bearer in ?access_token= query → 200 (for WebSocket auth), SPA shell loads without auth challenge, OIDC + Basic coexist, OIDC off ⇒ unchanged Basic behaviour, both nil ⇒ anonymous.
  • Verified end-to-end against a real Keycloak instance: login redirect, callback exchange, silent renew, refresh-token revocation, logout, WebSocket auth, CSP, webroot.

Things intentionally not changed

  • sendAPIAuthMiddleware and the Send API path — already auth-isolated, integrations keep using Basic Auth or --send-api-auth-accept-any.
  • SMTP / POP3 / Chaos / metrics / livez / readyz.
  • Webroot handling — all OIDC URLs follow the existing config.Webroot convention.
  • CORS, CSP (only an additive connect-src entry for the IdP origin), gzip, the skipUIAuthKey context flag.

pgbezerra added 2 commits May 11, 2026 21:58
Optional. Set MP_UI_OIDC_ISSUER and MP_UI_OIDC_CLIENT_ID to turn it on.
When unset, behaviour is identical to today.

The UI redirects to the IdP via Authorization Code + PKCE (handled in
the SPA by oidc-client-ts). Tokens live in sessionStorage and are
silently renewed via the refresh token. Each request carries the ID
token as a Bearer JWT; the server verifies it against the IdP's JWKS
(fetched once on boot and cached for 24h).

Basic Auth still works in parallel so API integrations don't break.
The oidc-client-ts bundle is only loaded when OIDC is enabled —
nothing extra is shipped to users on default deployments.
- Let the SPA shell (HTML/JS/CSS, /search, /view/<id>, /auth/callback)
  load without an auth challenge when OIDC is enabled, so the SPA can
  run the redirect itself. API and direct .html/.txt previews stay
  gated.
- Suppress WWW-Authenticate: Basic on 401s when OIDC is on, so the
  browser's native Basic dialog doesn't pop up on a SPA-side 401.
  Basic Auth still works for clients that send the header proactively.
- Add the IdP origin to connect-src in the CSP so the SPA can reach
  the discovery / JWKS / token endpoints.
- Drop the duplicate Basic Auth check in websockets.ServeWs — auth is
  the middleware's job, and the duplicate broke browser WS upgrades
  (browsers can't send Authorization on WS handshakes).
- Move the SPA-side OIDC config to a cached Promise so callers race-
  free await the same init. AuthCallbackView does a hard navigation
  after exchange so the router guard sees the freshly stored user.
- Store tokens in localStorage so a new tab inherits the session.
- Move the logout button next to "About Mailpit" and show the signed-
  in user's name beside it.
@pgbezerra pgbezerra force-pushed the feat/oidc-ui-auth branch from 603ee47 to f2aa36f Compare May 12, 2026 02:40
@axllent
Copy link
Copy Markdown
Owner

axllent commented May 12, 2026

Thanks @pgbezerra - this will likely take me some time to review properly, especially since it appears to be fully AI‑written (Am I wrong?) and it’s a fairly large code addition. I have several other changes I need to push out first, including some security fixes, so I’ll need to handle those before coming back to this.

I’m sure I’ll have more questions once I dig into it, but it may be about a week before I can make a start.

@D-i-t-gh
Copy link
Copy Markdown

Just my thoughts:
We already use a reverse proxy for that purpose, which works quite well. The only thing we are missing is a logout button in Mailpit that points to "/logout", but this is rather a nice-to-have feature. We also changed the way API requests are authenticated. So in our case, we would not benefit from this feature at all.

@pgbezerra
Copy link
Copy Markdown
Author

Thanks @pgbezerra - this will likely take me some time to review properly, especially since it appears to be fully AI‑written (Am I wrong?) and it’s a fairly large code addition. I have several other changes I need to push out first, including some security fixes, so I’ll need to handle those before coming back to this.

I’m sure I’ll have more questions once I dig into it, but it may be about a week before I can make a start.

Thanks for that, @axllent!
Yes, it was driven by AI, however I have reviewed it and iterated a lot to get it to the current state.

There is one thing I noticed and I haven't fixed, which is a function in internal/auth/oidc.go to help the tests SetJWKSTTLForTests. I was planning to get rid of that, and forgot about it.

Besides reviewing the code and iterating to not open an AI slop PR, I also tested all authentication flows and showed that in the YouTube video I posted above.

@pgbezerra
Copy link
Copy Markdown
Author

Just my thoughts: We already use a reverse proxy for that purpose, which works quite well. The only thing we are missing is a logout button in Mailpit that points to "/logout", but this is rather a nice-to-have feature. We also changed the way API requests are authenticated. So in our case, we would not benefit from this feature at all.

I thought about adding a reverse proxy as well, but don't you think this is more work than adding a client to the existing IdP, and two env vars in MailPit?

That was why I checked the code to see how hard it would be to implement this change directly in Mailpit.

My understanding was that it doesn't increase code complexity much, and the frontend bundle gets ~30k larger only if OIDC is enabled, which should not impact performance for those who don't have it enabled.

@pgbezerra
Copy link
Copy Markdown
Author

@axllent, do you want me to rebase and fix the go.mod, go.sum or wait for your review?

@axllent
Copy link
Copy Markdown
Owner

axllent commented May 18, 2026

@pgbezerra - feel free to rebase it if you like (there were a large number of changes recently for the v1.30.0 release). I am very time-poor at the moment, so have not started on this yet - and I'm not 100% sure when I will unfortunately. I know I had said about a week (about a week ago), but I got 4 CVE reports late last week which took priority, and I needed to get the changes out.

I definitely do plan to review this - hopefully within the next week or two - but I will need to spend a fairly considerable amount of time to fully understand the implications of these changes, as well as how OIDC works exactly. Sorry about the delay, but I have to be sure this is a good fit/feature, and it's a huge code change (addition). As I always say to contributors, it's not that the work wasn't done "for me", it's that I have to continue to maintain it in the future.

@pgbezerra
Copy link
Copy Markdown
Author

@pgbezerra - feel free to rebase it if you like (there were a large number of changes recently for the v1.30.0 release). I am very time-poor at the moment, so have not started on this yet - and I'm not 100% sure when I will unfortunately. I know I had said about a week (about a week ago), but I got 4 CVE reports late last week which took priority, and I needed to get the changes out.

I definitely do plan to review this - hopefully within the next week or two - but I will need to spend a fairly considerable amount of time to fully understand the implications of these changes, as well as how OIDC works exactly. Sorry about the delay, but I have to be sure this is a good fit/feature, and it's a huge code change (addition). As I always say to contributors, it's not that the work wasn't done "for me", it's that I have to continue to maintain it in the future.

Totally understandable @axllent. Appreciate your quick answer, and good luck on the CVEs.
I'll rebase it, re-test all the flows, and leave it ready for your review.

In two weeks, I'll double-check whether I need to rebase it again and ping you then, if that's okay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: OIDC authentication for the web UI

3 participants