feat(api): let a guild bring its own sign-in - #147
Merged
Conversation
`GET /api/auth/login` takes no parameters and reads no cookie, so it
cannot choose a guild's OAuth client from an identity it does not have
yet. `?guild={slug}` puts the guild in the URL, the state row carries it
across the round trip, and the callback reads it back -- so the state,
and nothing a caller sends, selects the client for the code exchange.
`ConsoleAuth` stops holding one `OAuthClient` and holds a `SignInClients`
instead, asked once at each end of the round trip. A sign-in with no
guild is unchanged and resolves the environment-configured client, which
a test pins.
Administrators of a guild register, re-register and remove its client
through five routes, and supply its secret through a request of its own.
The secret is wrapped to the guild and to its purpose, and there is
nowhere in the read model to put it: `GET` answers `has_secret` and never
a value. Every refusal on the sign-in path is one 404, so an unknown slug
and a half-configured one stay indistinguishable.
The Discord account-link flow stays on the environment-configured client:
`api` holds the master key and `link` does not, and the chart refuses to
render one onto it.
TheMeinerLP
force-pushed
the
feat/api-guild-oauth
branch
from
August 23, 2026 14:18
f0140d5 to
e0f7191
Compare
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A guild can now sign its people in against its own Outline, reached through a link that carries the guild in the URL. A deployment that configures none of this behaves exactly as it did in v0.15.0 — there is a test that says so, and it is the first thing to read if anything below looks like it changes the ordinary sign-in.
🥚 The chicken-and-egg problem, and the answer taken by the repository owner
GET /api/auth/logintakes no parameters and reads no cookie — there is no session yet, that is what login is for. To choose a guild's OAuth client it needs the guild; to learn the guild it needs an identity; to get an identity it must already have chosen a client.docs/superpowers/specs/2026-08-23-sturnus-phase-2.md§2.2 records the decision: a guild-specific sign-in link. The guild goes in the URL, which is the only place it can be before the round trip starts.The rejected alternative was a public page listing every guild Sturnus serves, so somebody could pick theirs. It discloses which organisations use this service to anyone, signed in or not. An administrator distributes their guild's link themselves, and keeping that disclosure closed is the property most of the decisions below exist to protect.
The seam, which is the whole change
ConsoleAuthheld oneOAuthClientfor the life of the process, which is what made "this guild's provider" unrepresentable. It now holds aSignInClients(sturnus.console.ports) and asks it twice, once at each end of the round trip, from the only thing available there:for_slugis asked before the redirect, because the slug is in the URL.for_guildis asked after it, because by then only the state names a guild —StateStore.consumereturns aConsumedSignIncarryingguild_idinstead of abool, andconsole_state.guild_idis what it reads. Nothing in the callback URL names a guild, so no caller can ask this process to spend one guild's client secret on a code issued by another guild's provider.for_guild(None)is the environment-configured client and never answersNone.The concrete side is
GuildSignInClients(sturnus.console.adapters). It builds anOutlineOAuthper sign-in rather than caching one per guild: a cached client holds a live credential and goes on completing sign-ins against a secret an administrator revoked for as long as it fails to notice. The cost is one indexed read and one unwrap per redirect.Everything unusable is one answer, and that is the point
for_slugreturnsNone— and/api/auth/login?guild=…answers404 {"error": "no such sign-in link"}— for all five of:Byte for byte, including a 400-vs-404 split for a malformed slug, which would otherwise have told an attacker which of the names they tried were well-formed but unregistered.
?guild=with an empty value is a slug that resolves to nothing, not "no guild" — treating it as the latter would substitute the deployment's own client on a link that was meant to select a guild's.The operator's way to tell the five apart is the log, not the response: a rotation that was not carried through emits
key.id_mismatchwith theguild_id.What may be a slug
It is a public path segment that selects a credential, so the rule is a pure function in
sturnus.domain.oauth_clientsthat both the sign-in path and the write path call — one rule, read from both ends, because two copies of it is how a login comes to look up something the write path would have refused./g/1289374650912837465/sign-inand a guild id in a path are the same string to a reader, and they select different thingsacme--xandacme-xare one glance apartapi,auth,login,logout,callback,sign-in,static,session(s),guild(s),console,healthz,readyz,sturnus,assets,well-knownNothing is normalised.
Acmeis refused, not lowercased: a slug quietly rewritten on the way into the table is a slug the administrator does not recognise in the link they handed out. A test asserts every reserved name would otherwise have been legal, so an entry that stops doing work is visible rather than decorative.The secret: bound to its row, and never sent back
The wrap already passes
secret_context("oauth-client", guild_id)as AAD, so a wrapped secret is bound to the guild and the purpose. This branch adds the two directions the store's tests did not have — B→A between guilds, and an OAuth secret planted in that same guild's export-target row — so both bindings are pinned symmetrically rather than in whichever direction happened to be written first.GETanswers the slug, the provider, the base URL, the client id, the redirect URI andhas_secret. Never the value, not masked, not truncated. That is why this is not aguild_configkey: the settings API renders every value it holds straight back to whichever administrator asks.GuildOAuthClienthas nowhere to put a secret,ConsoleGuildOAuthClientshas no method that returns one, and the test that pins it greps the raw text of every response the four endpoints can produce rather than checking a field name — a field nobody thought of is exactly how one would escape.Base URL and redirect URI must be
httpsaddresses with no userinfo, query or fragment.https://outline.example@evil.example/namesevil.exampleand reads to a human as the first host; it is the one case where parsing correctly is a security property.providermust beoutline, because storing a registration nothing here can exchange with would produce a guild whose link is permanently and silently broken — and indistinguishable from an unknown slug, so the administrator would never learn why.Every refusal on the configuration routes is also one 404
Not administering the guild, no such guild, and no client configured all answer
404 {"error": "no sign-in configuration"}. That is stricter thanroutes_settingsnext door, which answers 403 for a guild somebody does not administer, and the difference is deliberate: whether a guild has its own sign-in is the fact this design keeps undiscoverable, and a refusal that distinguished "not yours" from "not configured" is a two-request map of which guilds run their own.The one place a uniqueness answer is unavoidable is registration — an administrator cannot be told why their chosen name failed otherwise. So a reserved name and a name another guild already holds are one refusal,
409 {"error": "that sign-in name is not available"}, and which of the two it was cannot be read off the reply./linkis deliberately unaffected, permanentlyThe Discord account-link flow stays on the environment-configured client.
apiholds the master key andlinkdoes not —charts/sturnus/templates/_helpers.tplrefuses to renderSTURNUS_MASTER_KEYonto that component at all, from a values file as well as from the key list — solinkcannot unwrap a guild's secret and must not be given the ability to.That asymmetry is the architecture rather than a gap in it: it is what keeps the internet-facing link process unable to decrypt anything. It is now said out loud in
GuildSignInClients, insturnus.domain.oauth_clients, and indocs/operations.md§6.2.12, so that "fixing the asymmetry" later requires somebody to argue against three places that say why not.No chart change is in this branch, and none is needed:
apialready holds the master key.Audit
console.oauth_client_changedat WARNING — a level above the settings writes next door, because this is the credential that decides who gets a session at all: whoever controls the identity provider a slug points at controls who this console believes is signing in.guild_idrequested_byoutcomeregistered,secret_set,secret_cleared,removedNeither half of the credential is on the line. The secret is obvious; the client id is left off because it is one half of a pair, and a retained, Grafana-readable log is not the place to narrow the other half's blast radius by one guess. It is in the table, readable by an administrator of that guild, which is where it belongs. A refused change logs nothing — there is nothing to record, and an audit line per refused attempt would let anybody with a session write into an operator's log by guessing guild ids.
What an operator must do to configure a guild
docs/operations.md§6.2.12, in this order — step 4 is last for a reason.STURNUS_CONSOLE_REDIRECT_URI), unless the guild has a reason to use its own.PUT /api/guilds/{guild_id}/oauth-client.redirect_uri: nullmeans "this deployment's own callback", which is what nearly every guild wants.PUT /api/guilds/{guild_id}/oauth-client/secret. A separate request, and the only one that ever carries a credential: it is what makes "save the registration" demonstrably unable to, and what lets an administrator correct a base URL without re-typing a secret they may no longer have.…/api/auth/login?guild={slug}. Until step 3 the link answers exactly as an unknown one does, so distributing it earlier looks to its recipients like the guild does not exist.After a master-key rotation, re-do step 3 for every guild with a client: the secret is wrapped like an audio data key and its
encryption_key_idnames the key that wrapped it, so a rotation that leaves the old key behind makes that guild's link stop working — silently from outside, loudly in the log.If a secret leaks,
DELETE …/oauth-client/secretclears it and leaves the registration in place. The link stops working immediately and nobody else can claim the slug in the meantime, which deleting the whole registration would allow.For the console, since no Nuxt change is in this PR
?guild=is the parameter;/g/{slug}/sign-inis the page the console will serve, and this branch does not add it.Sign-in
GET /api/auth/login— unchanged. 302 to the environment-configured client.GET /api/auth/login?guild={slug}— 302 to that guild's authorize URL, or404 {"error": "no such sign-in link"}. Render a 404 as "this link does not work" and never as "no such guild".GET /api/auth/callback?code&state— unchanged from outside.400 {"error": "unknown or expired sign-in attempt"}now also covers a registration deleted while somebody was at the consent screen; both mean "start again".Configuration — all five behind a session, all five
404 {"error": "no sign-in configuration"}for any guild the caller does not administer or that has no client.GET /api/guilds/{guild_id}/oauth-clientPUT /api/guilds/{guild_id}/oauth-clientDELETE /api/guilds/{guild_id}/oauth-clientPUT /api/guilds/{guild_id}/oauth-client/secretDELETE /api/guilds/{guild_id}/oauth-client/secretThe one response shape, from all four that have a body:
{ "guild_id": "4711", "oauth_client": { "slug": "acme", "provider": "outline", "base_url": "https://outline.acme.example", "client_id": "acme-client", "redirect_uri": null, "has_secret": true, "created_at": "2026-08-21T12:00:00+00:00", "updated_at": "2026-08-21T12:00:00+00:00" } }guild_idis a string — a snowflake exceeds JavaScript's safe integer range, where a JSON number silently loses its last digits.redirect_uriis present and null rather than absent, so "the deployment's own callback" and "an API that does not send this field" are never the same thing to guess at.The two request bodies:
providerdefaults tooutlineand may be nothing else.redirect_urimay be omitted or null. Refusals, all with a fixed reason string and never an echo of what was refused — one of the values these handlers parse is a client secret:errorthe sign-in name must be lowercase letters, digits and hyphensthat sign-in name is not availableunsupported identity providerprovideris notoutlinethe base URL and redirect URI must be https addressesthe client id must be a non-empty stringthe client secret must be a non-empty stringmalformed request bodyno sign-in configurationnot signed inChecks
2624 passed, rebased ontomainat #145 (baseline there: 2483 — 64 domain tests for the slug and URL rules, 42 configuration-route tests, 15 sign-in route tests, 18 adapter tests against the real database, 2 more on the secret's binding).mypy,ruff checkandruff format --checkclean.Out of scope, deliberately: no console/Nuxt change, no migration (0013 created
guild_oauth_clientandconsole_state.guild_id), no change to/link, and no change to the session cookie's shape.