diff --git a/knotserver/router.go b/knotserver/router.go index e58ec99f..956fa7bc 100644 --- a/knotserver/router.go +++ b/knotserver/router.go @@ -130,7 +130,7 @@ func (h *Knot) GetMotdContent() []byte { } func (h *Knot) XrpcRouter() http.Handler { - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver.Directory(), h.c.Server.Did().String()) l := log.SubLogger(h.l, "xrpc") diff --git a/spindle/server.go b/spindle/server.go index 4648c663..74e8883b 100644 --- a/spindle/server.go +++ b/spindle/server.go @@ -360,7 +360,7 @@ func (s *Spindle) Router() http.Handler { } func (s *Spindle) XrpcRouter() http.Handler { - serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res.Directory(), s.cfg.Server.Did().String()) l := log.SubLogger(s.l, "xrpc") diff --git a/spindle/xrpc/xrpc.go b/spindle/xrpc/xrpc.go index 1adb2862..6d2ae2a1 100644 --- a/spindle/xrpc/xrpc.go +++ b/spindle/xrpc/xrpc.go @@ -20,7 +20,7 @@ import ( "tangled.org/core/xrpc/serviceauth" ) -const ActorDid string = "ActorDid" +const ActorDid = serviceauth.ActorDid type Xrpc struct { Logger *slog.Logger diff --git a/xrpc/serviceauth/service_auth.go b/xrpc/serviceauth/service_auth.go index a5a31bc9..70901259 100644 --- a/xrpc/serviceauth/service_auth.go +++ b/xrpc/serviceauth/service_auth.go @@ -5,16 +5,19 @@ import ( "encoding/json" "log/slog" "net/http" + "path" "strings" "github.com/bluesky-social/indigo/atproto/auth" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" - "tangled.org/core/idresolver" "tangled.org/core/log" xrpcerr "tangled.org/core/xrpc/errors" ) -const ActorDid string = "ActorDid" +type contextKey string + +const ActorDid contextKey = "ActorDid" func DidWeb(hostname string) syntax.DID { return syntax.DID("did:web:" + strings.ReplaceAll(hostname, ":", "%3A")) @@ -22,14 +25,14 @@ func DidWeb(hostname string) syntax.DID { type ServiceAuth struct { logger *slog.Logger - resolver *idresolver.Resolver + dir identity.Directory audienceDid string } -func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { +func NewServiceAuth(logger *slog.Logger, dir identity.Directory, audienceDid string) *ServiceAuth { return &ServiceAuth{ logger: log.SubLogger(logger, "serviceauth"), - resolver: resolver, + dir: dir, audienceDid: audienceDid, } } @@ -39,19 +42,26 @@ func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { token := r.Header.Get("Authorization") token = strings.TrimPrefix(token, "Bearer ") + lxm, err := syntax.ParseNSID(path.Base(r.URL.Path)) + if err != nil { + sa.logger.Error("could not derive lexicon method from request path", "path", r.URL.Path, "err", err) + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) + return + } + s := auth.ServiceAuthValidator{ Audience: sa.audienceDid, - Dir: sa.resolver.Directory(), + Dir: sa.dir, } - did, err := s.Validate(r.Context(), token, nil) + did, err := s.Validate(r.Context(), token, &lxm) if err != nil { sa.logger.Error("signature verification failed", "err", err) writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) return } - sa.logger.Debug("valid signature", ActorDid, did) + sa.logger.Debug("valid signature", "did", did) r = r.WithContext( context.WithValue(r.Context(), ActorDid, did), diff --git a/xrpc/serviceauth/service_auth_test.go b/xrpc/serviceauth/service_auth_test.go new file mode 100644 index 00000000..e20d1d41 --- /dev/null +++ b/xrpc/serviceauth/service_auth_test.go @@ -0,0 +1,114 @@ +package serviceauth + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/auth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + testIssuer = "did:plc:boltless" + testAudience = "did:web:knot.example" + testLxm = "sh.tangled.repo.create" +) + +func newTestServiceAuth(t *testing.T) (*ServiceAuth, atcrypto.PrivateKey) { + t.Helper() + priv, err := atcrypto.GeneratePrivateKeyP256() + if err != nil { + t.Fatalf("generate key: %v", err) + } + pub, err := priv.PublicKey() + if err != nil { + t.Fatalf("derive pubkey: %v", err) + } + dir := identity.NewMockDirectory() + dir.Insert(identity.Identity{ + DID: syntax.DID(testIssuer), + Keys: map[string]identity.VerificationMethod{ + "atproto": {Type: "Multikey", PublicKeyMultibase: pub.Multibase()}, + }, + }) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewServiceAuth(logger, dir, testAudience), priv +} + +func signed(t *testing.T, priv atcrypto.PrivateKey, lxm *syntax.NSID) string { + t.Helper() + token, err := auth.SignServiceAuth(syntax.DID(testIssuer), testAudience, time.Minute, lxm, priv) + if err != nil { + t.Fatalf("sign service auth: %v", err) + } + return token +} + +func serve(sa *ServiceAuth, path, token string) (*httptest.ResponseRecorder, *syntax.DID) { + var seen *syntax.DID + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if did, ok := r.Context().Value(ActorDid).(syntax.DID); ok { + seen = &did + } + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest(http.MethodPost, path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + sa.VerifyServiceAuth(next).ServeHTTP(rec, req) + return rec, seen +} + +func TestVerifyServiceAuth_MatchingLxmPasses(t *testing.T) { + sa, priv := newTestServiceAuth(t) + lxm := syntax.NSID(testLxm) + rec, seen := serve(sa, "/xrpc/"+testLxm, signed(t, priv, &lxm)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if seen == nil || seen.String() != testIssuer { + t.Fatalf("ActorDid = %v, want %s", seen, testIssuer) + } +} + +func TestVerifyServiceAuth_MismatchedLxmRejected(t *testing.T) { + sa, priv := newTestServiceAuth(t) + other := syntax.NSID("sh.tangled.knot.addMember") + rec, _ := serve(sa, "/xrpc/"+testLxm, signed(t, priv, &other)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for lxm bound to a different method", rec.Code) + } +} + +func TestVerifyServiceAuth_NoLxmClaimRejected(t *testing.T) { + sa, priv := newTestServiceAuth(t) + rec, _ := serve(sa, "/xrpc/"+testLxm, signed(t, priv, nil)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for a token carrying no lxm claim", rec.Code) + } +} + +func TestVerifyServiceAuth_UnparseablePathRejected(t *testing.T) { + sa, priv := newTestServiceAuth(t) + lxm := syntax.NSID(testLxm) + rec, _ := serve(sa, "/xrpc/notansid", signed(t, priv, &lxm)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 when the path tail is not a valid NSID", rec.Code) + } +} + +func TestVerifyServiceAuth_GarbageTokenRejected(t *testing.T) { + sa, _ := newTestServiceAuth(t) + rec, _ := serve(sa, "/xrpc/"+testLxm, "not.a.jwt") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 for an unverifiable token", rec.Code) + } +}