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.

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

Can we please close this PR? A reverse proxy that handles OIDC as a sidecar container is perfectly doable and the right architectural choice, as it keeps Maiplit's code base simple. I certainly don't want Mailpit to end like MailHog.

@axllent
Copy link
Copy Markdown
Owner

axllent commented May 24, 2026

I started looking into this today but got sidetracked by an urgent CVE that needed immediate attention. My apologies for the delay.

I'd like to share some observations and concerns based on what I've reviewed so far.

On multi-user authentication: I want to clarify that Mailpit's basic auth isn't limited to single users. The auth file can contain multiple unique user entries, so the multi-login capability you mentioned as a limitation isn't actually a constraint. I'm not suggesting it's as elegant as your solution, but it does address the staff turnover concern about password resets.

On the OIDC integration feature: I appreciate the contribution genuinely, but I have significant reservations that I need to raise.

My main concerns:

The OIDC mechanics are unclear to me, which creates a real support problem. I'm the person users will contact when something breaks or won't authenticate. Without understanding how this works, I can't effectively troubleshoot issues or guide users through setup.

Security risk. I've been focused on hardening Mailpit's attack surface, and integrating with external authentication systems likely expands that surface in ways I don't fully understand. Every feature that ships becomes a long-term maintenance obligation - I can't support something I don't comprehend, and I shouldn't have to rely on AI to maintain it.

Complexity beyond the demo. Your demo made it look straightforward - just two environment variables - but I suspect that real-world OIDC integration is rarely that simple. Different providers would likely use different security algorithms, key exchange methods, and configuration requirements etc. Supporting all the listed OIDC services would likely require many more options and edge cases than the current implementation suggests.

Alternatives exist. As noted, projects like oauth2-proxy already handle OIDC at the proxy layer, which keeps authentication concerns separate from Mailpit itself.

I'm not saying no yet, but these are significant long-term risks that need to be understood before I can confidently make a decision.

I'm interested in your thoughts about this @pgbezerra? As @D-i-t-gh stated, there are apparently already existing proxy-based solutions to achieve the same thing without requiring the functionality be added directly into Mailpit.

@sunnyagain
Copy link
Copy Markdown

My main concerns:

Security risk. I've been focused on hardening Mailpit's attack surface, and integrating with external authentication systems likely expands that surface in ways I don't fully understand. Every feature that ships becomes a long-term maintenance obligation - I can't support something I don't comprehend, and I shouldn't have to rely on AI to maintain it.

I'm interested in your thoughts about this @pgbezerra? As @D-i-t-gh stated, there are apparently already existing proxy-based solutions to achieve the same thing without requiring the functionality be added directly into Mailpit.

We also use similar reverse proxy with AWS ELB for our Auth dependencies as mentioned by @D-i-t-gh .

I agree with @axllent In current form this project is our go-to solution because it is lean, introducing these additional features would likely mean additional vetting, something I would like to avoid.

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

4 participants