feat: auth provider REST API and public provider discovery

This commit is contained in:
2026-08-03 10:51:35 +01:00
parent f3b9f6f286
commit dde47de145
6 changed files with 263 additions and 106 deletions
+205
View File
@@ -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"})
}
+13 -2
View File
@@ -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)
}
}
-37
View File
@@ -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})
}
+45
View File
@@ -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
-17
View File
@@ -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"`
}
-50
View File
@@ -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
}