feat: webfinger auth delegation. add webfinger-cli

This commit is contained in:
Jon Lundy
2023-01-15 17:00:25 -07:00
parent 2fb3fae61f
commit 7d78cfb10a
16 changed files with 759 additions and 56 deletions

35
app/webfinger/addr.go Normal file
View File

@@ -0,0 +1,35 @@
package webfinger
import (
"net/url"
"strings"
)
type Addr struct {
prefix []string
URL *url.URL
}
func Parse(s string) *Addr {
addr := &Addr{}
addr.URL, _ = url.Parse(s)
if addr.URL.Opaque == "" {
return addr
}
var hasPfx = true
pfx := addr.URL.Scheme
for hasPfx {
addr.prefix = append(addr.prefix, pfx)
pfx, addr.URL.Opaque, hasPfx = strings.Cut(addr.URL.Opaque, ":")
}
user, host, _ := strings.Cut(pfx, "@")
addr.URL.User = url.User(user)
addr.URL.Host = host
return addr
}

View File

@@ -1,6 +1,7 @@
package webfinger
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
@@ -8,7 +9,9 @@ import (
"sort"
"github.com/sour-is/ev/pkg/es/event"
"github.com/sour-is/ev/pkg/set"
"github.com/sour-is/ev/pkg/slice"
"gopkg.in/yaml.v3"
)
func StreamID(subject string) string {
@@ -20,13 +23,13 @@ func StreamID(subject string) string {
// JRD is a JSON Resource Descriptor, specifying properties and related links
// for a resource.
type JRD struct {
Subject string `json:"subject,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Properties map[string]*string `json:"properties,omitempty"`
Links Links `json:"links,omitempty"`
Subject string `json:"subject,omitempty" yaml:"subject,omitempty"`
Aliases []string `json:"aliases,omitempty" yaml:"aliases,omitempty"`
Properties map[string]*string `json:"properties,omitempty" yaml:"properties,omitempty"`
Links Links `json:"links,omitempty" yaml:"links,omitempty"`
deleted bool
event.AggregateRoot
deleted bool
event.AggregateRoot `yaml:"-"`
}
var _ event.Aggregate = (*JRD)(nil)
@@ -86,6 +89,19 @@ func (jrd *JRD) GetLinkByRel(rel string) *Link {
return nil
}
// GetLinksByRel returns the first *Link with the specified rel value.
func (jrd *JRD) GetLinksByRel(rel ...string) []*Link {
var lis []*Link
rels := set.New(rel...)
for _, link := range jrd.Links {
if rels.Has(link.Rel) {
lis = append(lis, link)
}
}
return lis
}
// GetProperty Returns the property value as a string.
// Per spec a property value can be null, empty string is returned in this case.
func (jrd *JRD) GetProperty(uri string) string {
@@ -100,6 +116,12 @@ func (a *JRD) SetProperty(name string, value *string) {
}
a.Properties[name] = value
}
func (a *JRD) DeleteProperty(name string) {
if a.Properties == nil {
return
}
delete(a.Properties, name)
}
func (a *JRD) IsDeleted() bool {
return a.deleted
}
@@ -118,6 +140,12 @@ func (link *Link) SetProperty(name string, value *string) {
}
link.Properties[name] = value
}
func (link *Link) DeleteProperty(name string) {
if link.Properties == nil {
return
}
delete(link.Properties, name)
}
// ApplyEvent implements event.Aggregate
func (a *JRD) ApplyEvent(events ...event.Event) {
@@ -133,6 +161,7 @@ func (a *JRD) ApplyEvent(events ...event.Event) {
case *SubjectDeleted:
a.deleted = true
a.Subject = e.Subject
a.Aliases = a.Aliases[:0]
a.Links = a.Links[:0]
a.Properties = map[string]*string{}
@@ -156,41 +185,43 @@ func (a *JRD) ApplyEvent(events ...event.Event) {
}
}
const NSpubkey = "https://sour.is/ns/pub"
const NSauth = "https://sour.is/ns/auth"
const NSpubkey = "https://sour.is/ns/pubkey"
const NSredirect = "https://sour.is/rel/redirect"
func (a *JRD) OnDelete(pubkey string, jrd *JRD) error {
if a.Version() == 0 || a.IsDeleted() {
return nil
}
func (a *JRD) OnAuth(claim, auth *JRD) error {
pubkey := claim.Properties[NSpubkey]
if v, ok := a.Properties[NSpubkey]; ok && v != nil && *v == pubkey {
if v, ok := auth.Properties[NSpubkey]; ok && v != nil && cmpPtr(v, pubkey) {
// pubkey matches!
} else {
return fmt.Errorf("pubkey does not match")
}
if a.Subject != jrd.Subject {
if a.Version() > 0 && !a.IsDeleted() && a.Subject != claim.Subject {
return fmt.Errorf("subject does not match")
}
if auth.Subject == claim.Subject {
claim.SetProperty(NSpubkey, pubkey)
} else {
claim.SetProperty(NSauth, &auth.Subject)
claim.DeleteProperty(NSpubkey)
}
return nil
}
func (a *JRD) OnDelete(jrd *JRD) error {
if a.Version() == 0 || a.IsDeleted() {
return nil
}
event.Raise(a, &SubjectDeleted{Subject: jrd.Subject})
return nil
}
func (a *JRD) OnClaims(pubkey string, jrd *JRD) error {
if a.Version() > 0 && !a.IsDeleted() {
if v, ok := a.Properties[NSpubkey]; ok && v != nil && *v == pubkey {
// pubkey matches!
} else {
return fmt.Errorf("pubkey does not match")
}
if a.Subject != jrd.Subject {
return fmt.Errorf("subject does not match")
}
}
jrd.SetProperty(NSpubkey, &pubkey)
func (a *JRD) OnClaims(jrd *JRD) error {
err := a.OnSubjectSet(jrd.Subject, jrd.Aliases, jrd.Properties)
if err != nil {
@@ -343,3 +374,11 @@ func cmpPtr[T comparable](l, r *T) bool {
return *l == *r
}
func (a *JRD) String() string {
b := &bytes.Buffer{}
y := yaml.NewEncoder(b)
_ = y.Encode(a)
return b.String()
}

View File

@@ -100,9 +100,6 @@ func TestEncodeJRD(t *testing.T) {
}
}
// { "properties":{"https://sour.is/ns/pubkey":"kex1d330ama4vnu3vll5dgwjv3k0pcxsccc5k2xy3j8khndggkszsmsq3hl4ru"},"links":[{"rel":"salty:public","type":"application/json+salty","href":"https://ev.sour.is/inbox/01GAEMKXYJ4857JQP1MJGD61Z5","properties":{"pub":"kex1r8zshlvkc787pxvauaq7hd6awa9kmheddxjj9k80qmenyxk6284s50uvpw"}}]}
//!= {"subject":"acct:me@sour.is","properties":{"https://sour.is/ns/pubkey":"kex1d330ama4vnu3vll5dgwjv3k0pcxsccc5k2xy3j8khndggkszsmsq3hl4ru"},"links":[{"rel":"salty:public","type":"application/json+salty","href":"https://ev.sour.is/inbox/01GAEMKXYJ4857JQP1MJGD61Z5","properties":{"pub":"kex1r8zshlvkc787pxvauaq7hd6awa9kmheddxjj9k80qmenyxk6284s50uvpw"}}]}
func TestApplyEvents(t *testing.T) {
is := is.New(t)
@@ -158,7 +155,7 @@ func TestApplyEvents(t *testing.T) {
}
t.Log(string(s))
if string(s) != `{"subject":"acct:me@sour.is"}` {
if string(s) != `{}` {
t.Fatal("output does not match")
}
}
@@ -177,6 +174,7 @@ func TestCommands(t *testing.T) {
"aliases": []string{"acct:xuu@sour.is"},
"properties": map[string]*string{
"https://example.com/ns/asdf": nil,
webfinger.NSpubkey: ptr(enc(pub)),
},
"links": []map[string]any{{
"rel": "salty:public",
@@ -214,6 +212,8 @@ func TestCommands(t *testing.T) {
c.JRD.Subject = c.Subject
c.StandardClaims.Subject = c.Subject
c.SetProperty(webfinger.NSpubkey, &c.PubKey)
pub, err := dec(c.PubKey)
return ed25519.PublicKey(pub), err
},
@@ -227,8 +227,49 @@ func TestCommands(t *testing.T) {
t.Logf("%#v", c)
a, err := ev.Upsert(ctx, es, webfinger.StreamID(c.Subject), func(ctx context.Context, a *webfinger.JRD) error {
a.OnClaims(c.PubKey, c.JRD)
return nil
var auth *webfinger.JRD
// does the target have a pubkey for self auth?
if _, ok := a.Properties[webfinger.NSpubkey]; ok {
auth = a
}
// Check current version for auth.
if authID, ok := a.Properties[webfinger.NSauth]; ok && authID != nil && auth == nil {
auth = &webfinger.JRD{}
auth.SetStreamID(webfinger.StreamID(*authID))
err := es.Load(ctx, auth)
if err != nil {
return err
}
}
if a.Version() == 0 || a.IsDeleted() {
// else does the new object claim auth from another object?
if authID, ok := c.Properties[webfinger.NSauth]; ok && authID != nil && auth == nil {
auth = &webfinger.JRD{}
auth.SetStreamID(webfinger.StreamID(*authID))
err := es.Load(ctx, auth)
if err != nil {
return err
}
}
// fall back to use auth from submitted claims
if auth == nil {
auth = c.JRD
}
}
if auth == nil {
return fmt.Errorf("auth not found")
}
err = a.OnAuth(c.JRD, auth)
if err != nil {
return err
}
return a.OnClaims(c.JRD)
})
is.NoErr(err)

View File

@@ -9,19 +9,32 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/sour-is/ev"
"github.com/sour-is/ev/internal/lg"
"github.com/sour-is/ev/pkg/es/event"
"github.com/sour-is/ev/pkg/set"
)
type service struct {
es *ev.EventStore
es *ev.EventStore
self set.Set[string]
}
func New(ctx context.Context, es *ev.EventStore) (*service, error) {
type Option interface {
ApplyWebfinger(s *service)
}
type WithHostnames []string
func (o WithHostnames) ApplyWebfinger(s *service) {
s.self = set.New(o...)
}
func New(ctx context.Context, es *ev.EventStore, opts ...Option) (*service, error) {
ctx, span := lg.Span(ctx)
defer span.End()
@@ -36,6 +49,10 @@ func New(ctx context.Context, es *ev.EventStore) (*service, error) {
}
svc := &service{es: es}
for _, o := range opts {
o.ApplyWebfinger(svc)
}
return svc, nil
}
@@ -52,7 +69,6 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, http.StatusText(http.StatusNotFound))
return
}
switch r.Method {
@@ -93,6 +109,8 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.JRD.Subject = c.Subject
c.StandardClaims.Subject = c.Subject
c.SetProperty(NSpubkey, &c.PubKey)
pub, err := dec(c.PubKey)
return ed25519.PublicKey(pub), err
},
@@ -117,10 +135,52 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
a, err := ev.Upsert(ctx, s.es, StreamID(c.Subject), func(ctx context.Context, a *JRD) error {
if r.Method == http.MethodDelete {
return a.OnDelete(c.PubKey, c.JRD)
var auth *JRD
// does the target have a pubkey for self auth?
if _, ok := a.Properties[NSpubkey]; ok {
auth = a
}
return a.OnClaims(c.PubKey, c.JRD)
// Check current version for auth.
if authID, ok := a.Properties[NSauth]; ok && authID != nil && auth == nil {
auth = &JRD{}
auth.SetStreamID(StreamID(*authID))
err := s.es.Load(ctx, auth)
if err != nil {
return err
}
}
if a.Version() == 0 || a.IsDeleted() {
// else does the new object claim auth from another object?
if authID, ok := c.Properties[NSauth]; ok && authID != nil && auth == nil {
auth = &JRD{}
auth.SetStreamID(StreamID(*authID))
err := s.es.Load(ctx, auth)
if err != nil {
return err
}
}
// fall back to use auth from submitted claims
if auth == nil {
auth = c.JRD
}
}
if auth == nil {
return fmt.Errorf("auth not found")
}
err = a.OnAuth(c.JRD, auth)
if err != nil {
return err
}
if r.Method == http.MethodDelete {
return a.OnDelete(c.JRD)
}
return a.OnClaims(c.JRD)
})
if err != nil {
@@ -131,9 +191,17 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/jrd+json")
w.WriteHeader(http.StatusCreated)
if version := a.Version(); r.Method == http.MethodDelete && version > 0 {
err = s.es.Truncate(ctx, a.StreamID(), int64(version))
span.RecordError(err)
}
w.Header().Set("Content-Type", "application/jrd+json")
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusNoContent)
} else {
w.WriteHeader(http.StatusCreated)
}
j := json.NewEncoder(w)
j.SetIndent("", " ")
err = j.Encode(a)
@@ -141,6 +209,18 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
resource := r.URL.Query().Get("resource")
rels := r.URL.Query()["rel"]
if u := Parse(resource); u != nil && !s.self.Has(u.URL.Hostname()) {
redirect := &url.URL{}
redirect.Scheme = "https"
redirect.Host = u.URL.Host
redirect.RawQuery = r.URL.RawQuery
redirect.Path = "/.well-known/webfinger"
w.Header().Set("location", redirect.String())
w.WriteHeader(http.StatusSeeOther)
return
}
a := &JRD{}
a.SetStreamID(StreamID(resource))
@@ -167,6 +247,18 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if len(rels) > 0 {
a.Links = a.GetLinksByRel(rels...)
}
if a.Properties != nil {
if redirect, ok := a.Properties[NSredirect]; ok && redirect != nil {
w.Header().Set("location", *redirect)
w.WriteHeader(http.StatusSeeOther)
return
}
}
w.Header().Set("Content-Type", "application/jrd+json")
w.WriteHeader(http.StatusOK)