diff --git a/workspace-server/internal/middleware/session_auth.go b/workspace-server/internal/middleware/session_auth.go new file mode 100644 index 00000000..78b9d2ff --- /dev/null +++ b/workspace-server/internal/middleware/session_auth.go @@ -0,0 +1,106 @@ +package middleware + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// sessionCache holds short-lived positive results for upstream-verified +// session cookies. Keyed by the raw Cookie header value so ANY change +// (logout, fresh session) invalidates by just being different bytes. +// +// TTL is deliberately short — 30s — because the SaaS session lives on +// the CP; if ops revokes a token, we want that reflected quickly. A +// longer TTL would let revoked sessions drift into the tenant. 30s is +// the sweet spot: fast enough for security, slow enough to avoid CP +// hammering on every canvas render. +var sessionCache sync.Map + +const sessionCacheTTL = 30 * time.Second + +type sessionCacheEntry struct { + verifiedAt time.Time + ok bool +} + +// cpSessionEndpointURL is where we verify. Reads the same env the +// router uses for the /cp/* reverse-proxy. Empty string → feature +// disabled (self-hosted / dev). Computed at first call so tests can +// override via env. +func cpSessionEndpointURL() string { + base := strings.TrimRight(os.Getenv("CP_UPSTREAM_URL"), "/") + if base == "" { + return "" + } + return base + "/cp/auth/me" +} + +// verifiedCPSession returns true when the request carries a cookie +// that the CP recognizes as a logged-in user. Caches positive results +// for sessionCacheTTL so burst canvas renders don't fan out to the CP +// on every admin fetch. +// +// Returns (false, false) when there is no cookie at all — callers +// distinguish "no credential presented" (fall through to other tiers) +// from "credential presented but invalid" (abort with 401). +func verifiedCPSession(cookieHeader string) (valid, presented bool) { + if cookieHeader == "" { + return false, false + } + endpoint := cpSessionEndpointURL() + if endpoint == "" { + return false, true + } + + // Cache lookup. + if v, ok := sessionCache.Load(cookieHeader); ok { + e := v.(sessionCacheEntry) + if time.Since(e.verifiedAt) < sessionCacheTTL { + return e.ok, true + } + sessionCache.Delete(cookieHeader) + } + + // Fetch /cp/auth/me with the presented cookie. Short timeout — + // a slow CP mustn't gate every canvas page render. + client := &http.Client{Timeout: 3 * time.Second} + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + log.Printf("verifiedCPSession: build req: %v", err) + return false, true + } + req.Header.Set("Cookie", cookieHeader) + // Browser-style User-Agent so the CP's bot-detection (if any) + // doesn't block us; we're a legitimate proxy for the UI. + req.Header.Set("User-Agent", "molecule-tenant-platform/session-verifier") + + resp, err := client.Do(req) + if err != nil { + log.Printf("verifiedCPSession: upstream: %v", err) + return false, true + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + sessionCache.Store(cookieHeader, sessionCacheEntry{verifiedAt: time.Now(), ok: false}) + return false, true + } + + // Parse minimally to make sure it's actually a session object, not + // an HTML error page from an upstream proxy shell. + var body struct { + UserID string `json:"user_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.UserID == "" { + sessionCache.Store(cookieHeader, sessionCacheEntry{verifiedAt: time.Now(), ok: false}) + return false, true + } + + sessionCache.Store(cookieHeader, sessionCacheEntry{verifiedAt: time.Now(), ok: true}) + return true, true +} diff --git a/workspace-server/internal/middleware/wsauth_middleware.go b/workspace-server/internal/middleware/wsauth_middleware.go index d0ff090b..9b9a57a7 100644 --- a/workspace-server/internal/middleware/wsauth_middleware.go +++ b/workspace-server/internal/middleware/wsauth_middleware.go @@ -123,6 +123,27 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc { } } + // SaaS-canvas path: when the request carries a WorkOS session + // cookie AND the CP confirms it's valid, accept without a + // bearer. This is how the tenant's Next.js canvas UI + // authenticates — the browser has a session cookie scoped + // to .moleculesai.app, and we verify it upstream against + // /cp/auth/me (short-cached; see verifiedCPSession). + // + // Only runs when CP_UPSTREAM_URL is set (prod SaaS); self- + // hosted / dev deploys without a CP fall through to the + // bearer-only path unchanged. + if cookieHeader := c.GetHeader("Cookie"); cookieHeader != "" { + if ok, _ := verifiedCPSession(cookieHeader); ok { + c.Next() + return + } + // Cookie presented but invalid: fall through to the + // bearer-check path, which will 401. We do NOT abort + // here so molecli / CLI users with both a cookie and + // a stale cookie + valid bearer still pass. + } + // Bearer token is the ONLY accepted credential for admin routes. tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization")) if tok == "" {