diff --git a/internal/services/paliadin_jwt.go b/internal/services/paliadin_jwt.go new file mode 100644 index 0000000..0ef94dc --- /dev/null +++ b/internal/services/paliadin_jwt.go @@ -0,0 +1,74 @@ +package services + +// Per-turn supabase JWT minting for Paliadin (t-paliad-156, folded into +// t-paliad-194 / m/paliad#38 Phase B). +// +// Each Paliadin turn carries a short-lived JWT scoped to the calling +// user. The JWT is signed with paliad's existing SUPABASE_JWT_SECRET so +// it has the same shape Supabase Auth itself issues — same claims, same +// signature, same role. The aichat backend writes it to a per-turn file +// the claude pane reads to `SET LOCAL request.jwt.claims = …` before +// every paliad.* query, which makes RLS evaluate as the user. +// +// TTL: short (default 2 min) — long enough to cover the persona's 120 s +// run-turn budget plus generous slack for queueing, short enough that a +// leaked JWT is uninteresting. Each turn mints fresh; nothing is cached. + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// ErrJWTSecretMissing signals that mintTurnJWT was called without the +// SUPABASE_JWT_SECRET configured. paliad's auth layer fails fast on the +// same condition at boot, but the per-turn mint path is reachable from +// tests + the disabled stub, so we surface a typed error rather than +// panicking. +var ErrJWTSecretMissing = errors.New("paliadin: SUPABASE_JWT_SECRET not configured") + +// DefaultPaliadinJWTTTL is the JWT lifetime when the caller doesn't +// override. 2 minutes covers aichat's 120 s persona timeout plus a few +// seconds of buffer for HTTP overhead and clock skew. +const DefaultPaliadinJWTTTL = 2 * time.Minute + +// mintTurnJWT signs a Supabase-shaped access token for the given user. +// Claims: +// +// sub : userID — RLS reads this via auth.uid() +// role : "authenticated" — required so SET LOCAL ROLE matches +// aud : "authenticated" — Supabase convention +// iss : "paliad/paliadin" — distinguishes from real GoTrue tokens in +// audit traces; not validated by RLS +// iat : now +// exp : now + ttl +// +// Signed HS256 with SUPABASE_JWT_SECRET (same secret paliad already +// verifies session cookies against in internal/auth.Client). The +// returned string is a standard 3-segment JWT. +func mintTurnJWT(userID uuid.UUID, ttl time.Duration, secret []byte) (string, error) { + if len(secret) == 0 { + return "", ErrJWTSecretMissing + } + if ttl <= 0 { + ttl = DefaultPaliadinJWTTTL + } + now := time.Now() + claims := jwt.MapClaims{ + "sub": userID.String(), + "role": "authenticated", + "aud": "authenticated", + "iss": "paliad/paliadin", + "iat": now.Unix(), + "exp": now.Add(ttl).Unix(), + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := tok.SignedString(secret) + if err != nil { + return "", fmt.Errorf("paliadin: sign turn JWT: %w", err) + } + return signed, nil +}