~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
110 lines · 3.3 KB Raw History
package atprotoauth

import (
	"context"
	"errors"
	"fmt"
	"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 MaxServiceAuthLifetime = 5 * time.Minute

var (
	ErrMalformed = errors.New("atprotoauth: malformed service-auth token")

	ErrExpired = errors.New("atprotoauth: service-auth token expired")

	ErrBadSignature = errors.New("atprotoauth: service-auth signature does not match the issuer")

	ErrNoKey = errors.New("atprotoauth: could not resolve the issuer's signing key")

	ErrBadAudience = errors.New("atprotoauth: service-auth token is for another service")

	ErrBadLxm = errors.New("atprotoauth: service-auth token is for another lexicon method")

	ErrBadIssuer = errors.New("atprotoauth: the issuer's DID method is not admitted at this door")

	ErrLifetimeTooLong = errors.New("atprotoauth: service-auth token lifetime exceeds the maximum")
)

func Verify(
	ctx context.Context,
	token string,
	wantLxm string,
	audiences []string,
	dir identity.Directory,
) (string, error) {
	if wantLxm == "" {
		return "", fmt.Errorf("%w: no lexicon method was required", ErrBadLxm)
	}
	nsid, err := syntax.ParseNSID(wantLxm)
	if err != nil {
		return "", fmt.Errorf("%w: %q is not an NSID: %v", ErrBadLxm, wantLxm, err)
	}
	if len(audiences) == 0 {
		return "", fmt.Errorf("%w: no audience is configured", ErrBadAudience)
	}

	var lastAud error
	for _, aud := range audiences {
		v := &auth.ServiceAuthValidator{Audience: aud, Dir: admitPlcAndWeb{dir}}
		did, err := v.Validate(ctx, token, &nsid)
		if err == nil {
			if err := checkLifetime(token); err != nil {
				return "", err
			}
			return did.String(), nil
		}
		if errors.Is(err, jwt.ErrTokenInvalidAudience) {
			lastAud = err
			continue
		}
		return "", classify(err)
	}

	return "", fmt.Errorf("%w: %v", ErrBadAudience, lastAud)
}

// ponytail: a validated token stays replayable until its short exp; if replay ever matters, keep a jti set keyed by exp and evict each entry at its exp.
func checkLifetime(token string) error {
	claims := jwt.MapClaims{}
	if _, _, err := jwt.NewParser().ParseUnverified(token, claims); err != nil {
		return fmt.Errorf("%w: %v", ErrMalformed, err)
	}
	exp, err := claims.GetExpirationTime()
	if err != nil || exp == nil {
		return fmt.Errorf("%w: no expiry claim", ErrMalformed)
	}
	iat, err := claims.GetIssuedAt()
	if err != nil || iat == nil {
		return fmt.Errorf("%w: no issued-at claim", ErrMalformed)
	}
	if lifetime := exp.Time.Sub(iat.Time); lifetime > MaxServiceAuthLifetime {
		return fmt.Errorf("%w: token lifetime %s exceeds the %s maximum", ErrLifetimeTooLong, lifetime, MaxServiceAuthLifetime)
	}
	return nil
}

func classify(err error) error {
	switch {
	case errors.Is(err, jwt.ErrTokenExpired):
		return fmt.Errorf("%w: %v", ErrExpired, err)
	case errors.Is(err, jwt.ErrTokenInvalidAudience):
		return fmt.Errorf("%w: %v", ErrBadAudience, err)
	case errors.Is(err, jwt.ErrTokenInvalidClaims):
		return fmt.Errorf("%w: %v", ErrBadLxm, err)
	case errors.Is(err, jwt.ErrTokenSignatureInvalid):
		return fmt.Errorf("%w: %v", ErrBadSignature, err)
	case errors.Is(err, ErrBadIssuer):
		return err
	case errors.Is(err, jwt.ErrTokenUnverifiable):
		return fmt.Errorf("%w: %v", ErrNoKey, err)
	default:
		return fmt.Errorf("%w: %v", ErrMalformed, err)
	}
}