README: document SSO-only, and correct two claims that stopped being true

The account-linking paragraph still described the rule that caused the account
takeover — "an account with no password is re-pointed at whichever provider
authenticated it" — which has not been true since the confinement fix. And the
discovery endpoint no longer answers with a bare boolean; it also says whether SSO is
required, which is what lets the login page hide the password field.

Adds the "Requiring single sign-on" section: what it does, that instance-wide
providers are refused too (a side door, not a convenience), that removal needs a
platform admin, why the approval email carries no link, why platform_admin is exempt,
and that the approval queue becomes an availability dependency.

Also states the resolution order plainly — instance-wide is the default, an
organization overrides only its own verified domains — and removes a duplicated
paragraph about claim expiry left over from an earlier edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
This commit is contained in:
ScreenTinker 2026-08-10 22:24:31 -05:00
parent 0e8ffa5444
commit 751d5343da

View file

@ -373,9 +373,15 @@ and keys are discovered from it and cached.
**Account rules.** A provider must assert a verified email, because the whole account model keys on
it. An SSO login never takes over an existing account that has a password — the owner signs in
locally and links from Settings. An account with no password is re-pointed at whichever provider
authenticated it. If the provider's stable subject (`sub`) changes for an address, the login is
refused rather than handing over an account to a recycled mailbox.
locally and links from Settings. If the provider's stable subject (`sub`) changes for an address, the
login is refused rather than handing an account to a recycled mailbox.
An account established by one provider is **not** adopted by another. A per-organization provider may
only claim an account that its own organization established, or a `local` account that has never set
a password (an invited user signing in for the first time); anything else is refused with
`account_exists_other_provider`. The earlier rule — "any account without a password may be re-pointed
at whichever provider spoke last" — was safe only while the operator chose every provider, and became
an account-takeover primitive the moment customers could add their own.
⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this
flow, matching the long-standing behaviour of the SSO and API-token paths.
@ -389,10 +395,16 @@ An organization can also bring **its own** identity provider, configured by an o
**Settings → Single sign-on**. No environment variable or restart is involved.
A per-org provider is **never listed publicly**. It appears only when someone types an email address
at one of that organization's domains, at which point the login page offers a generic
"Continue with single sign-on" button. The domain lookup answers with a boolean and nothing else —
no provider name, no slug — so a guessed domain cannot confirm who a customer is, and the mapping
back to a provider happens server-side on submit. Both endpoints are rate limited.
at one of that organization's **verified** domains, at which point the login page offers a generic
"Continue with single sign-on" button. The domain lookup answers only whether that domain uses SSO
and whether it is required — never a provider name or slug — so a guessed domain cannot confirm who
a customer is, and the mapping back to a provider happens server-side on submit. Both endpoints are
rate limited.
**Instance-wide is the default; an organization overrides only its own verified domains.** Type an
address whose domain no organization has verified and you get the local password form plus every
instance provider you configured. Type one that an organization has verified and its own button is
added — and if that organization requires SSO, it becomes the only option.
Each provider gets a randomly generated redirect URI, shown in Settings, which the admin registers
with their identity provider:
@ -440,21 +452,18 @@ describe a check that can never pass.
whoever controls its target prove the domain, turning an ordinary subdomain takeover into control of
every `@example.com` login. A delegated proof name is refused, which is stricter than ACME's dns-01.
**An unverified claim lapses, and lapsing RELEASES it.** Pressing Verify on an expired claim does
not reissue it in place — that renewed the clock, so one request per window held a domain forever.
The claim is released, the domain is free for anyone else, and re-adding it is a new claim: new
token, and the operator is notified again. Squatting is not impossible; it is loud.
**An unverified claim lapses after 8 hours, and lapsing RELEASES it.** Pressing Verify on an
expired claim does not reissue it in place — that renewed the clock, so one request per window held
a domain forever. The claim is released, the domain becomes free for anyone else, and re-adding it
is a new claim: new token, and the operator is notified again. A verified domain never expires;
re-proving on a timer would log a customer out over a DNS edit made months afterwards. Squatting is
not made impossible — it is made loud.
**Deleting a provider releases its domains and returns its accounts to local sign-in**, so the
organization can re-claim its own domain and its people can recover by password reset. Both used to
be stranded: a verified domain row outlived its provider and blocked that domain for everyone
permanently, and its users could neither sign in nor reset.
**An unverified claim lapses after 8 hours**, and lapsing rotates the token. This is what stops
squatting: a tenant cannot type a company's domain and hold it against the real owner, and a record
left in DNS from an abandoned attempt cannot satisfy a later claim. A verified domain never expires —
re-proving on a timer would log a customer out over a DNS edit made months afterwards.
Platform admins are emailed whenever a domain is claimed. Verification is what makes an unowned
claim worthless; the notification is what makes an attempt visible. Nothing is ever sent to the
claimed domain itself — that would let any tenant make this product email third parties.
@ -468,6 +477,47 @@ Signing in through an organization's provider makes the user a member of that or
demotes anyone. Client secrets are optional (PKCE), and are stored AES-256-GCM encrypted and never
returned by the API.
### Requiring single sign-on
An organization can turn off password sign-in for its verified domains, so its identity provider is
the only way in — which is the point of buying SSO: the IdP holds the MFA, the conditional access
and the instant removal of access, and a password box beside it is a way around all three.
Settings → Single sign-on → **Require single sign-on**. It needs at least one verified domain, so an
organization cannot leave its own people with no way to sign in, and cannot switch off passwords for
a domain it merely typed.
When it is on:
- the login page **hides** the password field for those domains rather than letting someone type a
password that is going to be refused and then send them to reset it;
- `POST /api/auth/login` refuses with `403 sso_required` — distinguishable from a wrong password,
because the page must not tell a user to fix a credential that is not the problem;
- **every other identity provider is refused too**, including the instance's own Google or
Microsoft. Those belong to the operator and are not domain-restricted, so leaving them available
would be a side door straight past the customer's MFA — blocking passwords while leaving
"Continue with Google" is not requiring single sign-on, it is renaming the bypass.
**Turning it off is a request, not a switch.** That direction re-opens password sign-in, so it is
the direction an attacker who has taken an org admin would take, and it is also what a customer will
demand at their worst moment — identity provider down, nobody can work — which is exactly when a
self-service toggle gets flipped without thinking. The org admin files a request; a **platform admin
approves it**, and nothing changes until they do.
The approval email deliberately carries **no action link**. A token that acts on its own would turn
every forwarded, archived or auto-previewed copy of that message into a way to switch off a
customer's single sign-on. The decision is made signed in, under Admin.
⚠️ **`platform_admin` is exempt from enforcement, and that exemption is load-bearing.** The operator
is who approves removal. If the operator's own address sat at an SSO-only domain and that identity
provider broke, nobody could sign in to approve anything and the instance would be bricked with no
way out. It is the break-glass — it applies to the people running the server, never to a customer's
own admins.
⚠️ **This makes the approval queue an availability dependency.** An organization whose IdP breaks is
locked out until an operator acts. That is the intended trade — deliberate friction on the dangerous
direction — but it should be a decision, not a surprise.
#### Email (Microsoft Graph or SMTP)
Email powers offline alerts, welcome/signup mail, admin notifications, and password reset. Two interchangeable transports are supported, selected by `EMAIL_TRANSPORT`: