From d0ed9885e7dc568e7aad2dae9762a7df451749f4 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 16:41:50 +0100 Subject: [PATCH] feat(auth): per-org OIDC resolver replaces global provider --- server/cmd/main.go | 4 - server/internal/api/handlers.go | 11 +- server/internal/auth/middleware.go | 5 - server/internal/auth/oidc.go | 151 +++++++++++++++++---------- server/internal/auth/session.go | 13 ++- server/internal/services/org_oidc.go | 52 +++++++++ 6 files changed, 160 insertions(+), 76 deletions(-) create mode 100644 server/internal/services/org_oidc.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 639e1bd..3199a39 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -53,10 +53,6 @@ func main() { } log.Println("connected to Redis") - if err := auth.InitOIDC(context.Background()); err != nil { - log.Fatalf("failed to initialise OIDC: %v", err) - } - // Background goroutine to mark offline servers go func() { ticker := time.NewTicker(2 * time.Minute) diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 7132c12..69b58aa 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -32,11 +32,14 @@ func RegisterRoutes(r *gin.Engine) { // group as flat JSON. r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup) - // Auth endpoints (no session required) - r.GET("/auth/login", auth.HandleLogin) - r.GET("/auth/callback", auth.HandleCallback) - r.GET("/auth/logout", auth.HandleLogout) + // Unauthenticated auth endpoints + r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus) + r.POST("/auth/bootstrap", auth.HandleBootstrap) + r.POST("/auth/login", auth.HandleLocalLogin) + r.POST("/auth/logout", auth.HandleLogout) r.GET("/auth/me", auth.HandleMe) + r.GET("/auth/oidc/start", auth.HandleOIDCStart) + r.GET("/auth/oidc/callback", auth.HandleOIDCCallback) // API endpoints protected by session middleware apiGroup := r.Group("/api") diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 6edb5e6..4118c56 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -37,11 +37,6 @@ func UserID(c *gin.Context) string { func Middleware() gin.HandlerFunc { return func(c *gin.Context) { - if !authEnabled { - c.Next() - return - } - cookie, err := c.Request.Cookie(sessionCookieName) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index 677956b..318766c 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -2,114 +2,149 @@ package auth import ( "context" - "log" + "fmt" "net/http" - "os" + "strings" + "sync" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" "golang.org/x/oauth2" ) -var ( - oidcProvider *oidc.Provider - oauth2Cfg *oauth2.Config - authEnabled bool -) - -func InitOIDC(ctx context.Context) error { - issuer := os.Getenv("OIDC_ISSUER") - if issuer == "" { - log.Println("OIDC_ISSUER not set; authentication disabled") - return nil - } - - p, err := oidc.NewProvider(ctx, issuer) - if err != nil { - return err - } - oidcProvider = p - oauth2Cfg = &oauth2.Config{ - ClientID: os.Getenv("OIDC_CLIENT_ID"), - ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), - RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), - Endpoint: p.Endpoint(), - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - } - authEnabled = true - log.Println("OIDC authentication enabled") - return nil +type orgProvider struct { + provider *oidc.Provider + oauth *oauth2.Config } -func Enabled() bool { return authEnabled } +var ( + provMu sync.Mutex + provCache = map[string]*orgProvider{} +) -func HandleLogin(c *gin.Context) { - state, err := randomHex(16) +func redirectURL(c *gin.Context) string { + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + scheme = "http" + } + return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host) +} + +func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*orgProvider, error) { + cfg, err := services.GetOrgOIDC(orgID) + if err != nil || !cfg.Enabled { + return nil, fmt.Errorf("org SSO not configured") + } + secret, err := services.GetOrgOIDCSecret(orgID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"}) + return nil, err + } + provMu.Lock() + op := provCache[orgID] + provMu.Unlock() + if op == nil || op.provider == nil { + p, err := oidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + return nil, err + } + op = &orgProvider{provider: p} + provMu.Lock() + provCache[orgID] = op + provMu.Unlock() + } + op.oauth = &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: secret, + RedirectURL: redirectURL(c), Endpoint: op.provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + return op, nil +} + +func HandleOIDCStart(c *gin.Context) { + org, ok := OrgFromHost(c) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"}) return } - if err := SaveState(c.Request.Context(), state); err != nil { + ctx := c.Request.Context() + op, err := providerForOrg(ctx, c, org.OrgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + state, err := randomHex(16) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"}) + return + } + if err := SaveStateOrg(ctx, state, org.OrgID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"}) return } - c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state)) + c.Redirect(http.StatusFound, op.oauth.AuthCodeURL(state)) } -func HandleCallback(c *gin.Context) { +func HandleOIDCCallback(c *gin.Context) { ctx := c.Request.Context() - - if !ConsumeState(ctx, c.Query("state")) { + orgID, ok := ConsumeStateOrg(ctx, c.Query("state")) + if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) return } - - token, err := oauth2Cfg.Exchange(ctx, c.Query("code")) + op, err := providerForOrg(ctx, c, orgID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + token, err := op.oauth.Exchange(ctx, c.Query("code")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"}) return } - rawIDToken, ok := token.Extra("id_token").(string) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"}) return } - - verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID}) - idToken, err := verifier.Verify(ctx, rawIDToken) + idToken, err := op.provider.Verifier(&oidc.Config{ClientID: op.oauth.ClientID}).Verify(ctx, rawIDToken) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"}) return } - var claims struct { - Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` } - if err := idToken.Claims(&claims); err != nil { + if err := idToken.Claims(&claims); err != nil || claims.Email == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"}) return } + email := strings.ToLower(claims.Email) + u, err := services.GetUserByEmail(email) + if err != nil { + // provision new member in THIS org + u, err = services.CreateUser(orgID, email, "", "member", "oidc") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"}) + return + } + } else if u.OrgID != orgID { + c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"}) + return + } + sessionID, err := SaveSession(ctx, &Session{ - UserID: claims.Sub, - Email: claims.Email, - Name: claims.Name, + UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) return } - + _ = services.TouchLastLogin(u.UserID) SetSessionCookie(c, sessionID) - - frontendURL := os.Getenv("PUBLIC_HOST") - if frontendURL == "" { - frontendURL = "/" - } - c.Redirect(http.StatusFound, frontendURL) + c.Redirect(http.StatusFound, "/") } func HandleLogout(c *gin.Context) { diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 969105a..b72a736 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -71,11 +71,14 @@ func DeleteSession(ctx context.Context, id string) error { return rdb.Del(ctx, sessionPrefix+id).Err() } -func SaveState(ctx context.Context, state string) error { - return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err() +func SaveStateOrg(ctx context.Context, state, orgID string) error { + return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err() } -func ConsumeState(ctx context.Context, state string) bool { - n, err := rdb.Del(ctx, statePrefix+state).Result() - return err == nil && n > 0 +func ConsumeStateOrg(ctx context.Context, state string) (string, bool) { + orgID, err := rdb.GetDel(ctx, statePrefix+state).Result() + if err != nil || orgID == "" { + return "", false + } + return orgID, true } diff --git a/server/internal/services/org_oidc.go b/server/internal/services/org_oidc.go new file mode 100644 index 0000000..1b37e03 --- /dev/null +++ b/server/internal/services/org_oidc.go @@ -0,0 +1,52 @@ +package services + +import ( + "context" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var o models.OrgOIDC + err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o) + if err != nil { + return nil, err + } + return &o, nil +} + +func GetOrgOIDCSecret(orgID string) (string, error) { + o, err := GetOrgOIDC(orgID) + if err != nil { + return "", err + } + return decryptString(o.ClientSecretEnc) +} + +// SaveOrgOIDC upserts the org's provider config. An empty clientSecret keeps the +// stored secret (so the UI need not resend it). +func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + set := bson.M{ + "org_id": orgID, "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("org_oidc").UpdateOne(ctx, + bson.M{"org_id": orgID}, bson.M{"$set": set}, + options.UpdateOne().SetUpsert(true)) + return err +}