Files
simplegit/gitrpc/middleware.go
T
2026-07-15 11:06:48 -04:00

295 lines
8.0 KiB
Go

package gitrpc
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"simplegit/common"
"strconv"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
jwt.RegisteredClaims
Username string `json:"username"`
}
func (c *Claims) UserID() int64 {
id, _ := strconv.ParseInt(c.Subject, 10, 64)
return id
}
type ctxKey struct{}
func WithClaims(ctx context.Context, c *Claims) context.Context {
return context.WithValue(ctx, ctxKey{}, c)
}
func FromContext(ctx context.Context) *Claims {
c, _ := ctx.Value(ctxKey{}).(*Claims)
return c
}
type JWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Alg string `json:"alg"`
Kid string `json:"kid"`
X string `json:"x"`
}
type JWKS struct {
Keys []JWK `json:"keys"`
}
func publicKeyFromJWK(k JWK) (ed25519.PublicKey, error) {
if k.Kty != "OKP" || k.Crv != "Ed25519" {
return nil, fmt.Errorf("unsupported jwk: kty=%q crv=%q", k.Kty, k.Crv)
}
x, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, fmt.Errorf("decode jwk x: %w", err)
}
if len(x) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bad ed25519 key length: %d", len(x))
}
return ed25519.PublicKey(x), nil
}
type jwksSource struct {
authURL string
client *http.Client
// self-registration: when non-empty, every jwks refresh also announces this
// service to the console (RBAC center) via X-Service-* headers, so the
// console can discover and dial this gitrpc server. The console is the
// service-discovery hub; the only side channel it needs is the jwks fetch
// every consumer already makes.
selfName string
selfKind string
selfAddr string
mu sync.RWMutex
keys map[string]ed25519.PublicKey
}
func newJWKSSource(authURL, selfName, selfKind, selfAddr string) *jwksSource {
return &jwksSource{
authURL: authURL,
client: &http.Client{Timeout: 10 * time.Second},
selfName: selfName,
selfKind: selfKind,
selfAddr: selfAddr,
keys: map[string]ed25519.PublicKey{},
}
}
func (s *jwksSource) refresh() error {
req, err := http.NewRequest(http.MethodGet, s.authURL+"/.well-known/jwks.json", nil)
if err != nil {
return fmt.Errorf("build jwks request: %w", err)
}
// Announce ourselves so the console registers this service's address. The
// console answers jwks AND records the caller as a reachable service.
if s.selfName != "" && s.selfAddr != "" {
req.Header.Set("X-Service-Name", s.selfName)
req.Header.Set("X-Service-Kind", s.selfKind)
req.Header.Set("X-Service-Addr", s.selfAddr)
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("fetch jwks: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks fetch status %d", resp.StatusCode)
}
var set JWKS
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
return fmt.Errorf("decode jwks: %w", err)
}
keys := make(map[string]ed25519.PublicKey, len(set.Keys))
for _, k := range set.Keys {
pub, err := publicKeyFromJWK(k)
if err != nil {
continue
}
keys[k.Kid] = pub
}
if len(keys) == 0 {
return errors.New("jwks has no usable ed25519 keys")
}
s.mu.Lock()
s.keys = keys
s.mu.Unlock()
return nil
}
func (s *jwksSource) resolve(kid string) (ed25519.PublicKey, error) {
s.mu.RLock()
pub, ok := s.keys[kid]
s.mu.RUnlock()
if ok {
return pub, nil
}
if err := s.refresh(); err != nil {
return nil, fmt.Errorf("refetch jwks for kid %q: %w", kid, err)
}
s.mu.RLock()
defer s.mu.RUnlock()
pub, ok = s.keys[kid]
if !ok {
return nil, fmt.Errorf("kid %q not in jwks", kid)
}
return pub, nil
}
type Verifier struct {
resolve func(kid string) (ed25519.PublicKey, error)
}
func NewJWKSVerifier(authURL, selfName, selfKind, selfAddr string) (*Verifier, error) {
src := newJWKSSource(authURL, selfName, selfKind, selfAddr)
if err := src.refresh(); err != nil {
return nil, err
}
return &Verifier{resolve: src.resolve}, nil
}
func (v *Verifier) Parse(tokenStr string) (*Claims, error) {
var c Claims
_, err := jwt.ParseWithClaims(tokenStr, &c, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodEdDSA {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
kid, _ := t.Header["kid"].(string)
pub, err := v.resolve(kid)
if err != nil {
return nil, err
}
return pub, nil
}, jwt.WithExpirationRequired(), jwt.WithIssuer("simpleconsole"))
if err != nil {
return nil, err
}
return &c, nil
}
type MiddlewareClient struct {
baseURL string
http *http.Client
verifier *Verifier
}
// NewMiddleware builds the console RBAC client. selfName/selfKind/selfAddr, when
// non-empty, are announced to the console on every jwks refresh so the console
// registers this server as a reachable service (service discovery). Pass the
// gitrpc HTTP address the console should dial back.
func NewMiddleware(baseURL, selfName, selfKind, selfAddr string) *MiddlewareClient {
verifier, err := NewJWKSVerifier(baseURL, selfName, selfKind, selfAddr)
if err != nil {
return nil
}
return &MiddlewareClient{
baseURL: baseURL,
http: &http.Client{Timeout: 4 * time.Second},
verifier: verifier,
}
}
// ParseToken verifies a console-issued JWT and returns its claims. An empty
// token returns (nil, nil) so the caller can distinguish "no credential"
// (anonymous) from "credential present but invalid" (non-nil error): the HTTP
// and SSH access planes use this to decide between anonymous fallthrough and a
// 401. skip-auth callers never invoke this (middleware is nil).
func (c *MiddlewareClient) ParseToken(tokenStr string) (*Claims, error) {
if tokenStr == "" {
return nil, nil
}
return c.verifier.Parse(tokenStr)
}
func (c *MiddlewareClient) Authz(ctx context.Context, userID int64, repo string, perm common.Perm) error {
body, _ := json.Marshal(struct {
Repo string `json:"repo"`
UserID string `json:"userId"`
Perm string `json:"perm"`
}{repo, strconv.FormatInt(userID, 10), string(perm)})
resp, err := c.postConnect(ctx, "/simpleconsole.v1.InternalService/Authz", body)
if err != nil {
return fmt.Errorf("authz call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authz status %d for %s %s", resp.StatusCode, repo, perm)
}
var out struct {
Allow bool `json:"allow"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("decode authz: %w", err)
}
if !out.Allow {
return ErrDenied
}
return nil
}
var ErrDenied = errors.New("access denied")
// ErrInvalidToken marks a credential that was present but could not be verified
// (expired, unsigned, malformed). It is distinct from anonymous (no credential
// at all) and from ErrDenied (verified identity lacking permission).
var ErrInvalidToken = errors.New("invalid or expired token")
func (c *MiddlewareClient) Authn(ctx context.Context, fingerprint string) (userID int64, username string, err error) {
body, _ := json.Marshal(struct {
Fingerprint string `json:"fingerprint"`
}{fingerprint})
resp, err := c.postConnect(ctx, "/simpleconsole.v1.InternalService/Authn", body)
if err != nil {
return 0, "", fmt.Errorf("authn call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, "", fmt.Errorf("authn status %d", resp.StatusCode)
}
var out struct {
UserID string `json:"userId"`
Username string `json:"username"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return 0, "", fmt.Errorf("decode authn: %w", err)
}
uid, err := strconv.ParseInt(out.UserID, 10, 64)
if err != nil {
return 0, "", fmt.Errorf("decode authn userId: %w", err)
}
return uid, out.Username, nil
}
func (c *MiddlewareClient) postConnect(ctx context.Context, procedure string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+procedure, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Connect-Protocol-Version", "1")
return c.http.Do(req)
}