Monorepo for Tangled
tangled.org
1package serviceauth
2
3import (
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
17const (
18 testIssuer = "did:plc:boltless"
19 testAudience = "did:web:knot.example"
20 testLxm = "sh.tangled.repo.create"
21)
22
23func 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
44func 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
53func 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
70func 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
82func 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
91func 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
99func 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
108func 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}