6 changed files with 1014 additions and 202 deletions
@ -0,0 +1,5 @@ |
|||||||
|
// Package httpauth provides helpers and encoders for nostr NIP-98 HTTP
|
||||||
|
// authentication header messages and a new JWT authentication message and
|
||||||
|
// delegation event kind 13004 that enables time limited expiring delegations of
|
||||||
|
// authentication (as with NIP-42 auth) for the HTTP API.
|
||||||
|
package httpauth |
||||||
@ -0,0 +1,75 @@ |
|||||||
|
package httpauth |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/base64" |
||||||
|
"net/http" |
||||||
|
"net/url" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"next.orly.dev/pkg/encoders/event" |
||||||
|
"next.orly.dev/pkg/encoders/kind" |
||||||
|
"next.orly.dev/pkg/encoders/tag" |
||||||
|
"next.orly.dev/pkg/encoders/timestamp" |
||||||
|
"next.orly.dev/pkg/interfaces/signer" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
HeaderKey = "Authorization" |
||||||
|
NIP98Prefix = "Nostr" |
||||||
|
) |
||||||
|
|
||||||
|
// MakeNIP98Event creates a new NIP-98 event. If expiry is given, method is
|
||||||
|
// ignored; otherwise either option is the same.
|
||||||
|
func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) { |
||||||
|
var t []*tag.T |
||||||
|
t = append(t, tag.NewFromAny("u", u)) |
||||||
|
if expiry > 0 { |
||||||
|
t = append( |
||||||
|
t, |
||||||
|
tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String()), |
||||||
|
) |
||||||
|
} else { |
||||||
|
t = append( |
||||||
|
t, |
||||||
|
tag.NewFromAny("method", strings.ToUpper(method)), |
||||||
|
) |
||||||
|
} |
||||||
|
if hash != "" { |
||||||
|
t = append(t, tag.NewFromAny("payload", hash)) |
||||||
|
} |
||||||
|
ev = &event.E{ |
||||||
|
CreatedAt: timestamp.Now().V, |
||||||
|
Kind: kind.HTTPAuth.K, |
||||||
|
Tags: tag.NewS(t...), |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
func CreateNIP98Blob( |
||||||
|
ur, method, hash string, expiry int64, sign signer.I, |
||||||
|
) (blob string, err error) { |
||||||
|
ev := MakeNIP98Event(ur, method, hash, expiry) |
||||||
|
if err = ev.Sign(sign); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
// log.T.F("nip-98 http auth event:\n%s\n", ev.SerializeIndented())
|
||||||
|
blob = base64.URLEncoding.EncodeToString(ev.Serialize()) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// AddNIP98Header creates a NIP-98 http auth event and adds the standard header to a provided
|
||||||
|
// http.Request.
|
||||||
|
func AddNIP98Header( |
||||||
|
r *http.Request, ur *url.URL, method, hash string, |
||||||
|
sign signer.I, expiry int64, |
||||||
|
) (err error) { |
||||||
|
var b64 string |
||||||
|
if b64, err = CreateNIP98Blob( |
||||||
|
ur.String(), method, hash, expiry, sign, |
||||||
|
); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
r.Header.Add(HeaderKey, "Nostr "+b64) |
||||||
|
return |
||||||
|
} |
||||||
@ -0,0 +1,191 @@ |
|||||||
|
package httpauth |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/base64" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"strings" |
||||||
|
"time" |
||||||
|
|
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/errorf" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
"next.orly.dev/pkg/encoders/event" |
||||||
|
"next.orly.dev/pkg/encoders/ints" |
||||||
|
"next.orly.dev/pkg/encoders/kind" |
||||||
|
) |
||||||
|
|
||||||
|
var ErrMissingKey = fmt.Errorf( |
||||||
|
"'%s' key missing from request header", HeaderKey, |
||||||
|
) |
||||||
|
|
||||||
|
// CheckAuth verifies a received http.Request has got a valid authentication
|
||||||
|
// event in it, with an optional specification for tolerance of before and
|
||||||
|
// after, and provides the public key that should be verified to be authorized
|
||||||
|
// to access the resource associated with the request.
|
||||||
|
func CheckAuth(r *http.Request, tolerance ...time.Duration) ( |
||||||
|
valid bool, |
||||||
|
pubkey []byte, err error, |
||||||
|
) { |
||||||
|
val := r.Header.Get(HeaderKey) |
||||||
|
if val == "" { |
||||||
|
err = ErrMissingKey |
||||||
|
valid = true |
||||||
|
return |
||||||
|
} |
||||||
|
if len(tolerance) == 0 { |
||||||
|
tolerance = append(tolerance, time.Minute) |
||||||
|
} |
||||||
|
// log.I.S(tolerance)
|
||||||
|
if tolerance[0] == 0 { |
||||||
|
tolerance[0] = time.Minute |
||||||
|
} |
||||||
|
tolerate := int64(tolerance[0] / time.Second) |
||||||
|
log.T.C(func() string { return fmt.Sprintf("validating auth '%s'", val) }) |
||||||
|
switch { |
||||||
|
case strings.HasPrefix(val, NIP98Prefix): |
||||||
|
split := strings.Split(val, " ") |
||||||
|
if len(split) == 1 { |
||||||
|
err = errorf.E( |
||||||
|
"missing nip-98 auth event from '%s' http header key: '%s'", |
||||||
|
HeaderKey, val, |
||||||
|
) |
||||||
|
} |
||||||
|
if len(split) > 2 { |
||||||
|
err = errorf.E( |
||||||
|
"extraneous content after second field space separated: %s", |
||||||
|
val, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
var evb []byte |
||||||
|
if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
ev := event.New() |
||||||
|
var rem []byte |
||||||
|
if rem, err = ev.Unmarshal(evb); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if len(rem) > 0 { |
||||||
|
err = errorf.E("rem", rem) |
||||||
|
return |
||||||
|
} |
||||||
|
// log.T.F("received http auth event:\n%s\n", ev.SerializeIndented())
|
||||||
|
// The kind MUST be 27235.
|
||||||
|
if ev.Kind != kind.HTTPAuth.K { |
||||||
|
err = errorf.E( |
||||||
|
"invalid kind %d %s in nip-98 http auth event, require %d %s", |
||||||
|
ev.Kind, kind.GetString(ev.Kind), kind.HTTPAuth.K, |
||||||
|
kind.HTTPAuth.Name(), |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
// if there is an expiration timestamp, check it supersedes the
|
||||||
|
// created_at for validity.
|
||||||
|
exp := ev.Tags.GetAll([]byte("expiration")) |
||||||
|
if len(exp) > 1 { |
||||||
|
err = errorf.E( |
||||||
|
"more than one \"expiration\" tag found", |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
var expiring bool |
||||||
|
if len(exp) == 1 { |
||||||
|
ex := ints.New(0) |
||||||
|
exp1 := exp[0] |
||||||
|
if rem, err = ex.Unmarshal(exp1.Value()); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
tn := time.Now().Unix() |
||||||
|
if tn > ex.Int64()+tolerate { |
||||||
|
err = errorf.E( |
||||||
|
"HTTP auth event is expired %d time now %d", |
||||||
|
tn, ex.Int64()+tolerate, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
expiring = true |
||||||
|
} else { |
||||||
|
// The created_at timestamp MUST be within a reasonable time window
|
||||||
|
// (suggestion 60 seconds)
|
||||||
|
ts := ev.CreatedAt |
||||||
|
tn := time.Now().Unix() |
||||||
|
if ts < tn-tolerate || ts > tn+tolerate { |
||||||
|
err = errorf.E( |
||||||
|
"timestamp %d is more than %d seconds divergent from now %d", |
||||||
|
ts, tolerate, tn, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
ut := ev.Tags.GetAll([]byte("u")) |
||||||
|
if len(ut) > 1 { |
||||||
|
err = errorf.E( |
||||||
|
"more than one \"u\" tag found", |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
// The u tag MUST be exactly the same as the absolute request URL
|
||||||
|
// (including query parameters).
|
||||||
|
proto := r.URL.Scheme |
||||||
|
// if this came through a proxy, we need to get the protocol to match
|
||||||
|
// the event
|
||||||
|
if p := r.Header.Get("X-Forwarded-Proto"); p != "" { |
||||||
|
proto = p |
||||||
|
} |
||||||
|
if proto == "" { |
||||||
|
proto = "http" |
||||||
|
} |
||||||
|
fullUrl := proto + "://" + r.Host + r.URL.RequestURI() |
||||||
|
evUrl := string(ut[0].Value()) |
||||||
|
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl) |
||||||
|
if expiring { |
||||||
|
// if it is expiring, the URL only needs to be the same prefix to
|
||||||
|
// allow its use with multiple endpoints.
|
||||||
|
if !strings.HasPrefix(fullUrl, evUrl) { |
||||||
|
err = errorf.E( |
||||||
|
"request URL %s is not prefixed with the u tag URL %s", |
||||||
|
fullUrl, evUrl, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
} else if fullUrl != evUrl { |
||||||
|
err = errorf.E( |
||||||
|
"request has URL %s but signed nip-98 event has url %s", |
||||||
|
fullUrl, string(ut[0].Value()), |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
if !expiring { |
||||||
|
// The method tag MUST be the same HTTP method used for the
|
||||||
|
// requested resource.
|
||||||
|
mt := ev.Tags.GetAll([]byte("method")) |
||||||
|
if len(mt) != 1 { |
||||||
|
err = errorf.E( |
||||||
|
"more than one \"method\" tag found", |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
if !strings.EqualFold(string(mt[0].Value()), r.Method) { |
||||||
|
err = errorf.E( |
||||||
|
"request has method %s but event has method %s", |
||||||
|
string(mt[0].Value()), r.Method, |
||||||
|
) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
if valid, err = ev.Verify(); chk.E(err) { |
||||||
|
return |
||||||
|
} |
||||||
|
if !valid { |
||||||
|
return |
||||||
|
} |
||||||
|
pubkey = ev.Pubkey |
||||||
|
default: |
||||||
|
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
return |
||||||
|
} |
||||||
Loading…
Reference in new issue