Files
inbuxa-server/docs/spec/contract.md
T
jcoffey-dev 0ed540f43a Contract spec: inbuxa-server, ihasmail-inbuxa and INBUXA Admin
Discovery and a contract version in the session; front ends configured once
(x:FrontEnds); OAuth with required registration, first-party clients,
server-hosted sign-in and consent for everything else; per-grant revocation;
cross-origin limited to the front ends; an admin lane by scope; push
unchanged. Records what upstream does today, including that it accepts any
client and redirect URI by default, and the phishing that allows.
2026-09-18 12:19:47 -07:00

14 KiB

Contract: inbuxa-server and its front ends

Status: draft, 2026-09-18. Expands SPEC.md §5.2.

Parties

Party What it is How it reaches the server
inbuxa-server The mail server
ihasmail-inbuxa The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) Its Node server calls inbuxa-server, server to server. The browser only ever talks to ihasmail-inbuxa
INBUXA Admin (inbuxa-admin) A static web app, a fork of Stalwart WebUI The browser calls inbuxa-server directly, cross-origin

That split decides most of what follows. Cross-origin rules matter only for INBUXA Admin. Token custody matters most for ihasmail-inbuxa, which holds tokens on its server for people who aren't there.

What upstream does today

Observed in the source at v0.16.22 and against a running inbuxa-server on 2026-09-18. It's shared AGPL code, not Enterprise.

  • OAuth endpoints: discovery at /.well-known/oauth-authorization-server and /.well-known/openid-configuration; the sign-in page at /login, which posts to /api/auth; /auth/token (authorization code with PKCE, device code, refresh token); /auth/device, /auth/introspect, /auth/userinfo; dynamic client registration at /auth/register, which issues "stateless" sealed client ids that carry their own redirect URIs; /api/discover/{user}, which INBUXA Admin uses to find the endpoints.
  • There's no revocation endpoint. Tokens are stateless and sealed, so a token can only be invalidated by changing the account's password.
  • Clients aren't required to be registered, by default. x:OidcProvider.requireClientRegistration defaults to false. With it off, any client_id with any redirect URI is accepted (HTTP ones only with PKCE). With it on, a registered x:OAuthClient must use one of its redirectUris, and an unregistered id is refused unless the person signing in holds oAuthClientOverride.
  • Cross-origin: x:Http.usePermissiveCors (all origins, *) defaults to off, and is forced on in recovery mode, which includes first-boot bootstrap. The OAuth metadata, token and OpenID endpoints answer * regardless. So on a configured server running normally, INBUXA Admin's JMAP calls from another origin are blocked, unless an operator allows every origin.
  • A server with no public URL returns relative OAuth endpoints (/login) and an empty issuer. INBUXA Admin now resolves them against the server's address (fixed 2026-09-18).
  • Endpoint gating: x:Http.allowedEndpoints is an expression that can refuse endpoints by path and client IP. JMAP administration shares /jmap with everything else, so it can't separate admin calls on its own.
  • ihasmail today (public, and so the starting point for ihasmail-inbuxa) signs in with HTTP Basic auth and keeps the password sealed in its session store (sealedCredentials: {username, password}), sending it on every upstream call. It registers JMAP push subscriptions to its own URL, and reads permissions and edition from /api/account.

Requirements

Each has an ID, and tests name the IDs they check.

Discovery and versions

  • C-1. The JMAP session carries a capability urn:inbuxa:jmap (the fork's own namespace, SPEC.md §2.4) with:
    • version: INBUXA's version (2026.9.18);
    • base: the Stalwart release it's built on (0.16.22), absent once the fork no longer tracks upstream;
    • contract: this contract's version, an integer, starting at 1;
    • webmailUrl and adminUrl: where the front ends are, if configured.
  • C-2. Each front end states the contract versions it supports and checks contract after signing in. Outside its range it stops, with a message naming both versions. For ihasmail-inbuxa this replaces public ihasmail's "Stalwart 0.16 or later" check.
  • C-3. A breaking change to anything in this document bumps contract. Adding optional fields doesn't.

The front ends, configured once

  • C-4. A server-level singleton, x:FrontEnds, in the fork's namespace, records webmailUrl, adminUrl, and extraOrigins (a list of further allowed origins). It's the single source for discovery (C-1), the OAuth client registrations (C-6), the cross-origin allowlist (C-14) and the admin lane (C-18). The installer sets it, and so does INBUXA Admin's setup wizard.

Sign-in

  • C-5. INBUXA requires client registration: requireClientRegistration defaults to true. This is a deliberate difference from upstream (see "Security note").
  • C-6. Two first-party clients are registered as x:OAuthClient whenever x:FrontEnds is set or changed:
    • inbuxa-admin: a public client (no secret), authorization code with PKCE S256, redirect URI {adminUrl}/oauth/callback.
    • ihasmail-inbuxa: a confidential client with a secret held by the ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI {webmailUrl}/api/auth/callback. INBUXA Admin's <meta name="oauth-client-id"> is set to inbuxa-admin. Until then it keeps upstream's stalwart-webui, which only works while registration isn't required.
  • C-7. Third-party mail clients (Thunderbird, mobile apps) keep working through dynamic registration (/auth/register), whose stateless client ids carry their own redirect URIs. That's upstream's mechanism and is left as it is.
  • C-8. People sign in on the server's own sign-in page (/login, already INBUXA-branded), never on a front end's form. Two-factor happens there, on the page's existing one-time-code step. Front ends never see a password. ihasmail-inbuxa's own sign-in form is retired in favor of a redirect.
  • C-9. Consent for anything that isn't first-party. When a client other than the two first-party ones asks to sign someone in, the sign-in page names the client and the host its redirect URI goes to ("Thunderbird wants access to your mail, and will return to localhost"). First-party clients skip it. Decision: this is what makes an authorization-code phishing link visible even where registration isn't required.

Tokens

  • C-10. ihasmail-inbuxa holds tokens, never passwords. It keeps the access and refresh token for each session sealed in its session store, where it now keeps sealed credentials, and refreshes the access token before it expires. The browser still holds only ihasmail-inbuxa's own session cookie. Public ihasmail's "the browser never holds a credential" property is kept.
  • C-11. INBUXA Admin holds its tokens in the browser, as upstream WebUI does, since it has no server of its own. So admin tokens are short-lived (C-13).
  • C-12. Revocation, which upstream can't do per token. Each refresh token belongs to a grant: one sign-in by one client on one device, with its own id. The server keeps a list of revoked grant ids until their tokens would have expired anyway, and checks it on every token use and refresh. Revoking is available:
    • per grant, through POST /auth/revoke (RFC 7009, for a token the client holds);
    • for the signed-in account: every grant, or every grant except this one ("sign out other sessions");
    • for an administrator: any account's grants;
    • automatically, on password change (as upstream does today) and on account deletion or suspension. A revoked token stops working on its next use, and never later than one access-token lifetime.
  • C-13. Grants are listed per account (client, device description, created, last used, IP), so ihasmail-inbuxa's "your sessions" screen shows server-side truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days for ihasmail-inbuxa; access tokens 15 minutes and refresh 8 hours for inbuxa-admin.

Cross-origin

  • C-14. Cross-origin requests are allowed only from the origins in x:FrontEnds: adminUrl's origin, webmailUrl's origin, and extraOrigins. The server echoes the matching origin in Access-Control-Allow-Origin with Vary: Origin, never *, and sends nothing for any other origin.
  • C-15. OAuth discovery metadata may stay *, since it's public and read-only. The token, revocation, introspection and userinfo endpoints follow C-14, so a random web page can't exchange or probe tokens.
  • C-16. Bootstrap and recovery mode keep upstream's permissive CORS, since no front ends are configured yet and the admin needs to reach the server. They're also when the recovery administrator applies (SPEC.md §6.2).
  • C-17. x:Http.usePermissiveCors stays available for operators, but turning it on logs a warning at startup saying which endpoints it opens.

The admin lane

  • C-18. Server-level administration (registry objects outside a tenant, x:Bootstrap, x:Action, x:Task, the telemetry and troubleshooting endpoints) requires a token issued to inbuxa-admin. That's the new OAuth scope inbuxa:admin, which only that client is ever granted. An admin account signing in through a mail client can't administer the server with that token, even though the account could.
  • C-19. ihasmail-inbuxa's own administration (accounts, domains, groups, lists, roles, tenants, the dashboard) uses the scope inbuxa:account-admin, granted only to ihasmail-inbuxa, and limited to those object types.
  • C-20. Optionally, x:FrontEnds.adminNetworks (a list of CIDRs) limits where inbuxa:admin requests may come from. Empty means anywhere.
  • C-21. Basic auth never reaches the admin lane outside recovery mode. An administrator who needs a script uses an API key or an app password with an explicit admin scope, which is a separate, auditable credential.

Push

  • C-22. Unchanged from public ihasmail: ihasmail-inbuxa registers JMAP push subscriptions to its own URL, with VAPID for browser notifications. The only difference is that it authenticates with its token rather than the password.

First boot

  1. The installer, or INBUXA Admin's setup wizard, completes bootstrap (SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the recovery administrator applies.
  2. It sets x:FrontEnds (webmail and admin URLs, the public URL), which registers both first-party clients (C-6). For ihasmail-inbuxa it returns the client secret once, for the installer to write into ihasmail-inbuxa's environment.
  3. After the restart out of bootstrap, CORS follows C-14, registration is required (C-5), and the recovery administrator is ignored (SPEC.md §6.2).

Security note: upstream accepts any client by default

With requireClientRegistration off, which is upstream's default, the server issues authorization codes to any client_id and any redirect URI, provided PKCE is used for HTTP redirects. That allows authorization-code phishing:

  1. An attacker sends someone a link to the genuine sign-in page, with the attacker's site as the redirect URI and a PKCE challenge the attacker made.
  2. The person signs in on the real page, correctly, since nothing about it is fake.
  3. The code goes to the attacker's site, and the attacker, holding the PKCE verifier, exchanges it for a working token to the person's mailbox.

INBUXA closes this with C-5 (registration required), C-9 (consent naming the redirect host), C-15 (token endpoint not callable from arbitrary pages) and C-18 (no admin scope for anything but inbuxa-admin).

INBUXA's production server runs upstream Stalwart Enterprise today. Whether requireClientRegistration is on there hasn't been checked. It needs an admin to read x:OidcProvider. Turning it on would break any client that uses OAuth without registering, so check which clients INBUXA's users sign in with before changing it.

Acceptance tests

  1. The session shows urn:inbuxa:jmap with version, base, contract: 1 and the front-end URLs (C-1).
  2. A front end supporting only contract 2 refuses a contract-1 server with both numbers in its message (C-2).
  3. Setting x:FrontEnds registers both clients with the right redirect URIs. Changing adminUrl updates inbuxa-admin's (C-4, C-6).
  4. An unregistered client_id is refused at sign-in. A registered one with a foreign redirect URI is refused (C-5).
  5. The phishing flow in the security note fails at step 1, and a registered third-party client with a non-first-party redirect shows the consent page (C-9).
  6. ihasmail-inbuxa signs in without ever handling a password. Its session store holds tokens only (C-8, C-10).
  7. Revoking one grant stops that session within one access-token lifetime, leaves others working, and "sign out other sessions" keeps the current one (C-12).
  8. A password change revokes every grant (C-12).
  9. A cross-origin request from the admin origin gets that origin echoed. From any other origin it gets no CORS headers, on /jmap and on /auth/token alike (C-14, C-15).
  10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16).
  11. An admin account's token from a third-party mail client can't read x:NetworkListener. The same account through inbuxa-admin can (C-18).
  12. ihasmail-inbuxa's token can manage accounts and tenants but not listeners or certificates (C-19).
  13. With adminNetworks set, an inbuxa:admin request from outside is refused (C-20).
  14. Basic auth to an admin method is refused on a configured server (C-21).

Open questions

  1. Whether x:FrontEnds is a new registry object (a schema addition in the fork's namespace) or kept in the fork's own store. A registry object is editable in INBUXA Admin for free, since the admin is schema-driven.
  2. The consent page's wording and whether it remembers a decision per client.
  3. API keys and app passwords with explicit scopes (C-21): what upstream's x:ApiKey already supports, to observe before specifying.
  4. Whether ihasmail-inbuxa's secret should rotate, and how.