Files
zeekayandHanzo Dev 3d02cf7c01 Bound what one connection can store, and replay what a client can read
A document was one blob that every frame rewrote whole, from a goroutine
started per frame, with nothing capping the blob or the file. Any IAM user
could stream frames at doc ids under their own org and fill the disk the
volume sits on, or stream at one doc until the goroutines queued behind the
write lock ran the pod out of memory.

Each frame is now its own row, so an append writes the frame and nothing
else. The reader persists a frame before it reads the next, so a connection
has at most one append in flight, and one database connection serializes
them. Four bounds hold: a frame over 1 MiB closes the connection (1009),
more than 50 frames a second past a burst of 200 closes it (1008), a
document stores at most 16 MiB, and max_page_count holds the file to
COLLAB_STORE_BYTES. The journal is a rollback journal, so the files never
pass twice that. The store applies its pragmas per connection through
hanzoai/sqlite's OpenPragma, which both driver backends honour; a DSN
carries them only on the pure-Go one.

Only frames that carry document content are stored: y-websocket sync step 2
and update. Awareness, sent on every cursor move, and sync step 1 are
relayed and dropped, since replaying them rebuilds nothing.

A joining peer gets each stored frame as its own message. They used to be
concatenated into one, and y-websocket decodes one message per frame, so a
client applied the first and lost the rest.

A token is read from the Authorization header or from a bearer.<token>
entry in the subprotocol list a browser offers beside yjs, never from the
query string, which the edge writes to its access log.

When a JWKS refresh fails, the keys of the last good fetch keep verifying,
so an IAM outage past the cache TTL no longer refuses every token for the
refresh window. A refresh that succeeds and drops a key still revokes it.

The S3 adapter goes: production runs SQLite, and the adapter had never
stored anything. The tables are new (doc, frame); a database written by
0.1.x keeps its docs table, which nothing reads.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 06:23:18 -07:00

214 lines
5.5 KiB
Go

// Package auth verifies Hanzo IAM JWTs via the published JWKS.
//
// We do NOT share code with team-go/extract because the WS upgrade
// path needs to extract the token from either the Authorization header
// or the offered WebSocket subprotocols (browsers cannot set headers on
// the WS handshake), and we want a tiny self-contained dependency surface.
package auth
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the subset of IAM token claims the collab service uses.
//
// `owner` is the org slug (Hanzo IAM convention — see HIP-0026).
type Claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Owner string `json:"owner"`
jwt.RegisteredClaims
}
// Verifier resolves JWKS keys and validates tokens. Safe for concurrent use.
type Verifier struct {
jwksURL string
client *http.Client
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetched time.Time
ttl time.Duration
// A token whose kid the cached set lacks forces a fetch, and anyone can
// send one, so fetches are spaced at least minRefresh apart: a caller
// inside the window gets the last fetch's outcome instead of a new fetch.
refreshMu sync.Mutex
attempted time.Time
lastErr error
minRefresh time.Duration
}
// New constructs a Verifier against the IAM JWKS URL.
func New(jwksURL string) *Verifier {
return &Verifier{
jwksURL: jwksURL,
client: &http.Client{Timeout: 10 * time.Second},
keys: map[string]*rsa.PublicKey{},
ttl: 10 * time.Minute,
minRefresh: 30 * time.Second,
}
}
// Verify parses + validates a raw bearer token, returning the typed claims.
func (v *Verifier) Verify(ctx context.Context, raw string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
kid, _ := t.Header["kid"].(string)
return v.lookupKey(ctx, kid)
})
if err != nil {
return nil, err
}
if !tok.Valid {
return nil, errors.New("token invalid")
}
return claims, nil
}
// ExtractToken pulls a bearer token from the Authorization header or, for a
// browser, whose WebSocket API cannot set headers, from a `bearer.<token>`
// entry in the Sec-WebSocket-Protocol list it offers alongside `yjs`:
//
// new WebSocket(url, ["yjs", "bearer." + token])
//
// A token is never read from the URL, because the edge writes query strings
// to its access log.
func ExtractToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); h != "" {
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
return strings.TrimSpace(h[7:])
}
}
for _, v := range r.Header.Values("Sec-WebSocket-Protocol") {
for p := range strings.SplitSeq(v, ",") {
if tok, ok := strings.CutPrefix(strings.TrimSpace(p), "bearer."); ok {
return tok
}
}
}
return ""
}
// lookupKey returns the key for kid, refreshing the JWKS when the cached set
// is stale or lacks kid. A key the last good fetch published still verifies
// when a refresh fails, so an IAM outage does not refuse the tokens IAM has
// already vouched for; a successful refresh that drops a key revokes it.
func (v *Verifier) lookupKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
v.mu.RLock()
k, ok := v.keys[kid]
fresh := time.Since(v.fetched) < v.ttl
v.mu.RUnlock()
if ok && fresh {
return k, nil
}
err := v.refresh(ctx)
v.mu.RLock()
defer v.mu.RUnlock()
if k, ok := v.keys[kid]; ok {
return k, nil
}
if err != nil {
return nil, err
}
return nil, fmt.Errorf("kid %q not found", kid)
}
type jwk struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
type jwks struct {
Keys []jwk `json:"keys"`
}
// refresh fetches the JWKS at most once per minRefresh and returns that
// fetch's result to every caller inside the window. The fetch ignores the
// caller's cancellation, so one aborted handshake cannot record a failure
// for everyone; the client timeout still bounds it.
func (v *Verifier) refresh(ctx context.Context) error {
v.refreshMu.Lock()
defer v.refreshMu.Unlock()
if time.Since(v.attempted) < v.minRefresh {
return v.lastErr
}
v.attempted = time.Now()
v.lastErr = v.fetch(context.WithoutCancel(ctx))
return v.lastErr
}
func (v *Verifier) fetch(ctx context.Context) error {
u, err := url.Parse(v.jwksURL)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
resp, err := v.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks fetch: %s", resp.Status)
}
var doc jwks
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return err
}
next := map[string]*rsa.PublicKey{}
for _, k := range doc.Keys {
if k.Kty != "RSA" {
continue
}
pk, err := decodeRSA(k.N, k.E)
if err != nil {
continue
}
next[k.Kid] = pk
}
v.mu.Lock()
v.keys = next
v.fetched = time.Now()
v.mu.Unlock()
return nil
}
func decodeRSA(nB64, eB64 string) (*rsa.PublicKey, error) {
n, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, err
}
e, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, err
}
ei := new(big.Int).SetBytes(e).Int64()
return &rsa.PublicKey{
N: new(big.Int).SetBytes(n),
E: int(ei),
}, nil
}