From 45f7c0c393b9f4280514d1704726b54bbb1ce1fd Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:30:21 +0100 Subject: [PATCH] feat: add AuthProvider model and identity provider presets --- server/internal/auth/presets.go | 119 ++++++++++++++++++++++++ server/internal/models/auth_provider.go | 38 ++++++++ 2 files changed, 157 insertions(+) create mode 100644 server/internal/auth/presets.go create mode 100644 server/internal/models/auth_provider.go diff --git a/server/internal/auth/presets.go b/server/internal/auth/presets.go new file mode 100644 index 0000000..60c92fd --- /dev/null +++ b/server/internal/auth/presets.go @@ -0,0 +1,119 @@ +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 +} diff --git a/server/internal/models/auth_provider.go b/server/internal/models/auth_provider.go new file mode 100644 index 0000000..7510bd1 --- /dev/null +++ b/server/internal/models/auth_provider.go @@ -0,0 +1,38 @@ +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" +)