# Multiple Auth Providers Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let an instance configure any number of named auth providers, show one login button per enabled provider, and allow local password login to be turned off.

**Architecture:** A new `auth_providers` collection replaces the one-per-instance `instance_oidc` document. Provider identity moves into the URL (`/auth/oidc/:providerId/start|callback`) and into the Redis state token, which now carries `{instance_id, provider_id}` as JSON instead of a bare instance ID. Presets are a Go lookup table that expands to a real issuer URL on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes a second branch in the callback that converges on one shared `completeSSOLogin`.

**Tech Stack:** Go 1.x, gin, `github.com/coreos/go-oidc/v3`, `golang.org/x/oauth2`, MongoDB (`mongo-driver/v2`), Redis (`go-redis`), Next.js 16 App Router, React, TanStack Query, Tailwind 3.

**Spec:** `docs/superpowers/specs/2026-08-03-multi-auth-providers-design.md`

## Global Constraints

- Module path is `gitea.hostxtra.co.uk/mrhid6/vantage`. Server imports are `.../server/internal/<pkg>`.
- Every document in the control plane carries `instance_id`, and **every service query is scoped by it**. No unscoped lookup by email, ever.
- `web/` carries **no hex colour values**. Use the existing Tailwind tokens only (`text-text-primary`, `bg-surface-2`, `border-border`, `text-danger`, `text-warning`, `text-success`, `bg-well`, `accent-accent`).
- `web/` is dark-only. Do not add a light theme or a `data-theme` selector.
- Client secrets are AES-256-GCM via the existing `encryptString`/`decryptString` in `server/internal/services`. A secret is never serialised to JSON — the struct tag is `json:"-"`.
- Every mutating API path writes an audit event via `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details)`. Pass `""` for `serverID` and `keyID`. The actor is `actorFromCtx(c)`.
- The licence feature gate for all of this is `services.GetLicenseState(instanceID).Feature("oidc")`, unchanged.
- Go: run `cd server && go build ./... && go vet ./...` before every commit. Tests: `cd server && go test ./...`.
- Web: run `cd web && npx tsc --noEmit` before every commit that touches `web/`.
- Commit messages use the repo's existing conventional prefixes (`feat:`, `fix:`, `docs:`, `refactor:`).

## Testing Reality

This repo has three `*_test.go` files total and **no MongoDB or gin test harness**. Do not invent one — standing up `mongodb-memory-server`-style infrastructure is out of scope for this work and would be a larger change than the feature.

Consequently:

- Logic that is **pure** gets real Go tests: preset expansion, the lockout guard predicate, GitHub email selection, the state token codec. These tasks are genuinely TDD.
- Logic that requires **Mongo, Redis or a browser** gets explicit manual verification steps with exact commands and expected output. Perform them; do not skip them and do not claim a step passed without running it.

## File Structure

**Server — created:**

| File | Responsibility |
| --- | --- |
| `server/internal/models/auth_provider.go` | The `AuthProvider` document. |
| `server/internal/auth/presets.go` | Preset table, issuer expansion, default scopes. Pure; no I/O. |
| `server/internal/auth/presets_test.go` | Tests for the above. |
| `server/internal/auth/state.go` | `oidcState` struct plus its Redis save/consume. |
| `server/internal/auth/state_test.go` | Codec round-trip tests. |
| `server/internal/auth/github.go` | The OAuth2 branch: token exchange to verified email. |
| `server/internal/auth/github_test.go` | Email-selection tests against `httptest`. |
| `server/internal/services/auth_provider.go` | CRUD, guards, index. All Mongo access for providers. |
| `server/internal/services/auth_provider_guard_test.go` | Tests for the pure guard predicate. |
| `server/internal/services/migrate_auth_providers.go` | Migration `0005_auth_providers`. |
| `server/internal/api/auth_providers.go` | The five `/api/auth/providers` handlers. |

**Server — modified:** `server/internal/auth/oidc.go`, `server/internal/auth/local.go`, `server/internal/auth/session.go`, `server/internal/api/handlers.go`, `server/internal/api/instance.go`, `server/internal/services/coreindexes.go`, `server/internal/services/settings.go`, `shared/models/settings.go`, `server/cmd/main.go`.

**Server — deleted:** `server/internal/models/instance_oidc.go`, `server/internal/services/instance_oidc.go`, and the two handlers in `instance.go`.

**Web — created:** `web/components/settings/AuthProvidersCard.tsx`, `web/components/settings/ProviderIcon.tsx`.

**Web — modified:** `web/lib/api.ts`, `web/app/login/page.tsx`, `web/app/(app)/settings/page.tsx`.

**Web — deleted:** `web/components/settings/OIDCCard.tsx`.

---

### Task 1: The AuthProvider model and preset table

**Files:**
- Create: `server/internal/models/auth_provider.go`
- Create: `server/internal/auth/presets.go`
- Test: `server/internal/auth/presets_test.go`

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `models.AuthProvider` struct (fields as written below).
  - `auth.Preset` struct and `auth.Presets() []Preset`.
  - `auth.PresetByID(id string) (Preset, bool)`.
  - `auth.ExpandIssuer(presetID, input string) (string, error)`.
  - `auth.DefaultScopes(presetID string) []string`.
  - `auth.KindFor(presetID string) string` returning `"oidc"` or `"oauth2"`.

- [ ] **Step 1: Write the failing test**

Create `server/internal/auth/presets_test.go`:

```go
package auth

import "testing"

func TestExpandIssuerEntra(t *testing.T) {
	got, err := ExpandIssuer("entra", "c0ffee00-1111-2222-3333-444455556666")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := "https://login.microsoftonline.com/c0ffee00-1111-2222-3333-444455556666/v2.0"
	if got != want {
		t.Errorf("got %q, want %q", got, want)
	}
}

func TestExpandIssuerGoogleIgnoresInput(t *testing.T) {
	got, err := ExpandIssuer("google", "anything")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "https://accounts.google.com" {
		t.Errorf("got %q", got)
	}
}

func TestExpandIssuerOkta(t *testing.T) {
	got, err := ExpandIssuer("okta", "acme.okta.com")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "https://acme.okta.com/oauth2/default" {
		t.Errorf("got %q", got)
	}
}

// A custom provider is stored exactly as typed. Rewriting it would break an
// issuer whose discovery document lives on a non-obvious path.
func TestExpandIssuerCustomIsVerbatim(t *testing.T) {
	got, err := ExpandIssuer("", "https://id.example.com/realms/main")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "https://id.example.com/realms/main" {
		t.Errorf("got %q", got)
	}
}

// GitHub is OAuth2 and has no issuer at all. Returning an empty string rather
// than an error keeps the save path branch-free.
func TestExpandIssuerGitHubIsEmpty(t *testing.T) {
	got, err := ExpandIssuer("github", "")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "" {
		t.Errorf("got %q, want empty", got)
	}
}

func TestExpandIssuerRequiresInputWhenTemplated(t *testing.T) {
	if _, err := ExpandIssuer("entra", ""); err == nil {
		t.Fatal("expected an error for an empty tenant ID")
	}
}

func TestExpandIssuerRejectsUnknownPreset(t *testing.T) {
	if _, err := ExpandIssuer("nope", "x"); err == nil {
		t.Fatal("expected an error for an unknown preset")
	}
}

func TestKindFor(t *testing.T) {
	if KindFor("github") != "oauth2" {
		t.Error("github should be oauth2")
	}
	if KindFor("entra") != "oidc" {
		t.Error("entra should be oidc")
	}
	if KindFor("") != "oidc" {
		t.Error("custom should be oidc")
	}
}

func TestDefaultScopes(t *testing.T) {
	got := DefaultScopes("github")
	want := []string{"read:user", "user:email"}
	if len(got) != len(want) {
		t.Fatalf("got %v", got)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("got %v, want %v", got, want)
		}
	}
}

// The returned slice must not alias the table, or one caller mutating its
// scopes rewrites the defaults for every provider created afterwards.
func TestDefaultScopesReturnsACopy(t *testing.T) {
	a := DefaultScopes("google")
	a[0] = "mutated"
	b := DefaultScopes("google")
	if b[0] == "mutated" {
		t.Fatal("DefaultScopes returned an aliased slice")
	}
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd server && go test ./internal/auth/ -run TestExpand -v`
Expected: FAIL to build, `undefined: ExpandIssuer`.

- [ ] **Step 3: Write the model**

Create `server/internal/models/auth_provider.go`:

```go
package models

import (
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
)

// AuthProvider is one configured identity provider for one instance.
//
// ProviderID is a short random identifier rather than the Mongo _id: it appears
// in the callback URL a customer pastes into their identity provider, and an
// _id there would publish a database key.
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 marks a provider whose redirect URI changed at the upgrade
	// to per-provider callbacks. Set only by migration 0005; cleared when an
	// administrator acknowledges it in settings.
	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"`
}

const (
	KindOIDC   = "oidc"
	KindOAuth2 = "oauth2"
)
```

- [ ] **Step 4: Write the preset table**

Create `server/internal/auth/presets.go`:

```go
package auth

import (
	"fmt"
	"strings"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)

// Preset describes one well-known identity provider.
//
// This is a Go table rather than a collection on purpose: adding a preset is a
// commit and a review, not a row somebody typed into production.
type Preset struct {
	ID            string   `json:"id"`
	Label         string   `json:"label"`          // shown in the add-provider picker
	Kind          string   `json:"kind"`           // models.KindOIDC | models.KindOAuth2
	IssuerFormat  string   `json:"-"`              // %s is replaced by InputValue; empty means no issuer
	InputLabel    string   `json:"input_label"`    // empty means the preset asks for nothing
	InputHint     string   `json:"input_hint"`
	Scopes        []string `json:"-"`
}

var presets = []Preset{
	{
		ID:           "entra",
		Label:        "Microsoft Entra ID",
		Kind:         models.KindOIDC,
		IssuerFormat: "https://login.microsoftonline.com/%s/v2.0",
		InputLabel:   "Directory (tenant) ID",
		InputHint:    "Found in Entra under Overview. A UUID, not your domain name.",
		Scopes:       []string{"openid", "profile", "email"},
	},
	{
		ID:           "google",
		Label:        "Google Workspace",
		Kind:         models.KindOIDC,
		IssuerFormat: "https://accounts.google.com",
		Scopes:       []string{"openid", "profile", "email"},
	},
	{
		ID:           "okta",
		Label:        "Okta",
		Kind:         models.KindOIDC,
		IssuerFormat: "https://%s/oauth2/default",
		InputLabel:   "Okta org domain",
		InputHint:    "e.g. acme.okta.com — no scheme, no trailing slash.",
		Scopes:       []string{"openid", "profile", "email"},
	},
	{
		ID:     "github",
		Label:  "GitHub",
		Kind:   models.KindOAuth2,
		Scopes: []string{"read:user", "user:email"},
	},
	{
		ID:           "",
		Label:        "Other (OpenID Connect)",
		Kind:         models.KindOIDC,
		IssuerFormat: "%s",
		InputLabel:   "Issuer URL",
		InputHint:    "The discovery base, e.g. https://id.example.com/realms/main",
		Scopes:       []string{"openid", "profile", "email"},
	},
}

// Presets returns the table for the settings UI to render a picker from.
func Presets() []Preset {
	out := make([]Preset, len(presets))
	copy(out, presets)
	return out
}

func PresetByID(id string) (Preset, bool) {
	for _, p := range presets {
		if p.ID == id {
			return p, true
		}
	}
	return Preset{}, false
}

// ExpandIssuer turns what the customer typed into the issuer URL that gets
// stored. The stored value is always fully resolved, so nothing downstream has
// to know a preset was involved.
func ExpandIssuer(presetID, input string) (string, error) {
	p, ok := PresetByID(presetID)
	if !ok {
		return "", fmt.Errorf("unknown provider preset %q", presetID)
	}
	if p.IssuerFormat == "" {
		return "", nil // OAuth2 providers have no issuer
	}
	if !strings.Contains(p.IssuerFormat, "%s") {
		return p.IssuerFormat, nil // fixed issuer, input ignored
	}
	input = strings.TrimSpace(strings.TrimSuffix(input, "/"))
	if input == "" {
		return "", fmt.Errorf("%s is required", p.InputLabel)
	}
	return fmt.Sprintf(p.IssuerFormat, input), nil
}

func DefaultScopes(presetID string) []string {
	p, ok := PresetByID(presetID)
	if !ok {
		return []string{"openid", "profile", "email"}
	}
	out := make([]string, len(p.Scopes))
	copy(out, p.Scopes)
	return out
}

func KindFor(presetID string) string {
	if p, ok := PresetByID(presetID); ok {
		return p.Kind
	}
	return models.KindOIDC
}
```

- [ ] **Step 5: Run the tests to verify they pass**

Run: `cd server && go test ./internal/auth/ -v`
Expected: PASS, all nine tests.

- [ ] **Step 6: Build and vet**

Run: `cd server && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 7: Commit**

```bash
git add server/internal/models/auth_provider.go server/internal/auth/presets.go server/internal/auth/presets_test.go
git commit -m "feat: add AuthProvider model and identity provider presets"
```

---

### Task 2: Provider service layer, guards and index

**Files:**
- Create: `server/internal/services/auth_provider.go`
- Test: `server/internal/services/auth_provider_guard_test.go`
- Modify: `server/internal/services/coreindexes.go:33-39` (replace the `instance_oidc` index)

**Interfaces:**
- Consumes: `models.AuthProvider`, `models.KindOIDC`, `auth` package NOT imported here (services must not import auth — auth imports services, and the reverse would be an import cycle). Preset expansion happens in the **API layer**, which may import both.
- Produces:
  - `services.ListAuthProviders(instanceID string) ([]models.AuthProvider, error)` — all, any enabled state, sorted by `order` then `created_at`.
  - `services.ListEnabledAuthProviders(instanceID string) ([]models.AuthProvider, error)`
  - `services.GetAuthProvider(instanceID, providerID string) (*models.AuthProvider, error)`
  - `services.GetAuthProviderSecret(instanceID, providerID string) (string, error)`
  - `services.CreateAuthProvider(p *models.AuthProvider, clientSecret string) (*models.AuthProvider, error)` — assigns `ProviderID`, `CreatedAt`, `UpdatedAt`, `Order`.
  - `services.UpdateAuthProvider(instanceID, providerID string, in AuthProviderUpdate) error`
  - `services.DeleteAuthProvider(instanceID, providerID string) error`
  - `services.AckAuthProviderNotice(instanceID, providerID string) error`
  - `services.AuthProviderUpdate` struct.
  - `services.ErrLastProvider`, `services.ErrLocalLoginRequired` sentinel errors.
  - `services.CheckLockout(localEnabled bool, enabledProviders int) error` — the pure predicate.
  - `services.EnsureAuthProviderIndexes() error`

- [ ] **Step 1: Write the failing guard test**

Create `server/internal/services/auth_provider_guard_test.go`:

```go
package services

import (
	"errors"
	"testing"
)

// CheckLockout answers one question: would this end state leave nobody able to
// sign in? It is pure so both endpoints that can reach the condition share it
// and cannot disagree.
func TestCheckLockoutAllowsLocalOnly(t *testing.T) {
	if err := CheckLockout(true, 0); err != nil {
		t.Errorf("local login with no providers must be allowed: %v", err)
	}
}

func TestCheckLockoutAllowsProvidersOnly(t *testing.T) {
	if err := CheckLockout(false, 1); err != nil {
		t.Errorf("providers with no local login must be allowed: %v", err)
	}
}

func TestCheckLockoutAllowsBoth(t *testing.T) {
	if err := CheckLockout(true, 2); err != nil {
		t.Errorf("both must be allowed: %v", err)
	}
}

func TestCheckLockoutRefusesNeither(t *testing.T) {
	err := CheckLockout(false, 0)
	if !errors.Is(err, ErrLockout) {
		t.Fatalf("got %v, want ErrLockout", err)
	}
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd server && go test ./internal/services/ -run TestCheckLockout -v`
Expected: FAIL to build, `undefined: CheckLockout`.

- [ ] **Step 3: Write the service layer**

Create `server/internal/services/auth_provider.go`:

```go
package services

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// ErrLockout is returned when a change would leave an instance with neither
// local password login nor an enabled provider — nobody could sign in, and no
// endpoint exists to undo it without database access.
var ErrLockout = errors.New("that would leave nobody able to sign in")

const authProviderCol = "auth_providers"

func authProviderCtx() (context.Context, context.CancelFunc) {
	return context.WithTimeout(context.Background(), 5*time.Second)
}

// CheckLockout is pure so the two endpoints that can reach this condition —
// saving settings and changing a provider — share one answer.
func CheckLockout(localEnabled bool, enabledProviders int) error {
	if localEnabled || enabledProviders > 0 {
		return nil
	}
	return ErrLockout
}

func EnsureAuthProviderIndexes() error {
	ctx, cancel := authProviderCtx()
	defer cancel()
	_, err := db.Col(authProviderCol).Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys:    bson.D{{Key: "instance_id", Value: 1}, {Key: "provider_id", Value: 1}},
		Options: options.Index().SetUnique(true),
	})
	return err
}

func ListAuthProviders(instanceID string) ([]models.AuthProvider, error) {
	ctx, cancel := authProviderCtx()
	defer cancel()
	cur, err := db.Col(authProviderCol).Find(ctx,
		bson.M{"instance_id": instanceID},
		options.Find().SetSort(bson.D{{Key: "order", Value: 1}, {Key: "created_at", Value: 1}}))
	if err != nil {
		return nil, err
	}
	out := []models.AuthProvider{}
	if err := cur.All(ctx, &out); err != nil {
		return nil, err
	}
	return out, nil
}

func ListEnabledAuthProviders(instanceID string) ([]models.AuthProvider, error) {
	all, err := ListAuthProviders(instanceID)
	if err != nil {
		return nil, err
	}
	out := []models.AuthProvider{}
	for _, p := range all {
		if p.Enabled {
			out = append(out, p)
		}
	}
	return out, nil
}

func CountEnabledAuthProviders(instanceID string) (int, error) {
	enabled, err := ListEnabledAuthProviders(instanceID)
	if err != nil {
		return 0, err
	}
	return len(enabled), nil
}

// GetAuthProvider is scoped by instance. There is deliberately no lookup by
// provider_id alone: a provider ID travels in a URL, and an unscoped lookup
// would let one instance's callback resolve another instance's provider.
func GetAuthProvider(instanceID, providerID string) (*models.AuthProvider, error) {
	ctx, cancel := authProviderCtx()
	defer cancel()
	var p models.AuthProvider
	err := db.Col(authProviderCol).FindOne(ctx,
		bson.M{"instance_id": instanceID, "provider_id": providerID}).Decode(&p)
	if err != nil {
		return nil, err
	}
	return &p, nil
}

func GetAuthProviderSecret(instanceID, providerID string) (string, error) {
	p, err := GetAuthProvider(instanceID, providerID)
	if err != nil {
		return "", err
	}
	if p.ClientSecretEnc == "" {
		return "", fmt.Errorf("provider %q has no client secret configured", p.Name)
	}
	return decryptString(p.ClientSecretEnc)
}

func CreateAuthProvider(p *models.AuthProvider, clientSecret string) (*models.AuthProvider, error) {
	if strings.TrimSpace(p.Name) == "" {
		return nil, errors.New("name is required")
	}
	if strings.TrimSpace(p.ClientID) == "" {
		return nil, errors.New("client ID is required")
	}
	if clientSecret == "" {
		return nil, errors.New("client secret is required")
	}
	enc, err := encryptString(clientSecret)
	if err != nil {
		return nil, err
	}
	id, err := randomProviderID()
	if err != nil {
		return nil, err
	}

	existing, err := ListAuthProviders(p.InstanceID)
	if err != nil {
		return nil, err
	}

	now := time.Now()
	p.ProviderID = id
	p.ClientSecretEnc = enc
	p.CallbackNotice = false
	p.Order = len(existing)
	p.CreatedAt = now
	p.UpdatedAt = now

	ctx, cancel := authProviderCtx()
	defer cancel()
	if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

// AuthProviderUpdate carries only what an edit may change. Pointer fields are
// "leave alone when nil", which is what lets an empty client secret mean "keep
// the stored one" rather than "erase it".
type AuthProviderUpdate struct {
	Name         *string
	Issuer       *string
	ClientID     *string
	ClientSecret *string
	Scopes       *[]string
	Enabled      *bool
	Order        *int
}

func UpdateAuthProvider(instanceID, providerID string, in AuthProviderUpdate) error {
	set := bson.M{"updated_at": time.Now()}
	if in.Name != nil {
		if strings.TrimSpace(*in.Name) == "" {
			return errors.New("name is required")
		}
		set["name"] = *in.Name
	}
	if in.Issuer != nil {
		set["issuer"] = *in.Issuer
	}
	if in.ClientID != nil {
		set["client_id"] = *in.ClientID
	}
	if in.Scopes != nil {
		set["scopes"] = *in.Scopes
	}
	if in.Order != nil {
		set["order"] = *in.Order
	}
	if in.Enabled != nil {
		set["enabled"] = *in.Enabled
	}
	// An empty secret means "keep what is stored". Only a non-empty one writes.
	if in.ClientSecret != nil && *in.ClientSecret != "" {
		enc, err := encryptString(*in.ClientSecret)
		if err != nil {
			return err
		}
		set["client_secret_enc"] = enc
	}

	ctx, cancel := authProviderCtx()
	defer cancel()
	res, err := db.Col(authProviderCol).UpdateOne(ctx,
		bson.M{"instance_id": instanceID, "provider_id": providerID},
		bson.M{"$set": set})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return mongo.ErrNoDocuments
	}
	return nil
}

func DeleteAuthProvider(instanceID, providerID string) error {
	ctx, cancel := authProviderCtx()
	defer cancel()
	res, err := db.Col(authProviderCol).DeleteOne(ctx,
		bson.M{"instance_id": instanceID, "provider_id": providerID})
	if err != nil {
		return err
	}
	if res.DeletedCount == 0 {
		return mongo.ErrNoDocuments
	}
	return nil
}

func AckAuthProviderNotice(instanceID, providerID string) error {
	ctx, cancel := authProviderCtx()
	defer cancel()
	_, err := db.Col(authProviderCol).UpdateOne(ctx,
		bson.M{"instance_id": instanceID, "provider_id": providerID},
		bson.M{"$set": bson.M{"callback_notice": false}})
	return err
}
```

- [ ] **Step 4: Add the provider ID generator**

Append to `server/internal/services/auth_provider.go`:

```go
// randomProviderID is 8 bytes hex: short enough to read in a URL, wide enough
// that guessing one is not a way to enumerate an instance's providers.
func randomProviderID() (string, error) {
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}
```

Add `"crypto/rand"` and `"encoding/hex"` to the import block.

- [ ] **Step 5: Run the guard test to verify it passes**

Run: `cd server && go test ./internal/services/ -run TestCheckLockout -v`
Expected: PASS, four tests.

- [ ] **Step 6: Swap the index builder**

In `server/internal/services/coreindexes.go`, replace lines 33-39 (the `instance_oidc` index block) with:

```go
	// auth_providers is control-plane only, so its index stays here. The
	// (instance_id, provider_id) pair is unique because a duplicate provider_id
	// inside one instance would make the callback ambiguous.
	if _, err := db.Col("auth_providers").Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys:    bson.D{{Key: "instance_id", Value: 1}, {Key: "provider_id", Value: 1}},
		Options: options.Index().SetUnique(true),
	}); err != nil {
		return err
	}
	return nil
```

Then delete `EnsureAuthProviderIndexes` from `auth_provider.go` — it is now redundant with `EnsureAuthIndexes`, and two builders for one index is how they drift. Remove `mongo.IndexModel` and `options` from `auth_provider.go`'s imports if nothing else there uses them (`options.Find` does, so keep `options`).

- [ ] **Step 7: Build, vet, test**

Run: `cd server && go build ./... && go vet ./... && go test ./...`
Expected: no build or vet output; tests PASS.

- [ ] **Step 8: Commit**

```bash
git add server/internal/services/auth_provider.go server/internal/services/auth_provider_guard_test.go server/internal/services/coreindexes.go
git commit -m "feat: add auth provider service layer and lockout guard"
```

---

### Task 3: `local_login_enabled` setting

**Files:**
- Modify: `shared/models/settings.go:32-40`
- Modify: `server/internal/services/settings.go`
- Test: `server/internal/services/settings_local_login_test.go` (create)

**Interfaces:**
- Consumes: nothing.
- Produces:
  - `shared.Settings.LocalLoginEnabled *bool`
  - `models.LocalLoginEnabled(s *Settings) bool` — nil-safe reader, in `shared/models/settings.go`.
  - `services.IsLocalLoginEnabled(instanceID string) bool`

- [ ] **Step 1: Write the failing test**

Create `server/internal/services/settings_local_login_test.go`:

```go
package services

import (
	"testing"

	shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
)

// Absent on every existing settings document. Go's zero value for bool is
// false, so a plain bool field would silently disable password login on every
// instance in the fleet at upgrade. The pointer is what makes absent mean on.
func TestLocalLoginEnabledDefaultsTrueWhenAbsent(t *testing.T) {
	s := &shared.Settings{}
	if !shared.LocalLoginEnabled(s) {
		t.Fatal("absent local_login_enabled must read as enabled")
	}
}

func TestLocalLoginEnabledRespectsExplicitFalse(t *testing.T) {
	no := false
	s := &shared.Settings{LocalLoginEnabled: &no}
	if shared.LocalLoginEnabled(s) {
		t.Fatal("explicit false must read as disabled")
	}
}

func TestLocalLoginEnabledNilSettingsIsEnabled(t *testing.T) {
	if !shared.LocalLoginEnabled(nil) {
		t.Fatal("nil settings must read as enabled")
	}
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd server && go test ./internal/services/ -run TestLocalLogin -v`
Expected: FAIL to build, `undefined: shared.LocalLoginEnabled`.

- [ ] **Step 3: Add the field and reader**

In `shared/models/settings.go`, add to the `Settings` struct after `WorkflowLogRetentionDays`:

```go
	// LocalLoginEnabled is a pointer because it is absent on every settings
	// document written before this feature existed, and a plain bool would read
	// absent as disabled — turning off password login for the entire fleet at
	// upgrade. Nil means enabled.
	LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
```

And append to the same file:

```go
// LocalLoginEnabled reads the setting with its absent-means-on default. Every
// caller must go through this rather than dereferencing the field.
func LocalLoginEnabled(s *Settings) bool {
	if s == nil || s.LocalLoginEnabled == nil {
		return true
	}
	return *s.LocalLoginEnabled
}
```

- [ ] **Step 4: Add the service reader**

Append to `server/internal/services/auth_provider.go`:

```go
// IsLocalLoginEnabled fails open. A settings read error must not lock an
// instance out of its own login page, and the safe direction here is the one
// that still asks for a password.
func IsLocalLoginEnabled(instanceID string) bool {
	s, err := GetSettings(instanceID)
	if err != nil {
		return true
	}
	return shared.LocalLoginEnabled(s)
}
```

Add `shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"` to the imports. If `GetSettings` has a different name or signature in `server/internal/services/settings.go`, use the real one — check it with `grep -n "func GetSettings" server/internal/services/settings.go` and adapt.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `cd server && go test ./internal/services/ -v`
Expected: PASS.

- [ ] **Step 6: Enforce the guard when settings are saved**

In `server/internal/services/settings.go`, find the save function (`SaveSettings` or equivalent). Before it writes, add:

```go
	// The guard lives here rather than in the handler so the settings path and
	// the provider path cannot disagree about what a lockout is.
	if in.LocalLoginEnabled != nil && !*in.LocalLoginEnabled {
		n, err := CountEnabledAuthProviders(instanceID)
		if err != nil {
			return err
		}
		if err := CheckLockout(false, n); err != nil {
			return err
		}
	}
```

Adapt the receiver name (`in`, `s`, `settings`) to whatever the existing signature uses.

- [ ] **Step 7: Map the guard to a 409 in the handler**

`ErrLockout` is a refusal about the resource's state, not a server fault, and a
500 would tell the operator Vantage broke rather than that they were stopped.

In `server/internal/api/handlers.go`, find `saveSettings` (it is in whichever
file defines it — `grep -rn "func saveSettings" server/internal/api/`). Where it
handles the error from the service call, add before the existing 500:

```go
	if errors.Is(err, services.ErrLockout) {
		c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
		return
	}
```

Add `"errors"` to that file's imports if absent.

- [ ] **Step 8: Build, vet, test**

Run: `cd server && go build ./... && go vet ./... && go test ./...`
Expected: clean.

- [ ] **Step 9: Commit**

```bash
git add shared/models/settings.go server/internal/services/settings.go server/internal/services/auth_provider.go server/internal/services/settings_local_login_test.go server/internal/api
git commit -m "feat: add local_login_enabled setting with absent-means-on default"
```

---

### Task 4: Migration 0005

**Files:**
- Create: `server/internal/services/migrate_auth_providers.go`
- Modify: `server/cmd/main.go:79-91` (call it inside `runSchemaSetup`)

**Interfaces:**
- Consumes: `models.AuthProvider`, `models.KindOIDC`.
- Produces: `services.MigrateAuthProviders() error`.

- [ ] **Step 1: Write the migration**

Create `server/internal/services/migrate_auth_providers.go`:

```go
package services

import (
	"context"
	"log"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// legacyInstanceOIDC is the pre-0005 shape: one document per instance, in a
// collection this migration is the last thing to read.
type legacyInstanceOIDC struct {
	InstanceID      string    `bson:"instance_id"`
	Issuer          string    `bson:"issuer"`
	ClientID        string    `bson:"client_id"`
	ClientSecretEnc string    `bson:"client_secret_enc"`
	Enabled         bool      `bson:"enabled"`
	UpdatedAt       time.Time `bson:"updated_at"`
}

// MigrateAuthProviders copies each instance_oidc document into auth_providers.
//
// The ciphertext is copied verbatim rather than decrypted and re-encrypted: a
// migration that needs KEY_ENCRYPTION_KEY fails on an instance that has none
// and strands the SSO configuration it was supposed to preserve.
//
// instance_oidc is left in place and no longer read. Nothing deletes it — a
// migration that drops the only copy of a client secret has no undo.
func MigrateAuthProviders() error {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	const marker = "0005_auth_providers"
	if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
		return nil
	}

	cur, err := db.Col("instance_oidc").Find(ctx, bson.M{})
	if err != nil {
		return err
	}
	var legacy []legacyInstanceOIDC
	if err := cur.All(ctx, &legacy); err != nil {
		return err
	}

	migrated := 0
	for _, l := range legacy {
		if l.InstanceID == "" {
			continue
		}
		// Idempotent by skipping an instance that already has a provider, so a
		// re-run after a partial failure completes rather than duplicating.
		n, err := db.Col(authProviderCol).CountDocuments(ctx, bson.M{"instance_id": l.InstanceID})
		if err != nil {
			return err
		}
		if n > 0 {
			continue
		}
		providerID, err := randomProviderID()
		if err != nil {
			return err
		}
		now := time.Now()
		p := models.AuthProvider{
			InstanceID:      l.InstanceID,
			ProviderID:      providerID,
			Name:            "Single sign-on",
			Kind:            models.KindOIDC,
			Preset:          "",
			Issuer:          l.Issuer,
			ClientID:        l.ClientID,
			ClientSecretEnc: l.ClientSecretEnc,
			Scopes:          []string{"openid", "profile", "email"},
			Enabled:         l.Enabled,
			// This provider's redirect URI has changed and nobody has been told
			// yet. The settings card raises it until an administrator dismisses.
			CallbackNotice: true,
			Order:          0,
			CreatedAt:      now,
			UpdatedAt:      now,
		}
		if _, err := db.Col(authProviderCol).InsertOne(ctx, p); err != nil {
			return err
		}
		migrated++
	}
	if migrated > 0 {
		log.Printf("0005: migrated %d OIDC configuration(s) to auth_providers", migrated)
	}

	_, err = db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
	return err
}
```

- [ ] **Step 2: Wire it into boot**

In `server/cmd/main.go`, inside `runSchemaSetup`, after the `EnsureAuthIndexes` block (currently lines 100-102) and before `EnsureSecretIndexes`, add:

```go
	// 0005 runs AFTER EnsureAuthIndexes: the unique (instance_id, provider_id)
	// index must exist before anything inserts providers, or a concurrent
	// re-run could double-insert before the index is there to refuse it.
	if err := services.MigrateAuthProviders(); err != nil {
		log.Fatalf("auth provider migration failed: %v", err)
	}
```

- [ ] **Step 3: Build and vet**

Run: `cd server && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 4: Verify against a real database**

This is the manual step — there is no Mongo test harness. Against a development database that has at least one `instance_oidc` document:

```bash
# before
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.instance_oidc.countDocuments({})'
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.countDocuments({})'
```

Boot the server once (`cd server && go run ./cmd`), then:

```bash
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.find({}, {name:1, enabled:1, callback_notice:1, provider_id:1, client_secret_enc:1}).pretty()'
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.migrations.findOne({_id:"0005_auth_providers"})'
```

Expected: one `auth_providers` document per `instance_oidc` document, `name: "Single sign-on"`, `callback_notice: true`, `enabled` matching the source, `client_secret_enc` **byte-identical** to the source, and the marker present.

- [ ] **Step 5: Verify idempotency**

Boot the server a second time, then re-run the count:

```bash
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.countDocuments({})'
```

Expected: the same number as after the first boot. Then delete the marker and boot a third time to exercise the skip-if-present branch:

```bash
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.migrations.deleteOne({_id:"0005_auth_providers"})'
```

Expected after boot: still the same count — the per-instance skip caught it.

- [ ] **Step 6: Commit**

```bash
git add server/internal/services/migrate_auth_providers.go server/cmd/main.go
git commit -m "feat: migrate instance_oidc into auth_providers (0005)"
```

---

### Task 5: State token carries the provider

**Files:**
- Create: `server/internal/auth/state.go`
- Test: `server/internal/auth/state_test.go`
- Modify: `server/internal/auth/session.go:93-103` (delete the two old functions)

**Interfaces:**
- Consumes: the `rdb`, `statePrefix` and `randomHex` already in `server/internal/auth/session.go`.
- Produces:
  - `auth.oidcState` struct with `InstanceID` and `ProviderID`.
  - `auth.saveState(ctx context.Context, state string, s oidcState) error`
  - `auth.consumeState(ctx context.Context, state string) (oidcState, bool)`
  - `auth.encodeState(s oidcState) (string, error)` / `auth.decodeState(raw string) (oidcState, bool)` — exported to the package only, tested directly.

- [ ] **Step 1: Write the failing test**

Create `server/internal/auth/state_test.go`:

```go
package auth

import "testing"

func TestStateRoundTrip(t *testing.T) {
	in := oidcState{InstanceID: "inst-1", ProviderID: "ab12cd34"}
	raw, err := encodeState(in)
	if err != nil {
		t.Fatalf("encode: %v", err)
	}
	out, ok := decodeState(raw)
	if !ok {
		t.Fatal("decode returned not-ok")
	}
	if out != in {
		t.Errorf("got %+v, want %+v", out, in)
	}
}

func TestDecodeStateRejectsGarbage(t *testing.T) {
	if _, ok := decodeState("not json"); ok {
		t.Fatal("garbage must not decode")
	}
}

func TestDecodeStateRejectsEmpty(t *testing.T) {
	if _, ok := decodeState(""); ok {
		t.Fatal("empty must not decode")
	}
}

// A state whose instance is missing is not a usable state: the callback would
// have nothing to scope its user lookup by.
func TestDecodeStateRejectsMissingInstance(t *testing.T) {
	if _, ok := decodeState(`{"provider_id":"ab12cd34"}`); ok {
		t.Fatal("a state without an instance must not decode")
	}
}

func TestDecodeStateRejectsMissingProvider(t *testing.T) {
	if _, ok := decodeState(`{"instance_id":"inst-1"}`); ok {
		t.Fatal("a state without a provider must not decode")
	}
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd server && go test ./internal/auth/ -run TestState -v`
Expected: FAIL to build, `undefined: oidcState`.

- [ ] **Step 3: Write the state codec**

Create `server/internal/auth/state.go`:

```go
package auth

import (
	"context"
	"encoding/json"
	"time"
)

// oidcState is what a login flow parks in Redis between the start redirect and
// the callback. It carries the provider as well as the instance: the callback's
// :providerId path segment is attacker-controlled, and this is the half that
// was issued by the start handler.
type oidcState struct {
	InstanceID string `json:"instance_id"`
	ProviderID string `json:"provider_id"`
}

func encodeState(s oidcState) (string, error) {
	b, err := json.Marshal(s)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

func decodeState(raw string) (oidcState, bool) {
	var s oidcState
	if raw == "" {
		return oidcState{}, false
	}
	if err := json.Unmarshal([]byte(raw), &s); err != nil {
		return oidcState{}, false
	}
	if s.InstanceID == "" || s.ProviderID == "" {
		return oidcState{}, false
	}
	return s, true
}

func saveState(ctx context.Context, state string, s oidcState) error {
	raw, err := encodeState(s)
	if err != nil {
		return err
	}
	return rdb.Set(ctx, statePrefix+state, raw, 10*time.Minute).Err()
}

// consumeState is GetDel: a state is single-use, so a replayed callback finds
// nothing and is refused.
func consumeState(ctx context.Context, state string) (oidcState, bool) {
	raw, err := rdb.GetDel(ctx, statePrefix+state).Result()
	if err != nil {
		return oidcState{}, false
	}
	return decodeState(raw)
}
```

- [ ] **Step 4: Delete the old state functions**

In `server/internal/auth/session.go`, delete `SaveStateInstance` and `ConsumeStateInstance` (lines 93-103). Remove `"time"` from its imports only if nothing else in the file uses it — `sessionTTL` likely does, so check with `go build` rather than assuming.

- [ ] **Step 5: Run the tests to verify they pass**

Run: `cd server && go test ./internal/auth/ -v`
Expected: PASS. The package will not compile until Task 6 updates `oidc.go`'s callers — if `go test` fails to build for that reason, that is expected here and Task 6 resolves it. To keep this task independently green, do Step 6 before committing.

- [ ] **Step 6: Stub the callers so the package builds**

In `server/internal/auth/oidc.go`, change `SaveStateInstance(ctx, state, inst.InstanceID)` to `saveState(ctx, state, oidcState{InstanceID: inst.InstanceID, ProviderID: ""})` and `ConsumeStateInstance(ctx, c.Query("state"))` to:

```go
	st, ok := consumeState(ctx, c.Query("state"))
	if !ok {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
		return
	}
	instanceID := st.InstanceID
```

This is temporary scaffolding that Task 6 replaces wholesale; it exists so this task's tests can run.

- [ ] **Step 7: Build, vet, test**

Run: `cd server && go build ./... && go vet ./... && go test ./internal/auth/ -v`
Expected: clean build; state tests PASS.

- [ ] **Step 8: Commit**

```bash
git add server/internal/auth/state.go server/internal/auth/state_test.go server/internal/auth/session.go server/internal/auth/oidc.go
git commit -m "refactor: carry provider id in the OIDC state token"
```

---

### Task 6: Per-provider OIDC flow

**Files:**
- Modify: `server/internal/auth/oidc.go` (rewrite most of it)
- Modify: `server/internal/api/handlers.go:38-39` (route change)

**Interfaces:**
- Consumes: `services.GetAuthProvider`, `services.GetAuthProviderSecret`, `auth.saveState`, `auth.consumeState`, `models.KindOIDC`, `models.KindOAuth2`.
- Produces:
  - `auth.HandleSSOStart(c *gin.Context)` — replaces `HandleOIDCStart`.
  - `auth.HandleSSOCallback(c *gin.Context)` — replaces `HandleOIDCCallback`.
  - `auth.EvictProvider(providerID string)` — replaces `EvictOIDCProvider`.
  - `auth.completeSSOLogin(c *gin.Context, instanceID, email, name string)` — shared tail, used by Task 7's GitHub branch.
  - `auth.CallbackURL(c *gin.Context, providerID string) string` — exported so the settings API can report it.

- [ ] **Step 1: Rewrite `oidc.go`**

Replace the whole file with:

```go
package auth

import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"sync"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

var (
	provMu    sync.Mutex
	provCache = map[string]*oidc.Provider{}
)

// EvictProvider drops a cached discovery document. Keyed on provider, not
// instance: an instance now has several, and evicting all of them because one
// changed would re-fetch discovery for providers nobody touched.
func EvictProvider(providerID string) {
	provMu.Lock()
	delete(provCache, providerID)
	provMu.Unlock()
}

func requestScheme(c *gin.Context) string {
	if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
		return "http"
	}
	return "https"
}

// CallbackURL must return the same string in the start and callback halves of
// one flow, or the identity provider rejects the token exchange.
func CallbackURL(c *gin.Context, providerID string) string {
	return fmt.Sprintf("%s://%s/auth/oidc/%s/callback", requestScheme(c), c.Request.Host, providerID)
}

func oauthConfigFor(ctx context.Context, c *gin.Context, p *models.AuthProvider, secret string) (*oidc.Provider, *oauth2.Config, error) {
	if p.Kind == models.KindOAuth2 {
		return nil, githubOAuthConfig(p, secret, CallbackURL(c, p.ProviderID)), nil
	}
	provMu.Lock()
	prov := provCache[p.ProviderID]
	provMu.Unlock()
	if prov == nil {
		var err error
		prov, err = oidc.NewProvider(ctx, p.Issuer)
		if err != nil {
			return nil, nil, fmt.Errorf("provider discovery failed: %w", err)
		}
		provMu.Lock()
		provCache[p.ProviderID] = prov
		provMu.Unlock()
	}
	return prov, &oauth2.Config{
		ClientID:     p.ClientID,
		ClientSecret: secret,
		RedirectURL:  CallbackURL(c, p.ProviderID),
		Endpoint:     prov.Endpoint(),
		Scopes:       p.Scopes,
	}, nil
}

// loadProvider resolves a provider strictly within one instance. There is no
// unscoped lookup: a provider ID travels in a URL, and an unscoped one would
// let a request against one instance's host drive another instance's provider.
func loadProvider(instanceID, providerID string) (*models.AuthProvider, string, error) {
	p, err := services.GetAuthProvider(instanceID, providerID)
	if err != nil {
		return nil, "", fmt.Errorf("unknown provider")
	}
	if !p.Enabled {
		return nil, "", fmt.Errorf("provider is disabled")
	}
	secret, err := services.GetAuthProviderSecret(instanceID, providerID)
	if err != nil {
		return nil, "", err
	}
	return p, secret, nil
}

func HandleSSOStart(c *gin.Context) {
	inst, ok := InstanceFromHost(c)
	if !ok {
		c.Redirect(http.StatusFound, "/login?error=unknown_host")
		return
	}
	// Losing the feature stops new SSO logins. It deliberately does not touch
	// session validation, so nobody is evicted mid-session.
	if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
		c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
		return
	}
	providerID := c.Param("providerId")
	p, secret, err := loadProvider(inst.InstanceID, providerID)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
		return
	}
	ctx := c.Request.Context()
	_, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
		return
	}
	state, err := randomHex(16)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=state_failed")
		return
	}
	if err := saveState(ctx, state, oidcState{InstanceID: inst.InstanceID, ProviderID: p.ProviderID}); err != nil {
		c.Redirect(http.StatusFound, "/login?error=state_failed")
		return
	}
	c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
}

func HandleSSOCallback(c *gin.Context) {
	ctx := c.Request.Context()
	st, ok := consumeState(ctx, c.Query("state"))
	if !ok {
		c.Redirect(http.StatusFound, "/login?error=invalid_state")
		return
	}

	// The path segment is attacker-controlled; the state was issued by the start
	// handler. A mismatch means the two halves of this flow disagree about which
	// provider is signing somebody in, and that is not a thing to resolve by
	// picking one. The state has already been consumed, so this is not replayable.
	if c.Param("providerId") != st.ProviderID {
		c.Redirect(http.StatusFound, "/login?error=invalid_state")
		return
	}

	// The start handler checks this too, but an ungated callback is the half
	// that matters: a start that refuses is a dead end, while a callback that
	// completes signs somebody in. A licence that lapsed mid-flow stops the
	// exchange here rather than after it.
	//
	// Resolved from the consumed state rather than from the host, because on
	// this route the instance is whatever the state said and nobody is signed
	// in yet.
	if !services.GetLicenseState(st.InstanceID).Feature("oidc") {
		c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
		return
	}

	p, secret, err := loadProvider(st.InstanceID, st.ProviderID)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unavailable")
		return
	}
	provider, oauthCfg, err := oauthConfigFor(ctx, c, p, secret)
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=provider_unreachable")
		return
	}
	token, err := oauthCfg.Exchange(ctx, c.Query("code"))
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=exchange_failed")
		return
	}

	var email, name string
	if p.Kind == models.KindOAuth2 {
		email, name, err = githubIdentity(ctx, oauthCfg, token)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=identity_failed")
			return
		}
	} else {
		rawIDToken, ok := token.Extra("id_token").(string)
		if !ok {
			c.Redirect(http.StatusFound, "/login?error=missing_id_token")
			return
		}
		idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=verification_failed")
			return
		}
		var claims struct {
			Email string `json:"email"`
			Name  string `json:"name"`
		}
		if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
			c.Redirect(http.StatusFound, "/login?error=missing_email")
			return
		}
		email, name = claims.Email, claims.Name
	}

	completeSSOLogin(c, st.InstanceID, email, name)
}

// completeSSOLogin is the tail both provider kinds share: resolve the user
// within the instance, provision on first sign-in, mint the session.
func completeSSOLogin(c *gin.Context, instanceID, email, name string) {
	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		c.Redirect(http.StatusFound, "/login?error=missing_email")
		return
	}

	// Scoped to the instance the callback state names, so an address that also
	// exists in another instance is invisible here. That scoping replaces the
	// cross-instance guard this code used to need: there is no longer a way for
	// the lookup to return a user belonging to somebody else.
	u, err := services.GetUserInInstanceByEmail(instanceID, email)
	if err != nil {
		u, err = services.CreateUser(instanceID, email, "", models.RoleMember, models.AuthOIDC)
		if err != nil {
			c.Redirect(http.StatusFound, "/login?error=provisioning_failed")
			return
		}
	}

	sessionID, err := SaveSession(c.Request.Context(), &Session{
		UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: name,
	})
	if err != nil {
		c.Redirect(http.StatusFound, "/login?error=session_failed")
		return
	}
	_ = services.TouchLastLogin(u.UserID)
	SetSessionCookie(c, sessionID)
	c.Redirect(http.StatusFound, "/")
}

func HandleLogout(c *gin.Context) {
	if cookie, err := c.Request.Cookie(sessionCookieName); err == nil {
		_ = DeleteSession(c.Request.Context(), cookie.Value)
	}
	http.SetCookie(c.Writer, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		MaxAge:   -1,
	})
	c.Redirect(http.StatusFound, "/")
}
```

Note the failure mode change: this file used to answer JSON errors on the callback, which a browser mid-redirect renders as raw JSON. Every failure now redirects to `/login?error=<code>` so the user lands somewhere they can act.

- [ ] **Step 2: Swap the routes**

In `server/internal/api/handlers.go`, replace lines 38-39 with:

```go
	r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
	r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback)
	r.GET("/auth/providers", auth.HandleListPublicProviders)
```

`HandleListPublicProviders` arrives in Task 8; if this task is being built alone, add the route then instead and keep the first two here.

- [ ] **Step 3: Build**

Run: `cd server && go build ./...`
Expected: failures naming `githubOAuthConfig` and `githubIdentity` — those are Task 7. If Task 7 is not yet done, add a temporary `server/internal/auth/github.go` containing only the two signatures returning `nil` and `("", "", errors.New("not implemented"))`, and remove the stub in Task 7.

- [ ] **Step 4: Verify gin accepts the route shape**

gin cannot register two different wildcard names at the same path position. `/auth/oidc/:providerId/start` and `/auth/oidc/:providerId/callback` share `:providerId`, so they are fine — but if any other route registers `/auth/oidc/:somethingElse`, gin panics at boot.

Run: `cd server && go run ./cmd 2>&1 | head -20`
Expected: normal boot logs, **no** panic mentioning `wildcard segment`.

- [ ] **Step 5: Commit**

```bash
git add server/internal/auth/oidc.go server/internal/api/handlers.go
git commit -m "feat: per-provider SSO start and callback routes"
```

---

### Task 7: GitHub OAuth2 branch

**Files:**
- Create: `server/internal/auth/github.go`
- Test: `server/internal/auth/github_test.go`

**Interfaces:**
- Consumes: `models.AuthProvider`.
- Produces:
  - `auth.githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oauth2.Config`
  - `auth.githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (email, name string, err error)`
  - `auth.selectGitHubEmail(emails []githubEmail) (string, error)` — the pure part, tested directly.
  - `auth.githubEmail` struct: `{Email string; Primary bool; Verified bool}`.
  - `auth.githubAPIBase` package variable, so tests can point it at `httptest`.

- [ ] **Step 1: Write the failing test**

Create `server/internal/auth/github_test.go`:

```go
package auth

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"golang.org/x/oauth2"
)

func TestSelectGitHubEmailPrefersPrimaryVerified(t *testing.T) {
	got, err := selectGitHubEmail([]githubEmail{
		{Email: "alt@example.com", Primary: false, Verified: true},
		{Email: "me@example.com", Primary: true, Verified: true},
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got != "me@example.com" {
		t.Errorf("got %q", got)
	}
}

// An unverified address is not proof of control. Accepting one would let
// anyone holding a GitHub account claim any address in the instance, including
// an owner's.
func TestSelectGitHubEmailRefusesUnverifiedPrimary(t *testing.T) {
	if _, err := selectGitHubEmail([]githubEmail{
		{Email: "me@example.com", Primary: true, Verified: false},
	}); err == nil {
		t.Fatal("an unverified primary address must be refused")
	}
}

// Deliberately NOT falling back to a verified non-primary address: the spec
// requires both, and a silent fallback would sign somebody in as an identity
// they did not choose to present.
func TestSelectGitHubEmailRefusesVerifiedNonPrimaryOnly(t *testing.T) {
	if _, err := selectGitHubEmail([]githubEmail{
		{Email: "alt@example.com", Primary: false, Verified: true},
	}); err == nil {
		t.Fatal("a verified but non-primary address must be refused")
	}
}

func TestSelectGitHubEmailRefusesEmpty(t *testing.T) {
	if _, err := selectGitHubEmail(nil); err == nil {
		t.Fatal("no addresses must be refused")
	}
}

func TestGitHubIdentityReadsAPI(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/user/emails":
			_ = json.NewEncoder(w).Encode([]githubEmail{
				{Email: "me@example.com", Primary: true, Verified: true},
			})
		case "/user":
			_ = json.NewEncoder(w).Encode(map[string]string{"name": "Real Name"})
		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))
	defer srv.Close()

	old := githubAPIBase
	githubAPIBase = srv.URL
	defer func() { githubAPIBase = old }()

	email, name, err := githubIdentity(context.Background(),
		&oauth2.Config{}, &oauth2.Token{AccessToken: "t"})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if email != "me@example.com" {
		t.Errorf("email: got %q", email)
	}
	if name != "Real Name" {
		t.Errorf("name: got %q", name)
	}
}

// A name is cosmetic; an email is identity. A failing /user must not fail the
// sign-in.
func TestGitHubIdentityToleratesMissingName(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/user/emails" {
			w.Header().Set("Content-Type", "application/json")
			_ = json.NewEncoder(w).Encode([]githubEmail{
				{Email: "me@example.com", Primary: true, Verified: true},
			})
			return
		}
		w.WriteHeader(http.StatusInternalServerError)
	}))
	defer srv.Close()

	old := githubAPIBase
	githubAPIBase = srv.URL
	defer func() { githubAPIBase = old }()

	email, name, err := githubIdentity(context.Background(),
		&oauth2.Config{}, &oauth2.Token{AccessToken: "t"})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if email != "me@example.com" {
		t.Errorf("email: got %q", email)
	}
	if name != "" {
		t.Errorf("name should be empty, got %q", name)
	}
}
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `cd server && go test ./internal/auth/ -run TestGitHub -v`
Expected: FAIL to build, `undefined: selectGitHubEmail`.

- [ ] **Step 3: Write the implementation**

Create `server/internal/auth/github.go` (replacing any stub from Task 6):

```go
package auth

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"golang.org/x/oauth2"
)

// githubAPIBase is a variable rather than a constant so tests can point it at
// an httptest server. Nothing in production reassigns it.
var githubAPIBase = "https://api.github.com"

const (
	githubAuthURL  = "https://github.com/login/oauth/authorize"
	githubTokenURL = "https://github.com/login/oauth/access_token"
)

type githubEmail struct {
	Email    string `json:"email"`
	Primary  bool   `json:"primary"`
	Verified bool   `json:"verified"`
}

// githubOAuthConfig builds the OAuth2 config for a GitHub provider. GitHub has
// no discovery document, so the endpoints are constants rather than fetched.
func githubOAuthConfig(p *models.AuthProvider, secret, redirectURL string) *oauth2.Config {
	return &oauth2.Config{
		ClientID:     p.ClientID,
		ClientSecret: secret,
		RedirectURL:  redirectURL,
		Scopes:       p.Scopes,
		Endpoint: oauth2.Endpoint{
			AuthURL:  githubAuthURL,
			TokenURL: githubTokenURL,
		},
	}
}

// selectGitHubEmail requires an address that is both primary and verified.
//
// Verified alone is not enough: a non-primary address is one the person happens
// to have proved, not the one they present as themselves. Primary alone is far
// worse — an unverified address is not proof of control at all, and accepting
// one would let anyone with a GitHub account claim any address in the instance.
func selectGitHubEmail(emails []githubEmail) (string, error) {
	for _, e := range emails {
		if e.Primary && e.Verified && e.Email != "" {
			return e.Email, nil
		}
	}
	return "", errors.New("no primary verified email address on the GitHub account")
}

func githubGetJSON(ctx context.Context, client *http.Client, url string, out any) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return errors.New("github api returned " + resp.Status)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

// githubIdentity resolves the signed-in GitHub account to an email and a name.
//
// The name is best-effort: it is cosmetic, and a failing /user must not fail a
// sign-in whose identity is already established.
func githubIdentity(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (string, string, error) {
	client := cfg.Client(ctx, token)

	var emails []githubEmail
	if err := githubGetJSON(ctx, client, githubAPIBase+"/user/emails", &emails); err != nil {
		return "", "", err
	}
	email, err := selectGitHubEmail(emails)
	if err != nil {
		return "", "", err
	}

	var user struct {
		Name string `json:"name"`
	}
	if err := githubGetJSON(ctx, client, githubAPIBase+"/user", &user); err != nil {
		return email, "", nil
	}
	return email, user.Name, nil
}
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd server && go test ./internal/auth/ -v`
Expected: PASS, all tests including the six GitHub ones.

- [ ] **Step 5: Build and vet**

Run: `cd server && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 6: Commit**

```bash
git add server/internal/auth/github.go server/internal/auth/github_test.go
git commit -m "feat: add GitHub OAuth2 provider branch"
```

---

### Task 8: Provider REST API, public discovery, remove the old endpoints

**Files:**
- Create: `server/internal/api/auth_providers.go`
- Modify: `server/internal/api/handlers.go` (routes)
- Modify: `server/internal/api/instance.go:122-157` (delete both handlers)
- Delete: `server/internal/models/instance_oidc.go`, `server/internal/services/instance_oidc.go`

**Interfaces:**
- Consumes: everything from Tasks 1-7.
- Produces:
  - `auth.HandleListPublicProviders(c *gin.Context)` — the unauthenticated `GET /auth/providers`.
  - `api.listAuthProviders`, `api.createAuthProvider`, `api.updateAuthProvider`, `api.deleteAuthProvider`, `api.testAuthProvider`, `api.ackAuthProviderNotice`, `api.listAuthPresets`.

- [ ] **Step 1: Write the public discovery handler**

Append to `server/internal/auth/local.go`:

```go
// HandleListPublicProviders is unauthenticated: it is what the login page reads
// to decide what to draw. It carries no issuer, no client ID and no secret —
// only what a button needs, because anyone who can reach the login page can
// read this.
func HandleListPublicProviders(c *gin.Context) {
	type publicProvider struct {
		ID     string `json:"id"`
		Name   string `json:"name"`
		Preset string `json:"preset"`
	}
	out := []publicProvider{}

	instanceID, err := resolveLoginInstance(c)
	if err != nil {
		// An unresolvable instance is not an error the login page can act on:
		// it still has to render a password form. Answer the safe shape.
		c.JSON(http.StatusOK, gin.H{"local_enabled": true, "providers": out})
		return
	}

	// A lapsed licence stops SSO, so a button that cannot work is not offered.
	if services.GetLicenseState(instanceID).Feature("oidc") {
		providers, err := services.ListEnabledAuthProviders(instanceID)
		if err == nil {
			for _, p := range providers {
				out = append(out, publicProvider{ID: p.ProviderID, Name: p.Name, Preset: p.Preset})
			}
		}
	}

	localEnabled := services.IsLocalLoginEnabled(instanceID)
	// Belt and braces against a hand-edited database: a login page with neither
	// a form nor a button is unrecoverable without database access.
	if !localEnabled && len(out) == 0 {
		localEnabled = true
	}
	c.JSON(http.StatusOK, gin.H{"local_enabled": localEnabled, "providers": out})
}
```

- [ ] **Step 2: Refuse local login when it is disabled**

In `server/internal/auth/local.go`, inside `HandleLocalLogin`, immediately after `instanceID, err := resolveLoginInstance(c)` succeeds, add:

```go
	// The login page hides the form, but the page is a courtesy and the API is
	// the boundary.
	if !services.IsLocalLoginEnabled(instanceID) {
		c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
		return
	}
```

- [ ] **Step 3: Write the management handlers**

Create `server/internal/api/auth_providers.go`:

```go
package api

import (
	"errors"
	"net/http"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

type authProviderView struct {
	models.AuthProvider
	ClientSecretSet bool   `json:"client_secret_set"`
	CallbackURL     string `json:"callback_url"`
}

func viewOf(c *gin.Context, p models.AuthProvider) authProviderView {
	return authProviderView{
		AuthProvider:    p,
		ClientSecretSet: p.ClientSecretEnc != "",
		CallbackURL:     auth.CallbackURL(c, p.ProviderID),
	}
}

func listAuthPresets(c *gin.Context) {
	c.JSON(http.StatusOK, auth.Presets())
}

func listAuthProviders(c *gin.Context) {
	providers, err := services.ListAuthProviders(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	out := make([]authProviderView, 0, len(providers))
	for _, p := range providers {
		out = append(out, viewOf(c, p))
	}
	c.JSON(http.StatusOK, out)
}

func createAuthProvider(c *gin.Context) {
	var body struct {
		Name         string `json:"name"`
		Preset       string `json:"preset"`
		IssuerInput  string `json:"issuer_input"`
		ClientID     string `json:"client_id"`
		ClientSecret string `json:"client_secret"`
		Enabled      bool   `json:"enabled"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	issuer, err := auth.ExpandIssuer(body.Preset, body.IssuerInput)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	instanceID := auth.InstanceID(c)
	p, err := services.CreateAuthProvider(&models.AuthProvider{
		InstanceID: instanceID,
		Name:       body.Name,
		Kind:       auth.KindFor(body.Preset),
		Preset:     body.Preset,
		Issuer:     issuer,
		ClientID:   body.ClientID,
		Scopes:     auth.DefaultScopes(body.Preset),
		Enabled:    body.Enabled,
	}, body.ClientSecret)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(instanceID, "auth_provider.create", actorFromCtx(c), "", "", p.Name)
	c.JSON(http.StatusCreated, viewOf(c, *p))
}

func updateAuthProvider(c *gin.Context) {
	var body struct {
		Name         *string `json:"name"`
		IssuerInput  *string `json:"issuer_input"`
		ClientID     *string `json:"client_id"`
		ClientSecret *string `json:"client_secret"`
		Enabled      *bool   `json:"enabled"`
		Order        *int    `json:"order"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	instanceID := auth.InstanceID(c)
	providerID := c.Param("id")

	existing, err := services.GetAuthProvider(instanceID, providerID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
		return
	}

	if err := guardProviderChange(instanceID, existing, body.Enabled, false); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
		return
	}

	in := services.AuthProviderUpdate{
		Name: body.Name, ClientID: body.ClientID,
		ClientSecret: body.ClientSecret, Enabled: body.Enabled, Order: body.Order,
	}
	if body.IssuerInput != nil {
		issuer, err := auth.ExpandIssuer(existing.Preset, *body.IssuerInput)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		in.Issuer = &issuer
	}
	if err := services.UpdateAuthProvider(instanceID, providerID, in); err != nil {
		if errors.Is(err, mongo.ErrNoDocuments) {
			c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
			return
		}
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	// Issuer, client ID or secret may have changed; the cached discovery
	// document was built from the old ones.
	auth.EvictProvider(providerID)
	services.LogEvent(instanceID, "auth_provider.update", actorFromCtx(c), "", "", existing.Name)
	c.JSON(http.StatusOK, gin.H{"saved": true})
}

func deleteAuthProvider(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	providerID := c.Param("id")

	existing, err := services.GetAuthProvider(instanceID, providerID)
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
		return
	}
	if err := guardProviderChange(instanceID, existing, nil, true); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "last_provider"})
		return
	}
	if err := services.DeleteAuthProvider(instanceID, providerID); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	auth.EvictProvider(providerID)
	services.LogEvent(instanceID, "auth_provider.delete", actorFromCtx(c), "", "", existing.Name)
	c.JSON(http.StatusOK, gin.H{"deleted": true})
}

// guardProviderChange asks whether the instance would still have a way in.
// Deleting and disabling reach the same condition, so they share one answer.
func guardProviderChange(instanceID string, existing *models.AuthProvider, enabled *bool, deleting bool) error {
	losing := deleting || (enabled != nil && !*enabled)
	if !losing || !existing.Enabled {
		return nil
	}
	n, err := services.CountEnabledAuthProviders(instanceID)
	if err != nil {
		return err
	}
	return services.CheckLockout(services.IsLocalLoginEnabled(instanceID), n-1)
}

func ackAuthProviderNotice(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	if err := services.AckAuthProviderNotice(instanceID, c.Param("id")); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"acknowledged": true})
}

// testAuthProvider proves the configuration is reachable. It signs nobody in.
func testAuthProvider(c *gin.Context) {
	instanceID := auth.InstanceID(c)
	p, err := services.GetAuthProvider(instanceID, c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
		return
	}
	if p.Kind == models.KindOAuth2 {
		// GitHub has no discovery document. The only meaningful check without
		// a user token is that credentials are present.
		if p.ClientID == "" || p.ClientSecretEnc == "" {
			c.JSON(http.StatusOK, gin.H{"ok": false, "message": "client ID and secret are required"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"ok": true, "message": "credentials are configured"})
		return
	}
	if _, err := oidc.NewProvider(c.Request.Context(), p.Issuer); err != nil {
		c.JSON(http.StatusOK, gin.H{"ok": false, "message": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"ok": true, "message": "discovery document fetched"})
}
```

- [ ] **Step 4: Register the routes and delete the old ones**

In `server/internal/api/handlers.go`, inside the `instance` group, replace lines 103-104 with nothing, and add a new group after it:

```go
		providers := apiGroup.Group("/auth/providers")
		providers.Use(auth.RequireRole("owner", "admin"), RequireFeature("oidc"))
		{
			providers.GET("", listAuthProviders)
			providers.POST("", createAuthProvider)
			providers.PUT("/:id", updateAuthProvider)
			providers.DELETE("/:id", deleteAuthProvider)
			providers.POST("/:id/test", testAuthProvider)
			providers.POST("/:id/ack-notice", ackAuthProviderNotice)
		}
		apiGroup.GET("/auth/presets", auth.RequireRole("owner", "admin"), listAuthPresets)
```

Confirm `r.GET("/auth/providers", auth.HandleListPublicProviders)` is present in the unauthenticated block (added in Task 6 Step 2).

- [ ] **Step 5: Delete the dead code**

```bash
git rm server/internal/models/instance_oidc.go server/internal/services/instance_oidc.go
```

Then delete `getInstanceOIDC` and `putInstanceOIDC` from `server/internal/api/instance.go` (lines 122-157), and remove any import left unused.

- [ ] **Step 6: Build, vet, test**

Run: `cd server && go build ./... && go vet ./... && go test ./...`
Expected: clean. If the build names `EvictOIDCProvider`, a caller was missed — it was renamed to `EvictProvider` in Task 6.

- [ ] **Step 7: Verify the endpoints by hand**

With the server running and a session cookie in `$C` (copy `km_session` from a browser devtools cookie jar):

```bash
# public discovery, no auth
curl -s localhost:8080/auth/providers | jq

# must contain no issuer, client_id or secret
curl -s localhost:8080/auth/providers | jq 'tostring | test("issuer|client_id|secret") | not'

# presets
curl -s -b "km_session=$C" localhost:8080/api/auth/presets | jq '.[].id'

# create
curl -s -b "km_session=$C" -X POST localhost:8080/api/auth/providers \
  -H 'Content-Type: application/json' \
  -d '{"name":"Google","preset":"google","client_id":"x","client_secret":"y","enabled":true}' | jq

# list
curl -s -b "km_session=$C" localhost:8080/api/auth/providers | jq '.[] | {name, provider_id, callback_url}'

# the removed routes
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/auth/oidc/start
curl -s -o /dev/null -w '%{http_code}\n' -b "km_session=$C" localhost:8080/api/instance/oidc
```

Expected: discovery returns `local_enabled` plus the created provider; the secret-leak check prints `true`; presets prints five IDs; create returns 201 with a `callback_url` containing the provider ID; both removed routes print `404`.

- [ ] **Step 8: Verify the lockout guard**

```bash
# turn local login off while one provider is enabled — should succeed
curl -s -b "km_session=$C" -X PUT localhost:8080/api/settings \
  -H 'Content-Type: application/json' -d '{"local_login_enabled":false}' | jq

# now disable the only provider — should be refused
PID=$(curl -s -b "km_session=$C" localhost:8080/api/auth/providers | jq -r '.[0].provider_id')
curl -s -w '\n%{http_code}\n' -b "km_session=$C" -X PUT "localhost:8080/api/auth/providers/$PID" \
  -H 'Content-Type: application/json' -d '{"enabled":false}'
```

Expected: the second call returns `409` with `"code":"last_provider"`. Re-enable local login afterwards so the instance is usable.

- [ ] **Step 9: Commit**

```bash
git add -A server/internal/api server/internal/auth/local.go server/internal/models server/internal/services
git commit -m "feat: auth provider REST API and public provider discovery"
```

---

### Task 9: Web API client and login page

**Files:**
- Modify: `web/lib/api.ts:352-360` (types), `:455-458` (redirect URL helper), `:482-487` (the two OIDC calls)
- Modify: `web/app/login/page.tsx`

**Interfaces:**
- Consumes: the endpoints from Task 8.
- Produces:
  - `PublicProvider`, `AuthProvider`, `AuthPreset`, `ProvidersResponse` TypeScript types.
  - `auth.providers()`, `api.listAuthProviders()`, `api.createAuthProvider()`, `api.updateAuthProvider()`, `api.deleteAuthProvider()`, `api.testAuthProvider()`, `api.ackAuthProviderNotice()`, `api.listAuthPresets()`.

- [ ] **Step 1: Replace the OIDC types**

In `web/lib/api.ts`, replace the `OrgOIDCConfig` and `OrgOIDCInput` interfaces (lines 352-360) with:

```ts
export interface PublicProvider {
    id: string;
    name: string;
    preset: string;
}

export interface ProvidersResponse {
    local_enabled: boolean;
    providers: PublicProvider[];
}

export interface AuthProvider {
    provider_id: string;
    instance_id: string;
    name: string;
    kind: "oidc" | "oauth2";
    preset: string;
    issuer: string;
    client_id: string;
    scopes: string[];
    enabled: boolean;
    callback_notice: boolean;
    order: number;
    created_at: string;
    updated_at: string;
    client_secret_set: boolean;
    callback_url: string;
}

export interface AuthPreset {
    id: string;
    label: string;
    kind: "oidc" | "oauth2";
    input_label: string;
    input_hint: string;
}

export interface AuthProviderInput {
    name: string;
    preset: string;
    issuer_input?: string;
    client_id: string;
    client_secret: string;
    enabled: boolean;
}

export interface AuthProviderUpdate {
    name?: string;
    issuer_input?: string;
    client_id?: string;
    client_secret?: string;
    enabled?: boolean;
    order?: number;
}
```

- [ ] **Step 2: Replace the client methods**

In the `auth` object, replace `oidcRedirectUrl` (lines 455-458) with:

```ts
    /** Unauthenticated: what the login page draws itself from. */
    providers(): Promise<ProvidersResponse> {
        return fetch(`${base()}/auth/providers`, { credentials: "include" }).then((r) => {
            if (!r.ok) throw new Error("could not load sign-in options");
            return r.json();
        });
    },

    /** Where a provider button sends the browser. */
    ssoStartUrl(providerId: string): string {
        return `/auth/oidc/${providerId}/start`;
    },
```

Match `base()` to whatever the file's existing unauthenticated calls use — check how `bootstrapStatus` at line 429 builds its URL and follow it exactly rather than introducing a second convention.

In the `api` object, replace `getInstanceOIDC` and `saveInstanceOIDC` (lines 482-487) with:

```ts
    listAuthPresets(): Promise<AuthPreset[]> {
        return request<AuthPreset[]>("/auth/presets");
    },

    listAuthProviders(): Promise<AuthProvider[]> {
        return request<AuthProvider[]>("/auth/providers");
    },

    createAuthProvider(input: AuthProviderInput): Promise<AuthProvider> {
        return request<AuthProvider>("/auth/providers", { method: "POST", body: JSON.stringify(input) });
    },

    updateAuthProvider(id: string, input: AuthProviderUpdate): Promise<{ saved: boolean }> {
        return request<{ saved: boolean }>(`/auth/providers/${id}`, { method: "PUT", body: JSON.stringify(input) });
    },

    deleteAuthProvider(id: string): Promise<{ deleted: boolean }> {
        return request<{ deleted: boolean }>(`/auth/providers/${id}`, { method: "DELETE" });
    },

    testAuthProvider(id: string): Promise<{ ok: boolean; message: string }> {
        return request<{ ok: boolean; message: string }>(`/auth/providers/${id}/test`, { method: "POST" });
    },

    ackAuthProviderNotice(id: string): Promise<{ acknowledged: boolean }> {
        return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" });
    },
```

- [ ] **Step 3: Add the provider icon component**

Create `web/components/settings/ProviderIcon.tsx`:

```tsx
/**
 * One glyph per preset, plus a neutral key for anything custom. These are
 * simplified marks drawn with currentColor rather than brand logos: a brand
 * logo carries usage terms, and currentColor is what lets a button match the
 * text beside it.
 */
export function ProviderIcon({ preset, className = "h-4 w-4" }: { preset: string; className?: string }) {
    switch (preset) {
        case "github":
            return (
                <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path d="M12 2a10 10 0 00-3.16 19.49c.5.09.68-.22.68-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.5 9.5 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10 10 0 0012 2z" />
                </svg>
            );
        case "google":
            return (
                <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path d="M12 11v2.6h6.2c-.25 1.6-1.87 4.7-6.2 4.7A6.3 6.3 0 1112 5.7c1.98 0 3.3.85 4.06 1.58l2.77-2.67A9.9 9.9 0 0012 2a10 10 0 100 20c5.77 0 9.6-4.06 9.6-9.77 0-.66-.07-1.16-.16-1.66H12z" />
                </svg>
            );
        case "entra":
            return (
                <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                    <path d="M12 2L3 19h5.4L12 12l3.6 7H21L12 2z" />
                </svg>
            );
        case "okta":
            return (
                <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
                    <circle cx="12" cy="12" r="7" />
                </svg>
            );
        default:
            return (
                <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
                    <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.03 5.91l-1.72 1.72a.75.75 0 01-.53.22H9.75v1.5a.75.75 0 01-.75.75H7.5v1.5a.75.75 0 01-.75.75H4.5a.75.75 0 01-.75-.75v-2.19a.75.75 0 01.22-.53l5.11-5.11A6 6 0 1121.75 8.25z" />
                </svg>
            );
    }
}
```

- [ ] **Step 4: Rewrite the login page**

Replace `web/app/login/page.tsx` with:

```tsx
"use client";

import { useEffect, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { auth, type PublicProvider } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Logo } from "@/components/Logo";
import { NetworkBackground } from "@/components/NetworkBackground";
import { ProviderIcon } from "@/components/settings/ProviderIcon";

const ERROR_MESSAGES: Record<string, string> = {
    oidc_unavailable: "Single sign-on is not available on this instance's plan.",
    provider_unavailable: "That sign-in method is no longer available.",
    provider_unreachable: "Could not reach the identity provider.",
    invalid_state: "That sign-in attempt expired. Please try again.",
    exchange_failed: "The identity provider rejected the sign-in.",
    verification_failed: "The identity provider's response could not be verified.",
    missing_id_token: "The identity provider returned no identity token.",
    missing_email: "The identity provider returned no email address.",
    identity_failed: "Could not read a verified email address from that account.",
    provisioning_failed: "Could not create your account on this instance.",
    session_failed: "Could not start your session. Please try again.",
    state_failed: "Could not start sign-in. Please try again.",
    unknown_host: "This address does not name a known instance.",
};

export default function LoginPage() {
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [instanceName, setInstanceName] = useState("");
    const [providers, setProviders] = useState<PublicProvider[]>([]);
    // Default true so a slow or failing discovery call still renders something
    // usable rather than an empty card.
    const [localEnabled, setLocalEnabled] = useState(true);
    const [ssoError, setSsoError] = useState("");

    useEffect(() => {
        const code = new URLSearchParams(window.location.search).get("error");
        if (code) setSsoError(ERROR_MESSAGES[code] ?? "Sign-in failed. Please try again.");
    }, []);

    useEffect(() => {
        (async () => {
            try {
                const s = await auth.bootstrapStatus();
                if (s.needs_setup) {
                    window.location.href = "/setup";
                    return;
                }
                if (s.instance_name) setInstanceName(s.instance_name);
            } catch {}
            try {
                const p = await auth.providers();
                setProviders(p.providers);
                setLocalEnabled(p.local_enabled);
            } catch {}
            try {
                await auth.me();
                window.location.href = "/";
            } catch {}
        })();
    }, []);

    const {
        mutate: signIn,
        isPending,
        error,
    } = useMutation({
        mutationFn: () => auth.login(email, password),
        onSuccess: () => {
            window.location.href = "/";
        },
    });

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        signIn();
    }

    const showLocal = localEnabled || providers.length === 0;
    const showDivider = showLocal && providers.length > 0;

    return (
        <div className="relative flex min-h-screen items-center justify-center p-4">
            <NetworkBackground />
            <div className="relative w-full max-w-sm">
                <div className="mb-8 flex flex-col items-center gap-3">
                    <Logo className="h-11 w-auto text-logo" />
                    <h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Sign in to Vantage</h1>
                    {/* A keyed label, not a heading: site/'s .tag treatment. */}
                    <p className="font-mono text-[0.7rem] uppercase tracking-[0.15em] text-text-secondary">{instanceName}</p>
                </div>

                <Card>
                    {ssoError && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{ssoError}</div>}

                    {showLocal && (
                        <form onSubmit={handleSubmit} className="space-y-4">
                            <div>
                                <label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
                                    Email
                                </label>
                                <input
                                    id="email"
                                    type="email"
                                    required
                                    autoComplete="username"
                                    value={email}
                                    onChange={(e) => setEmail(e.target.value)}
                                    className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
                                />
                            </div>

                            <div>
                                <label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
                                    Password
                                </label>
                                <input
                                    id="password"
                                    type="password"
                                    required
                                    autoComplete="current-password"
                                    value={password}
                                    onChange={(e) => setPassword(e.target.value)}
                                    className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
                                />
                            </div>

                            {error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}

                            <Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
                                Sign In
                            </Button>
                        </form>
                    )}

                    {showDivider && (
                        <div className="my-5 flex items-center gap-3">
                            <div className="h-px flex-1 bg-border" />
                            <span className="text-xs uppercase tracking-wider text-text-tertiary">or</span>
                            <div className="h-px flex-1 bg-border" />
                        </div>
                    )}

                    {providers.length > 0 && (
                        <div className="space-y-2">
                            {providers.map((p) => (
                                <a key={p.id} href={auth.ssoStartUrl(p.id)} className="block">
                                    <Button type="button" variant="secondary" className="w-full justify-center gap-2">
                                        <ProviderIcon preset={p.preset} />
                                        Continue with {p.name}
                                    </Button>
                                </a>
                            ))}
                        </div>
                    )}
                </Card>
            </div>
        </div>
    );
}
```

- [ ] **Step 5: Typecheck**

Run: `cd web && npx tsc --noEmit`
Expected: no errors. If `OrgOIDCConfig` is still referenced, `OIDCCard.tsx` has not been removed yet — that is Task 10; temporarily leave the old types in place and remove them there.

- [ ] **Step 6: Verify the three rendering states in a browser**

With the server and `web` running, and using `mongosh` to set state between checks:

```bash
# state 1: no providers, local on
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.updateMany({},{$set:{enabled:false}})'
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.settings.updateMany({},{$unset:{local_login_enabled:""}})'
```

Load `/login`. Expected: password form only, **no divider and no buttons**.

```bash
# state 2: providers on, local on
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.updateMany({},{$set:{enabled:true}})'
```

Reload. Expected: form, divider, one button per provider, each labelled `Continue with <name>`.

```bash
# state 3: providers on, local off
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.settings.updateMany({},{$set:{local_login_enabled:false}})'
```

Reload. Expected: buttons only, no form, no divider.

```bash
# state 4: the impossible one — hand-edited, must not render an empty card
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.auth_providers.updateMany({},{$set:{enabled:false}})'
```

Reload. Expected: the password form is back. Then restore:

```bash
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.settings.updateMany({},{$unset:{local_login_enabled:""}}); db.auth_providers.updateMany({},{$set:{enabled:true}})'
```

- [ ] **Step 7: Verify an error redirect renders**

Load `/login?error=invalid_state` directly.
Expected: "That sign-in attempt expired. Please try again." in a danger-styled box above the form.

- [ ] **Step 8: Commit**

```bash
git add web/lib/api.ts web/app/login/page.tsx web/components/settings/ProviderIcon.tsx
git commit -m "feat: render one login button per configured auth provider"
```

---

### Task 10: Settings card

**Files:**
- Create: `web/components/settings/AuthProvidersCard.tsx`
- Delete: `web/components/settings/OIDCCard.tsx`
- Modify: `web/app/(app)/settings/page.tsx:13` and `:194`

**Interfaces:**
- Consumes: the `api` methods from Task 9, `Field`/`inputClass` from `./Field`, `SectionCard` from `./SectionCard`, `ProviderIcon`.
- Produces: `AuthProvidersCard`.

- [ ] **Step 1: Write the card**

Create `web/components/settings/AuthProvidersCard.tsx`:

```tsx
"use client";

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AuthProvider, type AuthPreset } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
import { ProviderIcon } from "./ProviderIcon";

function IdentityIcon() {
    return (
        <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
            <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.964 0a9 9 0 10-11.964 0m11.964 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
            />
        </svg>
    );
}

function CallbackRow({ url }: { url: string }) {
    const [copied, setCopied] = useState(false);
    return (
        <div className="flex items-center gap-2">
            <code className="flex-1 overflow-x-auto rounded bg-well px-2 py-1.5 font-mono text-xs text-text-primary">{url}</code>
            <Button
                type="button"
                variant="ghost"
                size="sm"
                onClick={async () => {
                    await navigator.clipboard.writeText(url);
                    setCopied(true);
                    setTimeout(() => setCopied(false), 2000);
                }}
            >
                {copied ? "Copied!" : "Copy"}
            </Button>
        </div>
    );
}

function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) {
    const [preset, setPreset] = useState<string>("google");
    const [name, setName] = useState("");
    const [issuerInput, setIssuerInput] = useState("");
    const [clientId, setClientId] = useState("");
    const [clientSecret, setClientSecret] = useState("");

    const chosen = presets.find((p) => p.id === preset);

    const { mutate: create, isPending, error } = useMutation({
        mutationFn: () =>
            api.createAuthProvider({
                name: name || chosen?.label || "Single sign-on",
                preset,
                issuer_input: issuerInput,
                client_id: clientId,
                client_secret: clientSecret,
                enabled: true,
            }),
        onSuccess: onDone,
    });

    return (
        <form
            onSubmit={(e) => {
                e.preventDefault();
                create();
            }}
            className="space-y-4 rounded border border-border bg-surface-2 p-4"
        >
            <Field label="Provider type">
                <select value={preset} onChange={(e) => setPreset(e.target.value)} className={inputClass}>
                    {presets.map((p) => (
                        <option key={p.id || "custom"} value={p.id}>
                            {p.label}
                        </option>
                    ))}
                </select>
            </Field>

            <Field label="Button label" hint="What members see on the sign-in page.">
                <input type="text" required value={name} onChange={(e) => setName(e.target.value)} placeholder={chosen?.label ?? ""} className={inputClass} />
            </Field>

            {chosen?.input_label && (
                <Field label={chosen.input_label} hint={chosen.input_hint}>
                    <input type="text" required value={issuerInput} onChange={(e) => setIssuerInput(e.target.value)} className={inputClass} />
                </Field>
            )}

            <Field label="Client ID">
                <input type="text" required value={clientId} onChange={(e) => setClientId(e.target.value)} className={inputClass} />
            </Field>

            <Field label="Client secret">
                <input type="password" required autoComplete="new-password" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} className={inputClass} />
            </Field>

            {error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}

            <div className="flex items-center gap-3">
                <Button type="submit" variant="primary" loading={isPending}>
                    Add provider
                </Button>
                <Button type="button" variant="ghost" onClick={onDone}>
                    Cancel
                </Button>
            </div>
            <p className="text-xs text-text-tertiary">You will get a callback URL to register with your provider once it is added.</p>
        </form>
    );
}

function ProviderRow({ p }: { p: AuthProvider }) {
    const queryClient = useQueryClient();
    const [secret, setSecret] = useState("");
    const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
    const invalidate = () => queryClient.invalidateQueries({ queryKey: ["auth-providers"] });

    const { mutate: update, error: updateError } = useMutation({
        mutationFn: (patch: Parameters<typeof api.updateAuthProvider>[1]) => api.updateAuthProvider(p.provider_id, patch),
        onSuccess: () => {
            setSecret("");
            invalidate();
        },
    });
    const { mutate: remove, error: deleteError } = useMutation({
        mutationFn: () => api.deleteAuthProvider(p.provider_id),
        onSuccess: invalidate,
    });
    const { mutate: test, isPending: testing } = useMutation({
        mutationFn: () => api.testAuthProvider(p.provider_id),
        onSuccess: setTestResult,
    });
    const { mutate: ack } = useMutation({
        mutationFn: () => api.ackAuthProviderNotice(p.provider_id),
        onSuccess: invalidate,
    });

    const error = (updateError ?? deleteError) as Error | null;

    return (
        <div className="space-y-3 rounded border border-border bg-surface-2 p-4">
            <div className="flex items-center gap-3">
                <ProviderIcon preset={p.preset} className="h-5 w-5 text-text-secondary" />
                <div className="flex-1">
                    <p className="text-sm font-medium text-text-primary">{p.name}</p>
                    <p className="text-xs text-text-tertiary">{p.issuer || "GitHub"}</p>
                </div>
                <label className="flex items-center gap-2 text-sm text-text-secondary">
                    <input
                        type="checkbox"
                        checked={p.enabled}
                        onChange={(e) => update({ enabled: e.target.checked })}
                        className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
                    />
                    Enabled
                </label>
            </div>

            {p.callback_notice && (
                <div className="space-y-2 rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
                    <p>This provider&apos;s callback URL has changed. Update it in your identity provider or sign-in will fail.</p>
                    <Button type="button" variant="ghost" size="sm" onClick={() => ack()}>
                        I&apos;ve updated it
                    </Button>
                </div>
            )}

            <div>
                <p className="mb-1.5 text-xs font-medium text-text-secondary">Callback URL to register with this provider:</p>
                <CallbackRow url={p.callback_url} />
            </div>

            <div className="flex items-end gap-2">
                <div className="flex-1">
                    <Field label="Replace client secret" hint={p.client_secret_set ? "A secret is stored. Leave blank to keep it." : "No secret stored."}>
                        <input type="password" autoComplete="new-password" value={secret} onChange={(e) => setSecret(e.target.value)} className={inputClass} />
                    </Field>
                </div>
                <Button type="button" variant="secondary" disabled={!secret} onClick={() => update({ client_secret: secret })}>
                    Save
                </Button>
            </div>

            {testResult && (
                <div className={`rounded border px-3 py-2 text-sm ${testResult.ok ? "border-success/30 bg-success/10 text-success" : "border-danger/30 bg-danger/10 text-danger"}`}>
                    {testResult.message}
                </div>
            )}

            {error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error.message}</div>}

            <div className="flex items-center gap-2">
                <Button type="button" variant="ghost" size="sm" loading={testing} onClick={() => test()}>
                    Test connection
                </Button>
                <Button type="button" variant="ghost" size="sm" onClick={() => remove()}>
                    Remove
                </Button>
            </div>
        </div>
    );
}

export function AuthProvidersCard({ localLoginEnabled, onLocalLoginChange }: { localLoginEnabled: boolean; onLocalLoginChange: (v: boolean) => void }) {
    const queryClient = useQueryClient();
    const [adding, setAdding] = useState(false);
    const { data: providers, isLoading } = useQuery({ queryKey: ["auth-providers"], queryFn: api.listAuthProviders });
    const { data: presets } = useQuery({ queryKey: ["auth-presets"], queryFn: api.listAuthPresets });

    if (isLoading) {
        return (
            <Card>
                <div className="flex justify-center py-8">
                    <div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
                </div>
            </Card>
        );
    }

    const list = providers ?? [];
    const enabledCount = list.filter((p) => p.enabled).length;

    return (
        <SectionCard
            title="Sign-in providers"
            description="Members can sign in with any provider you add here. Users are provisioned into this instance on first sign-in."
            icon={<IdentityIcon />}
        >
            <div className="space-y-3">
                {list.length === 0 && <p className="text-sm text-text-tertiary">No providers configured. Members sign in with a password.</p>}
                {list.map((p) => (
                    <ProviderRow key={p.provider_id} p={p} />
                ))}
            </div>

            <div className="mt-4">
                {adding ? (
                    <AddProviderForm
                        presets={presets ?? []}
                        onDone={() => {
                            setAdding(false);
                            queryClient.invalidateQueries({ queryKey: ["auth-providers"] });
                        }}
                    />
                ) : (
                    <Button type="button" variant="secondary" onClick={() => setAdding(true)}>
                        Add provider
                    </Button>
                )}
            </div>

            <div className="mt-6 border-t border-border pt-4">
                <label className="flex items-center gap-2 text-sm text-text-secondary">
                    <input
                        type="checkbox"
                        checked={localLoginEnabled}
                        disabled={enabledCount === 0}
                        onChange={(e) => onLocalLoginChange(e.target.checked)}
                        className="h-4 w-4 rounded-sm border-border bg-surface-2 accent-accent"
                    />
                    Allow email and password sign-in
                </label>
                <p className="mt-1.5 text-xs text-text-tertiary">
                    {enabledCount === 0
                        ? "Enable at least one provider before turning this off, or nobody could sign in."
                        : "Turn this off to require members to use a provider above."}
                </p>
            </div>
        </SectionCard>
    );
}
```

- [ ] **Step 2: Wire it into the settings page**

In `web/app/(app)/settings/page.tsx`:

- Line 13: replace `import { OIDCCard } from "@/components/settings/OIDCCard";` with `import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";`
- Line 194: replace `<OIDCCard />` with:

```tsx
                    <AuthProvidersCard
                        localLoginEnabled={settings?.local_login_enabled ?? true}
                        onLocalLoginChange={(v) => saveSettings({ ...settingsPayload, local_login_enabled: v })}
                    />
```

Adapt `settingsPayload` to whatever the page already passes to its save mutation — read the surrounding code and match it rather than inventing a new shape. Add `local_login_enabled?: boolean` to `saveSettings`'s parameter type in `web/lib/api.ts:593`, and `local_login_enabled?: boolean` to the `Settings` interface.

- [ ] **Step 3: Delete the old card**

```bash
git rm web/components/settings/OIDCCard.tsx
```

- [ ] **Step 4: Typecheck**

Run: `cd web && npx tsc --noEmit`
Expected: no errors. If `OrgOIDCConfig` or `OrgOIDCInput` are now unreferenced, delete them from `web/lib/api.ts`.

- [ ] **Step 5: Verify in a browser**

At `/settings`, in the Access group:

1. The card lists existing providers, each with its callback URL and a working Copy button.
2. A migrated provider shows the amber callback-changed warning; clicking "I've updated it" makes it disappear and it stays gone after a reload.
3. "Add provider" with preset Google asks for a label, client ID and secret and **no issuer field**; with "Other" it asks for an issuer URL; with Entra it asks for a tenant ID.
4. "Test connection" on a Google provider reports the discovery document was fetched.
5. With one enabled provider, unticking "Allow email and password sign-in" saves. Then unticking that provider's Enabled reports the 409 message rather than a generic failure.
6. With zero enabled providers, the local-login checkbox is disabled and explains why.

- [ ] **Step 6: Commit**

```bash
git add -A web/components/settings web/app "web/lib/api.ts"
git commit -m "feat: manage multiple sign-in providers from settings"
```

---

### Task 11: End-to-end sign-in and documentation

**Files:**
- Modify: `CLAUDE.md` (Auth and Orgs, REST API, MongoDB Collections, Migrations sections)
- Modify: `docsite/docs/vantage/settings.md`
- Modify: `docsite/docs/getting-started/first-login.md`

**Interfaces:**
- Consumes: everything.
- Produces: no code.

- [ ] **Step 1: Sign in end-to-end with a real provider**

Configure one real OIDC provider (Google is the least setup) with the callback URL the settings card shows. Then:

1. Sign out.
2. On `/login`, click the provider's button.
3. Complete the provider's consent screen.

Expected: redirected to `/`, signed in, and a `users` document exists with `auth_source: "oidc"` scoped to this instance:

```bash
mongosh "$MONGO_URI/vantage" --quiet --eval 'db.users.find({auth_source:"oidc"},{email:1,instance_id:1,role:1})'
```

- [ ] **Step 2: Verify two providers in one instance resolve independently**

Add a second provider. Confirm each button lands on its own provider's consent screen, and that its callback URL contains its own provider ID.

- [ ] **Step 3: Verify the state cross-check refuses a tampered callback**

Start a sign-in, capture the `state` from the provider's redirect back, and replay it against a **different** provider's callback path:

```bash
curl -s -o /dev/null -w '%{redirect_url}\n' "localhost:8080/auth/oidc/<OTHER_PROVIDER_ID>/callback?state=<STATE>&code=x"
```

Expected: redirects to `/login?error=invalid_state`.

- [ ] **Step 4: Update CLAUDE.md**

In the **Auth and Orgs** section, replace the OIDC bullet with:

```markdown
- **Auth providers** — configured _per instance_ in `auth_providers`, any number of them, each named and independently enabled. Issuer, client ID and an encrypted client secret per provider. `/auth/oidc/:providerId/start` → `/auth/oidc/:providerId/callback`. Presets (Entra, Google, Okta, GitHub) are a Go table in `server/internal/auth/presets.go` and expand to a real issuer on save, so nothing downstream knows a preset existed. GitHub is OAuth2 rather than OIDC and takes its own branch, requiring an address that is both primary **and** verified — an unverified address is not proof of control.
- **Local login** — `settings.local_login_enabled`, a `*bool` because absent must mean enabled; a plain bool would disable password sign-in fleet-wide at upgrade. `services.CheckLockout` refuses any change leaving neither local login nor an enabled provider, and is enforced in the service layer so the settings path and the provider path cannot disagree.
```

In **REST API**, replace the `org` block's OIDC lines with:

```
providers    GET,POST /auth/providers · PUT,DELETE /auth/providers/:id
             POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets  (owner|admin)
```

and add `GET /auth/providers` to the unauthenticated list, noting it carries no issuer, client ID or secret.

In **MongoDB Collections**, replace `org_oidc` with `auth_providers` and add this note:

- `auth_providers.provider_id` 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. `callback_notice` marks a provider migrated from the old single-provider shape, whose redirect URI therefore changed.

In **Migrations**, add `0005_auth_providers` to the list with a note that it copies ciphertext verbatim rather than re-encrypting, so it does not need `KEY_ENCRYPTION_KEY`.

- [ ] **Step 5: Update the docsite**

In `docsite/docs/vantage/settings.md`, rewrite the single sign-on section to cover adding several providers, the per-provider callback URL, the presets and their inputs, and turning off password sign-in — including that at least one provider must be enabled first.

Add an upgrade note stating plainly: **existing single sign-on stops working until the callback URL shown in settings is registered with the identity provider**, because callbacks are now per provider. Password sign-in is unaffected, so an administrator can always sign in to make the change.

In `docsite/docs/getting-started/first-login.md`, update any text describing one SSO button to describe one button per configured provider, and note that no buttons appear when none are configured.

- [ ] **Step 6: Verify the docs build**

Run: `cd docsite && npm run build`
Expected: build succeeds, no broken-link warnings for the pages touched.

- [ ] **Step 7: Full check**

Run: `cd server && go build ./... && go vet ./... && go test ./...` then `cd web && npx tsc --noEmit`
Expected: all clean.

- [ ] **Step 8: Commit**

```bash
git add CLAUDE.md docsite/docs
git commit -m "docs: document multiple auth providers and the callback URL change"
```

---

## Self-Review Notes

Checked against the spec:

- Data model, presets, `local_login_enabled` pointer → Tasks 1, 3.
- Migration 0005 including verbatim ciphertext and idempotency → Task 4.
- Per-provider routes, removed legacy routes, state cross-check → Tasks 5, 6, 8.
- GitHub primary-and-verified → Task 7.
- Public discovery with no secrets, management API, lockout guards, audit → Task 8.
- Login page's four rendering states → Task 9 Step 6.
- Settings card with per-provider callback URL and dismissable notice → Task 10.
- Upgrade impact documented → Task 11.

Two deviations from the spec, both deliberate:

1. The spec puts `EnsureAuthProviderIndexes` in the provider service; Task 2 folds it into the existing `EnsureAuthIndexes` instead, because two builders for one index is how they drift, and `EnsureAuthIndexes` is already fatal-on-failure where this index needs to be.
2. The spec has the callback answer JSON errors as the current code does; Tasks 6 and 9 redirect to `/login?error=<code>` throughout, because a browser mid-redirect renders a JSON body as raw text on a blank page.
