295 lines
8.0 KiB
Go
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)
|
|
}
|