diff --git a/server/internal/api/auth_providers.go b/server/internal/api/auth_providers.go new file mode 100644 index 0000000..96fcc4a --- /dev/null +++ b/server/internal/api/auth_providers.go @@ -0,0 +1,205 @@ +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"}) +} diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index ff4972f..af42d68 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -38,6 +38,7 @@ func RegisterRoutes(r *gin.Engine) { r.GET("/auth/me", auth.HandleMe) r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart) r.GET("/auth/oidc/:providerId/callback", auth.HandleSSOCallback) + r.GET("/auth/providers", auth.HandleListPublicProviders) apiGroup := r.Group("/api") apiGroup.Use(auth.Middleware()) @@ -101,9 +102,19 @@ func RegisterRoutes(r *gin.Engine) { instance.POST("/users", createInstanceUser) instance.PUT("/users/:id/role", updateInstanceUserRole) instance.DELETE("/users/:id", deleteInstanceUser) - instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC) - instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC) } + + 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) } } diff --git a/server/internal/api/instance.go b/server/internal/api/instance.go index 92a1236..87a1b8d 100644 --- a/server/internal/api/instance.go +++ b/server/internal/api/instance.go @@ -118,40 +118,3 @@ func orgUserErrStatus(err error) int { } return http.StatusInternalServerError } - -func getInstanceOIDC(c *gin.Context) { - cfg, err := services.GetInstanceOIDC(auth.InstanceID(c)) - if err != nil { - c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "instance_id": cfg.InstanceID, - "issuer": cfg.Issuer, - "client_id": cfg.ClientID, - "enabled": cfg.Enabled, - "updated_at": cfg.UpdatedAt, - "client_secret_set": cfg.ClientSecretEnc != "", - }) -} - -func putInstanceOIDC(c *gin.Context) { - var body struct { - Issuer string `json:"issuer"` - 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 - } - if err := services.SaveInstanceOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - auth.EvictProvider(auth.InstanceID(c)) - c.JSON(http.StatusOK, gin.H{"saved": true}) -} diff --git a/server/internal/auth/local.go b/server/internal/auth/local.go index c17098d..84d68da 100644 --- a/server/internal/auth/local.go +++ b/server/internal/auth/local.go @@ -65,6 +65,12 @@ func HandleLocalLogin(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // 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 + } u, err := services.GetUserInInstanceByEmail(instanceID, body.Email) if err != nil || !services.VerifyPassword(u, body.Password) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) @@ -82,6 +88,45 @@ func HandleLocalLogin(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } +// 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}) +} + func HandleBootstrapStatus(c *gin.Context) { var ( n int64 diff --git a/server/internal/models/instance_oidc.go b/server/internal/models/instance_oidc.go deleted file mode 100644 index fe759d7..0000000 --- a/server/internal/models/instance_oidc.go +++ /dev/null @@ -1,17 +0,0 @@ -package models - -import ( - "time" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -type InstanceOIDC struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - Issuer string `bson:"issuer" json:"issuer"` - ClientID string `bson:"client_id" json:"client_id"` - ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"` - Enabled bool `bson:"enabled" json:"enabled"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` -} diff --git a/server/internal/services/instance_oidc.go b/server/internal/services/instance_oidc.go deleted file mode 100644 index 208da1e..0000000 --- a/server/internal/services/instance_oidc.go +++ /dev/null @@ -1,50 +0,0 @@ -package services - -import ( - "context" - "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/options" -) - -func GetInstanceOIDC(instanceID string) (*models.InstanceOIDC, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - var o models.InstanceOIDC - err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o) - if err != nil { - return nil, err - } - return &o, nil -} - -func GetInstanceOIDCSecret(instanceID string) (string, error) { - o, err := GetInstanceOIDC(instanceID) - if err != nil { - return "", err - } - return decryptString(o.ClientSecretEnc) -} - -func SaveInstanceOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - set := bson.M{ - "instance_id": instanceID, "issuer": issuer, "client_id": clientID, - "enabled": enabled, "updated_at": time.Now(), - } - if clientSecret != "" { - enc, err := encryptString(clientSecret) - if err != nil { - return err - } - set["client_secret_enc"] = enc - } - _, err := db.Col("instance_oidc").UpdateOne(ctx, - bson.M{"instance_id": instanceID}, bson.M{"$set": set}, - options.UpdateOne().SetUpsert(true)) - return err -}