138 lines
4.2 KiB
Go
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)
|
|
}
|