|
| 1 | +# OIDC login for the web UI |
| 2 | + |
| 3 | +Mailpit can put the web UI behind a corporate OIDC IdP (Keycloak, Okta, |
| 4 | +Google, Auth0, Authentik, …) so a shared team instance is gated by SSO |
| 5 | +instead of one shared Basic Auth password. |
| 6 | + |
| 7 | +OIDC is **opt-in**. When the issuer is unset, behaviour is unchanged — |
| 8 | +the UI is open or guarded by Basic Auth exactly as before. |
| 9 | + |
| 10 | +Basic Auth keeps working alongside OIDC, so the Send API and any |
| 11 | +scripts / integrations that already use `Authorization: Basic …` don't |
| 12 | +need to change. |
| 13 | + |
| 14 | +## Configure Mailpit |
| 15 | + |
| 16 | +Set two values (CLI flags or env vars): |
| 17 | + |
| 18 | +| Flag | Env var | What it is | |
| 19 | +|-------------------------|--------------------------|-----------------------------------------------| |
| 20 | +| `--ui-oidc-issuer` | `MP_UI_OIDC_ISSUER` | Issuer URL (the OIDC discovery endpoint root) | |
| 21 | +| `--ui-oidc-client-id` | `MP_UI_OIDC_CLIENT_ID` | Client ID registered with the IdP | |
| 22 | + |
| 23 | +Example: |
| 24 | + |
| 25 | +```shell |
| 26 | +MP_UI_OIDC_ISSUER=https://idp.example.com/realms/myrealm \ |
| 27 | +MP_UI_OIDC_CLIENT_ID=mailpit \ |
| 28 | +mailpit |
| 29 | +``` |
| 30 | + |
| 31 | +On boot, Mailpit fetches the IdP's discovery document and JWKS once |
| 32 | +and caches them for 24h. A failure here is fatal — a typo in the |
| 33 | +issuer URL or an unreachable IdP causes startup to fail with a clear |
| 34 | +error, so misconfiguration is loud. |
| 35 | + |
| 36 | +## Configure your IdP |
| 37 | + |
| 38 | +Register Mailpit as a **public** client with **Authorization Code + |
| 39 | +PKCE** and a single redirect URI: |
| 40 | + |
| 41 | +``` |
| 42 | +<scheme>://<mailpit-host>:<port><webroot>auth/callback |
| 43 | +``` |
| 44 | + |
| 45 | +For a default local install: `http://localhost:8025/auth/callback`. |
| 46 | +If `MP_WEBROOT=/mailpit/` is set, it becomes |
| 47 | +`http://localhost:8025/mailpit/auth/callback`. |
| 48 | + |
| 49 | +Recommended client settings: |
| 50 | + |
| 51 | +- Public client (no client secret) |
| 52 | +- Standard flow (Authorization Code) **enabled** |
| 53 | +- Implicit / Direct Access Grants / Service Accounts: **disabled** |
| 54 | +- PKCE method: `S256` |
| 55 | +- Scopes requested by the SPA: `openid email profile offline_access` |
| 56 | + (the `offline_access` scope must be allowed so refresh-token rotation |
| 57 | + works and users aren't bounced back to the IdP mid-session) |
| 58 | +- Post-logout redirect URI: the Mailpit root, e.g. `http://localhost:8025/` |
| 59 | + |
| 60 | +No client secret is needed on the Mailpit side — the SPA drives the |
| 61 | +PKCE dance directly with the IdP. Mailpit only needs the issuer URL |
| 62 | +to verify incoming Bearer JWTs against the IdP's JWKS. |
| 63 | + |
| 64 | +## How it works (one-screen overview) |
| 65 | + |
| 66 | +- The SPA redirects unauthenticated users to the IdP via PKCE and |
| 67 | + exchanges the auth code for tokens client-side. |
| 68 | +- Tokens live in `localStorage` (so new tabs inherit the session) and |
| 69 | + are silently renewed via the refresh token — no hidden iframes, no |
| 70 | + third-party cookies. |
| 71 | +- Every API call carries `Authorization: Bearer <id_token>`. The |
| 72 | + WebSocket (`/api/events`) carries the same token as |
| 73 | + `?access_token=…` because browsers can't set headers on WS upgrades. |
| 74 | +- The server verifies each JWT against the IdP's JWKS (cached for 24h |
| 75 | + or until restart). |
| 76 | +- Basic Auth is still honoured for any request that proactively sends |
| 77 | + it, so API integrations continue to work. |
| 78 | + |
| 79 | +No state is kept on the server: no cookies, no session store, no HMAC |
| 80 | +secret. |
| 81 | + |
| 82 | +## Try it locally with Keycloak |
| 83 | + |
| 84 | +A throwaway Keycloak instance is enough to exercise the full flow. |
| 85 | + |
| 86 | +`docker-compose.yml`: |
| 87 | + |
| 88 | +```yaml |
| 89 | +services: |
| 90 | + keycloak: |
| 91 | + image: quay.io/keycloak/keycloak:26.0 |
| 92 | + command: ["start-dev", "--import-realm"] |
| 93 | + environment: |
| 94 | + KC_BOOTSTRAP_ADMIN_USERNAME: admin |
| 95 | + KC_BOOTSTRAP_ADMIN_PASSWORD: admin |
| 96 | + KC_HTTP_ENABLED: "true" |
| 97 | + KC_HOSTNAME: "http://localhost:8080" |
| 98 | + KC_HOSTNAME_STRICT: "false" |
| 99 | + ports: |
| 100 | + - "8080:8080" |
| 101 | + volumes: |
| 102 | + - ./keycloak-realm.json:/opt/keycloak/data/import/mailpit-realm.json:ro |
| 103 | + |
| 104 | + mailpit: |
| 105 | + image: axllent/mailpit:latest |
| 106 | + # Host network so Mailpit reaches Keycloak at the same hostname |
| 107 | + # the browser uses (localhost:8080). The token's `iss` claim and |
| 108 | + # the configured issuer URL must match exactly. |
| 109 | + network_mode: host |
| 110 | + environment: |
| 111 | + MP_UI_OIDC_ISSUER: http://localhost:8080/realms/mailpit |
| 112 | + MP_UI_OIDC_CLIENT_ID: mailpit |
| 113 | + depends_on: |
| 114 | + - keycloak |
| 115 | +``` |
| 116 | +
|
| 117 | +`keycloak-realm.json`: |
| 118 | + |
| 119 | +```json |
| 120 | +{ |
| 121 | + "realm": "mailpit", |
| 122 | + "enabled": true, |
| 123 | + "loginWithEmailAllowed": true, |
| 124 | + "users": [ |
| 125 | + { |
| 126 | + "username": "test@example.com", |
| 127 | + "email": "test@example.com", |
| 128 | + "emailVerified": true, |
| 129 | + "enabled": true, |
| 130 | + "credentials": [ |
| 131 | + { "type": "password", "value": "test", "temporary": false } |
| 132 | + ], |
| 133 | + "realmRoles": ["default-roles-mailpit", "offline_access"] |
| 134 | + } |
| 135 | + ], |
| 136 | + "clients": [ |
| 137 | + { |
| 138 | + "clientId": "mailpit", |
| 139 | + "enabled": true, |
| 140 | + "publicClient": true, |
| 141 | + "standardFlowEnabled": true, |
| 142 | + "directAccessGrantsEnabled": false, |
| 143 | + "implicitFlowEnabled": false, |
| 144 | + "protocol": "openid-connect", |
| 145 | + "redirectUris": ["http://localhost:8025/auth/callback"], |
| 146 | + "webOrigins": ["+"], |
| 147 | + "attributes": { |
| 148 | + "pkce.code.challenge.method": "S256", |
| 149 | + "post.logout.redirect.uris": "http://localhost:8025/" |
| 150 | + }, |
| 151 | + "optionalClientScopes": ["offline_access"] |
| 152 | + } |
| 153 | + ] |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +Then: |
| 158 | + |
| 159 | +```shell |
| 160 | +docker compose up |
| 161 | +``` |
| 162 | + |
| 163 | +Open <http://localhost:8025>, log in with `test@example.com` / `test`, |
| 164 | +and you should land in the inbox. The user's name shows in the |
| 165 | +sidebar next to a sign-out button. |
| 166 | + |
| 167 | +## Coexistence with Basic Auth |
| 168 | + |
| 169 | +If both `MP_UI_AUTH` (htpasswd) and OIDC are configured: |
| 170 | + |
| 171 | +- The browser SPA always goes through OIDC. |
| 172 | +- `curl -u user:pass …/api/v1/messages` and other clients that send |
| 173 | + `Authorization: Basic …` still work — Mailpit accepts either. |
| 174 | +- A 401 from an unauthenticated request returns |
| 175 | + `X-Mp-Auth-Required: oidc` and does **not** include a |
| 176 | + `WWW-Authenticate: Basic` header, so the browser never pops up its |
| 177 | + native Basic-Auth dialog on a SPA-side 401. |
| 178 | + |
| 179 | +## Notes & limits |
| 180 | + |
| 181 | +- **No allowlist of any kind.** Anyone the IdP authenticates is |
| 182 | + allowed in. Restrict who reaches the IdP itself (groups, federation, |
| 183 | + etc.) instead. |
| 184 | +- **Webroot.** All flows follow `MP_WEBROOT` — adjust the redirect URI |
| 185 | + in the IdP accordingly. |
| 186 | +- **CSP.** Mailpit's Content Security Policy is automatically extended |
| 187 | + with the IdP's origin in `connect-src` so the SPA can reach the |
| 188 | + discovery, JWKS, and token endpoints. |
| 189 | +- **Send API.** Unchanged. It has its own auth path |
| 190 | + (`--send-api-auth` or `--send-api-auth-accept-any`) and is not |
| 191 | + affected by the UI's OIDC config. |
0 commit comments