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

239 lines
6.4 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestExtractToken(t *testing.T) {
cases := []struct {
name string
req func() *http.Request
want string
}{
{
name: "bearer header",
req: func() *http.Request {
r := httptest.NewRequest("GET", "/v1/collab/x", nil)
r.Header.Set("Authorization", "Bearer abc.def.ghi")
return r
},
want: "abc.def.ghi",
},
{
name: "bearer header lowercase",
req: func() *http.Request {
r := httptest.NewRequest("GET", "/v1/collab/x", nil)
r.Header.Set("Authorization", "bearer foo")
return r
},
want: "foo",
},
{
name: "subprotocol",
req: func() *http.Request {
r := httptest.NewRequest("GET", "/v1/collab/x", nil)
r.Header.Set("Sec-WebSocket-Protocol", "yjs, bearer.s1.s2.s3")
return r
},
want: "s1.s2.s3",
},
{
name: "subprotocol in a second header line",
req: func() *http.Request {
r := httptest.NewRequest("GET", "/v1/collab/x", nil)
r.Header.Add("Sec-WebSocket-Protocol", "yjs")
r.Header.Add("Sec-WebSocket-Protocol", "bearer.s1")
return r
},
want: "s1",
},
{
name: "query param is never read",
req: func() *http.Request {
return httptest.NewRequest("GET", "/v1/collab/x?token=q1.q2.q3", nil)
},
want: "",
},
{
name: "none",
req: func() *http.Request {
return httptest.NewRequest("GET", "/v1/collab/x", nil)
},
want: "",
},
{
name: "header beats subprotocol",
req: func() *http.Request {
r := httptest.NewRequest("GET", "/v1/collab/x", nil)
r.Header.Set("Sec-WebSocket-Protocol", "yjs, bearer.p")
r.Header.Set("Authorization", "Bearer h")
return r
},
want: "h",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ExtractToken(tc.req())
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
// jwksServer serves one RSA key under kid and counts the fetches it answers.
func jwksServer(t *testing.T, kid string, pub *rsa.PublicKey) (*httptest.Server, *atomic.Int32) {
t.Helper()
var fetches atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fetches.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
"kty": "RSA",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}}})
}))
t.Cleanup(srv.Close)
return srv, &fetches
}
func sign(t *testing.T, key *rsa.PrivateKey, kid string) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, &Claims{
Owner: "hanzo",
RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
})
tok.Header["kid"] = kid
raw, err := tok.SignedString(key)
if err != nil {
t.Fatal(err)
}
return raw
}
// A token naming an unknown kid must not buy a JWKS fetch: inside the
// refresh window every such token is refused on the cached set.
func TestUnknownKidFetchesAtMostOncePerWindow(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
srv, fetches := jwksServer(t, "good", &key.PublicKey)
v := New(srv.URL)
if _, err := v.Verify(context.Background(), sign(t, key, "good")); err != nil {
t.Fatalf("valid token refused: %v", err)
}
var wg sync.WaitGroup
for i := range 50 {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := v.Verify(context.Background(), sign(t, key, fmt.Sprintf("forged-%d", i))); err == nil {
t.Error("token with unknown kid accepted")
}
}()
}
wg.Wait()
if n := fetches.Load(); n != 1 {
t.Fatalf("JWKS fetched %d times, want 1", n)
}
if _, err := v.Verify(context.Background(), sign(t, key, "good")); err != nil {
t.Fatalf("valid token refused after forged ones: %v", err)
}
}
// Past the window an unknown kid fetches again, once, so a key IAM has just
// rotated in is picked up.
func TestUnknownKidRefetchesAfterWindow(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
srv, fetches := jwksServer(t, "good", &key.PublicKey)
v := New(srv.URL)
v.minRefresh = 500 * time.Millisecond
if _, err := v.Verify(context.Background(), sign(t, key, "new")); err == nil {
t.Fatal("token with unknown kid accepted")
}
tok := sign(t, key, "new")
time.Sleep(600 * time.Millisecond)
for range 5 {
_, _ = v.Verify(context.Background(), tok)
}
if n := fetches.Load(); n != 2 {
t.Fatalf("JWKS fetched %d times, want 2", n)
}
}
// A handshake whose context is already cancelled must not record a failed
// fetch that every other caller inside the window would then inherit.
func TestCancelledCallerDoesNotPoisonWindow(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
srv, _ := jwksServer(t, "good", &key.PublicKey)
v := New(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := v.Verify(ctx, sign(t, key, "good")); err != nil {
t.Fatalf("cancelled caller refused a valid token: %v", err)
}
if _, err := v.Verify(context.Background(), sign(t, key, "good")); err != nil {
t.Fatalf("valid token refused after a cancelled caller: %v", err)
}
}
// Once the cached set is stale, a refresh that fails still lets the keys the
// last good fetch published verify; a kid it never published stays refused.
func TestFailedRefreshKeepsCachedKeys(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
good, _ := jwksServer(t, "good", &key.PublicKey)
var down atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down.Load() {
http.Error(w, "iam down", http.StatusBadGateway)
return
}
good.Config.Handler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
v := New(srv.URL)
v.ttl = time.Millisecond
v.minRefresh = 0
if _, err := v.Verify(context.Background(), sign(t, key, "good")); err != nil {
t.Fatalf("valid token refused: %v", err)
}
down.Store(true)
time.Sleep(5 * time.Millisecond)
if _, err := v.Verify(context.Background(), sign(t, key, "good")); err != nil {
t.Fatalf("valid token refused while IAM is down: %v", err)
}
if _, err := v.Verify(context.Background(), sign(t, key, "forged")); err == nil {
t.Fatal("unknown kid accepted while IAM is down")
}
}