Feat v0.6.6 final hardening (#41)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 29s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #41.
This commit is contained in:
2026-03-31 17:40:40 +00:00
committed by xcaliber
parent 81c28a50bf
commit 7915d84c8b
19 changed files with 625 additions and 131 deletions

View File

@@ -246,6 +246,7 @@ type oidcClaims struct {
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Nonce string `json:"nonce"`
Groups []string `json:"groups"`
RealmAccess realmAccess `json:"realm_access"`
ResourceAccess interface{} `json:"resource_access"`
@@ -304,6 +305,23 @@ func (p *OIDCProvider) validateToken(tokenString string) (*oidcClaims, error) {
return claims, nil
}
// ValidateIDTokenNonce validates that the ID token's nonce claim matches the
// stored nonce from the authorization request. This prevents token replay and
// substitution attacks in the authorization code flow.
func (p *OIDCProvider) ValidateIDTokenNonce(tokenString, expectedNonce string) error {
if expectedNonce == "" {
return nil // no nonce to validate (e.g. direct token flow)
}
claims, err := p.validateToken(tokenString)
if err != nil {
return fmt.Errorf("token validation failed: %w", err)
}
if claims.Nonce != expectedNonce {
return fmt.Errorf("nonce mismatch: expected %q, got %q", expectedNonce, claims.Nonce)
}
return nil
}
// isIdPAdmin checks whether the OIDC claims include the configured admin role.
func (p *OIDCProvider) isIdPAdmin(claims *oidcClaims) bool {
for _, role := range claims.RealmAccess.Roles {

View File

@@ -97,6 +97,12 @@ type Config struct {
ClusterHeartbeatInterval time.Duration
ClusterStaleThreshold time.Duration
ClusterEndpoint string
// Package signing (future)
// PACKAGE_VERIFY_SIGNATURES: when true, log warnings for unsigned packages.
// No cryptographic verification yet — this reserves the manifest schema slot
// and config plumbing so it can be implemented without a breaking change.
PackageVerifySignatures bool
}
// Load reads configuration from environment variables.
@@ -161,6 +167,8 @@ func Load() *Config {
ClusterHeartbeatInterval: getEnvDuration("CLUSTER_HEARTBEAT_INTERVAL", 10*time.Second),
ClusterStaleThreshold: getEnvDuration("CLUSTER_STALE_THRESHOLD", 30*time.Second),
ClusterEndpoint: getEnv("CLUSTER_ENDPOINT", ""),
PackageVerifySignatures: getEnvBool("PACKAGE_VERIFY_SIGNATURES", false),
}
}

View File

@@ -112,6 +112,8 @@ func (b *Bus) publishLocal(event Event) {
// in separate goroutines. Useful for I/O-heavy handlers.
// Also calls the broadcastHook asynchronously if set.
func (b *Bus) PublishAsync(event Event) {
b.publishCount.Add(1)
b.mu.RLock()
var matched []Handler
for pattern, subs := range b.subs {
@@ -125,6 +127,7 @@ func (b *Bus) PublishAsync(event Event) {
b.mu.RUnlock()
for _, h := range matched {
b.deliverCount.Add(1)
go h(event)
}
if hook != nil {

View File

@@ -218,8 +218,8 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
return
}
// Verify state (nonce + redirectTo retrieved but not yet validated — TODO)
_, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state)
// Verify state and retrieve nonce for ID token validation
storedNonce, _, err := h.stores.GlobalConfig.ConsumeOIDCState(c.Request.Context(), state)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired state"})
return
@@ -246,6 +246,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
return
}
// Validate nonce in the ID token to prevent token replay/substitution
if tokenResp.IDToken != "" && storedNonce != "" {
if err := oidcProv.ValidateIDTokenNonce(tokenResp.IDToken, storedNonce); err != nil {
log.Printf("[auth/oidc] nonce validation failed: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "nonce validation failed"})
return
}
}
// Use the ID token (or access token) to authenticate via the provider
// Temporarily set the Authorization header so Authenticate() can read it
tokenToValidate := tokenResp.IDToken

View File

@@ -0,0 +1,154 @@
package handlers
import (
"encoding/json"
"fmt"
"regexp"
)
// validManifestID matches lowercase alphanumeric slugs with optional hyphens.
// Duplicated from packages.go for use in standalone validation.
var validManifestID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// ManifestInfo holds the parsed and validated fields from a manifest.json.
type ManifestInfo struct {
ID string
Title string
Type string // surface, extension, full, workflow, library
Version string
Description string
Author string
Tier string
SchemaVersion int
HasRoute bool
HasTools bool
HasPipes bool
HasHooks bool
HasSettings bool
HasExports bool
Dependencies map[string]any
Requires []string
Signature string // reserved for future package signing
}
// ValidateManifest parses a manifest map and validates all required fields,
// type constraints, and structural rules. Returns a ManifestInfo or an error
// describing the first validation failure.
func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
if manifest == nil {
return nil, fmt.Errorf("manifest is nil")
}
info := &ManifestInfo{}
// ── Required fields ──────────────────────────────────────────
info.ID, _ = manifest["id"].(string)
info.Title, _ = manifest["title"].(string)
if info.ID == "" || info.Title == "" {
return nil, fmt.Errorf("manifest must have 'id' and 'title'")
}
// ── Package ID slug validation ───────────────────────────────
if !validManifestID.MatchString(info.ID) {
return nil, fmt.Errorf("package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)")
}
// ── Type validation ──────────────────────────────────────────
info.Type, _ = manifest["type"].(string)
if info.Type == "" {
info.Type = "surface" // backward compat
}
validTypes := map[string]bool{
"surface": true, "extension": true, "full": true,
"workflow": true, "library": true,
}
if !validTypes[info.Type] {
return nil, fmt.Errorf("manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'")
}
// ── Extract optional fields ──────────────────────────────────
info.Version, _ = manifest["version"].(string)
if info.Version == "" {
info.Version = "0.0.0"
}
info.Description, _ = manifest["description"].(string)
info.Author, _ = manifest["author"].(string)
info.Tier, _ = manifest["tier"].(string)
if info.Tier == "" {
info.Tier = "browser"
}
info.Signature, _ = manifest["signature"].(string)
info.HasRoute = manifest["route"] != nil || manifest["routes"] != nil
info.HasTools = manifest["tools"] != nil
info.HasPipes = manifest["pipes"] != nil
info.HasHooks = manifest["hooks"] != nil
info.HasSettings = manifest["settings"] != nil
info.HasExports = false
if deps, ok := manifest["dependencies"].(map[string]any); ok {
info.Dependencies = deps
}
if reqs, ok := manifest["requires"].([]any); ok {
for _, r := range reqs {
if s, ok := r.(string); ok && s != "" {
info.Requires = append(info.Requires, s)
}
}
}
info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ────────────────────────────────
hasExtBehavior := info.HasTools || info.HasPipes || info.HasHooks
switch info.Type {
case "surface":
// route is optional for surfaces
case "extension":
if !hasExtBehavior {
return nil, fmt.Errorf("extension packages require at least one of: tools, pipes, hooks")
}
if info.HasRoute {
return nil, fmt.Errorf("extension packages cannot have a route — use type 'full' for both")
}
case "full":
if !info.HasRoute {
return nil, fmt.Errorf("full packages require a route")
}
if !hasExtBehavior && !info.HasSettings {
return nil, fmt.Errorf("full packages require at least one of: tools, pipes, hooks, settings")
}
case "workflow":
if manifest["workflow_definition"] == nil {
return nil, fmt.Errorf("workflow packages require a 'workflow_definition' in the manifest")
}
case "library":
exports, _ := manifest["exports"].([]any)
if len(exports) == 0 {
return nil, fmt.Errorf("library packages require an 'exports' array")
}
info.HasExports = true
if info.HasTools || info.HasPipes {
return nil, fmt.Errorf("library packages cannot have tools or pipes")
}
if info.HasRoute {
return nil, fmt.Errorf("library packages cannot have a route")
}
}
return info, nil
}
// ValidateManifestJSON parses raw JSON bytes into a manifest map and validates.
func ValidateManifestJSON(data []byte) (map[string]any, *ManifestInfo, error) {
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, nil, fmt.Errorf("invalid manifest.json: %w", err)
}
info, err := ValidateManifest(manifest)
if err != nil {
return nil, nil, err
}
return manifest, info, nil
}

View File

@@ -0,0 +1,166 @@
package handlers
import (
"testing"
)
func TestValidateManifest_ValidSurface(t *testing.T) {
m := map[string]any{
"id": "my-surface",
"title": "My Surface",
"type": "surface",
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.ID != "my-surface" {
t.Errorf("expected id 'my-surface', got %q", info.ID)
}
if info.Type != "surface" {
t.Errorf("expected type 'surface', got %q", info.Type)
}
if info.Version != "0.0.0" {
t.Errorf("expected default version '0.0.0', got %q", info.Version)
}
}
func TestValidateManifest_MissingID(t *testing.T) {
m := map[string]any{
"title": "No ID",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for missing id")
}
}
func TestValidateManifest_MissingTitle(t *testing.T) {
m := map[string]any{
"id": "no-title",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for missing title")
}
}
func TestValidateManifest_InvalidID(t *testing.T) {
m := map[string]any{
"id": "UPPERCASE-BAD",
"title": "Bad ID",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for invalid package id")
}
}
func TestValidateManifest_InvalidType(t *testing.T) {
m := map[string]any{
"id": "my-pkg",
"title": "My Package",
"type": "invalid",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for invalid type")
}
}
func TestValidateManifest_ExtensionNoTools(t *testing.T) {
m := map[string]any{
"id": "my-ext",
"title": "My Extension",
"type": "extension",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for extension without tools/pipes/hooks")
}
}
func TestValidateManifest_LibraryNoExports(t *testing.T) {
m := map[string]any{
"id": "my-lib",
"title": "My Library",
"type": "library",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for library without exports")
}
}
func TestValidateManifest_ValidLibrary(t *testing.T) {
m := map[string]any{
"id": "my-lib",
"title": "My Library",
"type": "library",
"version": "1.2.0",
"exports": []any{"module_a", "module_b"},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.Version != "1.2.0" {
t.Errorf("expected version '1.2.0', got %q", info.Version)
}
if !info.HasExports {
t.Error("expected HasExports to be true")
}
}
func TestValidateManifest_NilManifest(t *testing.T) {
_, err := ValidateManifest(nil)
if err == nil {
t.Fatal("expected error for nil manifest")
}
}
func TestValidateManifest_SignatureField(t *testing.T) {
m := map[string]any{
"id": "my-pkg",
"title": "Signed Package",
"type": "surface",
"signature": "sha256:abc123",
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.Signature != "sha256:abc123" {
t.Errorf("expected signature 'sha256:abc123', got %q", info.Signature)
}
}
func TestValidateManifest_Dependencies(t *testing.T) {
m := map[string]any{
"id": "consumer",
"title": "Consumer Package",
"type": "surface",
"dependencies": map[string]any{
"chat-core": ">=1.0.0",
},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(info.Dependencies) != 1 {
t.Errorf("expected 1 dependency, got %d", len(info.Dependencies))
}
}
func TestValidateManifest_WorkflowNoDef(t *testing.T) {
m := map[string]any{
"id": "my-wf",
"title": "My Workflow",
"type": "workflow",
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for workflow without workflow_definition")
}
}

View File

@@ -29,10 +29,11 @@ var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// PackageHandler manages unified package lifecycle (admin-only).
// Replaces SurfaceHandler.
type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
sandbox *sandbox.Sandbox
runner *sandbox.Runner
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
bundledDir string // e.g. /app/bundled-packages — .pkg archive source
sandbox *sandbox.Sandbox
runner *sandbox.Runner
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -43,6 +44,12 @@ func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
return &PackageHandler{stores: s, packagesDir: dir}
}
// SetBundledDir sets the path to the bundled packages directory.
// Used by dependency auto-activation to resolve missing dependencies.
func (h *PackageHandler) SetBundledDir(dir string) {
h.bundledDir = dir
}
// SetSandbox attaches a Starlark sandbox for schema migrations.
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
h.sandbox = sb
@@ -319,79 +326,22 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// Validate required fields
pkgID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if pkgID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
// Validate manifest structure (required fields, type, constraints)
mInfo, err := ValidateManifest(manifest)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pkgID := mInfo.ID
title := mInfo.Title
pkgType := mInfo.Type
// Validate package ID is a safe slug
if !validPackageID.MatchString(pkgID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Determine type (default: surface for backward compat)
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
return
}
// Type-specific validation
hasRoute := manifest["route"] != nil || manifest["routes"] != nil
hasTools := manifest["tools"] != nil
hasPipes := manifest["pipes"] != nil
hasHooks := manifest["hooks"] != nil
hasSettings := manifest["settings"] != nil
hasExtBehavior := hasTools || hasPipes || hasHooks
switch pkgType {
case "surface":
// route is optional for surfaces (core surfaces don't always have it in manifest)
case "extension":
if !hasExtBehavior {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
return
}
case "full":
if !hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
return
}
// or a settings schema. A surface-with-settings is a valid "full" package
// (e.g. editor: browser-tier surface + admin-configurable settings).
if !hasExtBehavior && !hasSettings {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks, settings"})
return
}
case "workflow":
if manifest["workflow_definition"] == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
return
}
case "library":
exports, _ := manifest["exports"].([]any)
if len(exports) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
return
}
if hasTools || hasPipes {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
return
// Package signing hook (schema reserved — no crypto verification yet)
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
if mInfo.Signature != "" {
log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", pkgID, mInfo.Signature)
} else {
log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", pkgID)
}
}
@@ -443,17 +393,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
// Use validated manifest fields for DB columns
version := mInfo.Version
description := mInfo.Description
author := mInfo.Author
tier := mInfo.Tier
userID := c.GetString("user_id")
@@ -543,6 +487,7 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
// Libraries must be installed first; consumers declare them.
// If a dependency is missing but available as a bundled package, auto-install it.
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
// Clear stale dependency records from a previous install of the same consumer.
if h.stores.Dependencies != nil {
@@ -551,8 +496,25 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
for libID, vSpec := range deps {
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
if err != nil || lib == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
return
// Attempt auto-install from bundled packages
if h.bundledDir != "" {
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
if _, statErr := os.Stat(pkgPath); statErr == nil {
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner); installErr != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
})
return
}
// Re-fetch after install
lib, err = h.stores.Packages.Get(c.Request.Context(), libID)
}
}
if lib == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"})
return
}
}
if lib.Type != "library" {
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})

View File

@@ -146,10 +146,14 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
return "", err
}
pkgID, _ := manifest["id"].(string)
if pkgID == "" {
return "", fmt.Errorf("manifest missing 'id'")
// Validate manifest structure
mInfo, err := ValidateManifest(manifest)
if err != nil {
return "", fmt.Errorf("invalid manifest: %w", err)
}
pkgID := mInfo.ID
title := mInfo.Title
pkgType := mInfo.Type
// Skip if already exists (admin uninstalled → stays uninstalled)
existing, _ := stores.Packages.Get(ctx, pkgID)
@@ -157,16 +161,6 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
return "skipped", nil
}
title, _ := manifest["title"].(string)
if title == "" {
return "", fmt.Errorf("manifest missing 'title'")
}
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
// Extract static assets to packagesDir/{id}/
if packagesDir != "" {
if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil {
@@ -174,17 +168,11 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
}
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
// Use validated manifest fields for DB columns
version := mInfo.Version
description := mInfo.Description
author := mInfo.Author
tier := mInfo.Tier
// Register in database via Seed (upsert)
if err := stores.Packages.Seed(ctx, pkgID, title, "bundled", manifest); err != nil {

View File

@@ -171,6 +171,7 @@ func TestBundledInstall_DormantPackage(t *testing.T) {
"id": "dormant-pkg",
"title": "Dormant Package",
"type": "extension",
"hooks": []string{"on_install"},
"requires": []string{"chat"},
})

View File

@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"fmt"
"log"
"go.starlark.net/starlark"
)
@@ -90,9 +91,13 @@ func ParseSchemaVersion(manifest any) int {
return 0
}
// RunSchemaMigrations runs extension schema migrations between versions.
// Stub — full implementation will be added when extension DB schemas are stabilized.
// RunSchemaMigrations logs a schema version change during package update.
//
// Only additive schema changes are supported: extensions can ADD columns and
// tables via db_tables in the manifest, but cannot modify or delete existing
// schema. If a migration between versions requires destructive changes,
// uninstall and reinstall the package manually.
func RunSchemaMigrations(ctx any, sandbox any, stores any, db any, isPostgres bool, packageID string, manifest any, fromVersion, toVersion int) error {
// TODO: implement extension schema migrations
log.Printf("[pkg-migrate] %s: schema version %d → %d (only additive changes are applied automatically; destructive migrations require manual uninstall/reinstall)", packageID, fromVersion, toVersion)
return nil
}

View File

@@ -451,6 +451,7 @@ func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) {
"title": "Future",
"type": "extension",
"version": "1.0.0",
"hooks": []string{"on_install"},
"requires": []string{"kernel>=99.0.0"},
})

View File

@@ -764,6 +764,7 @@ func main() {
}
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig()))
pkgAdm.SetBundledDir(cfg.BundledPackagesDir)
// Package registry — must be registered before /packages/:id
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
@@ -867,7 +868,7 @@ func main() {
middleware.AuthOrRedirect(cfg, stores.Users, userCache),
middleware.RequireAdminPage(stores),
},
Session: middleware.AuthOrRedirect(cfg, stores.Users, userCache), // TODO: session middleware for workflow visitors
Session: middleware.OptionalAuth(cfg, stores.Users, userCache),
})
// Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect).

View File

@@ -97,6 +97,64 @@ func RequireAdminPage(stores store.Stores) gin.HandlerFunc {
}
}
// OptionalAuth authenticates the user if a valid token is present (cookie,
// header, or query param) but allows anonymous pass-through otherwise.
// Use for page routes that should be accessible by both authenticated users
// and anonymous visitors (e.g. public workflow entry pages).
func OptionalAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc {
return func(c *gin.Context) {
if !database.IsConnected() {
c.Next()
return
}
tokenString := ""
if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" {
tokenString = cookie
}
if tokenString == "" {
header := c.GetHeader("Authorization")
if strings.HasPrefix(header, "Bearer ") {
tokenString = strings.TrimPrefix(header, "Bearer ")
}
}
if tokenString == "" {
tokenString = c.Query("token")
}
// No token → anonymous pass-through
if tokenString == "" {
c.Next()
return
}
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
// Invalid token → treat as anonymous
c.Next()
return
}
if claims.UserID != "" {
if entry, hit := cache.get(claims.UserID); hit {
if entry.isActive {
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
}
} else {
user, err := users.GetByID(c.Request.Context(), claims.UserID)
if err == nil && user.IsActive {
cache.set(claims.UserID, true)
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
}
}
}
c.Next()
}
}
func redirectToLogin(c *gin.Context, loginPath string) {
// Save intended destination for post-login redirect
intended := c.Request.URL.Path