"EMAIL Stalwart authbridge plugin — JMAP/IMAP/SMTP auth delegates to AUTH JWT validation + per-tenant mailbox scoping"
§1 — Description (BCP-14 normative)
The EMAIL service MUST ship Stalwart authbridge plugin at services/email/src/authbridge/ validating TASK-AUTH-004 JWTs against per-tenant mailbox scope, with 60s Redis cache, TASK-PORTAL-004 SCIM revoke cascade, 6-outcome enum, and 4 memory audit kinds.
- MUST define closed
auth_outcomeenum:('success','jwt_invalid','jwt_expired','jwt_wrong_audience','subject_revoked','mailbox_unauthorized')per DEC-1463. Cardinality 6.
- MUST expose
POST /v1/email/auth(Stalwart HTTP auth backend) body{ username, password, protocol }. Handler:
- Treats
passwordfield as JWT per DEC-1461. - Validates JWT against TASK-AUTH-004 issuer + JWKS.
- Validates
usernamematches JWT's subject_id @ tenant. - Checks Redis cache; hit → return cached outcome.
- Else verify + cache + return.
- Returns 200 +
{ outcome, mailbox_path, permissions }for Stalwart enforcement.
- MUST resolve mailbox path per DEC-1462:
subject_id@tenant_slug.cyberos.world. Per-Engagement aliases possible via TASK-PORTAL-002 CNAME mapping.
- MUST cache validated JWTs 60s per DEC-1464. Redis key =
email_auth:{jti}; value ={ outcome, mailbox_path, permissions, exp }.
- MUST subscribe to TASK-PORTAL-004 SCIM revoke NATS events per DEC-1465. Consumer invalidates Redis cache for subject + emits
email.auth_failedreason='subject_revoked' for next attempt.
- MUST define
email_auth_logat migration0001:(id BIGSERIAL, tenant_id UUID, subject_id UUID, protocol TEXT, outcome auth_outcome, source_ip_hash16 TEXT, ts TIMESTAMPTZ DEFAULT now()). Append-only.
- MUST emit 4 memory audit kinds per DEC-1466. PII-scrub source_ip via TASK-MEMORY-111.
- MUST thread trace_id end-to-end.
- MUST NOT cache JWT > 60s per DEC-1464.
- MUST NOT allow cross-tenant mailbox per DEC-1462.
§2 — Why this design (rationale)
Why JWT-as-password (DEC-1461)? IMAP/SMTP clients expect username/password; JWT in password field = client-transparent SSO without new protocols. Industry pattern (Google App Passwords replaced by OAuth, but base auth still username/password mechanism).
Why 60s cache (DEC-1464)? IMAP IDLE keeps connection open + re-auths frequently. Per-request JWT verify = ~5ms × 100 req/s = 500ms/s CPU; 60s cache = ~99% hit rate.
Why SCIM cascade (DEC-1465)? Without it, JWT exp window (8h IdP-auth) is the only revocation timeline. Cascade brings it to <60s.
§3 — API contract
-- 0001_email_auth_log.sql
CREATE TYPE auth_outcome AS ENUM ('success','jwt_invalid','jwt_expired','jwt_wrong_audience','subject_revoked','mailbox_unauthorized');
CREATE TABLE email_auth_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID,
subject_id UUID,
protocol TEXT NOT NULL CHECK (protocol IN ('imap','smtp','jmap','managesieve')),
outcome auth_outcome NOT NULL,
source_ip_hash16 TEXT,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
trace_id CHAR(32)
);
CREATE INDEX idx_auth_log_subject ON email_auth_log(subject_id, ts DESC);
ALTER TABLE email_auth_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY email_auth_log_rls ON email_auth_log
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON email_auth_log FROM cyberos_app;
Stalwart backend HTTP contract: POST /v1/email/auth returns { outcome, mailbox_path, permissions: [...] }.
§4 — Acceptance criteria
- auth_outcome cardinality 6.
- Valid JWT → success + mailbox_path returned.
- Expired JWT → jwt_expired + no cache.
- Wrong audience → jwt_wrong_audience.
- Username mismatch → mailbox_unauthorized.
- 60s cache hit — second request within 60s returns cached.
- Cache TTL respected — at T+61s, re-validates.
- SCIM revoke invalidates cache — NATS event → next request 'subject_revoked'.
- 4 memory audit kinds emitted.
- Per-tenant mailbox path correct —
subj@tenant.cyberos.worldformat. - Cross-tenant attempt denied — subject from tenant A trying tenant B mailbox → mailbox_unauthorized.
- Source IP PII-scrubbed.
- Trace_id end-to-end.
- Stalwart integration smoke — real IMAP login via Stalwart succeeds.
- Redis unavailable fallback — falls through to per-request JWT verify.
- JWKS rotation handled — TASK-AUTH-004 key rotation propagates.
- Protocol enum validated — unknown protocol → 400.
- Non-JWT password rejected — random string → jwt_invalid.
- RLS denies cross-tenant log read.
- Audit on every outcome.
§5 — Verification
#[tokio::test]
async fn valid_jwt_returns_success() {
let ctx = TestContext::new().await;
let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
let r = ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
assert_eq!(r.status(), 200);
let body: serde_json::Value = r.json().await.unwrap();
assert_eq!(body["outcome"], "success");
assert_eq!(body["mailbox_path"], "alice@acme.cyberos.world");
}
#[tokio::test]
async fn cache_hit_within_60s() {
let ctx = TestContext::new().await;
let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
let metrics_before = ctx.jwt_verify_count();
ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
let metrics_after = ctx.jwt_verify_count();
assert_eq!(metrics_after, metrics_before); // cached, no re-verify
}
#[tokio::test]
async fn scim_revoke_invalidates() {
let ctx = TestContext::new().await;
let jwt = ctx.mint_jwt(ctx.subject_id, ctx.tenant_id).await;
ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
ctx.publish_scim_revoke_event(ctx.subject_id).await;
tokio::time::sleep(Duration::from_millis(200)).await;
let r = ctx.post_auth("alice@acme.cyberos.world", &jwt, "imap").await;
let body: serde_json::Value = r.json().await.unwrap();
assert_eq!(body["outcome"], "subject_revoked");
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-EMAIL-001, TASK-AUTH-004. Cross-module: TASK-PORTAL-004 (SCIM cascade), TASK-AI-003, TASK-MEMORY-111.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| JWKS unreachable | timeout 5s | Sev-2; serve from cached JWKS | AUTH recovery |
| Redis unavailable | error | Fallback per-request verify; sev-3 | Redis recovery |
| JWT clock skew | exp check tolerance ±60s | Inherent | NTP |
| Stalwart auth timeout | 10s default | Sev-2 | Stalwart investigation |
| Cross-tenant attempt | username check | mailbox_unauthorized | Inherent |
| SCIM cascade delay | NATS lag | Sev-3; cache TTL covers worst case | NATS recovery |
| Same JWT replay > 60s past exp | exp check | jwt_expired | Inherent |
| Cache poisoning | Redis ACL | Inherent | Redis isolation |
| Protocol enum unknown | check | 400 | Stalwart config fix |
| Per-request rate spike | rate limit | 429 | Inherent |
§11 — Implementation notes
§11.1 Stalwart auth.backend.type = http configured to point at /v1/email/auth. §11.2 JWT-as-password tested with real IMAP clients (Apple Mail, Thunderbird). §11.3 Per-tenant mailbox isolation enforced by Stalwart given correct mailbox_path return. §11.4 Redis cache pruned at TTL automatically. §11.5 Source IP hashed before persist; never raw IP in audit chain.
End of TASK-EMAIL-002 spec.