// Package api — JWT auth-exchange endpoints for web-tui (T018). // // The SPA → web-tui-gateway → dev-pod-api chain authenticates as: // // 1. SPA → POST /v1/auth/exchange with Authorization: Bearer . // 2. web-tui-gateway proxies the request to dev-pod-api's // /api/v1/auth/exchange, forwarding the bearer header. // 3. dev-pod-api validates the API key via the existing AuthMiddleware, // reads the authenticated user from the request context, and mints // a short-lived JWT (aud="web-tui", sub=user.ID, ttl=5m). // 4. web-tui-gateway returns the JWT to the SPA. // 5. SPA attaches per-pane WebSockets with Sec-WebSocket-Protocol: // bearer.. // // web-tui-gateway verifies the JWT's signature against the JWKS exposed // at /.well-known/jwks.json — which is public (served outside any auth // middleware). package api import ( "crypto/ed25519" "encoding/base64" "encoding/json" "net/http" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) // JWTSigner mints short-lived JWTs used by web-tui's gateway to attach // per-pane WebSockets. type JWTSigner interface { Sign(sub string, ttl time.Duration) (token string, expiresIn int, err error) JWKS() ([]byte, error) } // Ed25519Signer is the reference signer. Generate a key once at startup // and persist the seed in a Secret; in dev a freshly-generated keypair // is fine (it causes connected clients to re-auth on restart, by // design). type Ed25519Signer struct { Kid string Private ed25519.PrivateKey Public ed25519.PublicKey } // NewEd25519SignerFromSeed reconstructs a signer from a 32-byte seed. // Useful when you want stable keys across pod restarts — store the // base64-encoded seed in a Secret and load via env. func NewEd25519SignerFromSeed(kid string, seed []byte) (*Ed25519Signer, error) { if len(seed) != ed25519.SeedSize { return nil, errInvalidSeed } priv := ed25519.NewKeyFromSeed(seed) return &Ed25519Signer{Kid: kid, Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil } var errInvalidSeed = jwt.ErrInvalidKey // Sign implements JWTSigner. func (s *Ed25519Signer) Sign(sub string, ttl time.Duration) (string, int, error) { now := time.Now() claims := jwt.MapClaims{ "sub": sub, "aud": "web-tui", "iat": now.Unix(), "exp": now.Add(ttl).Unix(), "jti": uuid.NewString(), } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) tok.Header["kid"] = s.Kid signed, err := tok.SignedString(s.Private) if err != nil { return "", 0, err } return signed, int(ttl.Seconds()), nil } // JWKS returns the public key in JWK Set form (RFC 7517). func (s *Ed25519Signer) JWKS() ([]byte, error) { body := map[string]any{ "keys": []map[string]any{{ "kid": s.Kid, "kty": "OKP", "alg": "EdDSA", "crv": "Ed25519", "use": "sig", "x": base64.RawURLEncoding.EncodeToString(s.Public), }}, } return json.Marshal(body) } // ---- HTTP handlers ---- type exchangeBody struct { Token string `json:"token"` ExpiresIn int `json:"expiresIn"` Sub string `json:"sub"` } // handleAuthExchange runs under AuthMiddleware — user is in context by // the time we get here. func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { if s.JWTSigner == nil { writeError(w, http.StatusServiceUnavailable, "JWT signer not configured") return } u := UserFromContext(r.Context()) if u == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } tok, ttl, err := s.JWTSigner.Sign(u.ID, 5*time.Minute) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, exchangeBody{Token: tok, ExpiresIn: ttl, Sub: u.ID}) } // handleJWKS is public (no auth required). web-tui-gateway caches the // response so this endpoint is read rarely. func (s *Server) handleJWKS(w http.ResponseWriter, r *http.Request) { if s.JWTSigner == nil { writeError(w, http.StatusServiceUnavailable, "JWT signer not configured") return } body, err := s.JWTSigner.JWKS() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") _, _ = w.Write(body) }