+46
api/server/auth.go
+46
api/server/auth.go
···
1
+
package server
2
+
3
+
import (
4
+
"fmt"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
7
+
"github.com/golang-jwt/jwt/v5"
8
+
)
9
+
10
+
func initSigningMethods() {
11
+
if jwt.GetSigningMethod("ES256K") == nil {
12
+
jwt.RegisterSigningMethod("ES256K", func() jwt.SigningMethod {
13
+
return &SigningMethodAtproto{
14
+
alg: "ES256K",
15
+
}
16
+
})
17
+
}
18
+
19
+
if jwt.GetSigningMethod("ES256") == nil {
20
+
jwt.RegisterSigningMethod("ES256", func() jwt.SigningMethod {
21
+
return &SigningMethodAtproto{
22
+
alg: "ES256",
23
+
}
24
+
})
25
+
}
26
+
}
27
+
28
+
type SigningMethodAtproto struct {
29
+
alg string
30
+
}
31
+
32
+
func (sm *SigningMethodAtproto) Verify(signingString string, sig []byte, key any) error {
33
+
pub, ok := key.(atcrypto.PublicKey)
34
+
if !ok {
35
+
return fmt.Errorf("wrong key type")
36
+
}
37
+
return pub.HashAndVerifyLenient([]byte(signingString), sig)
38
+
}
39
+
40
+
func (sm *SigningMethodAtproto) Sign(signingString string, key any) ([]byte, error) {
41
+
return nil, fmt.Errorf("signing not supported")
42
+
}
43
+
44
+
func (sm *SigningMethodAtproto) Alg() string {
45
+
return sm.alg
46
+
}
+6
-4
api/server/feedgetposts.go
+6
-4
api/server/feedgetposts.go
···
213
213
214
214
func (s *Server) handleGetPosts(e echo.Context) error {
215
215
ctx := e.Request().Context()
216
+
viewer := getViewer(e)
216
217
217
-
logger := s.logger.With("name", "handleGetPost")
218
+
logger := s.logger.With("name", "handleGetPost", "viewer", viewer)
218
219
219
220
var input GetFeedPostsInput
220
221
if err := e.Bind(&input); err != nil {
···
235
236
return NewValidationError("uris", "all URIs must be valid AT-URIs")
236
237
}
237
238
238
-
postViews, err := s.getPostViews(ctx, input.Uris, "")
239
+
postViews, err := s.getPostViews(ctx, input.Uris, viewer)
239
240
if err != nil {
240
241
logger.Error("failed to get posts", "err", err)
241
242
return ErrInternalServerErr
···
268
269
269
270
func (s *Server) handleGetActorPosts(e echo.Context) error {
270
271
ctx := e.Request().Context()
272
+
viewer := getViewer(e)
271
273
272
-
logger := s.logger.With("name", "handleGetActorPosts")
274
+
logger := s.logger.With("name", "handleGetActorPosts", "viewer", viewer)
273
275
274
276
var input GetFeedActorPostsInput
275
277
if err := e.Bind(&input); err != nil {
···
304
306
return ErrInternalServerErr
305
307
}
306
308
307
-
postViews, err := s.postsToPostViews(ctx, resp.Posts, "") // TODO: set viewer
309
+
postViews, err := s.postsToPostViews(ctx, resp.Posts, viewer)
308
310
if err != nil {
309
311
s.logger.Error("failed to get post views", "err", err)
310
312
return ErrInternalServerErr
+16
-21
api/server/identity.go
+16
-21
api/server/identity.go
···
4
4
"context"
5
5
"errors"
6
6
"fmt"
7
-
"strings"
8
7
9
8
"github.com/bluesky-social/indigo/atproto/syntax"
10
9
)
···
14
13
ErrActorNotValid = errors.New("actor was not a valid did or handle")
15
14
)
16
15
17
-
func (s *Server) handleFromDid(ctx context.Context, did string) (string, error) {
18
-
doc, err := s.passport.FetchDoc(ctx, did)
16
+
func (s *Server) handleFromDid(ctx context.Context, did syntax.DID) (syntax.Handle, error) {
17
+
doc, err := s.directory.LookupDID(ctx, did)
19
18
if err != nil {
20
19
return "", fmt.Errorf("failed to fetch did doc: %w", err)
21
20
}
22
-
for _, aka := range doc.AlsoKnownAs {
23
-
if after, ok := strings.CutPrefix(aka, "at://"); ok {
24
-
return after, nil
25
-
}
26
-
}
27
-
return "", ErrAtHandleNotPresent
21
+
22
+
return doc.Handle, nil
28
23
}
29
24
30
25
// Given either a valid DID or handle, finds both the DID and handle for said actor and returns them.
···
32
27
func (s *Server) fetchDidHandleFromActor(ctx context.Context, actor string) (string, string, error) {
33
28
logger := s.logger.With("name", "fetchDidHandleForActor", "actor", actor)
34
29
35
-
var did string
36
-
var handle string
30
+
var did *syntax.DID
31
+
var handle *syntax.Handle
37
32
if parsed, err := syntax.ParseDID(actor); err == nil {
38
-
did = parsed.String()
33
+
did = &parsed
39
34
} else if parsed, err := syntax.ParseHandle(actor); err == nil {
40
-
handle = parsed.String()
35
+
handle = &parsed
41
36
}
42
37
43
38
logger = logger.With("did", did, "handle", handle)
44
39
45
-
if did == "" && handle == "" {
40
+
if did == nil && handle == nil {
46
41
logger.Error("actor was not a valid did or handle")
47
42
return "", "", ErrActorNotValid
48
43
}
49
44
50
-
if did != "" {
51
-
maybeHandle, err := s.handleFromDid(ctx, did)
45
+
if did != nil {
46
+
maybeHandle, err := s.handleFromDid(ctx, *did)
52
47
if err != nil {
53
48
logger.Error("error getting handle", "err", err)
54
49
return "", "", err
55
50
}
56
-
handle = maybeHandle
57
-
} else if handle != "" {
58
-
maybeDid, err := s.passport.ResolveHandle(ctx, handle)
51
+
handle = &maybeHandle
52
+
} else if handle != nil {
53
+
maybeDid, err := s.directory.ResolveHandle(ctx, *handle)
59
54
if err != nil {
60
55
logger.Error("error getting did", "err", err)
61
56
return "", "", err
62
57
}
63
-
did = maybeDid
58
+
did = &maybeDid
64
59
}
65
60
66
-
return did, handle, nil
61
+
return did.String(), handle.String(), nil
67
62
}
+143
-13
api/server/server.go
+143
-13
api/server/server.go
···
8
8
"net/http"
9
9
"os"
10
10
"os/signal"
11
+
"strings"
11
12
"syscall"
12
13
"time"
13
14
14
-
"github.com/bluesky-social/go-util/pkg/robusthttp"
15
-
"github.com/haileyok/cocoon/identity"
15
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
16
+
"github.com/bluesky-social/indigo/atproto/identity"
17
+
"github.com/bluesky-social/indigo/atproto/syntax"
18
+
"github.com/golang-jwt/jwt/v5"
16
19
"github.com/labstack/echo-contrib/echoprometheus"
17
20
"github.com/labstack/echo/v4"
18
21
"github.com/labstack/echo/v4/middleware"
19
22
slogecho "github.com/samber/slog-echo"
20
23
"github.com/vylet-app/go/database/client"
24
+
"golang.org/x/time/rate"
21
25
)
22
26
23
27
type Server struct {
24
-
logger *slog.Logger
25
-
httpd *http.Server
26
-
echo *echo.Echo
27
-
client *client.Client
28
-
passport *identity.Passport
28
+
logger *slog.Logger
29
+
httpd *http.Server
30
+
echo *echo.Echo
31
+
client *client.Client
32
+
directory *identity.CacheDirectory
29
33
}
30
34
31
35
type Args struct {
···
38
42
if args.Logger == nil {
39
43
args.Logger = slog.Default()
40
44
}
45
+
46
+
initSigningMethods()
41
47
42
48
logger := args.Logger
43
49
···
59
65
return nil, fmt.Errorf("failed to create new database client: %w", err)
60
66
}
61
67
62
-
passport := identity.NewPassport(robusthttp.NewClient(), identity.NewMemCache(10_000))
68
+
baseDirectory := identity.BaseDirectory{
69
+
PLCURL: "https://plc.directory",
70
+
HTTPClient: http.Client{
71
+
Timeout: time.Second * 5,
72
+
},
73
+
PLCLimiter: rate.NewLimiter(rate.Limit(10), 1),
74
+
TryAuthoritativeDNS: false,
75
+
SkipDNSDomainSuffixes: []string{".bsky.social", ".staging.bsky.dev"},
76
+
}
77
+
directory := identity.NewCacheDirectory(&baseDirectory, 100_000, time.Hour*48, time.Minute*15, time.Minute*15)
63
78
64
79
server := Server{
65
-
logger: logger,
66
-
echo: echo,
67
-
httpd: &httpd,
68
-
client: client,
69
-
passport: passport,
80
+
logger: logger,
81
+
echo: echo,
82
+
httpd: &httpd,
83
+
client: client,
84
+
directory: &directory,
70
85
}
71
86
72
87
server.echo.HTTPErrorHandler = server.errorHandler
88
+
server.echo.Use(server.didAuthMiddleware())
73
89
74
90
server.registerHandlers()
75
91
···
131
147
s.echo.GET("/xrpc/app.vylet.feed.getPosts", s.handleGetPosts)
132
148
s.echo.GET("/xrpc/app.vylet.feed.getSubjectLikes", s.handleGetSubjectLikes)
133
149
s.echo.GET("/xrpc/app.vylet.feed.getActorPosts", s.handleGetActorPosts)
150
+
151
+
// SAMPLE
152
+
s.echo.GET("/xrpc/authed", nil, requireAuth)
134
153
}
135
154
136
155
func (s *Server) errorHandler(err error, c echo.Context) {
···
159
178
c.Logger().Error(err)
160
179
}
161
180
}
181
+
182
+
type AtProtoClaims struct {
183
+
Sub string `json:"sub"`
184
+
Aud string `json:"aud"`
185
+
Iss string `json:"iss"`
186
+
jwt.RegisteredClaims
187
+
}
188
+
189
+
func (s *Server) getKeyForDid(ctx context.Context, did syntax.DID) (atcrypto.PublicKey, error) {
190
+
ident, err := s.directory.LookupDID(ctx, did)
191
+
if err != nil {
192
+
return nil, err
193
+
}
194
+
return ident.PublicKey()
195
+
}
196
+
197
+
func (s *Server) fetchKey(ctx context.Context) func(tok *jwt.Token) (any, error) {
198
+
return func(tok *jwt.Token) (any, error) {
199
+
issuer, ok := tok.Claims.(jwt.MapClaims)["iss"].(string)
200
+
if !ok {
201
+
return nil, fmt.Errorf("missing 'iss' field from auth header JWT")
202
+
}
203
+
204
+
did, err := syntax.ParseDID(issuer)
205
+
if err != nil {
206
+
return nil, fmt.Errorf("invalid DID in 'iss' field from auth header JWT")
207
+
}
208
+
209
+
k, err := s.getKeyForDid(ctx, did)
210
+
if err != nil {
211
+
return nil, fmt.Errorf("failed to look up public key for DID (%q): %w", did, err)
212
+
}
213
+
214
+
return k, nil
215
+
}
216
+
}
217
+
218
+
func (s *Server) checkJwt(ctx context.Context, token string) (string, error) {
219
+
ctx, cancel := context.WithTimeout(ctx, time.Second*5)
220
+
defer cancel()
221
+
222
+
validMethods := []string{"ES256K", "ES256"}
223
+
config := []jwt.ParserOption{jwt.WithValidMethods(validMethods)}
224
+
225
+
p := jwt.NewParser(config...)
226
+
t, err := p.Parse(token, s.fetchKey(ctx))
227
+
if err != nil {
228
+
return "", fmt.Errorf("failed to parse auth header jwt: %w", err)
229
+
}
230
+
231
+
clms, ok := t.Claims.(jwt.MapClaims)
232
+
if !ok {
233
+
return "", fmt.Errorf("invalid token claims")
234
+
}
235
+
236
+
did, ok := clms["iss"].(string)
237
+
if !ok {
238
+
return "", fmt.Errorf("no issuer present in returned claims")
239
+
}
240
+
241
+
return did, nil
242
+
}
243
+
244
+
func (s *Server) didAuthMiddleware() echo.MiddlewareFunc {
245
+
return func(next echo.HandlerFunc) echo.HandlerFunc {
246
+
return func(e echo.Context) error {
247
+
authHeader := e.Request().Header.Get("Authorization")
248
+
249
+
if authHeader == "" {
250
+
return next(e)
251
+
}
252
+
253
+
if !strings.HasPrefix(authHeader, "Bearer ") {
254
+
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format")
255
+
}
256
+
257
+
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
258
+
259
+
ctx := e.Request().Context()
260
+
userDid, err := s.checkJwt(ctx, tokenString)
261
+
if err != nil {
262
+
return echo.NewHTTPError(http.StatusUnauthorized,
263
+
fmt.Sprintf("Token verification failed: %v", err))
264
+
}
265
+
266
+
e.Set("viewer", userDid)
267
+
268
+
return next(e)
269
+
}
270
+
}
271
+
}
272
+
273
+
func requireAuth(next echo.HandlerFunc) echo.HandlerFunc {
274
+
return func(e echo.Context) error {
275
+
viewer := getViewer(e)
276
+
277
+
if viewer == "" {
278
+
return ErrUnauthorized
279
+
}
280
+
281
+
return next(e)
282
+
}
283
+
}
284
+
285
+
func getViewer(e echo.Context) string {
286
+
viewer, ok := e.Get("viewer").(string)
287
+
if ok {
288
+
return viewer
289
+
}
290
+
return ""
291
+
}
+4
-2
go.mod
+4
-2
go.mod
···
7
7
github.com/bluesky-social/go-util v0.0.0-20251012040650-2ebbf57f5934
8
8
github.com/bluesky-social/indigo v0.0.0-20251206005924-d49b45419635
9
9
github.com/gocql/gocql v1.7.0
10
+
github.com/golang-jwt/jwt/v4 v4.5.2
11
+
github.com/golang-jwt/jwt/v5 v5.2.2
10
12
github.com/golang-migrate/migrate/v4 v4.19.1
11
13
github.com/gorilla/websocket v1.5.1
12
14
github.com/haileyok/cocoon v0.6.0
···
14
16
github.com/joho/godotenv v1.5.1
15
17
github.com/labstack/echo-contrib v0.17.4
16
18
github.com/labstack/echo/v4 v4.13.3
19
+
github.com/multiformats/go-multihash v0.2.3
17
20
github.com/prometheus/client_golang v1.23.2
18
21
github.com/samber/slog-echo v1.18.0
19
22
github.com/twmb/franz-go v1.19.5
20
23
github.com/urfave/cli/v2 v2.27.7
21
24
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
25
+
golang.org/x/sync v0.18.0
22
26
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
23
27
google.golang.org/grpc v1.74.2
24
28
google.golang.org/protobuf v1.36.9
···
74
78
github.com/multiformats/go-base32 v0.1.0 // indirect
75
79
github.com/multiformats/go-base36 v0.2.0 // indirect
76
80
github.com/multiformats/go-multibase v0.2.0 // indirect
77
-
github.com/multiformats/go-multihash v0.2.3 // indirect
78
81
github.com/multiformats/go-varint v0.0.7 // indirect
79
82
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
80
83
github.com/opentracing/opentracing-go v1.2.0 // indirect
···
107
110
golang.org/x/crypto v0.45.0 // indirect
108
111
golang.org/x/mod v0.29.0 // indirect
109
112
golang.org/x/net v0.47.0 // indirect
110
-
golang.org/x/sync v0.18.0 // indirect
111
113
golang.org/x/sys v0.38.0 // indirect
112
114
golang.org/x/text v0.31.0 // indirect
113
115
golang.org/x/time v0.12.0 // indirect
+4
go.sum
+4
go.sum
···
69
69
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
70
70
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
71
71
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
72
+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
73
+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
74
+
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
75
+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
72
76
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
73
77
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
74
78
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=