From c56bfb72709c185fbdf1e386f974ad8289370465 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:23:35 +0100 Subject: [PATCH] docs: implementation plan for multiple auth providers --- .../plans/2026-08-03-multi-auth-providers.md | 3047 +++++++++++++++++ 1 file changed, 3047 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-multi-auth-providers.md diff --git a/docs/superpowers/plans/2026-08-03-multi-auth-providers.md b/docs/superpowers/plans/2026-08-03-multi-auth-providers.md new file mode 100644 index 0000000..54e9493 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-multi-auth-providers.md @@ -0,0 +1,3047 @@ +# 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/`. +- 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=` 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 { + 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 { + return request("/auth/presets"); + }, + + listAuthProviders(): Promise { + return request("/auth/providers"); + }, + + createAuthProvider(input: AuthProviderInput): Promise { + return request("/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 ( + + ); + case "google": + return ( + + ); + case "entra": + return ( + + ); + case "okta": + return ( + + ); + default: + return ( + + ); + } +} +``` + +- [ ] **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 = { + 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([]); + // 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 ( +
+ +
+
+ +

Sign in to Vantage

+ {/* A keyed label, not a heading: site/'s .tag treatment. */} +

{instanceName}

+
+ + + {ssoError &&
{ssoError}
} + + {showLocal && ( +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error &&
{(error as Error).message}
} + + +
+ )} + + {showDivider && ( +
+
+ or +
+
+ )} + + {providers.length > 0 && ( +
+ {providers.map((p) => ( + + + + ))} +
+ )} + +
+
+ ); +} +``` + +- [ ] **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 `. + +```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 ( + + + + ); +} + +function CallbackRow({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + return ( +
+ {url} + +
+ ); +} + +function AddProviderForm({ presets, onDone }: { presets: AuthPreset[]; onDone: () => void }) { + const [preset, setPreset] = useState("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 ( +
{ + e.preventDefault(); + create(); + }} + className="space-y-4 rounded border border-border bg-surface-2 p-4" + > + + + + + + setName(e.target.value)} placeholder={chosen?.label ?? ""} className={inputClass} /> + + + {chosen?.input_label && ( + + setIssuerInput(e.target.value)} className={inputClass} /> + + )} + + + setClientId(e.target.value)} className={inputClass} /> + + + + setClientSecret(e.target.value)} className={inputClass} /> + + + {error &&
{(error as Error).message}
} + +
+ + +
+

You will get a callback URL to register with your provider once it is added.

+
+ ); +} + +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[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 ( +
+
+ +
+

{p.name}

+

{p.issuer || "GitHub"}

+
+ +
+ + {p.callback_notice && ( +
+

This provider's callback URL has changed. Update it in your identity provider or sign-in will fail.

+ +
+ )} + +
+

Callback URL to register with this provider:

+ +
+ +
+
+ + setSecret(e.target.value)} className={inputClass} /> + +
+ +
+ + {testResult && ( +
+ {testResult.message} +
+ )} + + {error &&
{error.message}
} + +
+ + +
+
+ ); +} + +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 ( + +
+
+
+ + ); + } + + const list = providers ?? []; + const enabledCount = list.filter((p) => p.enabled).length; + + return ( + } + > +
+ {list.length === 0 &&

No providers configured. Members sign in with a password.

} + {list.map((p) => ( + + ))} +
+ +
+ {adding ? ( + { + setAdding(false); + queryClient.invalidateQueries({ queryKey: ["auth-providers"] }); + }} + /> + ) : ( + + )} +
+ +
+ +

+ {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."} +

+
+
+ ); +} +``` + +- [ ] **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 `` with: + +```tsx + 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//callback?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=` throughout, because a browser mid-redirect renders a JSON body as raw text on a blank page.