forked from molecule-ai/molecule-core
ValidateToken, WorkspaceFromToken, and ValidateAnyToken each duplicated
the same JOIN+WHERE auth predicate:
FROM workspace_auth_tokens t
JOIN workspaces w ON w.id = t.workspace_id
WHERE t.token_hash = $1
AND t.revoked_at IS NULL
AND w.status != 'removed'
Same drift class as the SaaS provision-mint bug fixed in #2366. A
future safety addition (e.g. exclude paused workspaces from auth) had
to be applied to all three queries; a partial application would
silently re-open one auth path while closing the others.
Fix: hoist the predicate into lookupTokenByHash, which projects
(id, workspace_id) — the union of fields any caller needs. Each
public function picks what it uses:
- ValidateToken — needs both (compares workspaceID, updates last_used_at by id)
- WorkspaceFromToken — needs workspace_id
- ValidateAnyToken — needs id
The trivial perf cost of selecting one extra column per call is worth
the single-source-of-truth guarantee for the auth predicate.
Test mock updates: two upstream test files (a2a_proxy_test, middleware
wsauth_middleware_test{,_canvasorbearer_test}) had hand-typed regex
matchers and row shapes pinned to the per-function SELECT projection.
Updated to the unified shape; behavior is unchanged.
All wsauth + middleware + handlers + full-module tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
9.9 KiB
Go
257 lines
9.9 KiB
Go
// Package wsauth — workspace authentication tokens (Phase 30.1).
|
|
//
|
|
// Tokens are opaque random strings (256 bits, base64url-encoded). The
|
|
// plaintext is returned to the agent exactly once at issuance time; only
|
|
// sha256(plaintext) is ever stored in the database. The agent presents the
|
|
// token on every subsequent request via the `Authorization: Bearer <token>`
|
|
// header. The ValidateToken function looks up the hash, confirms the
|
|
// workspace matches, updates last_used_at, and returns the workspace ID.
|
|
//
|
|
// This package deliberately avoids JWT — we don't need signed claims, only
|
|
// opaque bearer credentials that can be rotated and revoked per workspace.
|
|
package wsauth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// tokenPayloadBytes controls the raw-random length of a token before
|
|
// base64-encoding. 32 bytes → 256-bit entropy → 43-char URL-safe string,
|
|
// which comfortably resists guessing attacks over the public internet.
|
|
const tokenPayloadBytes = 32
|
|
|
|
// tokenPrefixLen is how many leading characters we keep in the `prefix`
|
|
// column for display / debugging. Short enough to reveal nothing usable;
|
|
// long enough to correlate log lines with rotated tokens.
|
|
const tokenPrefixLen = 8
|
|
|
|
// ErrInvalidToken is returned by ValidateToken when the presented token
|
|
// doesn't match a live row. Callers should return HTTP 401 on this error —
|
|
// do NOT leak the underlying database error or whether the workspace ID
|
|
// was known.
|
|
var ErrInvalidToken = errors.New("invalid or revoked workspace token")
|
|
|
|
// IssueToken mints a fresh token, stores its hash + prefix against the
|
|
// given workspace, and returns the plaintext to show the caller exactly
|
|
// once. The plaintext is never recoverable from the database afterwards.
|
|
//
|
|
// Callers should treat the returned string as secret material and pass it
|
|
// straight to the agent (env var, bundle response body, etc.) without
|
|
// logging it.
|
|
func IssueToken(ctx context.Context, db *sql.DB, workspaceID string) (string, error) {
|
|
buf := make([]byte, tokenPayloadBytes)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("wsauth: generate token: %w", err)
|
|
}
|
|
plaintext := base64.RawURLEncoding.EncodeToString(buf)
|
|
|
|
hash := sha256.Sum256([]byte(plaintext))
|
|
prefix := plaintext[:tokenPrefixLen]
|
|
|
|
_, err := db.ExecContext(ctx, `
|
|
INSERT INTO workspace_auth_tokens (workspace_id, token_hash, prefix)
|
|
VALUES ($1, $2, $3)
|
|
`, workspaceID, hash[:], prefix)
|
|
if err != nil {
|
|
return "", fmt.Errorf("wsauth: persist token: %w", err)
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
// lookupTokenByHash is the single source of truth for "find a live
|
|
// workspace token by its sha256 hash, scoped to a non-removed workspace"
|
|
// — the auth predicate every public token-validating function needs.
|
|
//
|
|
// Returns ErrInvalidToken on any miss (no row, removed workspace, DB
|
|
// error). All three failure modes collapse to the same public error so
|
|
// callers can't accidentally distinguish "bad token" vs. "wrong
|
|
// workspace" vs. "DB hiccup" — that distinction is a side-channel
|
|
// callers must not expose.
|
|
//
|
|
// Defense-in-depth (#682, #696, #697): the JOIN on workspaces filters
|
|
// tokens belonging to removed workspaces. Future safety changes (e.g.
|
|
// "also exclude paused workspaces from auth") go in ONE place; without
|
|
// this helper, the same WHERE/JOIN was duplicated across ValidateToken,
|
|
// WorkspaceFromToken, and ValidateAnyToken — same drift class as the
|
|
// 2026-04-30 SaaS provision-mint bug fixed in #2366.
|
|
//
|
|
// SELECT projects both columns even when only one is needed by the
|
|
// caller. The trivial perf cost is worth the single-source-of-truth
|
|
// guarantee for the auth predicate.
|
|
func lookupTokenByHash(ctx context.Context, db *sql.DB, hash []byte) (tokenID, workspaceID string, err error) {
|
|
err = db.QueryRowContext(ctx, `
|
|
SELECT t.id, t.workspace_id
|
|
FROM workspace_auth_tokens t
|
|
JOIN workspaces w ON w.id = t.workspace_id
|
|
WHERE t.token_hash = $1
|
|
AND t.revoked_at IS NULL
|
|
AND w.status != 'removed'
|
|
`, hash).Scan(&tokenID, &workspaceID)
|
|
if err != nil {
|
|
return "", "", ErrInvalidToken
|
|
}
|
|
return tokenID, workspaceID, nil
|
|
}
|
|
|
|
// ValidateToken confirms the presented plaintext matches a live row whose
|
|
// workspace_id equals expectedWorkspaceID. On success it refreshes
|
|
// last_used_at (best-effort — failure to update is logged by the caller,
|
|
// not propagated as an auth failure).
|
|
//
|
|
// The expectedWorkspaceID binding is required because a token is only
|
|
// valid for the workspace it was issued to. A compromised token from
|
|
// workspace A must never authenticate workspace B.
|
|
func ValidateToken(ctx context.Context, db *sql.DB, expectedWorkspaceID, plaintext string) error {
|
|
if plaintext == "" || expectedWorkspaceID == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
hash := sha256.Sum256([]byte(plaintext))
|
|
|
|
tokenID, workspaceID, err := lookupTokenByHash(ctx, db, hash[:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if workspaceID != expectedWorkspaceID {
|
|
return ErrInvalidToken
|
|
}
|
|
|
|
// Best-effort last_used_at update. A failure here (DB hiccup, etc.)
|
|
// must not cause an otherwise-valid request to 401.
|
|
_, _ = db.ExecContext(ctx,
|
|
`UPDATE workspace_auth_tokens SET last_used_at = now() WHERE id = $1`, tokenID)
|
|
return nil
|
|
}
|
|
|
|
// WorkspaceFromToken resolves the bearer token's owning workspace_id without
|
|
// requiring the caller to know it up front. Used by HTTP handlers that need
|
|
// to identify the source workspace of an inbound request when the caller
|
|
// didn't (or couldn't) set the X-Workspace-ID header — e.g. third-party SDKs
|
|
// or external integrations that authenticate purely via bearer (issue #2306).
|
|
//
|
|
// Returns ErrInvalidToken on any failure (no live token, removed workspace,
|
|
// DB error). Like ValidateToken, the failure modes are collapsed to a single
|
|
// error so handlers can't accidentally distinguish "no token" vs "wrong
|
|
// workspace" — both should result in the same caller-facing response.
|
|
//
|
|
// Does NOT update last_used_at — the calling handler chain typically also
|
|
// runs the bearer through ValidateToken or ValidateAnyToken, which already
|
|
// performs that update.
|
|
func WorkspaceFromToken(ctx context.Context, db *sql.DB, plaintext string) (string, error) {
|
|
if plaintext == "" {
|
|
return "", ErrInvalidToken
|
|
}
|
|
hash := sha256.Sum256([]byte(plaintext))
|
|
|
|
_, workspaceID, err := lookupTokenByHash(ctx, db, hash[:])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return workspaceID, nil
|
|
}
|
|
|
|
// RevokeAllForWorkspace invalidates every live token for a workspace.
|
|
// Called from the workspace-delete handler so compromised credentials
|
|
// can't outlive the workspace, and from future rotation flows.
|
|
func RevokeAllForWorkspace(ctx context.Context, db *sql.DB, workspaceID string) error {
|
|
_, err := db.ExecContext(ctx, `
|
|
UPDATE workspace_auth_tokens
|
|
SET revoked_at = now()
|
|
WHERE workspace_id = $1 AND revoked_at IS NULL
|
|
`, workspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("wsauth: revoke: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WorkspaceExists reports whether a workspace row is present in the
|
|
// database. Used by WorkspaceAuth to close the #318 fail-open gap —
|
|
// the lazy-bootstrap grace period is meant for real workspaces that
|
|
// haven't yet been issued a token, NOT for fabricated UUIDs an
|
|
// unauthenticated caller is using to probe our API surface.
|
|
//
|
|
// Kept in this package (rather than handlers) so the middleware does not
|
|
// need to reach across the handlers boundary for a 1-column EXISTS query.
|
|
func WorkspaceExists(ctx context.Context, db *sql.DB, workspaceID string) (bool, error) {
|
|
var exists bool
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`, workspaceID,
|
|
).Scan(&exists)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// HasAnyLiveToken reports whether the given workspace has at least one
|
|
// live (non-revoked) token on file. Used by the lazy-bootstrap path in
|
|
// the heartbeat handler — a legacy workspace that registered before
|
|
// tokens existed needs exactly one issued on its first post-upgrade
|
|
// heartbeat rather than being rejected outright.
|
|
func HasAnyLiveToken(ctx context.Context, db *sql.DB, workspaceID string) (bool, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM workspace_auth_tokens
|
|
WHERE workspace_id = $1 AND revoked_at IS NULL
|
|
`, workspaceID).Scan(&n)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// BearerTokenFromHeader extracts the token from an Authorization header
|
|
// value. Returns the empty string if the header is missing or malformed,
|
|
// which callers MUST treat as an authentication failure — we deliberately
|
|
// do not return an error so the handler control-flow stays `if token == ""`
|
|
// rather than `if err != nil`.
|
|
func BearerTokenFromHeader(h string) string {
|
|
const prefix = "Bearer "
|
|
if !strings.HasPrefix(h, prefix) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(h[len(prefix):])
|
|
}
|
|
|
|
// HasAnyLiveTokenGlobal reports whether ANY workspace has at least one live
|
|
// (non-revoked) token on file. Used by AdminAuth to decide whether to enforce
|
|
// auth on global/admin routes — fresh installs with no tokens fail open.
|
|
func HasAnyLiveTokenGlobal(ctx context.Context, db *sql.DB) (bool, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM workspace_auth_tokens WHERE revoked_at IS NULL
|
|
`).Scan(&n)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// ValidateAnyToken confirms the presented plaintext matches any live workspace
|
|
// token (not scoped to a specific workspace). Used for admin/global routes
|
|
// where workspace-scoped auth is not applicable — any authenticated agent may
|
|
// access platform-wide settings.
|
|
func ValidateAnyToken(ctx context.Context, db *sql.DB, plaintext string) error {
|
|
if plaintext == "" {
|
|
return ErrInvalidToken
|
|
}
|
|
hash := sha256.Sum256([]byte(plaintext))
|
|
|
|
tokenID, _, err := lookupTokenByHash(ctx, db, hash[:])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Best-effort last_used_at update.
|
|
_, _ = db.ExecContext(ctx,
|
|
`UPDATE workspace_auth_tokens SET last_used_at = now() WHERE id = $1`, tokenID)
|
|
return nil
|
|
}
|