From 0858693d572b9154ebe9ea574c28b9fcc885fdae Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 3 Aug 2026 10:33:36 +0100 Subject: [PATCH] feat: add auth provider service layer and lockout guard --- server/internal/services/auth_provider.go | 241 ++++++++++++++++++++++ server/internal/services/coreindexes.go | 8 +- 2 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 server/internal/services/auth_provider.go diff --git a/server/internal/services/auth_provider.go b/server/internal/services/auth_provider.go new file mode 100644 index 0000000..d9b11fc --- /dev/null +++ b/server/internal/services/auth_provider.go @@ -0,0 +1,241 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "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") + +// ErrLastProvider is returned when a change would remove or disable the last +// enabled provider while local login is also off. +var ErrLastProvider = errors.New("cannot remove or disable the last sign-in method") + +// ErrLocalLoginRequired is returned when disabling local login would leave no +// enabled provider to sign in with. +var ErrLocalLoginRequired = errors.New("local login is required until a provider is enabled") + +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 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 +} + +// 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 +} diff --git a/server/internal/services/coreindexes.go b/server/internal/services/coreindexes.go index 6e30833..8a51797 100644 --- a/server/internal/services/coreindexes.go +++ b/server/internal/services/coreindexes.go @@ -30,9 +30,11 @@ func EnsureAuthIndexes() error { return err } - // instance_oidc is control-plane only, so its index stays here. - if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "instance_id", Value: 1}}, + // 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