Monorepo for Tangled tangled.org
12

Configure Feed

Select the types of activity you want to include in your feed.

xrpc/serviceauth: service auth in lexicon method, typed context key

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 16, 2026, 9:04 PM +0300) commit 30a2d031 parent 5c97f1cc change-id qputtvsy
+135 -11
+1 -1
knotserver/router.go
··· 130 130 } 131 131 132 132 func (h *Knot) XrpcRouter() http.Handler { 133 - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 133 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver.Directory(), h.c.Server.Did().String()) 134 134 135 135 l := log.SubLogger(h.l, "xrpc") 136 136
+1 -1
spindle/server.go
··· 360 360 } 361 361 362 362 func (s *Spindle) XrpcRouter() http.Handler { 363 - serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 363 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res.Directory(), s.cfg.Server.Did().String()) 364 364 365 365 l := log.SubLogger(s.l, "xrpc") 366 366
+1 -1
spindle/xrpc/xrpc.go
··· 20 20 "tangled.org/core/xrpc/serviceauth" 21 21 ) 22 22 23 - const ActorDid string = "ActorDid" 23 + const ActorDid = serviceauth.ActorDid 24 24 25 25 type Xrpc struct { 26 26 Logger *slog.Logger
+18 -8
xrpc/serviceauth/service_auth.go
··· 5 5 "encoding/json" 6 6 "log/slog" 7 7 "net/http" 8 + "path" 8 9 "strings" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/auth" 12 + "github.com/bluesky-social/indigo/atproto/identity" 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.org/core/idresolver" 13 14 "tangled.org/core/log" 14 15 xrpcerr "tangled.org/core/xrpc/errors" 15 16 ) 16 17 17 - const ActorDid string = "ActorDid" 18 + type contextKey string 19 + 20 + const ActorDid contextKey = "ActorDid" 18 21 19 22 func DidWeb(hostname string) syntax.DID { 20 23 return syntax.DID("did:web:" + strings.ReplaceAll(hostname, ":", "%3A")) ··· 22 25 23 26 type ServiceAuth struct { 24 27 logger *slog.Logger 25 - resolver *idresolver.Resolver 28 + dir identity.Directory 26 29 audienceDid string 27 30 } 28 31 29 - func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 32 + func NewServiceAuth(logger *slog.Logger, dir identity.Directory, audienceDid string) *ServiceAuth { 30 33 return &ServiceAuth{ 31 34 logger: log.SubLogger(logger, "serviceauth"), 32 - resolver: resolver, 35 + dir: dir, 33 36 audienceDid: audienceDid, 34 37 } 35 38 } ··· 39 42 token := r.Header.Get("Authorization") 40 43 token = strings.TrimPrefix(token, "Bearer ") 41 44 45 + lxm, err := syntax.ParseNSID(path.Base(r.URL.Path)) 46 + if err != nil { 47 + sa.logger.Error("could not derive lexicon method from request path", "path", r.URL.Path, "err", err) 48 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 49 + return 50 + } 51 + 42 52 s := auth.ServiceAuthValidator{ 43 53 Audience: sa.audienceDid, 44 - Dir: sa.resolver.Directory(), 54 + Dir: sa.dir, 45 55 } 46 56 47 - did, err := s.Validate(r.Context(), token, nil) 57 + did, err := s.Validate(r.Context(), token, &lxm) 48 58 if err != nil { 49 59 sa.logger.Error("signature verification failed", "err", err) 50 60 writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 51 61 return 52 62 } 53 63 54 - sa.logger.Debug("valid signature", ActorDid, did) 64 + sa.logger.Debug("valid signature", "did", did) 55 65 56 66 r = r.WithContext( 57 67 context.WithValue(r.Context(), ActorDid, did),
+114
xrpc/serviceauth/service_auth_test.go
··· 1 + package serviceauth 2 + 3 + import ( 4 + "io" 5 + "log/slog" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + "github.com/bluesky-social/indigo/atproto/auth" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + const ( 18 + testIssuer = "did:plc:boltless" 19 + testAudience = "did:web:knot.example" 20 + testLxm = "sh.tangled.repo.create" 21 + ) 22 + 23 + func newTestServiceAuth(t *testing.T) (*ServiceAuth, atcrypto.PrivateKey) { 24 + t.Helper() 25 + priv, err := atcrypto.GeneratePrivateKeyP256() 26 + if err != nil { 27 + t.Fatalf("generate key: %v", err) 28 + } 29 + pub, err := priv.PublicKey() 30 + if err != nil { 31 + t.Fatalf("derive pubkey: %v", err) 32 + } 33 + dir := identity.NewMockDirectory() 34 + dir.Insert(identity.Identity{ 35 + DID: syntax.DID(testIssuer), 36 + Keys: map[string]identity.VerificationMethod{ 37 + "atproto": {Type: "Multikey", PublicKeyMultibase: pub.Multibase()}, 38 + }, 39 + }) 40 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 41 + return NewServiceAuth(logger, dir, testAudience), priv 42 + } 43 + 44 + func signed(t *testing.T, priv atcrypto.PrivateKey, lxm *syntax.NSID) string { 45 + t.Helper() 46 + token, err := auth.SignServiceAuth(syntax.DID(testIssuer), testAudience, time.Minute, lxm, priv) 47 + if err != nil { 48 + t.Fatalf("sign service auth: %v", err) 49 + } 50 + return token 51 + } 52 + 53 + func serve(sa *ServiceAuth, path, token string) (*httptest.ResponseRecorder, *syntax.DID) { 54 + var seen *syntax.DID 55 + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 + if did, ok := r.Context().Value(ActorDid).(syntax.DID); ok { 57 + seen = &did 58 + } 59 + w.WriteHeader(http.StatusOK) 60 + }) 61 + req := httptest.NewRequest(http.MethodPost, path, nil) 62 + if token != "" { 63 + req.Header.Set("Authorization", "Bearer "+token) 64 + } 65 + rec := httptest.NewRecorder() 66 + sa.VerifyServiceAuth(next).ServeHTTP(rec, req) 67 + return rec, seen 68 + } 69 + 70 + func TestVerifyServiceAuth_MatchingLxmPasses(t *testing.T) { 71 + sa, priv := newTestServiceAuth(t) 72 + lxm := syntax.NSID(testLxm) 73 + rec, seen := serve(sa, "/xrpc/"+testLxm, signed(t, priv, &lxm)) 74 + if rec.Code != http.StatusOK { 75 + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 76 + } 77 + if seen == nil || seen.String() != testIssuer { 78 + t.Fatalf("ActorDid = %v, want %s", seen, testIssuer) 79 + } 80 + } 81 + 82 + func TestVerifyServiceAuth_MismatchedLxmRejected(t *testing.T) { 83 + sa, priv := newTestServiceAuth(t) 84 + other := syntax.NSID("sh.tangled.knot.addMember") 85 + rec, _ := serve(sa, "/xrpc/"+testLxm, signed(t, priv, &other)) 86 + if rec.Code != http.StatusForbidden { 87 + t.Fatalf("status = %d, want 403 for lxm bound to a different method", rec.Code) 88 + } 89 + } 90 + 91 + func TestVerifyServiceAuth_NoLxmClaimRejected(t *testing.T) { 92 + sa, priv := newTestServiceAuth(t) 93 + rec, _ := serve(sa, "/xrpc/"+testLxm, signed(t, priv, nil)) 94 + if rec.Code != http.StatusForbidden { 95 + t.Fatalf("status = %d, want 403 for a token carrying no lxm claim", rec.Code) 96 + } 97 + } 98 + 99 + func TestVerifyServiceAuth_UnparseablePathRejected(t *testing.T) { 100 + sa, priv := newTestServiceAuth(t) 101 + lxm := syntax.NSID(testLxm) 102 + rec, _ := serve(sa, "/xrpc/notansid", signed(t, priv, &lxm)) 103 + if rec.Code != http.StatusForbidden { 104 + t.Fatalf("status = %d, want 403 when the path tail is not a valid NSID", rec.Code) 105 + } 106 + } 107 + 108 + func TestVerifyServiceAuth_GarbageTokenRejected(t *testing.T) { 109 + sa, _ := newTestServiceAuth(t) 110 + rec, _ := serve(sa, "/xrpc/"+testLxm, "not.a.jwt") 111 + if rec.Code != http.StatusForbidden { 112 + t.Fatalf("status = %d, want 403 for an unverifiable token", rec.Code) 113 + } 114 + }