molecule-core/workspace-server/internal/middleware/session_auth.go
molecule-ai[bot] 01fcc9a4b6
fix(canvas/a11y): aria-hidden SVGs, MissingKeysModal dialog, session cookie auth
* fix(canvas/a11y): aria-hidden SVGs, MissingKeysModal dialog semantics, session cookie auth

Three fixes cherry-picked from issue #1744:

1. aria-hidden on decorative SVG icons:
   - DeleteCascadeConfirmDialog.tsx: warning triangle SVG gets aria-hidden="true"
   - MissingKeysModal.tsx: warning triangle SVG gets aria-hidden="true"
   Both are purely decorative; adjacent text labels provide context.

2. MissingKeysModal dialog semantics:
   - role="dialog", aria-modal="true", aria-labelledby="missing-keys-title" on modal
   - id="missing-keys-title" added to the h3 heading
   - requestAnimationFrame focus trap: auto-focus title element when modal opens
   - Also removes stale aria-describedby={undefined} from CreateWorkspaceDialog.tsx

3. Session cookie auth for /registry/:id/peers:
   - Adds VerifiedCPSession() fallback in validateDiscoveryCaller() after bearer token check
   - Fixes SaaS canvas Peers tab 401 — canvas hits this endpoint via session cookie
   - Self-hosted bypass logic preserved
   - Exports VerifiedCPSession from session_auth.go for cross-package use

Test fix (bundled, same branch):
   - ContextMenu keyboard test: add getState() stub to useCanvasStore mock
   - Required after ContextMenu.tsx gained a direct getState() call at line 169

GitHub issue: #1740 (test), #1744 (a11y)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(workspace-server): remove duplicate VerifiedCPSession declaration

The branch accidentally added a second func VerifiedCPSession declaration
that shadows the real implementation, causing go build to fail with:
  internal/middleware/session_auth.go:238:6: VerifiedCPSession redeclared in this block

Remove the stub alias so the original full implementation is used directly.
The function already exports correctly for cross-package use via the
VerifiedCPSession() call in discovery.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(workspace-server): correct VerifiedCPSession condition in discovery.go

Fix Go build error — 'presented' was declared and not used.
The cookie fallback check was using `if ok, presented := ...; ok` instead
of `if ok, presented := ...; presented`, causing the build to fail in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(workspace-server): fix declared and not used 'presented' in discovery.go

Fixes Go build failure:
  discovery.go:355:10: declared and not used: presented
  discovery.go:358:6: undefined: presented

Variable shadowing in the second VerifiedCPSession call reused the outer
scope's `ok` and `presented` names, causing a compile error. Renamed to
ok2/presented2 to avoid shadowing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Molecule AI Core-FE <core-fe@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 04:30:26 +00:00

233 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package middleware
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"math/rand/v2"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
// sessionCache holds short-lived verification results for upstream
// session-cookie checks. Entries are scoped BY TENANT SLUG so one
// tenant's cache can't satisfy another tenant's check even when the
// same cookie is presented.
//
// Keyed by a sha256 of (slug + cookie) rather than raw cookie bytes:
// - Avoids storing raw session tokens in memory for longer than
// needed to look them up.
// - Makes the cache lookup deterministic regardless of cookie
// ordering / whitespace that browsers sometimes introduce.
//
// Bounded: we evict random entries when size breaches sessionCacheMax.
// Periodic sweeper GCs expired entries even when they aren't re-hit.
var sessionCache = struct {
sync.Mutex
entries map[string]sessionCacheEntry
}{entries: make(map[string]sessionCacheEntry)}
const (
// Positive TTL: on the higher end because a valid session is
// stable until logout. 30s means logout or role change takes at
// most 30s to propagate.
sessionCacheTTLOK = 30 * time.Second
// Negative TTL: shorter, because a transient CP 502 (see
// controlplane issue #157 — terms-status flake) must heal
// quickly. 5s still absorbs a burst of retries from a single
// page render without fanning out to CP.
sessionCacheTTLFail = 5 * time.Second
// Cap on cached entries. 10k × ~100 bytes = ~1 MB — enough
// headroom for realistic tenant traffic without a slow leak.
sessionCacheMax = 10_000
// Sweeper runs opportunistically; cost is O(N) per sweep.
sessionCacheSweepEvery = 2 * time.Minute
)
type sessionCacheEntry struct {
expiresAt time.Time
ok bool
}
// cacheKey derives the lookup key. Using sha256 here isn't about
// cryptographic secrecy — it's about keying by (tenant, cookie) in a
// fixed-size string and not sprinkling raw tokens around the map.
func cacheKey(slug, cookie string) string {
h := sha256.New()
h.Write([]byte(slug))
h.Write([]byte{0}) // separator so ("a","bc") ≠ ("ab","c")
h.Write([]byte(cookie))
return hex.EncodeToString(h.Sum(nil))
}
// sessionCacheGet returns (ok, hit). hit=false means expired or absent.
func sessionCacheGet(key string) (ok bool, hit bool) {
sessionCache.Lock()
defer sessionCache.Unlock()
e, present := sessionCache.entries[key]
if !present {
return false, false
}
if time.Now().After(e.expiresAt) {
delete(sessionCache.entries, key)
return false, false
}
return e.ok, true
}
// sessionCachePut stores the result with the appropriate TTL. On
// overflow it evicts a pseudo-random entry so the cache stays
// bounded. This isn't LRU — we don't need precise recency, just
// ceiling behaviour. Random eviction is O(1) expected and avoids
// the bookkeeping of a doubly-linked list.
func sessionCachePut(key string, ok bool) {
ttl := sessionCacheTTLFail
if ok {
ttl = sessionCacheTTLOK
}
sessionCache.Lock()
defer sessionCache.Unlock()
if len(sessionCache.entries) >= sessionCacheMax {
// Evict N random entries to amortize the sweep cost. Pick
// the first N in map-iteration order (Go randomizes this).
const evictBatch = 128
i := 0
for k := range sessionCache.entries {
delete(sessionCache.entries, k)
i++
if i >= evictBatch {
break
}
}
}
sessionCache.entries[key] = sessionCacheEntry{
expiresAt: time.Now().Add(ttl),
ok: ok,
}
}
func init() {
go func() {
// Jitter startup so restarts don't align sweeps.
time.Sleep(time.Duration(rand.Int64N(int64(sessionCacheSweepEvery))))
t := time.NewTicker(sessionCacheSweepEvery)
defer t.Stop()
for range t.C {
sweepExpired()
}
}()
}
// sweepExpired removes expired entries so a low-hit-rate cache still
// releases memory. Cheap — we hold the lock briefly per entry.
func sweepExpired() {
now := time.Now()
sessionCache.Lock()
defer sessionCache.Unlock()
for k, e := range sessionCache.entries {
if now.After(e.expiresAt) {
delete(sessionCache.entries, k)
}
}
}
// cpSessionVerifyURL builds the upstream /cp/auth/tenant-member URL
// with the tenant slug attached. Returns "" when the tenant isn't
// configured for CP verification (CP_UPSTREAM_URL unset).
func cpSessionVerifyURL(slug string) string {
base := strings.TrimRight(os.Getenv("CP_UPSTREAM_URL"), "/")
if base == "" {
return ""
}
return base + "/cp/auth/tenant-member?slug=" + url.QueryEscape(slug)
}
// tenantSlug returns the slug this platform represents. Pulled from
// the MOLECULE_ORG_SLUG env at provision time; falls back to empty
// when unset (self-hosted / dev).
func tenantSlug() string {
return strings.TrimSpace(os.Getenv("MOLECULE_ORG_SLUG"))
}
// VerifiedCPSession returns true when the request carries a cookie
// that the CP confirms belongs to a MEMBER of THIS tenant's org (not
// just "someone is logged in"). The difference is the authz boundary:
// any WorkOS-authed user could hit /cp/auth/me successfully; only
// actual org members pass /cp/auth/tenant-member?slug=<us>.
//
// Returns (false, false) when no cookie at all, so callers can
// distinguish "no credential presented" (fall through to bearer)
// from "credential presented but invalid" (abort with 401).
//
// Also returns (false, false) when MOLECULE_ORG_SLUG isn't configured
// — fail-safe: better to refuse session auth than to accept it
// without knowing which tenant we ARE. Deployments that want session
// auth MUST set both CP_UPSTREAM_URL and MOLECULE_ORG_SLUG.
func VerifiedCPSession(cookieHeader string) (valid, presented bool) {
if cookieHeader == "" {
return false, false
}
slug := tenantSlug()
if slug == "" {
return false, false
}
verifyURL := cpSessionVerifyURL(slug)
if verifyURL == "" {
return false, true
}
key := cacheKey(slug, cookieHeader)
if ok, hit := sessionCacheGet(key); hit {
return ok, true
}
// Short timeout — a slow CP mustn't gate every canvas render.
client := &http.Client{Timeout: 3 * time.Second}
req, err := http.NewRequest("GET", verifyURL, nil)
if err != nil {
log.Printf("VerifiedCPSession: build req: %v", err)
return false, true
}
req.Header.Set("Cookie", cookieHeader)
req.Header.Set("User-Agent", "molecule-tenant-platform/session-verifier")
resp, err := client.Do(req)
if err != nil {
log.Printf("VerifiedCPSession: upstream: %v", err)
// NOTE: we deliberately do NOT cache transport failures.
// Caching them would mean a 3s CP blip locks out all users
// for the negative-TTL window. Next request retries.
return false, true
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
sessionCachePut(key, false)
return false, true
}
var body struct {
Member bool `json:"member"`
UserID string `json:"user_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
sessionCachePut(key, false)
return false, true
}
if !body.Member || body.UserID == "" {
sessionCachePut(key, false)
return false, true
}
sessionCachePut(key, true)
return true, true
}