validate.go raw

   1  package httpauth
   2  
   3  import (
   4  	"encoding/base64"
   5  	"fmt"
   6  	"net/http"
   7  	"strings"
   8  	"time"
   9  
  10  	"next.orly.dev/pkg/nostr/encoders/event"
  11  	"next.orly.dev/pkg/nostr/encoders/ints"
  12  	"next.orly.dev/pkg/nostr/encoders/kind"
  13  	"next.orly.dev/pkg/lol/chk"
  14  	"next.orly.dev/pkg/lol/errorf"
  15  	"next.orly.dev/pkg/lol/log"
  16  )
  17  
  18  var ErrMissingKey = fmt.Errorf(
  19  	"'%s' key missing from request header", HeaderKey,
  20  )
  21  
  22  // CheckAuth verifies a received http.Request has got a valid authentication
  23  // event in it, with an optional specification for tolerance of before and
  24  // after, and provides the public key that should be verified to be authorized
  25  // to access the resource associated with the request.
  26  func CheckAuth(r *http.Request, tolerance ...time.Duration) (
  27  	valid bool,
  28  	pubkey []byte, err error,
  29  ) {
  30  	val := r.Header.Get(HeaderKey)
  31  	if val == "" {
  32  		err = ErrMissingKey
  33  		valid = true
  34  		return
  35  	}
  36  	if len(tolerance) == 0 {
  37  		tolerance = append(tolerance, time.Minute)
  38  	}
  39  	// log.I.S(tolerance)
  40  	if tolerance[0] == 0 {
  41  		tolerance[0] = time.Minute
  42  	}
  43  	tolerate := int64(tolerance[0] / time.Second)
  44  	log.T.C(func() string { return fmt.Sprintf("validating auth '%s'", val) })
  45  	switch {
  46  	case strings.HasPrefix(val, NIP98Prefix):
  47  		split := strings.Split(val, " ")
  48  		if len(split) == 1 {
  49  			err = errorf.E(
  50  				"missing nip-98 auth event from '%s' http header key: '%s'",
  51  				HeaderKey, val,
  52  			)
  53  		}
  54  		if len(split) > 2 {
  55  			err = errorf.E(
  56  				"extraneous content after second field space separated: %s",
  57  				val,
  58  			)
  59  			return
  60  		}
  61  		var evb []byte
  62  		if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) {
  63  			return
  64  		}
  65  		ev := event.New()
  66  		var rem []byte
  67  		if rem, err = ev.Unmarshal(evb); chk.E(err) {
  68  			return
  69  		}
  70  		if len(rem) > 0 {
  71  			err = errorf.E("rem", rem)
  72  			return
  73  		}
  74  		// log.T.F("received http auth event:\n%s\n", ev.SerializeIndented())
  75  		// The kind MUST be 27235.
  76  		if ev.Kind != kind.HTTPAuth.K {
  77  			err = errorf.E(
  78  				"invalid kind %d %s in nip-98 http auth event, require %d %s",
  79  				ev.Kind, kind.GetString(ev.Kind), kind.HTTPAuth.K,
  80  				kind.HTTPAuth.Name(),
  81  			)
  82  			return
  83  		}
  84  		// if there is an expiration timestamp, check it supersedes the
  85  		// created_at for validity.
  86  		exp := ev.Tags.GetAll([]byte("expiration"))
  87  		if len(exp) > 1 {
  88  			err = errorf.E(
  89  				"more than one \"expiration\" tag found",
  90  			)
  91  			return
  92  		}
  93  		var expiring bool
  94  		if len(exp) == 1 {
  95  			ex := ints.New(0)
  96  			exp1 := exp[0]
  97  			if rem, err = ex.Unmarshal(exp1.Value()); chk.E(err) {
  98  				return
  99  			}
 100  			tn := time.Now().Unix()
 101  			if tn > ex.Int64()+tolerate {
 102  				err = errorf.E(
 103  					"HTTP auth event is expired %d time now %d",
 104  					tn, ex.Int64()+tolerate,
 105  				)
 106  				return
 107  			}
 108  			expiring = true
 109  		} else {
 110  			// The created_at timestamp MUST be within a reasonable time window
 111  			// (suggestion 60 seconds)
 112  			ts := ev.CreatedAt
 113  			tn := time.Now().Unix()
 114  			if ts < tn-tolerate || ts > tn+tolerate {
 115  				err = errorf.E(
 116  					"timestamp %d is more than %d seconds divergent from now %d",
 117  					ts, tolerate, tn,
 118  				)
 119  				return
 120  			}
 121  		}
 122  		ut := ev.Tags.GetAll([]byte("u"))
 123  		if len(ut) > 1 {
 124  			err = errorf.E(
 125  				"more than one \"u\" tag found",
 126  			)
 127  			return
 128  		}
 129  		// The u tag MUST be exactly the same as the absolute request URL
 130  		// (including query parameters).
 131  		proto := r.URL.Scheme
 132  		// if this came through a proxy, we need to get the protocol to match
 133  		// the event
 134  		if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
 135  			proto = p
 136  		}
 137  		if proto == "" {
 138  			// Check if this is a direct TLS connection
 139  			if r.TLS != nil {
 140  				proto = "https"
 141  			} else {
 142  				proto = "http"
 143  			}
 144  		}
 145  		fullUrl := proto + "://" + r.Host + r.URL.RequestURI()
 146  		evUrl := string(ut[0].Value())
 147  		log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
 148  		if expiring {
 149  			// if it is expiring, the URL only needs to be the same prefix to
 150  			// allow its use with multiple endpoints.
 151  			if !strings.HasPrefix(fullUrl, evUrl) {
 152  				err = errorf.E(
 153  					"request URL %s is not prefixed with the u tag URL %s",
 154  					fullUrl, evUrl,
 155  				)
 156  				return
 157  			}
 158  		} else if fullUrl != evUrl {
 159  			err = errorf.E(
 160  				"request has URL %s but signed nip-98 event has url %s",
 161  				fullUrl, string(ut[0].Value()),
 162  			)
 163  			return
 164  		}
 165  		if !expiring {
 166  			// The method tag MUST be the same HTTP method used for the
 167  			// requested resource.
 168  			mt := ev.Tags.GetAll([]byte("method"))
 169  			if len(mt) != 1 {
 170  				err = errorf.E(
 171  					"more than one \"method\" tag found",
 172  				)
 173  				return
 174  			}
 175  			if !strings.EqualFold(string(mt[0].Value()), r.Method) {
 176  				err = errorf.E(
 177  					"request has method %s but event has method %s",
 178  					string(mt[0].Value()), r.Method,
 179  				)
 180  				return
 181  			}
 182  		}
 183  		if valid, err = ev.Verify(); chk.E(err) {
 184  			return
 185  		}
 186  		if !valid {
 187  			return
 188  		}
 189  		pubkey = ev.Pubkey
 190  	default:
 191  		err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
 192  		return
 193  	}
 194  
 195  	return
 196  }
 197