~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
170 lines · 4.8 KB Raw History
package market

import (
	"context"
	"fmt"
	"net"
	"regexp"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/atproto/auth"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/golang-jwt/jwt/v5"
)

const (
	ServiceAuthIssuedAtLeeway = 30 * time.Second

	ServiceAuthExpiryLeeway = 5 * time.Second
)

var bearerRE = regexp.MustCompile(`^Bearer (.+)$`)

func ExtractBearer(header string) (string, error) {
	m := bearerRE.FindStringSubmatch(strings.TrimSpace(header))
	if m == nil {
		return "", fmt.Errorf("%w: missing or malformed Authorization Bearer header", ErrInvalidAuth)
	}
	return m[1], nil
}

func ServiceDIDForHost(hostname string) string {
	return "did:web:" + hostname
}

type ServiceAuthResult struct {
	IssuerDID string
	Audience  string
	ServiceID string
}

type VerifyServiceAuthOptions struct {
	AuthHeader        string
	Hostname          string
	LXM               string
	ServiceIDs        []string
	ExtraAudienceDIDs []string
}

type serviceAudience struct {
	aud       string
	serviceID string
}

func VerifyServiceAuth(ctx context.Context, dir identity.Directory, opts VerifyServiceAuthOptions) (*ServiceAuthResult, error) {
	token, err := ExtractBearer(opts.AuthHeader)
	if err != nil {
		return nil, err
	}
	if dir == nil {
		return nil, fmt.Errorf("%w: no identity directory", ErrInvalidAuth)
	}
	if opts.Hostname == "" {
		return nil, fmt.Errorf("%w: no service hostname", ErrInvalidAuth)
	}
	if opts.LXM == "" {
		return nil, fmt.Errorf("%w: no lexicon method", ErrInvalidAuth)
	}
	nsid, err := syntax.ParseNSID(opts.LXM)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidAuth, err)
	}
	leeway, err := serviceAuthLeeway(token, time.Now())
	if err != nil {
		return nil, err
	}
	serviceDID := ServiceDIDForHost(strings.ToLower(opts.Hostname))
	dids := append([]string{serviceDID, ServiceDIDForHost(opts.Hostname)}, opts.ExtraAudienceDIDs...)
	if name, _, err := net.SplitHostPort(opts.Hostname); err == nil && name != "" {
		dids = append(dids, ServiceDIDForHost(strings.ToLower(name)))
	}
	audiences := make([]serviceAudience, 0, len(dids)*(len(opts.ServiceIDs)+1))
	for _, did := range dids {
		audiences = append(audiences, serviceAudience{aud: did})
		for _, id := range opts.ServiceIDs {
			if id == "" {
				continue
			}
			audiences = append(audiences, serviceAudience{aud: did + "#" + id, serviceID: id})
		}
	}
	var lastErr error
	for _, candidate := range audiences {
		validator := &auth.ServiceAuthValidator{Audience: candidate.aud, Dir: dir, TimestampLeeway: leeway}
		issuer, err := validator.Validate(ctx, token, &nsid)
		if err != nil {
			lastErr = err
			continue
		}
		iss := issuer.String()
		if i := strings.IndexByte(iss, '#'); i >= 0 {
			iss = iss[:i]
		}
		if !strings.HasPrefix(iss, "did:") {
			return nil, fmt.Errorf("%w: service auth token missing DID issuer", ErrInvalidAuth)
		}
		return &ServiceAuthResult{IssuerDID: iss, Audience: candidate.aud, ServiceID: candidate.serviceID}, nil
	}
	return nil, fmt.Errorf("%w: %v", ErrInvalidAuth, lastErr)
}

func AuthorizeRecord(issuerDID, recordURI string) error {
	if issuerDID != ATURIAuthority(recordURI) {
		return fmt.Errorf("%w: service-auth token issuer must author the referenced record", ErrForbidden)
	}
	return nil
}

func ServiceAudienceDIDs(host string) []string {
	host = strings.TrimSpace(host)
	if host == "" {
		return nil
	}
	candidates := []string{host, strings.ToLower(host)}
	if name, _, err := net.SplitHostPort(host); err == nil && name != "" {
		candidates = append(candidates, name, strings.ToLower(name))
	}
	unique := []string{}
	seen := map[string]bool{}
	for _, candidate := range candidates {
		if candidate == "" {
			continue
		}
		did := ServiceDIDForHost(candidate)
		if seen[did] {
			continue
		}
		seen[did] = true
		unique = append(unique, did)
	}
	return unique
}

func serviceAuthLeeway(token string, now time.Time) (time.Duration, error) {
	claims := jwt.RegisteredClaims{}
	if _, _, err := jwt.NewParser().ParseUnverified(token, &claims); err != nil {
		return 0, fmt.Errorf("%w: %v", ErrInvalidAuth, err)
	}
	if claims.ExpiresAt == nil {
		return 0, fmt.Errorf("%w: the service auth token has no exp", ErrInvalidAuth)
	}
	if late := now.Sub(claims.ExpiresAt.Time); late > ServiceAuthExpiryLeeway {
		return 0, fmt.Errorf("%w: the service auth token expired %s ago", ErrInvalidAuth, late.Round(time.Second))
	}
	leeway := ServiceAuthExpiryLeeway
	for _, at := range []*jwt.NumericDate{claims.IssuedAt, claims.NotBefore} {
		if at == nil {
			continue
		}
		skew := at.Sub(now)
		if skew > ServiceAuthIssuedAtLeeway {
			return 0, fmt.Errorf("%w: the service auth token is issued %s in this host's future (clock skew)", ErrInvalidAuth, skew.Round(time.Second))
		}
		if skew+time.Second > leeway {
			leeway = skew + time.Second
		}
	}
	return leeway, nil
}