a73x

internal/server/api/ticket.go

Ref:   Size: 2.4 KiB   History

package api

import (
	"sync"
	"time"

	"github.com/a73x/eitri/internal/random"
)

// streamTicketTTL bounds how long a minted stream ticket stays redeemable.
// It only needs to cover the gap between the SPA's mint call and the
// EventSource connect — one minute is generous.
const streamTicketTTL = time.Minute

// ticketEntry is one live stream ticket: its expiry and the tenant of the
// principal that minted it. The SSE stream and console WS both consume tickets
// and scope their per-connection view to this tenant.
type ticketEntry struct {
	exp    time.Time
	tenant string
}

// ticketStore holds one-time SSE stream tickets in memory. Tickets are
// deliberately endpoint-agnostic: the SSE stream and the console WS share this
// one store. Each ticket carries the minting principal's tenant so the stream a
// browser opens is scoped to exactly that tenant (browsers cannot set an auth
// header on an EventSource/WebSocket dial, so the ticket is the only credential —
// and it must not be a fleet-wide one). Tickets are ephemeral session bootstrap —
// a server restart just means the client mints a fresh one on its next reconnect
// — so no durability is needed. now is injectable for tests.
type ticketStore struct {
	mu      sync.Mutex
	tickets map[string]ticketEntry // ticket → entry
	now     func() time.Time
}

func newTicketStore(now func() time.Time) *ticketStore {
	return &ticketStore{tickets: map[string]ticketEntry{}, now: now}
}

// mint issues a fresh one-time ticket bound to tenant, pruning expired ones while
// it holds the lock (mints are operator-paced; the map stays tiny).
func (t *ticketStore) mint(tenant string) string {
	t.mu.Lock()
	defer t.mu.Unlock()
	now := t.now()
	for k, e := range t.tickets {
		if now.After(e.exp) {
			delete(t.tickets, k)
		}
	}
	tick := random.Hex(16)
	t.tickets[tick] = ticketEntry{exp: now.Add(streamTicketTTL), tenant: tenant}
	return tick
}

// consume redeems a ticket exactly once, returning the tenant it was minted for;
// expired or unknown tickets fail (ok=false, empty tenant). The map lookup is not
// constant-time by design: tickets are 128-bit crypto-random, single-use, and
// 60s-TTL, so timing attacks are academic.
func (t *ticketStore) consume(tick string) (tenant string, ok bool) {
	t.mu.Lock()
	defer t.mu.Unlock()
	e, found := t.tickets[tick]
	if !found {
		return "", false
	}
	delete(t.tickets, tick)
	if t.now().After(e.exp) {
		return "", false
	}
	return e.tenant, true
}