Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Auth Parity Plan — Webmail Off amazon-cognito-identity-js, Native Sign-Up, Native Admin Recognition

Context

AWS has announced the end of support for the Amplify JavaScript v5 ecosystem: maintenance mode from 2026-09-01, end of support on 2027-03-01. The notice names amazon-cognito-identity-js explicitly, and that is the one Amplify-ecosystem package in the repository: the React webmail’s entire Cognito surface goes through it (react/admin/src/App.jsx). Nothing else is affected. The browser extension uses the Hosted UI with PKCE, and the Apple and Android clients speak Cognito’s JSON API directly (CognitoAuthService in each kit). After the deadline the library keeps working but receives no fixes, including security fixes.

AWS’s suggested path is Amplify v6 (aws-amplify/auth). This plan takes the other one: drop the library and call the Cognito JSON API directly, as the native clients already do. Every webmail auth operation is one JSON call; the result is a small first-party module with no AWS JavaScript dependency to chase through future end-of-support cycles, and all three clients then share one auth flow. Amplify v6 would also store tokens in localStorage by default, so it would need a custom token store anyway (see decision D2).

Two further goals ride on that parity:

The three workstreams are independent. Only A carries the deadline; B and C build on the native kits’ existing JSON clients and do not wait for A.

Progress

Phase Work item Status
A1 Webmail: Cognito JSON client module + tests Done 2026-10-18 (#2103)
A2 Webmail: port App.jsx flows, drop the dependency Done 2026-10-18 (#2107, #2116)
A3 Promote the auth contract to docs/auth.md Done 2026-10-18
B1 Native kits: sign-up contract fixes (Apple + Android) Done 2026-10-18 (#2131, #2133)
B2 Apple: sign-up, confirm and resend screens Done 2026-10-19 (#2143)
B3 Android: sign-up, confirm and resend screens Done 2026-10-19 (#2145)
B4 Native: recovery-email gate after sign-in Done 2026-10-20 (#2152, #2153)
C1 Native kits: isAdmin from the ID token’s cognito:groups Done 2026-10-18 (#2124, #2126)
C2 Native: first admin feature — Users (approve, disable, delete) Done 2026-10-18 (#2127, #2128)

Tracking issues: A #1950, B #1951, C #1952.

Decisions

D1 — Sign-in flow: USER_PASSWORD_AUTH everywhere. The native clients already use it. The webmail uses the library’s default, USER_SRP_AUTH (App.jsx never calls setAuthenticationFlowType). The pool clients (cabal_admin_client, cabal_mfa_enroll_client) declare the legacy explicit_auth_flows = ["USER_PASSWORD_AUTH"], which permits SRP, password and refresh alike, so no Terraform change is needed for A, and a later move to ALLOW_* values can drop SRP once no client uses it. SRP’s advantage (the password never leaves the browser) is small next to TLS to Cognito, and implementing SRP by hand is the one piece of this work that would justify keeping a library.

D2 — Webmail session persistence: sessionStorage. Decided by the operator 2026-10-18. The refresh token and the username go to sessionStorage; the ID and access tokens stay in memory; nothing goes to localStorage. A reload stays signed in by refreshing; a new tab, or a tab closed and reopened, signs in again (with TOTP).

Why it had to be decided: CLAUDE.md says auth tokens live in a module-level variable in App.jsx and are never written to localStorage. That is true of _token, but the library itself persists the ID, access and refresh tokens to localStorage (CognitoIdentityServiceProvider.<clientId>.* keys, since App.jsx passes no Storage in poolData), and the reload restore at App.jsx:309 (getCurrentUser() + getSession) depends on it: a reload within the refresh token’s 7 days lands signed in. The alternatives were keeping the refresh token in localStorage (today’s UX, with the CLAUDE.md rule corrected) and memory only (every reload signs in again). sessionStorage was chosen because the webmail is the secondary client, and a new tab asking for a sign-in is a modest cost for not keeping a 7-day credential in long-lived storage. A2 rewrites the CLAUDE.md sentence to describe it.

D3 — Native admin recognition is a UI hint only. Clients read the ID token’s cognito:groups claim the way the webmail does (App.jsx:316) to decide what to show. Authorization stays on the server (lambda/api/_shared/admin_limits.py, whole-element matching). A client never sends its own idea of admin-ness anywhere.

D4 — The invitation code is the only sign-up gate. Confirmed by the operator 2026-10-07: an invitation code, then admin approval of the new account. Native sign-up matches the webmail exactly; no new gate, no client-side policy beyond what /config.js advertises.

D5 — Contacts at sign-up: a phone with SMS, an email without. Decided by the operator 2026-10-18, so that Cabalmail can be someone’s only email service. With sms_enabled, the phone (with SMS consent) is required and the recovery email is optional. Without it, the email is required, since it is then the only channel that can verify or recover the account. After sign-in, an account with no email is asked to add one unless SMS is on and its phone is verified; an email the user gave is always offered for verification. The cost is that a phone-only account recovers by SMS alone, so a SIM swap could reset its password; the authenticator suggestion is its defence. The webmail implements it (the PR that recorded this decision), including a recovery-email section in its Security view so a phone-only account can still add an address later, and docs/auth.md carries the rule. B1’s kits take both contacts as optional, so the rule lives in the screens.

The auth contract

Moved to docs/auth.md in A3 (2026-10-18), which also records what each client implements today. B and C are checked against it there.

Phase A — Webmail

A1 — Cognito JSON client module

Status: Done 2026-10-18 (#2103). Nothing imports it yet; A2 wires it in.

A module under react/admin/src/auth/ exposing promise-returning functions for every operation in the contract, plus a token holder that implements the D2 choice and a single-flight refresh. No React in it; Vitest tests against a stubbed fetch for each operation’s request shape and each documented error mapping (invalid invitation code, wrong MFA code, NotAuthorizedException, the require_admin_mfa refusal).

As built (index.js re-exports all of it):

Checked live against the stage pool on 2026-10-18 (no account created): a sign-up with a wrong invitation code maps to isInvalidInvitationCode and signUpErrorMessage words it, and a sign-in for an unknown username maps to isNotAuthorized.

A2 — Port App.jsx, remove the dependency

Status: Done 2026-10-18: #2107, merged to stage only after the batch ahead of it was promoted, so A2 soaks and releases on its own, and #2116 (below). Checked live on 2026-10-18 against the stage pool, with the A2 bundle served at the stage origin and signed in as the second test account: sign-up with a wrong invitation code, forgot password (code sent, not used), a wrong password, the TOTP challenge with a wrong and then the right code, the Security view, a reload restoring without the sign-in form, a new tab starting signed out, the legacy-key purge, an idle tab refreshing before expiry (page clock jumped forward), and sign-out. Neither test account is an admin, so admin views were checked hidden only. Three checks rest on App.test.jsx alone: a valid-code sign-up (it would leave an account nobody here can delete), locked-out enrollment (both test accounts are enrolled), and the admin case.

Replace every CognitoUser / CognitoUserPool / CognitoUserAttribute use in App.jsx with the A1 module. Behaviour is unchanged apart from D1 and D2. On load, also remove the library’s leftover CognitoIdentityServiceProvider.* keys from localStorage: a user signed in before the port still has a 7-day refresh token there, and D2 says none stays. Today’s checkSession signs the user out when the 12-hour ID token expires; the port refreshes instead, which is what the native clients do. Remove amazon-cognito-identity-js from package.json and the lockfile, rewrite the CLAUDE.md token-storage sentence, and add a changed changelog fragment. Verify on stage as the shared tester account: sign-up with and without a valid code, TOTP sign-in, locked-out enrollment, forgot password, the email gate, a reload, an idle refresh, and admin views appearing for an admin only.

As built:

A3 — Promote the contract

Status: Done 2026-10-18.

Move “The auth contract” to docs/auth.md, link it from docs/operations.md, and leave a pointer here. As built, docs/auth.md also covers the pool’s app clients and triggers, the error mapping, where each client keeps its session, and a per-client implementation table. The table’s gaps are B and C. The sign-up wording “pending admin approval” maps to the domain grant: a new account is confirmed at once but can create no addresses until an admin grants it mail domains.

Phase B — Native sign-up

B1 — Kit contract fixes (Apple and Android)

Status: Done 2026-10-18 (#2131 Apple, #2133 Android).

Both signUp implementations (AuthService.swift, CognitoAuthService.kt) need: preferred_username; an invitationCode parameter sent as ValidationData; a return value that distinguishes “code sent to <medium, destination>” from “auto-confirmed, pending approval”; a typed error for an invalid invitation code. The kits’ config models read invitation_required and sms_enabled so the screens can hide the invitation and phone fields when they are not needed. Kit tests on each side pin the request body against the contract. Protocol changes update every conformer, including the shared test doubles.

As built (Apple): signUp(username:password:email:phone:invitationCode:) returns a SignUpResult: .confirmationCodeSent(CodeDelivery) (medium and masked destination) or .confirmed (check_invite confirmed the account itself). email and phone are each sent only when given, so which a deployment asks for is the screens’ call, not the kit’s. A refused invitation code throws CabalmailError.invalidInvitationCode. A refused username stays .server(code: "UserLambdaValidationException", ...) with the trigger’s sentence, which the sign-in form already shows as written. Other Cognito refusals (UsernameExistsException, InvalidPasswordException) pass through as .server, so B2 words them. resendConfirmationCode returns the new code’s CodeDelivery. Configuration reads invitationRequired and smsEnabled with the webmail’s defaults (a literal true, and anything but false). A malformed or missing flag never fails the file, so a cached copy saved before the keys still loads. The new types are in CabalmailKit/Auth/SignUp.swift.

As built (Android, #2133): the same shape in kit/.../auth/SignUp.kt. signUp(username, password, email?, phone?, invitationCode?) returns SignUpResult.ConfirmationCodeSent(CodeDelivery) or SignUpResult.Confirmed; a refused code throws CabalmailException.InvalidInvitationCode, and a refused username stays a ServerError("UserLambdaValidationException", ...) with the trigger’s sentence. resendConfirmationCode returns CodeDelivery?. Config gains invitationRequired and smsEnabled, read through LiteralFlagSerializer (config/Config.kt), so they follow the same literal-true and anything-but-false rules and a malformed flag never fails the file. Every AuthService fake in the kit tests is updated; the app module has none.

B2 / B3 — Sign-up screens

Status: Done: B2 (Apple) 2026-10-19 (#2143), B3 (Android) 2026-10-19 (#2145).

A “Create account” entry on each sign-in screen, using the control domain already typed there. Screens: sign-up form (username, password, invitation code when required, and the contacts D5 sets: with sms_enabled a required phone with consent and an optional recovery email, otherwise a required recovery email), confirm-code with resend, and the pending-approval result. Apple and Android land as separate PRs, Apple first.

SMS consent: native sign-up shows the webmail’s checkbox text word for word, under the existing 10DLC registration with no amendment (decided by the operator 2026-10-18). The text names the operator’s legal name, which the apps read from config.json’s operator_name (#2139, from the OPERATOR_NAME GitHub variable); without it the brand is Cabalmail alone, as in a webmail build without that variable. scripts/tests/test_sms_consent_text.py fails if the Apple text drifts from the webmail’s.

As built (Apple): Create Account on the sign-in form swaps it for the sign-up steps inside the same chrome (CabalmailUI/Session/SignUp/). SignUpModel holds the flow: it loads config.json through the session’s loader and talks to Cognito through a bare CognitoAuthService over an InMemorySecureStore, so sign-up builds no session client and touches no keychain. It normalizes before sending: the username trimmed and lowercased, the phone stripped to E.164 (a leading + and country code are required), the email and invitation code trimmed, and an empty email left out. A disabled Create Account always says what is missing. Cognito’s refusals get their own sentences (SignUpErrorText); a NotAuthorizedException on confirm means the account is already confirmed and counts as created; a rate limit stops Resend for the rest of the screen. The steps draw their buttons through AuthFormActions, which branches on a new HostPlatform.formActionsInDialogRow instead of #if os(...); the password hint names the character classes of Cognito’s default policy, which the pool uses. Done returns to sign-in with the username filled in.

As built (Android): the same flow in app/.../ui/auth/. SignUpRules holds the rules, the normalizing, the consent text and the refusal sentences as plain functions; SignUpViewModel the steps, loading config.json through configService.load as sign-in does and talking to Cognito through a CognitoAuthService over a token store that keeps nothing; SignUpScreen one composable per step, laid out like the sign-in screen. SignedOutScreens swaps sign-in and sign-up: the sign-in form’s typed server and username are held while sign-up shows (a SaveableStateHolder), each Create Account opens a fresh flow, and a rotation keeps the one in progress. SMS consent is a checkbox, unchecked by default, whose whole row toggles it. The kit’s Config gains operatorName, read leniently like the flags, and test_sms_consent_text.py now pins the Android text too.

B4 — Recovery-email gate

Status: Done 2026-10-20 (#2152 Apple, #2153 Android).

Neither native client checks for a verified recovery email after sign-in. A natively created account (B2/B3) with SMS on that gave an email would never be asked to verify it, so this lands with or right after B2/B3. Same advisory rules as the webmail, which D5 sets: no ask to add an email when SMS is on and the phone is verified. Because a phone-only account is then never asked, B4 also adds a recovery-email row to each client’s settings, as the webmail’s Security view has (docs/auth.md, “Recovery email”).

As built (Apple): the Kit gains RecoveryContacts (from GetUser’s attributes) with the D5 rule as promptAfterSignIn(smsEnabled:), and four AuthService calls: recoveryContacts, setRecoveryEmail, sendRecoveryEmailCode and verifyRecoveryEmail. To fit them, the Cognito request and its error mapping moved out of the actor into CognitoWire, which CognitoAuthService calls through. The three new writes have rows in docs/request-replay.md (sent once). In the app, RecoveryEmailModel drives both Settings › Account › Recovery Email and the sheet the check raises; the check runs in the window whose sign-in form completed the sign-in (SignInView.onSignedIn, read by ContentView), so a second window doesn’t raise a second sheet, and a failed GetUser raises nothing. The sheet can be skipped. Making room in SignInView for that callback moved its second-factor step into MfaChallengeForm, unchanged; that step’s two platform conditionals and two cancel-role buttons moved with it, so the allowlist row and the role scan’s entry moved too.

As built (Android, #2153): the kit gains the same RecoveryContacts and RecoveryEmailPrompt and the four AuthService calls, authenticated with an access token refreshed under the same mutex as the ID token. RecoveryEmailViewModel drives Settings › Account › Recovery email (the recovery-email route) and the full-screen dialog MainActivity shows after an interactive sign-in or MFA answer; SignInViewModel runs the check through recoveryEmailPrompt(), which answers null on any failure, so the check can’t turn a sign-in that worked into an error.

Locked-out TOTP enrollment through enrollClientId is the remaining native gap. Android’s config already carries the field. It only matters once an MFA gate is enforced, so it is noted here and not scheduled.

Phase C — Native admin recognition

C1 — isAdmin in the kits

Status: Done 2026-10-18: Apple #2124, Android #2126.

Decode the ID token payload (base64url JSON, no signature check; the token came from Cognito over TLS and D3 makes it a hint), expose isAdmin alongside the existing token observers, and re-derive it on refresh, sign-out and account switch. Kit tests cover the claim renderings admin_limits.is_admin accepts and a group whose name only contains “admin”.

As built (Apple): IdTokenClaims(idToken:) in CabalmailKit/Auth/IdTokenClaims.swift (groups, isAdmin), plus AuthTokens.isAdmin and an AuthService.isAdmin() protocol extension. The extension computes the answer from currentTokens() on every call rather than caching it, so it follows a refresh (a group granted or removed shows up in the next token Cognito issues), a sign-out, and an account switch (an ended session reads no tokens), with no new protocol requirement for the test doubles. Nothing in the app reads it yet. C2 decides where the admin views appear and reads it at session start. TestFixtures.idToken(claims:) builds unsigned test tokens.

As built (Android, #2126): the same shape in kit/.../auth/IdTokenClaims.kt. AuthTokens.isAdmin is an extension property, so the serialized token format is untouched, and AuthService.isAdmin() is an extension function, so the app’s fakes gain no interface member. testIdToken(claimsJson) builds test tokens.

C2 — First admin feature: Users

Status: Done 2026-10-18: Apple #2127, Android #2128.

The webmail’s Users view on native: list users (/list_users), approve (/confirm_user), enable/disable, delete, and per-user domain access (/list_user_domain_access, /set_user_domain_access). It pairs with B: someone signs up on a phone and an admin approves them from theirs. Shown only when isAdmin; a 403 from the server is treated as the authority. The other admin views (all-user Addresses, DMARC, CAA) are later work and out of scope here.

As built (Apple):

As built (Android, #2128): the same screens and guards, in ui/admin/.