# M6.6 — Webmail SSO via Bulwark `/api/auth/impersonate`

## Why

The current webmail SSO path (M6.2 / ADR-0045 — panel-api POSTs to Bulwark
`/api/auth/session`) is broken end-to-end. Two compounding bugs:

1. **Bulwark serverUrl gate.** Bulwark 1.7.x rejects `/api/auth/session`
   requests where `serverUrl` isn't in the admin-configured `jmapServers`
   list. Panel-api sent `https://mail.<tenant>` which never matched
   the `JMAP_SERVER_URL=https://<panel-hostname>` default → HTTP 502
   `{"error":"Failed to verify JMAP session"}`.
2. **Cookie / localStorage drift.** Even when the gate is patched
   (env-add `JMAP_SERVERS=[{...}]`), Bulwark sets cookies but the SPA
   checks `localStorage[user].serverUrl` against the cookie's serverUrl
   on first page load. localStorage is empty (set only by the SPA's
   own login form submit) → mismatch → bounces to `/en/login` with
   "Your session has expired".

Bulwark issue [#296](https://github.com/bulwarkmail/webmail/issues/296)
documents both, and the maintainer (`@rathlinus`) shipped a dedicated
SSO endpoint in 1.7.1:

```
GET /api/auth/impersonate?token=<HS256 JWT>
```

Endpoint verifies the JWT, builds a Stalwart master-user Basic header
(`mailbox%master@host:masterpw` per Stalwart docs), sets `jmap_session`
+ `jmap_stalwart_ctx` cookies under the **impersonation slot** that
the SPA actually accepts on next page load, 303 → `/`.

This blueprint pivots panel-api off the doomed `/api/auth/session`
path onto `/api/auth/impersonate`, supersedes ADR-0045's session-POST
design, and ships the install.sh + reconciler wiring needed for the
master-user account + per-tenant `JMAP_SERVERS` list.

## Wire contract (Bulwark 1.7.1 source)

### JWT (HS256)

| Claim          | Type    | Required | Notes                                                    |
|----------------|---------|----------|----------------------------------------------------------|
| `iss`          | string  | yes      | Must match `BULWARK_JWT_AUTH_ISSUER` (default `platform-api/webmail`) |
| `iat`          | number  | yes      | issued-at (unix sec)                                     |
| `exp`          | number  | yes      | expiry (unix sec); max lifetime 300s                     |
| `nbf`          | number  | optional | not-before (unix sec)                                    |
| `jti`          | string  | yes      | unique token id (replay protection)                      |
| `mailbox`      | string  | yes      | target mailbox email (`<local>@<domain>`)                |
| `tenant_id`    | string  | optional | logged for audit (`actor_user_id` is panel admin's ULID) |
| `actor_user_id`| string  | optional | logged for audit                                         |

Constants from `lib/impersonation/jwt.ts`:
- `MAX_TOKEN_LIFETIME_SEC = 300`
- `CLOCK_SKEW_SEC = 60`
- `MIN_SECRET_LENGTH = 32`

### Bulwark env (from `lib/impersonation/master-config.ts`)

| Var | Required | Notes |
|---|---|---|
| `BULWARK_JWT_AUTH_SECRET` | yes | HMAC key, ≥32 chars |
| `BULWARK_STALWART_MASTER_USER` | yes | e.g. `master@<panel-hostname>` |
| `BULWARK_STALWART_MASTER_PASSWORD` | yes | random, ≥16 chars |
| `BULWARK_JWT_AUTH_ISSUER` | optional | default `platform-api/webmail`; we override to `jabali-panel/webmail-sso` |
| `JMAP_SERVERS` | yes | JSON array of `{id, label, url, domains:[]}` so Bulwark can resolve the tenant URL |

### Stalwart side

Master account must:
- Exist as an `Account` in Stalwart
- Have `Role` = `Admin` (impersonation is Admin-gated per Stalwart docs)
- Use a password Bulwark can Basic-auth against (matching `BULWARK_STALWART_MASTER_PASSWORD`)

Stalwart master-user auth format: `Authorization: Basic base64(<target>%<master>:<masterpw>)`
where `target` is the impersonated mailbox.

## Steps

### Step 1 — install.sh: generate + persist SSO secrets

- `_install_bulwark_impersonate_secrets()` function called from
  `install_bulwark()` after `bulwark.env` is rendered.
- Generates (idempotent):
  - `/etc/jabali-panel/bulwark-jwt-auth.secret` — `openssl rand -base64 48 | tr -d /+= | head -c 48`
  - `/etc/jabali-panel/bulwark-stalwart-master.password` — same size
- Both `chmod 0640 root:jabali-webmail` so Bulwark service can read.
- Appends to `bulwark.env`:
  ```
  BULWARK_JWT_AUTH_SECRET=<from secret file>
  BULWARK_STALWART_MASTER_USER=master@${PANEL_HOSTNAME}
  BULWARK_STALWART_MASTER_PASSWORD=<from password file>
  BULWARK_JWT_AUTH_ISSUER=jabali-panel/webmail-sso
  ```
- Bumps `systemctl restart jabali-webmail` if the env actually changed
  (diff-aware to avoid restart-on-every-update spam).

### Step 2 — install.sh: provision Stalwart master account

- `_install_stalwart_master_user()` called after Stalwart is healthy
  (`_install_stalwart` already gates on Stalwart `health` probe).
- Uses `stalwart-cli` (already installed by M6) to:
  1. Read existing master account by email if present (`stalwart-cli query Account --filter email=master@<host>`).
  2. If absent, create via `stalwart-cli create Account` with name=master, email=`master@${PANEL_HOSTNAME}`, password=`<from password file>`, role=Admin.
  3. If present but role != Admin OR password hash != current, update via `stalwart-cli update Account`.
- Idempotent. Re-runs on every install/update.

### Step 3 — panel-api: HS256 JWT minter

- New package `panel-api/internal/webmailsso/jwt.go`.
- `Minter` struct holds secret bytes + issuer string.
- `New(secret []byte, issuer string) (*Minter, error)` — validates `len(secret) >= 32`.
- `Mint(mailbox string, lifetime time.Duration) (string, error)` — builds claims, HMAC-SHA256 signs, returns compact JWT.
- Helpers: `base64UrlEncode`, `randomJTI` (use `ids.NewULID` for `jti`).
- Tests: golden JWT shape, expiry math, replay-jti uniqueness, error paths.

### Step 4 — panel-api: rewrite `webmail_sso.go` `land` handler

- Replace the POST-to-Bulwark `/api/auth/session` flow with:
  1. Validate SSO token (existing `SSOTokens.FindByHash` etc.) — unchanged.
  2. Load mailbox + domain (existing) — unchanged.
  3. Mint JWT via the new minter (mailbox claim, 60s lifetime, jti from ULID).
  4. Burn the SSO token (move from after-Bulwark-success to before-redirect — the panel-side gate is the only validation that matters now).
  5. 303 redirect to `https://mail.<dom.Name>/api/auth/impersonate?token=<jwt>`.
- Delete: Bulwark HTTP client setup, `bulwarkBase()`, `defaultBulwarkBaseURL`, `BulwarkBaseURL` config field, the entire POST/Set-Cookie forwarding logic.
- Wire minter into `SSOHandlerConfig` (load secret from `/etc/jabali-panel/bulwark-jwt-auth.secret` at server boot).
- Tests: `webmail_sso_test.go` rewritten to assert 303 + Location header shape, no Bulwark roundtrip.

### Step 5 — agent verb + reconciler: keep `JMAP_SERVERS` env in sync

- Bulwark `/api/auth/impersonate` calls `resolveImpersonationServerUrl()`
  which reads `configManager.get('jmapServerUrl')` first, then `JMAP_SERVER_URL`
  env. **For multi-tenant**, that single global URL isn't enough — we still
  need the per-tenant URLs registered in `JMAP_SERVERS` so each `mail.<tenant>`
  domain answers correctly.
- New agent verb `webmail.env_sync` (panel-agent):
  - Params: `{ jmap_servers: [{ id, label, url, domains: [] }, ...] }`
  - Reads `/etc/jabali-panel/bulwark.env`.
  - Diff-aware: rewrites only the `JMAP_SERVERS=` line.
  - If line changed, `systemctl restart jabali-webmail`.
  - Returns `{ changed: bool }`.
- Reconciler hook in `reconcileWebmailVhosts` (already iterates email_enabled domains):
  - Collect `[{id: <ulid-sans-prefix>, label: <domain>, url: "https://mail.<domain>", domains: ["<domain>"]}]`.
  - Call `webmail.env_sync` once per ReconcileAll pass with the full list.
- Idempotent. Restart only on real diff.

### Step 6 — live smoke on .14 + ADR amendment

- Cleanup .14: remove the manual `JMAP_SERVERS=...` from earlier debug.
- Run `jabali update` → install.sh seeds new env + master account.
- Click "Login to Webmail" in panel → assert: 303 → `mail.<tenant>/api/auth/impersonate?token=...` → 303 → `/` → SPA loads with inbox.
- Tail Bulwark logs for `Impersonation session granted` audit line.
- ADR-0045 amendment: SSO-via-session-POST superseded by impersonation JWT. Reference Bulwark #296. Note master-user creds rotate-on-every-install (op note for ops).

## Risks + rollback

- **Stalwart master-user has Admin rights.** Compromise of `BULWARK_STALWART_MASTER_PASSWORD` = full mail server takeover. Mitigation: file is `0640 root:jabali-webmail`, never exposed via panel UI, rotation per install.
- **JWT secret reuse across install + bulwark.env.** Atomic re-render — restart bulwark only after env is fully synced.
- **Bulwark version drift.** `/api/auth/impersonate` exists in 1.7.1; if a future install downgrades Bulwark, SSO 404s. Guard: panel-api logs explicit error and surfaces "Login to Webmail" disabled with "Bulwark version too old" tooltip when impersonation env is absent.
- **Rollback:** revert install.sh changes + revert webmail_sso.go to POST flow. Live mailboxes don't lose data (session creation only). `bulwark.env` keeps the orphan vars (no behavior change without the route call).

## Out of scope (deferred follow-ups)

- Stalwart master-user rotation CLI (`jabali admin rotate-bulwark-master`) — manual file edit + restart works today.
- Audit-log surface in admin UI — Bulwark logs to journald already.
- Multi-actor impersonation (Stalwart supports `actor=` claim for "as user X"); we only impersonate-as-mailbox-owner today.
