this repo has no description

add viewer auth

Changed files
+219 -40
api
+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
··· 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
··· 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
··· 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
··· 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
··· 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=