dev-pod-api-build/internal/api/auth_exchange.go
2026-04-16 04:16:36 +00:00

138 lines
4.2 KiB
Go

// 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 <apiKey>.
// 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.<jwt>.
//
// 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)
}