A fork of the Cocoon PDS but being made more distributed.
11

Configure Feed

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

move middleware to their own file

Hailey (Jul 12, 2025, 12:03 PM -0700) dedf8103 be005ba5

+274 -262
+1 -1
README.md
··· 25 25 - [x] com.atproto.repo.deleteRecord 26 26 - [x] com.atproto.repo.describeRepo 27 27 - [x] com.atproto.repo.getRecord 28 - - [ ] com.atproto.repo.importRepo 28 + - [x] com.atproto.repo.importRepo (Works "okay". You still have to handle PLC operations on your own when migrating. Use with extreme caution.) 29 29 - [x] com.atproto.repo.listRecords 30 30 - [ ] com.atproto.repo.listMissingBlobs 31 31
+269
server/middleware.go
··· 1 + package server 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "github.com/Azure/go-autorest/autorest/to" 11 + "github.com/golang-jwt/jwt/v4" 12 + "github.com/haileyok/cocoon/internal/helpers" 13 + "github.com/haileyok/cocoon/models" 14 + "github.com/haileyok/cocoon/oauth/provider" 15 + "github.com/labstack/echo/v4" 16 + "gitlab.com/yawning/secp256k1-voi" 17 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 + "gorm.io/gorm" 19 + ) 20 + 21 + func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 22 + return func(e echo.Context) error { 23 + username, password, ok := e.Request().BasicAuth() 24 + if !ok || username != "admin" || password != s.config.AdminPassword { 25 + return helpers.InputError(e, to.StringPtr("Unauthorized")) 26 + } 27 + 28 + if err := next(e); err != nil { 29 + e.Error(err) 30 + } 31 + 32 + return nil 33 + } 34 + } 35 + 36 + func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 37 + return func(e echo.Context) error { 38 + authheader := e.Request().Header.Get("authorization") 39 + if authheader == "" { 40 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 41 + } 42 + 43 + pts := strings.Split(authheader, " ") 44 + if len(pts) != 2 { 45 + return helpers.ServerError(e, nil) 46 + } 47 + 48 + // move on to oauth session middleware if this is a dpop token 49 + if pts[0] == "DPoP" { 50 + return next(e) 51 + } 52 + 53 + tokenstr := pts[1] 54 + token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{}) 55 + claims, ok := token.Claims.(jwt.MapClaims) 56 + if !ok { 57 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 58 + } 59 + 60 + var did string 61 + var repo *models.RepoActor 62 + 63 + // service auth tokens 64 + lxm, hasLxm := claims["lxm"] 65 + if hasLxm { 66 + pts := strings.Split(e.Request().URL.String(), "/") 67 + if lxm != pts[len(pts)-1] { 68 + s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err) 69 + return helpers.InputError(e, nil) 70 + } 71 + 72 + maybeDid, ok := claims["iss"].(string) 73 + if !ok { 74 + s.logger.Error("no iss in service auth token", "error", err) 75 + return helpers.InputError(e, nil) 76 + } 77 + did = maybeDid 78 + 79 + maybeRepo, err := s.getRepoActorByDid(did) 80 + if err != nil { 81 + s.logger.Error("error fetching repo", "error", err) 82 + return helpers.ServerError(e, nil) 83 + } 84 + repo = maybeRepo 85 + } 86 + 87 + if token.Header["alg"] != "ES256K" { 88 + token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 89 + if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 90 + return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 91 + } 92 + return s.privateKey.Public(), nil 93 + }) 94 + if err != nil { 95 + s.logger.Error("error parsing jwt", "error", err) 96 + // NOTE: https://github.com/bluesky-social/atproto/discussions/3319 97 + return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 98 + } 99 + 100 + if !token.Valid { 101 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 102 + } 103 + } else { 104 + kpts := strings.Split(tokenstr, ".") 105 + signingInput := kpts[0] + "." + kpts[1] 106 + hash := sha256.Sum256([]byte(signingInput)) 107 + sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2]) 108 + if err != nil { 109 + s.logger.Error("error decoding signature bytes", "error", err) 110 + return helpers.ServerError(e, nil) 111 + } 112 + 113 + if len(sigBytes) != 64 { 114 + s.logger.Error("incorrect sigbytes length", "length", len(sigBytes)) 115 + return helpers.ServerError(e, nil) 116 + } 117 + 118 + rBytes := sigBytes[:32] 119 + sBytes := sigBytes[32:] 120 + rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes)) 121 + ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes)) 122 + 123 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 124 + if err != nil { 125 + s.logger.Error("can't load private key", "error", err) 126 + return err 127 + } 128 + 129 + pubKey, ok := sk.Public().(*secp256k1secec.PublicKey) 130 + if !ok { 131 + s.logger.Error("error getting public key from sk") 132 + return helpers.ServerError(e, nil) 133 + } 134 + 135 + verified := pubKey.VerifyRaw(hash[:], rr, ss) 136 + if !verified { 137 + s.logger.Error("error verifying", "error", err) 138 + return helpers.ServerError(e, nil) 139 + } 140 + } 141 + 142 + isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 143 + scope, _ := claims["scope"].(string) 144 + 145 + if isRefresh && scope != "com.atproto.refresh" { 146 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 147 + } else if !hasLxm && !isRefresh && scope != "com.atproto.access" { 148 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 149 + } 150 + 151 + table := "tokens" 152 + if isRefresh { 153 + table = "refresh_tokens" 154 + } 155 + 156 + if isRefresh { 157 + type Result struct { 158 + Found bool 159 + } 160 + var result Result 161 + if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil { 162 + if err == gorm.ErrRecordNotFound { 163 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 164 + } 165 + 166 + s.logger.Error("error getting token from db", "error", err) 167 + return helpers.ServerError(e, nil) 168 + } 169 + 170 + if !result.Found { 171 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 172 + } 173 + } 174 + 175 + exp, ok := claims["exp"].(float64) 176 + if !ok { 177 + s.logger.Error("error getting iat from token") 178 + return helpers.ServerError(e, nil) 179 + } 180 + 181 + if exp < float64(time.Now().UTC().Unix()) { 182 + return helpers.InputError(e, to.StringPtr("ExpiredToken")) 183 + } 184 + 185 + if repo == nil { 186 + maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string)) 187 + if err != nil { 188 + s.logger.Error("error fetching repo", "error", err) 189 + return helpers.ServerError(e, nil) 190 + } 191 + repo = maybeRepo 192 + did = repo.Repo.Did 193 + } 194 + 195 + e.Set("repo", repo) 196 + e.Set("did", did) 197 + e.Set("token", tokenstr) 198 + 199 + if err := next(e); err != nil { 200 + e.Error(err) 201 + } 202 + 203 + return nil 204 + } 205 + } 206 + 207 + func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 208 + return func(e echo.Context) error { 209 + authheader := e.Request().Header.Get("authorization") 210 + if authheader == "" { 211 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 212 + } 213 + 214 + pts := strings.Split(authheader, " ") 215 + if len(pts) != 2 { 216 + return helpers.ServerError(e, nil) 217 + } 218 + 219 + if pts[0] != "DPoP" { 220 + return next(e) 221 + } 222 + 223 + accessToken := pts[1] 224 + 225 + nonce := s.oauthProvider.NextNonce() 226 + if nonce != "" { 227 + e.Response().Header().Set("DPoP-Nonce", nonce) 228 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 229 + } 230 + 231 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 232 + if err != nil { 233 + s.logger.Error("invalid dpop proof", "error", err) 234 + return helpers.InputError(e, to.StringPtr(err.Error())) 235 + } 236 + 237 + var oauthToken provider.OauthToken 238 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil { 239 + s.logger.Error("error finding access token in db", "error", err) 240 + return helpers.InputError(e, nil) 241 + } 242 + 243 + if oauthToken.Token == "" { 244 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 245 + } 246 + 247 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 248 + s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) 249 + return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) 250 + } 251 + 252 + if time.Now().After(oauthToken.ExpiresAt) { 253 + return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 254 + } 255 + 256 + repo, err := s.getRepoActorByDid(oauthToken.Sub) 257 + if err != nil { 258 + s.logger.Error("could not find actor in db", "error", err) 259 + return helpers.ServerError(e, nil) 260 + } 261 + 262 + e.Set("repo", repo) 263 + e.Set("did", repo.Repo.Did) 264 + e.Set("token", accessToken) 265 + e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " ")) 266 + 267 + return next(e) 268 + } 269 + }
+4 -261
server/server.go
··· 4 4 "bytes" 5 5 "context" 6 6 "crypto/ecdsa" 7 - "crypto/sha256" 8 7 "embed" 9 - "encoding/base64" 10 8 "errors" 11 9 "fmt" 12 10 "io" ··· 15 13 "net/smtp" 16 14 "os" 17 15 "path/filepath" 18 - "strings" 19 16 "sync" 20 17 "text/template" 21 18 "time" 22 19 23 - "github.com/Azure/go-autorest/autorest/to" 24 20 "github.com/aws/aws-sdk-go/aws" 25 21 "github.com/aws/aws-sdk-go/aws/credentials" 26 22 "github.com/aws/aws-sdk-go/aws/session" ··· 32 28 "github.com/bluesky-social/indigo/xrpc" 33 29 "github.com/domodwyer/mailyak/v3" 34 30 "github.com/go-playground/validator" 35 - "github.com/golang-jwt/jwt/v4" 36 31 "github.com/gorilla/sessions" 37 32 "github.com/haileyok/cocoon/identity" 38 33 "github.com/haileyok/cocoon/internal/db" ··· 47 42 "github.com/labstack/echo/v4" 48 43 "github.com/labstack/echo/v4/middleware" 49 44 slogecho "github.com/samber/slog-echo" 50 - "gitlab.com/yawning/secp256k1-voi" 51 - secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 52 45 "gorm.io/driver/sqlite" 53 46 "gorm.io/gorm" 54 47 ) ··· 197 190 return t.templates.ExecuteTemplate(w, name, data) 198 191 } 199 192 200 - func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 201 - return func(e echo.Context) error { 202 - username, password, ok := e.Request().BasicAuth() 203 - if !ok || username != "admin" || password != s.config.AdminPassword { 204 - return helpers.InputError(e, to.StringPtr("Unauthorized")) 205 - } 206 - 207 - if err := next(e); err != nil { 208 - e.Error(err) 209 - } 210 - 211 - return nil 212 - } 213 - } 214 - 215 - func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 216 - return func(e echo.Context) error { 217 - authheader := e.Request().Header.Get("authorization") 218 - if authheader == "" { 219 - return e.JSON(401, map[string]string{"error": "Unauthorized"}) 220 - } 221 - 222 - pts := strings.Split(authheader, " ") 223 - if len(pts) != 2 { 224 - return helpers.ServerError(e, nil) 225 - } 226 - 227 - // move on to oauth session middleware if this is a dpop token 228 - if pts[0] == "DPoP" { 229 - return next(e) 230 - } 231 - 232 - tokenstr := pts[1] 233 - token, _, err := new(jwt.Parser).ParseUnverified(tokenstr, jwt.MapClaims{}) 234 - claims, ok := token.Claims.(jwt.MapClaims) 235 - if !ok { 236 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 237 - } 238 - 239 - var did string 240 - var repo *models.RepoActor 241 - 242 - // service auth tokens 243 - lxm, hasLxm := claims["lxm"] 244 - if hasLxm { 245 - pts := strings.Split(e.Request().URL.String(), "/") 246 - if lxm != pts[len(pts)-1] { 247 - s.logger.Error("service auth lxm incorrect", "lxm", lxm, "expected", pts[len(pts)-1], "error", err) 248 - return helpers.InputError(e, nil) 249 - } 250 - 251 - maybeDid, ok := claims["iss"].(string) 252 - if !ok { 253 - s.logger.Error("no iss in service auth token", "error", err) 254 - return helpers.InputError(e, nil) 255 - } 256 - did = maybeDid 257 - 258 - maybeRepo, err := s.getRepoActorByDid(did) 259 - if err != nil { 260 - s.logger.Error("error fetching repo", "error", err) 261 - return helpers.ServerError(e, nil) 262 - } 263 - repo = maybeRepo 264 - } 265 - 266 - if token.Header["alg"] != "ES256K" { 267 - token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 268 - if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 269 - return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 270 - } 271 - return s.privateKey.Public(), nil 272 - }) 273 - if err != nil { 274 - s.logger.Error("error parsing jwt", "error", err) 275 - // NOTE: https://github.com/bluesky-social/atproto/discussions/3319 276 - return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 277 - } 278 - 279 - if !token.Valid { 280 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 281 - } 282 - } else { 283 - kpts := strings.Split(tokenstr, ".") 284 - signingInput := kpts[0] + "." + kpts[1] 285 - hash := sha256.Sum256([]byte(signingInput)) 286 - sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2]) 287 - if err != nil { 288 - s.logger.Error("error decoding signature bytes", "error", err) 289 - return helpers.ServerError(e, nil) 290 - } 291 - 292 - if len(sigBytes) != 64 { 293 - s.logger.Error("incorrect sigbytes length", "length", len(sigBytes)) 294 - return helpers.ServerError(e, nil) 295 - } 296 - 297 - rBytes := sigBytes[:32] 298 - sBytes := sigBytes[32:] 299 - rr, _ := secp256k1.NewScalarFromBytes((*[32]byte)(rBytes)) 300 - ss, _ := secp256k1.NewScalarFromBytes((*[32]byte)(sBytes)) 301 - 302 - sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 303 - if err != nil { 304 - s.logger.Error("can't load private key", "error", err) 305 - return err 306 - } 307 - 308 - pubKey, ok := sk.Public().(*secp256k1secec.PublicKey) 309 - if !ok { 310 - s.logger.Error("error getting public key from sk") 311 - return helpers.ServerError(e, nil) 312 - } 313 - 314 - verified := pubKey.VerifyRaw(hash[:], rr, ss) 315 - if !verified { 316 - s.logger.Error("error verifying", "error", err) 317 - return helpers.ServerError(e, nil) 318 - } 319 - } 320 - 321 - isRefresh := e.Request().URL.Path == "/xrpc/com.atproto.server.refreshSession" 322 - scope, _ := claims["scope"].(string) 323 - 324 - if isRefresh && scope != "com.atproto.refresh" { 325 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 326 - } else if !hasLxm && !isRefresh && scope != "com.atproto.access" { 327 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 328 - } 329 - 330 - table := "tokens" 331 - if isRefresh { 332 - table = "refresh_tokens" 333 - } 334 - 335 - if isRefresh { 336 - type Result struct { 337 - Found bool 338 - } 339 - var result Result 340 - if err := s.db.Raw("SELECT EXISTS(SELECT 1 FROM "+table+" WHERE token = ?) AS found", nil, tokenstr).Scan(&result).Error; err != nil { 341 - if err == gorm.ErrRecordNotFound { 342 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 343 - } 344 - 345 - s.logger.Error("error getting token from db", "error", err) 346 - return helpers.ServerError(e, nil) 347 - } 348 - 349 - if !result.Found { 350 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 351 - } 352 - } 353 - 354 - exp, ok := claims["exp"].(float64) 355 - if !ok { 356 - s.logger.Error("error getting iat from token") 357 - return helpers.ServerError(e, nil) 358 - } 359 - 360 - if exp < float64(time.Now().UTC().Unix()) { 361 - return helpers.InputError(e, to.StringPtr("ExpiredToken")) 362 - } 363 - 364 - if repo == nil { 365 - maybeRepo, err := s.getRepoActorByDid(claims["sub"].(string)) 366 - if err != nil { 367 - s.logger.Error("error fetching repo", "error", err) 368 - return helpers.ServerError(e, nil) 369 - } 370 - repo = maybeRepo 371 - did = repo.Repo.Did 372 - } 373 - 374 - e.Set("repo", repo) 375 - e.Set("did", did) 376 - e.Set("token", tokenstr) 377 - 378 - if err := next(e); err != nil { 379 - e.Error(err) 380 - } 381 - 382 - return nil 383 - } 384 - } 385 - 386 - func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 387 - return func(e echo.Context) error { 388 - authheader := e.Request().Header.Get("authorization") 389 - if authheader == "" { 390 - return e.JSON(401, map[string]string{"error": "Unauthorized"}) 391 - } 392 - 393 - pts := strings.Split(authheader, " ") 394 - if len(pts) != 2 { 395 - return helpers.ServerError(e, nil) 396 - } 397 - 398 - if pts[0] != "DPoP" { 399 - return next(e) 400 - } 401 - 402 - accessToken := pts[1] 403 - 404 - nonce := s.oauthProvider.NextNonce() 405 - if nonce != "" { 406 - e.Response().Header().Set("DPoP-Nonce", nonce) 407 - e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 408 - } 409 - 410 - proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 411 - if err != nil { 412 - s.logger.Error("invalid dpop proof", "error", err) 413 - return helpers.InputError(e, to.StringPtr(err.Error())) 414 - } 415 - 416 - var oauthToken provider.OauthToken 417 - if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil { 418 - s.logger.Error("error finding access token in db", "error", err) 419 - return helpers.InputError(e, nil) 420 - } 421 - 422 - if oauthToken.Token == "" { 423 - return helpers.InputError(e, to.StringPtr("InvalidToken")) 424 - } 425 - 426 - if *oauthToken.Parameters.DpopJkt != proof.JKT { 427 - s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) 428 - return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) 429 - } 430 - 431 - if time.Now().After(oauthToken.ExpiresAt) { 432 - return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 433 - } 434 - 435 - repo, err := s.getRepoActorByDid(oauthToken.Sub) 436 - if err != nil { 437 - s.logger.Error("could not find actor in db", "error", err) 438 - return helpers.ServerError(e, nil) 439 - } 440 - 441 - e.Set("repo", repo) 442 - e.Set("did", repo.Repo.Did) 443 - e.Set("token", accessToken) 444 - e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " ")) 445 - 446 - return next(e) 447 - } 448 - } 449 - 450 193 func New(args *Args) (*Server, error) { 451 194 if args.Addr == "" { 452 195 return nil, fmt.Errorf("addr must be set") ··· 726 469 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 727 470 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 728 471 729 - // are there any routes that we should be allowing without auth? i dont think so but idk 730 - s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 731 - s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 732 - 733 472 // admin routes 734 473 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 735 474 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 475 + 476 + // are there any routes that we should be allowing without auth? i dont think so but idk 477 + s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 478 + s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 736 479 } 737 480 738 481 func (s *Server) Serve(ctx context.Context) error {