feat: auth provider REST API and public provider discovery
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user