build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
138
internal/api/auth_exchange.go
Normal file
138
internal/api/auth_exchange.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue