docs: design for multiple auth providers
This commit is contained in:
@@ -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=<code>` 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.
|
||||
Reference in New Issue
Block a user