Skip to content

feat(api): let a guild bring its own sign-in - #147

Merged
TheMeinerLP merged 1 commit into
mainfrom
feat/api-guild-oauth
Aug 23, 2026
Merged

feat(api): let a guild bring its own sign-in#147
TheMeinerLP merged 1 commit into
mainfrom
feat/api-guild-oauth

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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/login takes 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.

GET /api/auth/login?guild=acme
      │
      ├─ for_slug("acme") ──▶ guild id + that guild's client
      │                             │
      │        the guild id goes into the console_state row
      ▼                             │
302 to the guild's Outline          │
      ⋮  (the browser, the consent screen, the way back)
      ▼                             ▼
GET /api/auth/callback?code&state ──▶ for_guild(state.guild_id) ──▶ the same client

The seam, which is the whole change

ConsoleAuth held one OAuthClient for the life of the process, which is what made "this guild's provider" unrepresentable. It now holds a SignInClients (sturnus.console.ports) and asks it twice, once at each end of the round trip, from the only thing available there:

class SignInClients(Protocol):
    async def for_slug(self, slug: str) -> GuildSignIn | None: ...
    async def for_guild(self, guild_id: int | None) -> OAuthClient | None: ...

for_slug is asked before the redirect, because the slug is in the URL. for_guild is asked after it, because by then only the state names a guild — StateStore.consume returns a ConsumedSignIn carrying guild_id instead of a bool, and console_state.guild_id is 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 answers None.

The concrete side is GuildSignInClients (sturnus.console.adapters). It builds an OutlineOAuth per 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_slug returns None — and /api/auth/login?guild=… answers 404 {"error": "no such sign-in link"} — for all five of:

  • no guild holds that slug,
  • the slug is not spelled like a slug at all,
  • a guild holds it and has no secret stored yet,
  • it is registered against a provider this deployment cannot exchange with,
  • its secret is wrapped by a master key this process does not hold.

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_mismatch with the guild_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_clients that 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.

Rule Why
3–32 characters short enough to read back over chat; the lower bound keeps a guild off the one- and two-letter segments a deployment wants for itself
lowercase ASCII letters, digits, hyphens it goes into a URL unescaped; a slug an administrator has to percent-encode is a link nobody can read back to them
must begin with a letter a Discord snowflake is digits — /g/1289374650912837465/sign-in and a guild id in a path are the same string to a reader, and they select different things
no leading, trailing or doubled hyphen acme--x and acme-x are one glance apart
not api, auth, login, logout, callback, sign-in, static, session(s), guild(s), console, healthz, readyz, sturnus, assets, well-known a route added later must not be shadowed by a name a guild already claimed — that collision has no resolution afterwards, because the loser is somebody's published link

Nothing is normalised. Acme is 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.

GET answers the slug, the provider, the base URL, the client id, the redirect URI and has_secret. Never the value, not masked, not truncated. That is why this is not a guild_config key: the settings API renders every value it holds straight back to whichever administrator asks. GuildOAuthClient has nowhere to put a secret, ConsoleGuildOAuthClients has 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 https addresses with no userinfo, query or fragment. https://outline.example@evil.example/ names evil.example and reads to a human as the first host; it is the one case where parsing correctly is a security property. provider must be outline, 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 than routes_settings next 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.

/link is deliberately unaffected, permanently

The Discord account-link flow stays on the environment-configured client. api holds the master key and link does not — charts/sturnus/templates/_helpers.tpl refuses to render STURNUS_MASTER_KEY onto that component at all, from a values file as well as from the key list — so link cannot 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, in sturnus.domain.oauth_clients, and in docs/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: api already holds the master key.

Audit

console.oauth_client_changed at 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.

Field What it says
guild_id which guild's sign-in changed
requested_by who changed it
outcome registered, secret_set, secret_cleared, removed

Neither 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.

  1. Register an OAuth application in that guild's own Outline. Its redirect URI is this deployment's console callback (STURNUS_CONSOLE_REDIRECT_URI), unless the guild has a reason to use its own.
  2. Register the client, without its secretPUT /api/guilds/{guild_id}/oauth-client. redirect_uri: null means "this deployment's own callback", which is what nearly every guild wants.
  3. Supply the secretPUT /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.
  4. Hand out …/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_id names 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/secret clears 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-in is 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, or 404 {"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-client 200 with the object below
PUT /api/guilds/{guild_id}/oauth-client 200 with the object below
DELETE /api/guilds/{guild_id}/oauth-client 204, no body
PUT /api/guilds/{guild_id}/oauth-client/secret 200 with the object below
DELETE /api/guilds/{guild_id}/oauth-client/secret 200 with the object below

The 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_id is a string — a snowflake exceeds JavaScript's safe integer range, where a JSON number silently loses its last digits. redirect_uri is 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:

PUT /api/guilds/{guild_id}/oauth-client
{"slug": "acme",
 "provider": "outline",
 "base_url": "https://outline.acme.example",
 "client_id": "acme-client",
 "redirect_uri": null}

PUT /api/guilds/{guild_id}/oauth-client/secret
{"client_secret": ""}

provider defaults to outline and may be nothing else. redirect_uri may 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:

Status error When
400 the sign-in name must be lowercase letters, digits and hyphens slug shape
409 that sign-in name is not available reserved, or another guild's
400 unsupported identity provider provider is not outline
400 the base URL and redirect URI must be https addresses either URL
400 the client id must be a non-empty string
400 the client secret must be a non-empty string
400 malformed request body not JSON, or not an object
404 no sign-in configuration every authorisation refusal, and no client
401 not signed in no session, before anything is read

Checks

2624 passed, rebased onto main at #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 check and ruff format --check clean.

Out of scope, deliberately: no console/Nuxt change, no migration (0013 created guild_oauth_client and console_state.guild_id), no change to /link, and no change to the session cookie's shape.

`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
TheMeinerLP force-pushed the feat/api-guild-oauth branch from f0140d5 to e0f7191 Compare August 23, 2026 14:18
@TheMeinerLP
TheMeinerLP merged commit b831c05 into main Aug 23, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant