From 89013a78a2b75e58225f790f182b3f8b554deea3 Mon Sep 17 00:00:00 2001 From: Ed Costello Date: Tue, 28 Oct 2025 03:28:17 +1300 Subject: [PATCH] Support service auth for create account (#34) * Handle service auth and existing users on create account * Remove duplicate registration of create account --- identity/types.go | 2 +- internal/db/db.go | 6 ++ internal/helpers/helpers.go | 16 +++++ server/handle_server_create_account.go | 80 ++++++++++++---------- server/server.go | 1 - server/service_auth.go | 91 ++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 server/service_auth.go diff --git a/identity/types.go b/identity/types.go index c9877af..9d2c474 100644 --- a/identity/types.go +++ b/identity/types.go @@ -4,7 +4,7 @@ type DidDoc struct { Context []string `json:"@context"` Id string `json:"id"` AlsoKnownAs []string `json:"alsoKnownAs"` - VerificationMethods []DidDocVerificationMethod `json:"verificationMethods"` + VerificationMethods []DidDocVerificationMethod `json:"verificationMethod"` Service []DidDocService `json:"service"` } diff --git a/internal/db/db.go b/internal/db/db.go index 709c600..73c3577 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -25,6 +25,12 @@ func (db *DB) Create(value any, clauses []clause.Expression) *gorm.DB { return db.cli.Clauses(clauses...).Create(value) } +func (db *DB) Save(value any, clauses []clause.Expression) *gorm.DB { + db.mu.Lock() + defer db.mu.Unlock() + return db.cli.Clauses(clauses...).Save(value) +} + func (db *DB) Exec(sql string, clauses []clause.Expression, values ...any) *gorm.DB { db.mu.Lock() defer db.mu.Unlock() diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index b069208..af88a2d 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -32,6 +32,22 @@ func ServerError(e echo.Context, suffix *string) error { return genericError(e, 400, msg) } +func UnauthorizedError(e echo.Context, suffix *string) error { + msg := "Unauthorized" + if suffix != nil { + msg += ". " + *suffix + } + return genericError(e, 401, msg) +} + +func ForbiddenError(e echo.Context, suffix *string) error { + msg := "Forbidden" + if suffix != nil { + msg += ". " + *suffix + } + return genericError(e, 403, msg) +} + func InvalidTokenError(e echo.Context) error { return InputError(e, to.StringPtr("InvalidToken")) } diff --git a/server/handle_server_create_account.go b/server/handle_server_create_account.go index 6bb0f15..e0a42d5 100644 --- a/server/handle_server_create_account.go +++ b/server/handle_server_create_account.go @@ -10,7 +10,6 @@ import ( "github.com/Azure/go-autorest/autorest/to" "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/atcrypto" - "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/events" "github.com/bluesky-social/indigo/repo" "github.com/bluesky-social/indigo/util" @@ -39,22 +38,6 @@ type ComAtprotoServerCreateAccountResponse struct { func (s *Server) handleCreateAccount(e echo.Context) error { var request ComAtprotoServerCreateAccountRequest - var signupDid string - customDidHeader := e.Request().Header.Get("authorization") - if customDidHeader != "" { - pts := strings.Split(customDidHeader, " ") - if len(pts) != 2 { - return helpers.InputError(e, to.StringPtr("InvalidDid")) - } - - _, err := syntax.ParseDID(pts[1]) - if err != nil { - return helpers.InputError(e, to.StringPtr("InvalidDid")) - } - - signupDid = pts[1] - } - if err := e.Bind(&request); err != nil { s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err) return helpers.ServerError(e, nil) @@ -85,18 +68,38 @@ func (s *Server) handleCreateAccount(e echo.Context) error { } } } + + var signupDid string + if request.Did != nil { + signupDid = *request.Did; + + token := strings.TrimSpace(strings.Replace(e.Request().Header.Get("authorization"), "Bearer ", "", 1)) + if token == "" { + return helpers.UnauthorizedError(e, to.StringPtr("must authenticate to use an existing did")) + } + authDid, err := s.validateServiceAuth(e.Request().Context(), token, "com.atproto.server.createAccount") + + if err != nil { + s.logger.Warn("error validating authorization token", "endpoint", "com.atproto.server.createAccount", "error", err) + return helpers.UnauthorizedError(e, to.StringPtr("invalid authorization token")) + } + + if authDid != signupDid { + return helpers.ForbiddenError(e, to.StringPtr("auth did did not match signup did")) + } + } // see if the handle is already taken - _, err := s.getActorByHandle(request.Handle) + actor, err := s.getActorByHandle(request.Handle) if err != nil && err != gorm.ErrRecordNotFound { s.logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err) return helpers.ServerError(e, nil) } - if err == nil { + if err == nil && actor.Did != signupDid { return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) } - if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != "" { + if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != signupDid { return helpers.InputError(e, to.StringPtr("HandleNotAvailable")) } @@ -114,12 +117,12 @@ func (s *Server) handleCreateAccount(e echo.Context) error { } // see if the email is already taken - _, err = s.getRepoByEmail(request.Email) + existingRepo, err := s.getRepoByEmail(request.Email) if err != nil && err != gorm.ErrRecordNotFound { s.logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err) return helpers.ServerError(e, nil) } - if err == nil { + if err == nil && existingRepo.Did != signupDid { return helpers.InputError(e, to.StringPtr("EmailNotAvailable")) } @@ -160,22 +163,29 @@ func (s *Server) handleCreateAccount(e echo.Context) error { SigningKey: k.Bytes(), } - actor := models.Actor{ - Did: signupDid, - Handle: request.Handle, - } - - if err := s.db.Create(&urepo, nil).Error; err != nil { - s.logger.Error("error inserting new repo", "error", err) - return helpers.ServerError(e, nil) - } + if actor == nil { + actor = &models.Actor{ + Did: signupDid, + Handle: request.Handle, + } - if err := s.db.Create(&actor, nil).Error; err != nil { - s.logger.Error("error inserting new actor", "error", err) - return helpers.ServerError(e, nil) + if err := s.db.Create(&urepo, nil).Error; err != nil { + s.logger.Error("error inserting new repo", "error", err) + return helpers.ServerError(e, nil) + } + + if err := s.db.Create(&actor, nil).Error; err != nil { + s.logger.Error("error inserting new actor", "error", err) + return helpers.ServerError(e, nil) + } + } else { + if err := s.db.Save(&actor, nil).Error; err != nil { + s.logger.Error("error inserting new actor", "error", err) + return helpers.ServerError(e, nil) + } } - if customDidHeader == "" { + if request.Did == nil || *request.Did == "" { bs := s.getBlockstore(signupDid) r := repo.NewRepo(context.TODO(), signupDid, bs) diff --git a/server/server.go b/server/server.go index 90eebd2..749b31b 100644 --- a/server/server.go +++ b/server/server.go @@ -423,7 +423,6 @@ func (s *Server) addRoutes() { // public s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle) s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) - s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) diff --git a/server/service_auth.go b/server/service_auth.go new file mode 100644 index 0000000..d1709d8 --- /dev/null +++ b/server/service_auth.go @@ -0,0 +1,91 @@ +package server + +import ( + "context" + "fmt" + "strings" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/identity" + atproto_identity "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/golang-jwt/jwt/v4" +) + +type ES256KSigningMethod struct { + alg string +} + +func (m *ES256KSigningMethod) Alg() string { + return m.alg +} + +func (m *ES256KSigningMethod) Verify(signingString string, signature string, key interface{}) error { + signatureBytes, err := jwt.DecodeSegment(signature) + if err != nil { + return err + } + return key.(atcrypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signatureBytes) +} + +func (m *ES256KSigningMethod) Sign(signingString string, key interface{}) (string, error) { + return "", fmt.Errorf("unimplemented") +} + +func init() { + ES256K := ES256KSigningMethod{alg: "ES256K"} + jwt.RegisterSigningMethod(ES256K.Alg(), func() jwt.SigningMethod { + return &ES256K + }) +} + +func (s *Server) validateServiceAuth(ctx context.Context, rawToken string, nsid string) (string, error) { + token := strings.TrimSpace(rawToken) + + parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string)) + didDoc, err := s.passport.FetchDoc(ctx, did.String()); + if err != nil { + return nil, fmt.Errorf("unable to resolve did %s: %s", did, err) + } + + verificationMethods := make([]atproto_identity.DocVerificationMethod, len(didDoc.VerificationMethods)) + for i, verificationMethod := range didDoc.VerificationMethods { + verificationMethods[i] = atproto_identity.DocVerificationMethod{ + ID: verificationMethod.Id, + Type: verificationMethod.Type, + PublicKeyMultibase: verificationMethod.PublicKeyMultibase, + Controller: verificationMethod.Controller, + } + } + services := make([]atproto_identity.DocService, len(didDoc.Service)) + for i, service := range didDoc.Service { + services[i] = atproto_identity.DocService{ + ID: service.Id, + Type: service.Type, + ServiceEndpoint: service.ServiceEndpoint, + } + } + parsedIdentity := atproto_identity.ParseIdentity(&identity.DIDDocument{ + DID: did, + AlsoKnownAs: didDoc.AlsoKnownAs, + VerificationMethod: verificationMethods, + Service: services, + }) + + key, err := parsedIdentity.PublicKey() + if err != nil { + return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) + } + return key, nil + }) + if err != nil { + return "", fmt.Errorf("invalid token: %s", err) + } + + claims := parsedToken.Claims.(jwt.MapClaims) + if claims["lxm"] != nsid { + return "", fmt.Errorf("bad jwt lexicon method (\"lxm\"). must match: %s", nsid) + } + return claims["iss"].(string), nil +} \ No newline at end of file -- 2.51.2