Per-organisation OIDC SSO with identifier-first login
Signing in through each organisation's own identity provider, behind an email-first login, when the admins who configure that provider cannot be trusted.
An organisation on the platform can send its people through its own identity provider, over OpenID Connect, and the login page decides where each person goes from their email alone. The provider's token is read once, during the callback, and then the platform issues its own session, the same one a password sign-in gets. Rotation, revocation and the blocklist therefore apply to an SSO session unchanged, and nothing downstream knows which way someone signed in.
Email first, then the route#
The login page asks for an email and nothing else. A public endpoint looks up whether that address belongs to a domain an organisation has put behind its provider, and the page either reveals the password field, focused and ready, or redirects to the provider. An address nobody knows gets the same answer as an address with a password, so the endpoint cannot be used to find out which accounts exist. The lookup matches the domain in SQL rather than loading every provider and comparing in Python, since it answers anyone who asks.
Before any of this, the code that decoded a session token had been copied into four places: the request dependency, the refresh and logout routes, and the WebSocket handshake. It moved into one module, which is where the only new behaviour went, a small optional tolerance for clock drift against an external provider. The same work closed a gap: the WebSocket handshake now demands an access token, as the HTTP path always did, and refuses a refresh token.
Self-serve means the provider is untrusted#
Organisation admins configure their provider themselves, and that is deliberate. It also means the issuer, its keys and the email domains are all chosen by a customer, so everything the provider asserts is input from a party that may be hostile, and the design follows from treating it that way.
The first version got one part of this wrong, and the review caught it before it merged. When a provider asserted an email, the platform matched an existing account by that email across every organisation. An admin pointing their organisation at a provider they control could therefore have asserted the address of someone in another organisation and been handed that person's session. Provisioning is now bound to the organisation: an existing account is linked only when it is already a member of the organisation whose provider vouched for it, and attaching someone from another organisation as a member is not something an organisation admin can do. Creating a brand-new account on first sign-in is off unless the organisation opts in, and needs a verified email.
The role a provider hands its new members had the same shape of problem. It was a setting the admin wrote, applied with system privileges on every sign-in, so it could have granted any role at all. It now goes through the escalation guard that already limits manual role assignment: an admin can make new members anything they could grant by hand, and nothing more.
Every fetch is a request someone else chose#
The issuer is a URL an admin types, and the token and key endpoints are discovered from it, so all three are addresses the server fetches on a customer's say-so. That is the textbook setup for a server-side request forgery. Every outbound call goes through one guard that requires HTTPS and refuses private, loopback, link-local and reserved addresses, which covers a cloud metadata endpoint and the network the API sits in. Checking the name is not enough on its own, because the check and the connection would resolve it twice, and a record can answer differently the second time. So the name is resolved once, the connection is pinned to the address that was checked, and TLS still verifies the certificate against the hostname. Redirects are not followed.
The discovery document is checked against the issuer that was configured, and a document naming a different issuer is refused. Keys are cached per issuer, and when a token arrives signed by a key the cache does not know, the key set is fetched once more before the token is refused, so a provider rotating its keys does not break sign-in for an hour.
The flow's own guarantees#
The authorisation request uses PKCE and a nonce, and the state that ties the callback to the request is single-use, consumed with an atomic get-and-delete so a replayed callback finds nothing. Single use stops a replay but not a login CSRF, where someone starts a flow and gets a victim to finish it, landing the victim in the attacker's account. The state is therefore also bound to the browser that started the flow, through a short-lived cookie the callback compares before issuing anything.
Two failure policies sit side by side on purpose. If the cache holding that state is unreachable, sign-in fails closed with a 503 and a retry delay, because validating state is a security control and skipping it is not an option. The per-address rate limits on the public endpoints fail open, because a cache blip should not lock everyone out. The provider's client secret is encrypted at rest, can be written but never read back, and the sign-in logs mask email addresses.
Failures a person can act on#
The callback is a navigation in the browser, not an API call, so a failure there cannot answer with a JSON error. Every terminal failure redirects to the login page with one of three coarse reasons: something went wrong and a retry may help, the attempt expired, or access was refused and the organisation's admin is the person to ask. Refused covers every case where an account cannot be used, including one that belongs elsewhere and one that does not exist, so the reason tells an attacker nothing about accounts outside their own organisation.
One domain, one provider#
Two organisations can claim the same email domain, and the login page has to pick one. My first answer was a check in the application before enabling a provider, which two concurrent requests could both pass. It became a constraint in the database: each claimed domain is mirrored as its own row, and a partial unique index allows only one enabled provider per domain, so the race cannot be won twice. A conflicting change comes back as a readable error rather than a server error.
The work went through several rounds of review against the same rubric the pull request check uses before it merged, and the SSO code now triggers that check's deeper security pass on every change that touches it. SAML has a place reserved in the provider layer, so it can be added later without touching anything that calls it.