Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 54 additions & 5 deletions platform/internal/middleware/wsauth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"database/sql"
"log"
"net/http"
"os"
"strings"

"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -60,6 +62,19 @@ func WorkspaceAuth(database *sql.DB) gin.HandlerFunc {
//
// Any valid workspace bearer token is accepted — the route is not scoped to
// a specific workspace so we only verify the token is live and unrevoked.
//
// Issue #168 — canvas Origin fallback:
// Canvas makes all its fetch calls with credentials:"include" but does NOT
// set an Authorization header. PR #167 gated several canvas-facing routes
// (viewport, events, bundles) behind AdminAuth, breaking them silently.
//
// Fix: after Bearer auth fails (no header), allow requests whose Origin
// header matches the CORS_ORIGINS env var or the localhost defaults. This
// is not a strict auth boundary — non-browser clients can set an arbitrary
// Origin — but it matches what CORS already enforces in the browser. The
// real perimeter defence against external threats is the network layer
// (CORS_ORIGINS is set to the canonical canvas URL in production).
// Bearer token auth is unchanged for API clients and agents.
func AdminAuth(database *sql.DB) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
Expand All @@ -71,16 +86,50 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
return
}
if hasLive {
// Primary path: Authorization: Bearer <token> header (API clients,
// molecli, agent-to-platform calls).
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
if tok != "" {
if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"})
return
}
c.Next()
return
}
if err := wsauth.ValidateAnyToken(ctx, database, tok); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin auth token"})
return

// Canvas fallback (#168): trust requests from a configured canvas
// origin. Origin is set by the browser automatically for all
// cross-origin fetch() calls and cannot be overridden by page JS.
// Non-browser clients (curl/agents) are expected to use Bearer.
origin := c.GetHeader("Origin")
if origin != "" {
for _, allowed := range canvasOrigins() {
if strings.TrimSpace(allowed) == origin {
c.Next()
return
}
}
}

c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"})
return
}
c.Next()
}
}

// canvasOrigins returns the set of browser origins that AdminAuth trusts for the
// canvas-fallback path. Reads CORS_ORIGINS at call time (not init) so the value
// can be overridden in tests via t.Setenv without a process restart.
func canvasOrigins() []string {
origins := []string{"http://localhost:3000", "http://localhost:3001"}
if v := os.Getenv("CORS_ORIGINS"); v != "" {
for _, o := range strings.Split(v, ",") {
if o = strings.TrimSpace(o); o != "" {
origins = append(origins, o)
}
}
}
return origins
}
119 changes: 119 additions & 0 deletions platform/internal/middleware/wsauth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,125 @@ func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) {
// global bearer-token contract for /admin/secrets, /settings/secrets).
// ────────────────────────────────────────────────────────────────────────────

// ── Issue #168 regression — canvas Origin fallback ───────────────────────────
//
// PR #167 gated PUT /canvas/viewport, GET /events/:workspaceId,
// GET /bundles/export/:id, and POST /bundles/import behind AdminAuth (Bearer
// only). Canvas sends credentials:"include" without an Authorization header, so
// every one of those routes 401'd. Fix: AdminAuth also accepts requests whose
// Origin header matches the configured CORS_ORIGINS or the localhost defaults.
// Bearer takes precedence; Origin is the canvas fallback.
//
// Three tests:
// 1. Bearer path still works (regression guard)
// 2. Canvas Origin trusted (new canvas fallback path)
// 3. No credentials and no matching Origin → 401

// TestAdminAuth_Issue168_BearerValid verifies the existing Authorization:Bearer
// path is not disturbed by the Origin fallback extension.
func TestAdminAuth_Issue168_BearerValid(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
defer mockDB.Close()

tok := "canvas-bearer-regression-token"
h := sha256.Sum256([]byte(tok))

mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(validateAnyTokenSelectQuery).
WithArgs(h[:]).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-canvas-1"))
mock.ExpectExec(validateTokenUpdateQuery).
WithArgs("tok-canvas-1").
WillReturnResult(sqlmock.NewResult(0, 1))

r := gin.New()
r.PUT("/canvas/viewport", AdminAuth(mockDB), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/canvas/viewport", nil)
req.Header.Set("Authorization", "Bearer "+tok)
r.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("#168 bearer regression: expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}

// TestAdminAuth_Issue168_CanvasOriginTrusted verifies that a canvas request with
// Origin: http://localhost:3000 (always in the default allowed set) is passed
// through without a Bearer token. No token DB queries should fire — the Origin
// check short-circuits before ValidateAnyToken.
func TestAdminAuth_Issue168_CanvasOriginTrusted(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
defer mockDB.Close()

// Tokens exist → auth is enforced → Origin fallback path is exercised.
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
// No ValidateAnyToken expectation — Origin match must short-circuit before DB.

r := gin.New()
r.GET("/bundles/export/:id", AdminAuth(mockDB), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/bundles/export/ws-abc", nil)
// Canvas sends credentials:"include"; no Authorization header; Origin is set
// by the browser automatically for all cross-origin fetch() calls.
req.Header.Set("Origin", "http://localhost:3000")
r.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("#168 canvas origin: expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}

// TestAdminAuth_Issue168_NoCreds_Returns401 verifies that a request with neither
// Authorization header nor a recognized Origin is rejected with 401.
func TestAdminAuth_Issue168_NoCreds_Returns401(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
defer mockDB.Close()

mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

r := gin.New()
r.PUT("/canvas/viewport", AdminAuth(mockDB), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})

w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/canvas/viewport", nil)
// No Authorization header. No Origin header (e.g. curl / agent direct call).
r.ServeHTTP(w, req)

if w.Code != http.StatusUnauthorized {
t.Errorf("#168 no-creds: expected 401, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}

// TestAdminAuth_FailOpen_NoTokensGlobally — C10/C11: on a fresh install (no
// live tokens anywhere) the middleware must let the request through so existing
// deployments keep working during the Phase-30 rollout.
Expand Down
Loading