# 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"`
    CallbackNotice  bool          `bson:"callback_notice" json:"callback_notice"`
    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.
- `CallbackNotice: true` — this provider's redirect URI has changed and an
  administrator has not yet acknowledged it. Set only by the migration; cleared
  by the settings UI. New providers are created `false`.

`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
```

The old unparameterised `/auth/oidc/start` and `/auth/oidc/callback` are
**removed**, not retained. See Upgrade impact below — this breaks configured SSO
until the customer updates their IdP, and that is accepted deliberately rather
than carried as a compatibility path.

The state token in Redis stores `{instance_id, provider_id}` rather than the
bare instance ID. The callback resolves its provider from the consumed state
and cross-checks it against `:providerId` in the path, refusing a mismatch —
the path alone is attacker-controlled, and the state is the half that was
issued by the start handler.

`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, providerID)` returns the one per-provider shape, and returns the
same URL in the start and callback halves of a flow — an IdP rejects the token
exchange if they differ.

### 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 removed along with the old auth routes. Its only
caller is `OIDCCard.tsx`, which this work replaces, and a compatibility shim
over a one-of-many model would have to invent which provider it means.

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.

Every provider row shows its **callback URL** with click-to-copy — that is the
value the customer pastes into their IdP, it now differs per provider, and after
the upgrade every migrated provider needs it re-pasted. A migrated provider
additionally carries a warning until an administrator dismisses it, naming the
change and the URL. Dismissal is per provider, stored on the document.

### 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.
- Per-provider callback: two providers in one instance, each resolving to its
  own configuration; a `provider_id` from another instance answers 404.
- A callback whose `:providerId` disagrees with the consumed state is refused,
  and the state is consumed rather than left replayable.
- The removed routes (`/auth/oidc/start`, `/auth/oidc/callback`,
  `/api/org/oidc`) answer 404.
- GitHub: primary+verified selected; verified-only-absent refused.
- `/auth/providers` response contains no issuer, client ID or secret.

## Upgrade impact

**This release breaks configured SSO until each customer updates their identity
provider.** The old `/auth/oidc/callback` is gone, migrated providers are
reachable only at `/auth/oidc/<providerId>/callback`, and an IdP still pointing
at the old URL fails the flow.

It is a deliberate trade: one callback shape rather than two, no
`legacy_callback` branch through `redirectURL`, and no permanently retained
route whose only purpose is a single past upgrade.

Mitigations, in order of who sees them first:

- The settings card shows the new callback URL per provider with click-to-copy,
  and a migrated provider carries a dismissable warning naming the change.
- The failure is visible rather than silent: an IdP rejects the redirect URI
  before Vantage is reached, so the customer sees their own provider's error.
- Local password login is unaffected, so no instance is locked out — an
  administrator can always sign in to fix the URL. This is why
  `local_login_enabled` defaults to true and why nothing in this migration
  turns it off.
- Release notes and `docsite/docs/vantage/settings.md` state the required
  action.

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