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.
This commit is contained in:
+4
-1
@@ -336,6 +336,9 @@ which this design exists to avoid.
|
||||
|
||||
### 5.2 The contract between them
|
||||
|
||||
The full contract is in `contract.md` (drafted 2026-09-18). What follows is
|
||||
the summary it expands.
|
||||
|
||||
Versioned, and advertised in the JMAP session so either side can check it.
|
||||
|
||||
- **Discovery.** The server's session names its webmail URL and the contract
|
||||
@@ -510,7 +513,7 @@ before anything that could prompt that notice happens.
|
||||
|
||||
## 8. Open decisions
|
||||
|
||||
- The INBUXA fork of ihasmail: its name, its repository, and how it tracks
|
||||
- The INBUXA fork of ihasmail is **ihasmail-inbuxa** (named 2026-09-18). Open: its repository, and how it tracks
|
||||
public ihasmail (§5).
|
||||
- Product name: whether the shipped product is called inbuxa-server or
|
||||
something else inside the INBUXA brand.
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user