From c5aae0614ac779800bb97a161768e47f2d6bb91b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:09:13 +0100 Subject: [PATCH] docs: design for multiple auth providers --- .../2026-08-03-multi-auth-providers-design.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-multi-auth-providers-design.md diff --git a/docs/superpowers/specs/2026-08-03-multi-auth-providers-design.md b/docs/superpowers/specs/2026-08-03-multi-auth-providers-design.md new file mode 100644 index 0000000..c96a505 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-multi-auth-providers-design.md @@ -0,0 +1,271 @@ +# Multiple auth providers + +Date: 2026-08-03 + +## Problem + +An instance can configure exactly one OIDC provider. `instance_oidc` holds one +document per instance, `/auth/oidc/start` takes no argument, and `/login` +renders an unconditional "Sign in with your instance's SSO" button whether or +not anything is configured behind it. Customers who federate with more than one +identity source cannot, and customers who federate with none are shown a button +that leads to an error. + +## Goals + +- N auth providers per instance, each independently enabled and named. +- Login page renders one button per enabled provider, and none when there are + none. +- Local email/password login can be turned off per instance. +- Presets for the common identity providers, so a customer supplies a tenant ID + rather than an issuer URL. +- Existing configured SSO keeps working across the upgrade with no customer + action. + +## Non-goals + +- SAML. Different protocol, metadata parsing and certificate handling; not in + this work. +- Per-provider role or group mapping. Provisioned users remain `member`, as + today. +- Provider-specific account linking. An email address is an email address; the + existing instance-scoped lookup stands. + +## Data model + +New collection `auth_providers`, one document per provider: + +```go +type AuthProvider struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + ProviderID string `bson:"provider_id" json:"provider_id"` + Name string `bson:"name" json:"name"` + Kind string `bson:"kind" json:"kind"` // "oidc" | "oauth2" + Preset string `bson:"preset" json:"preset"` // "" for custom + Issuer string `bson:"issuer" json:"issuer"` + ClientID string `bson:"client_id" json:"client_id"` + ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"` + Scopes []string `bson:"scopes" json:"scopes"` + Enabled bool `bson:"enabled" json:"enabled"` + LegacyCallback bool `bson:"legacy_callback" json:"legacy_callback"` + Order int `bson:"order" json:"order"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} +``` + +`ProviderID` is a short random identifier, not the Mongo `_id`: it appears in +the callback URL a customer pastes into their IdP, and an `_id` there would +publish a database key. + +Unique index on `(instance_id, provider_id)`. Index build is fatal on failure, +matching `EnsureAuthIndexes` — a duplicate `provider_id` within an instance +would make the callback ambiguous. + +`ClientSecretEnc` is AES-256-GCM under `KEY_ENCRYPTION_KEY`, as +`instance_oidc.client_secret_enc` is today, and is never serialised. + +### Presets + +A Go table in `server/internal/auth/presets.go`, not database rows — adding one +is a commit, not a migration. + +| Preset | Kind | Issuer | Input asked of the customer | Default scopes | +| ------- | -------- | ------------------------------------------------- | --------------------------- | ----------------------------- | +| `entra` | `oidc` | `https://login.microsoftonline.com/{tenant}/v2.0` | Directory (tenant) ID | `openid profile email` | +| `google`| `oidc` | `https://accounts.google.com` | none | `openid profile email` | +| `okta` | `oidc` | `https://{domain}/oauth2/default` | Okta org domain | `openid profile email` | +| `github`| `oauth2` | n/a | none | `read:user user:email` | +| `` (custom) | `oidc` | supplied verbatim | Issuer URL | `openid profile email` | + +The issuer template is expanded server-side on save; the stored `Issuer` is +always the resolved URL, so nothing downstream has to know a preset existed. + +### Settings + +`settings.local_login_enabled bool`, defaulting true. Absent on existing +documents, and Go's zero value for `bool` is false, so the field is read through +a `*bool` and a nil pointer means enabled. A plain `bool` would silently +disable password login on every instance in the fleet at upgrade. + +## Migration + +`0005_auth_providers` — the next free number; `0004_instance_rename` is the +highest recorded today. For each document in `instance_oidc`, insert one +`auth_providers` document: + +- `Name: "Single sign-on"` +- `Preset: ""`, `Kind: "oidc"` +- `Issuer`, `ClientID`, `Enabled` copied +- `ClientSecretEnc` copied **verbatim**, not decrypted and re-encrypted — a + migration that needs `KEY_ENCRYPTION_KEY` fails on an instance that has none + and strands the SSO configuration. +- `Scopes: ["openid", "profile", "email"]`, matching what `oidc.go` hardcodes + today. +- `ProviderID` freshly generated, `LegacyCallback: true`. + +`instance_oidc` is left in place and no longer read. Idempotent by skipping any +instance that already has an `auth_providers` document, so a re-run after a +partial failure completes rather than duplicating. + +## Auth flow + +Routes: + +``` +GET /auth/oidc/:providerId/start +GET /auth/oidc/:providerId/callback +GET /auth/oidc/callback # legacy, retained +``` + +The state token in Redis stores `{instance_id, provider_id}` rather than the +bare instance ID. That is what lets the legacy callback keep working: a +migrated provider whose IdP still points at `/auth/oidc/callback` resolves its +provider from the consumed state, so **no customer has to touch their IdP +configuration at upgrade**. New providers are given the per-provider URL. Both +paths run identical code after state consumption. + +`providerForInstance` becomes `providerFor(ctx, c, instanceID, providerID)`. +The `go-oidc` provider cache keys on `provider_id`, not instance. Saving, +disabling or deleting a provider evicts that key. + +`redirectURL(c)` gains the provider ID, and must return the same URL in the +start and callback halves of one flow or the token exchange is rejected — for a +legacy provider it therefore returns the legacy path. `LegacyCallback` is what +records that shape, and is set true only by the migration. + +### OIDC providers + +Unchanged from the current implementation: `AuthCodeURL` with the stored +scopes, exchange, `id_token` verified against the provider's key set with +`ClientID` as audience, `email` and `name` claims extracted. + +### GitHub (`kind: "oauth2"`) + +GitHub is OAuth2 and issues no `id_token`, so it takes a separate branch: +exchange the code, then `GET https://api.github.com/user/emails` with the access +token and take the address that is both `primary` and `verified`. An +unverified-only response is refused — an unverified address is not proof of +control, and accepting one would let anyone holding a GitHub account claim any +address in the instance. `name` comes from `GET https://api.github.com/user`. + +Both branches converge on one function: + +```go +func completeSSOLogin(c *gin.Context, instanceID, email, name string) error +``` + +which holds today's lookup-or-provision, session creation, `TouchLastLogin` and +cookie set, verbatim. Email is lower-cased before lookup, and the lookup stays +`GetUserInInstanceByEmail` — instance-scoped, as it is now. + +### Licence gate + +`services.GetLicenseState(instanceID).Feature("oidc")` continues to gate both +the start and the callback, for every provider kind, and is checked on the +callback against the instance named by the consumed state rather than the host. +Unchanged behaviour, applied to more providers. + +## REST API + +Unauthenticated: + +``` +GET /auth/providers + -> {"local_enabled": true, + "providers": [{"id": "...", "name": "...", "preset": "entra"}]} +``` + +Instance is resolved from the host, as `/auth/bootstrap-status` already does. +The response carries **no issuer, no client ID and no secret** — it is served to +anyone who can reach the login page. + +Session-authed, `owner|admin`, under `/api`: + +``` +GET,POST /auth/providers +PUT,DELETE /auth/providers/:id +POST /auth/providers/:id/test +``` + +`test` fetches the provider's discovery document (or, for GitHub, calls the API +with the stored credentials) and reports reachability. It does not sign anyone +in. + +`GET,PUT /api/org/oidc` is retained, reading and writing whichever provider +carries `legacy_callback`, so existing callers keep working. It creates nothing: +a PUT against an instance with no legacy provider answers 404. + +Every mutation writes an audit event, as every mutating path does. + +### Lockout guards + +Both refused with 409 and a distinct error code: + +- `local_login_required` — disabling local login while zero providers are + enabled. +- `last_provider` — disabling or deleting the last enabled provider while local + login is off. + +These are enforced in the service layer, not the handler, so the two endpoints +that can reach the condition cannot disagree. + +## Frontend + +### Settings + +`web/components/settings/OIDCCard.tsx` becomes `AuthProvidersCard`, in the +Access group of `/settings` where the OIDC card already lives. It renders the +provider list with per-row enable toggle, edit, delete and drag ordering, an +Add flow that asks for the preset first and then only the fields that preset +needs, and the local-login toggle beneath the list. A guard violation surfaces +the 409's message rather than a generic failure. + +### Login page + +`web/app/login/page.tsx` calls `/auth/providers` on mount alongside the existing +`bootstrapStatus` call, and renders on the result: + +| `local_enabled` | providers | Rendered | +| --------------- | --------- | --------------------------------------------------- | +| true | none | Password form only. No divider, no buttons. | +| true | some | Password form, divider, one button per provider. | +| false | some | Buttons only. No form, no divider. | +| false | none | Password form (see below). | + +The last row cannot be reached through the API — the guards above prevent it — +but a hand-edited database could produce it, and a login page that renders +nothing at all is unrecoverable without database access. It therefore falls back +to the password form. + +The current unconditional SSO button and its "SSO must be enabled for this +instance by an administrator" note are both removed; the button now only exists +when it works. + +Buttons are labelled with the provider's `Name` and carry the preset's icon +where there is one, a neutral key glyph otherwise. Presets never override the +name — a customer who calls their Entra provider "Staff" gets "Staff". + +Errors keep the existing `/login?error=` redirect convention. + +## Testing + +- Migration: an `instance_oidc` document produces one enabled provider with the + ciphertext byte-identical; a re-run inserts nothing further. +- `local_login_enabled` absent decodes as enabled. +- Guards: both 409 paths, and the enable/disable sequences that approach them + without crossing. +- Legacy callback: a start on the legacy provider and a callback on + `/auth/oidc/callback` complete, and the redirect URL matches across the two + halves. +- Per-provider callback: two providers in one instance, each resolving to its + own configuration; a `provider_id` from another instance answers 404. +- GitHub: primary+verified selected; verified-only-absent refused. +- `/auth/providers` response contains no issuer, client ID or secret. + +## Deployment notes + +No new environment variables. No agent change. `KEY_ENCRYPTION_KEY` is already +required wherever OIDC was configured, and the migration does not add a +dependency on it.