~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
351 lines · 9.1 KB Raw History
package oauth

import (
	"cmp"
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/bluesky-social/indigo/atproto/atcrypto"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/ttlcache"
)

const (
	HeaderDPoP      = "DPoP"
	HeaderDPoPNonce = "DPoP-Nonce"

	AuthSchemeDPoP = "DPoP"

	DefaultDPoPLeeway       = 30 * time.Second
	DefaultDPoPReplayWindow = 5 * time.Minute
	DefaultNonceLifetime    = 5 * time.Minute
	DefaultNonceGrace       = 30 * time.Second
)

type DPoPProof struct {
	JKT   string
	JTI   string
	Nonce string
}

type DPoPVerifier struct {
	Nonces       NonceStore
	Leeway       time.Duration
	ReplayWindow time.Duration
	RequireNonce bool
}

func NewDPoPVerifier(nonces NonceStore) *DPoPVerifier {
	return &DPoPVerifier{
		Nonces:       nonces,
		Leeway:       DefaultDPoPLeeway,
		ReplayWindow: DefaultDPoPReplayWindow,
		RequireNonce: true,
	}
}

type dpopHeader struct {
	Typ string   `json:"typ"`
	Alg string   `json:"alg"`
	JWK *dpopJWK `json:"jwk"`
}

type dpopJWK struct {
	Kty string          `json:"kty"`
	Crv string          `json:"crv"`
	X   string          `json:"x"`
	Y   string          `json:"y"`
	D   json.RawMessage `json:"d"`
}

type dpopPayload struct {
	HTM   string      `json:"htm"`
	HTU   string      `json:"htu"`
	IAT   json.Number `json:"iat"`
	JTI   string      `json:"jti"`
	ATH   string      `json:"ath"`
	Nonce string      `json:"nonce"`
	Exp   json.Number `json:"exp"`
}

func (v *DPoPVerifier) Verify(ctx context.Context, proof, method, requestURL, accessToken string) (*DPoPProof, error) {
	if proof == "" {
		return nil, fmt.Errorf("%w: missing DPoP proof", ErrUseDPoPNonce)
	}

	leeway := v.Leeway
	leeway = opt.Positive(leeway, DefaultDPoPLeeway)
	replayWindow := v.ReplayWindow
	replayWindow = opt.Positive(replayWindow, DefaultDPoPReplayWindow)

	headerPart, payloadPart, signaturePart, ok := splitJWT(proof)
	if !ok {
		return nil, fmt.Errorf("%w: malformed JWT", ErrInvalidDPoPProof)
	}

	var header dpopHeader
	if err := decodeSegment(headerPart, &header); err != nil {
		return nil, fmt.Errorf("%w: malformed header", ErrInvalidDPoPProof)
	}
	if header.Typ != "dpop+jwt" {
		return nil, fmt.Errorf("%w: unexpected typ", ErrInvalidDPoPProof)
	}
	if header.Alg != "ES256" {
		return nil, fmt.Errorf("%w: unexpected alg", ErrInvalidDPoPProof)
	}
	if header.JWK == nil {
		return nil, fmt.Errorf("%w: missing jwk", ErrInvalidDPoPProof)
	}
	if header.JWK.Kty != "EC" || header.JWK.Crv != "P-256" {
		return nil, fmt.Errorf("%w: unsupported jwk", ErrInvalidDPoPProof)
	}
	if len(header.JWK.D) > 0 && string(header.JWK.D) != "null" {
		return nil, fmt.Errorf("%w: private jwk", ErrInvalidDPoPProof)
	}
	if header.JWK.X == "" || header.JWK.Y == "" {
		return nil, fmt.Errorf("%w: incomplete jwk", ErrInvalidDPoPProof)
	}

	publicKey, err := atcrypto.ParsePublicJWK(atcrypto.JWK{
		KeyType: header.JWK.Kty,
		Curve:   header.JWK.Crv,
		X:       header.JWK.X,
		Y:       header.JWK.Y,
	})
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidDPoPProof, err)
	}

	signature, err := base64.RawURLEncoding.DecodeString(signaturePart)
	if err != nil {
		return nil, fmt.Errorf("%w: malformed signature", ErrInvalidDPoPProof)
	}
	if err := publicKey.HashAndVerifyLenient([]byte(headerPart+"."+payloadPart), signature); err != nil {
		return nil, fmt.Errorf("%w: bad signature", ErrInvalidDPoPProof)
	}

	var payload dpopPayload
	if err := decodeSegment(payloadPart, &payload); err != nil {
		return nil, fmt.Errorf("%w: malformed payload", ErrInvalidDPoPProof)
	}
	if payload.HTM != strings.ToUpper(method) {
		return nil, fmt.Errorf("%w: htm mismatch", ErrInvalidDPoPProof)
	}
	proofTarget, ok := normalizeHTU(payload.HTU)
	if !ok {
		return nil, fmt.Errorf("%w: invalid htu", ErrInvalidDPoPProof)
	}
	requestTarget, ok := normalizeHTU(requestURL)
	if !ok {
		return nil, fmt.Errorf("%w: invalid request URL", ErrInvalidDPoPProof)
	}
	if proofTarget != requestTarget {
		return nil, fmt.Errorf("%w: htu mismatch", ErrInvalidDPoPProof)
	}
	if payload.JTI == "" {
		return nil, fmt.Errorf("%w: missing jti", ErrInvalidDPoPProof)
	}

	iat, err := strconv.ParseInt(payload.IAT.String(), 10, 64)
	if err != nil {
		return nil, fmt.Errorf("%w: missing iat", ErrInvalidDPoPProof)
	}
	now := time.Now()
	skew := now.Unix() - iat
	if skew < 0 {
		skew = -skew
	}
	if time.Duration(skew)*time.Second > leeway {
		return nil, fmt.Errorf("%w: stale iat", ErrInvalidDPoPProof)
	}
	if payload.Exp != "" {
		exp, err := strconv.ParseInt(payload.Exp.String(), 10, 64)
		if err != nil {
			return nil, fmt.Errorf("%w: invalid exp", ErrInvalidDPoPProof)
		}
		if now.Unix() >= exp {
			return nil, fmt.Errorf("%w: expired proof", ErrInvalidDPoPProof)
		}
	}

	if accessToken != "" {
		if payload.ATH == "" {
			return nil, fmt.Errorf("%w: missing ath", ErrInvalidDPoPProof)
		}
		sum := sha256.Sum256([]byte(accessToken))
		expected := base64.RawURLEncoding.EncodeToString(sum[:])
		if subtle.ConstantTimeCompare([]byte(payload.ATH), []byte(expected)) != 1 {
			return nil, fmt.Errorf("%w: ath mismatch", ErrInvalidDPoPProof)
		}
	}

	if payload.Nonce != "" || v.RequireNonce {
		if payload.Nonce == "" {
			return nil, fmt.Errorf("%w: missing nonce", ErrUseDPoPNonce)
		}
		if v.Nonces == nil {
			return nil, fmt.Errorf("%w: no nonce store", ErrUseDPoPNonce)
		}
		origin, ok := dpopOrigin(requestURL)
		if !ok {
			return nil, fmt.Errorf("%w: invalid request URL", ErrInvalidDPoPProof)
		}
		valid, err := v.Nonces.VerifyNonce(ctx, origin, payload.Nonce)
		if err != nil {
			return nil, fmt.Errorf("%w: %v", ErrUseDPoPNonce, err)
		}
		if !valid {
			return nil, fmt.Errorf("%w: unknown nonce", ErrUseDPoPNonce)
		}
	}

	if v.Nonces != nil {
		fresh, err := v.Nonces.RecordJTI(ctx, "dpop:"+payload.JTI, now.Add(replayWindow))
		if err != nil {
			return nil, fmt.Errorf("%w: %v", ErrInvalidDPoPProof, err)
		}
		if !fresh {
			return nil, fmt.Errorf("%w: replayed jti", ErrInvalidDPoPProof)
		}
	}

	return &DPoPProof{
		JKT:   jktFromJWK(header.JWK),
		JTI:   payload.JTI,
		Nonce: payload.Nonce,
	}, nil
}

func jktFromJWK(jwk *dpopJWK) string {
	canonical := struct {
		Crv string `json:"crv"`
		Kty string `json:"kty"`
		X   string `json:"x"`
		Y   string `json:"y"`
	}{Crv: jwk.Crv, Kty: jwk.Kty, X: jwk.X, Y: jwk.Y}
	raw, err := json.Marshal(canonical)
	if err != nil {
		return ""
	}
	sum := sha256.Sum256(raw)
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

func normalizeHTU(raw string) (string, bool) {
	parsed, err := url.Parse(raw)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return "", false
	}
	scheme := strings.ToLower(parsed.Scheme)
	host := strings.ToLower(parsed.Hostname())
	if host == "" {
		return "", false
	}
	port := parsed.Port()
	if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
		port = ""
	}
	if strings.Contains(host, ":") {
		host = "[" + host + "]"
	}
	if port != "" {
		host = host + ":" + port
	}
	path := parsed.EscapedPath()
	path = cmp.Or(path, "/")
	return scheme + "://" + host + path, true
}

func dpopOrigin(raw string) (string, bool) {
	parsed, err := url.Parse(raw)
	if err != nil || parsed.Scheme == "" || parsed.Host == "" {
		return "", false
	}
	scheme := strings.ToLower(parsed.Scheme)
	host := strings.ToLower(parsed.Hostname())
	if host == "" {
		return "", false
	}
	port := parsed.Port()
	if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
		port = ""
	}
	if strings.Contains(host, ":") {
		host = "[" + host + "]"
	}
	if port != "" {
		host = host + ":" + port
	}
	return scheme + "://" + host, true
}

type memoryNonceEntry struct {
	current         string
	previous        string
	previousExpires time.Time
}

type MemoryNonceStore struct {
	issueMu sync.Mutex
	origins *ttlcache.Cache[string, memoryNonceEntry]
	jtis    *ttlcache.Cache[string, struct{}]
}

func NewMemoryNonceStore() *MemoryNonceStore {
	return &MemoryNonceStore{
		origins: ttlcache.New[string, memoryNonceEntry](ttlcache.Options{TTL: DefaultNonceLifetime}),
		jtis:    ttlcache.New[string, struct{}](ttlcache.Options{}),
	}
}

func (s *MemoryNonceStore) IssueNonce(_ context.Context, origin string) (string, error) {
	nonce, err := randomToken(16)
	if err != nil {
		return "", err
	}
	s.issueMu.Lock()
	defer s.issueMu.Unlock()
	entry, _ := s.origins.Get(origin)
	entry.previous = entry.current
	if entry.current != "" {
		entry.previousExpires = time.Now().Add(DefaultNonceGrace)
	}
	entry.current = nonce
	s.origins.Set(origin, entry)
	return nonce, nil
}

func (s *MemoryNonceStore) VerifyNonce(_ context.Context, origin, nonce string) (bool, error) {
	if nonce == "" {
		return false, nil
	}
	entry, ok := s.origins.Get(origin)
	if !ok {
		return false, nil
	}
	if entry.current == nonce {
		return true, nil
	}
	if entry.previous != "" && entry.previous == nonce && time.Now().Before(entry.previousExpires) {
		return true, nil
	}
	return false, nil
}

func (s *MemoryNonceStore) RecordJTI(_ context.Context, jti string, expiresAt time.Time) (bool, error) {
	ttl := time.Until(expiresAt)
	ttl = opt.Positive(ttl, -1)
	return s.jtis.Add(jti, struct{}{}, ttl), nil
}

var _ NonceStore = (*MemoryNonceStore)(nil)