loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

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

feat(activitiypub): enable HTTP signatures on all ActivityPub endpoints (#7035)

- Set the right keyID and use the right signing keys for outgoing requests.
- Verify the HTTP signature of all incoming requests, except for the server actor.
- Caches keys of incoming requests for users and servers actors.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7035
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: famfo <famfo@famfo.xyz>
Co-committed-by: famfo <famfo@famfo.xyz>

authored by

famfo
famfo
and committed by
Gusted
77b02755 ba5b157f

+681 -122
+9 -6
models/forgefed/federationhost.go
··· 4 4 package forgefed 5 5 6 6 import ( 7 + "database/sql" 7 8 "fmt" 8 9 "strings" 9 10 "time" ··· 15 16 // FederationHost data type 16 17 // swagger:model 17 18 type FederationHost struct { 18 - ID int64 `xorm:"pk autoincr"` 19 - HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` 20 - NodeInfo NodeInfo `xorm:"extends NOT NULL"` 21 - LatestActivity time.Time `xorm:"NOT NULL"` 22 - Created timeutil.TimeStamp `xorm:"created"` 23 - Updated timeutil.TimeStamp `xorm:"updated"` 19 + ID int64 `xorm:"pk autoincr"` 20 + HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` 21 + NodeInfo NodeInfo `xorm:"extends NOT NULL"` 22 + LatestActivity time.Time `xorm:"NOT NULL"` 23 + Created timeutil.TimeStamp `xorm:"created"` 24 + Updated timeutil.TimeStamp `xorm:"updated"` 25 + KeyID sql.NullString `xorm:"key_id UNIQUE"` 26 + PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` 24 27 } 25 28 26 29 // Factory function for FederationHost. Created struct is asserted to be valid.
+10 -2
models/forgefed/federationhost_repository.go
··· 30 30 return host, nil 31 31 } 32 32 33 - func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) { 33 + func findFederationHostFromDB(ctx context.Context, searchKey, searchValue string) (*FederationHost, error) { 34 34 host := new(FederationHost) 35 - has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host) 35 + has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(host) 36 36 if err != nil { 37 37 return nil, err 38 38 } else if !has { ··· 42 42 return nil, err 43 43 } 44 44 return host, nil 45 + } 46 + 47 + func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) { 48 + return findFederationHostFromDB(ctx, "host_fqdn=?", strings.ToLower(fqdn)) 49 + } 50 + 51 + func FindFederationHostByKeyID(ctx context.Context, keyID string) (*FederationHost, error) { 52 + return findFederationHostFromDB(ctx, "key_id=?", keyID) 45 53 } 46 54 47 55 func CreateFederationHost(ctx context.Context, host *FederationHost) error {
+2
models/forgejo_migrations/migrate.go
··· 94 94 NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect), 95 95 // v27 -> v28 96 96 NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser), 97 + // v28 -> v29 98 + NewMigration("Add public key information to `FederatedUser` and `FederationHost`", AddPublicKeyInformationForFederation), 97 99 } 98 100 99 101 // GetCurrentDBVersion returns the current Forgejo database version.
+29
models/forgejo_migrations/v29.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import ( 7 + "database/sql" 8 + 9 + "xorm.io/xorm" 10 + ) 11 + 12 + func AddPublicKeyInformationForFederation(x *xorm.Engine) error { 13 + type FederationHost struct { 14 + KeyID sql.NullString `xorm:"key_id UNIQUE"` 15 + PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` 16 + } 17 + 18 + err := x.Sync(&FederationHost{}) 19 + if err != nil { 20 + return err 21 + } 22 + 23 + type FederatedUser struct { 24 + KeyID sql.NullString `xorm:"key_id UNIQUE"` 25 + PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` 26 + } 27 + 28 + return x.Sync(&FederatedUser{}) 29 + }
+44
models/user/activitypub.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package user 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + "net/url" 10 + 11 + "forgejo.org/models/db" 12 + "forgejo.org/modules/setting" 13 + "forgejo.org/modules/validation" 14 + ) 15 + 16 + // APActorID returns the IRI to the api endpoint of the user 17 + func (u *User) APActorID() string { 18 + if u.IsAPServerActor() { 19 + return fmt.Sprintf("%sapi/v1/activitypub/actor", setting.AppURL) 20 + } 21 + 22 + return fmt.Sprintf("%sapi/v1/activitypub/user-id/%s", setting.AppURL, url.PathEscape(fmt.Sprintf("%d", u.ID))) 23 + } 24 + 25 + // APActorKeyID returns the ID of the user's public key 26 + func (u *User) APActorKeyID() string { 27 + return u.APActorID() + "#main-key" 28 + } 29 + 30 + func GetUserByFederatedURI(ctx context.Context, federatedURI string) (*User, error) { 31 + user := new(User) 32 + has, err := db.GetEngine(ctx).Where("normalized_federated_uri=?", federatedURI).Get(user) 33 + if err != nil { 34 + return nil, err 35 + } else if !has { 36 + return nil, nil 37 + } 38 + 39 + if res, err := validation.IsValid(*user); !res { 40 + return nil, err 41 + } 42 + 43 + return user, nil 44 + }
+34 -4
models/user/federated_user.go
··· 4 4 package user 5 5 6 6 import ( 7 + "context" 8 + "database/sql" 9 + 10 + "forgejo.org/models/db" 7 11 "forgejo.org/modules/validation" 8 12 ) 9 13 10 14 type FederatedUser struct { 11 - ID int64 `xorm:"pk autoincr"` 12 - UserID int64 `xorm:"NOT NULL"` 13 - ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` 14 - FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` 15 + ID int64 `xorm:"pk autoincr"` 16 + UserID int64 `xorm:"NOT NULL"` 17 + ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` 18 + FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` 19 + KeyID sql.NullString `xorm:"key_id UNIQUE"` 20 + PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` 15 21 } 16 22 17 23 func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) { ··· 24 30 return FederatedUser{}, err 25 31 } 26 32 return result, nil 33 + } 34 + 35 + func getFederatedUserFromDB(ctx context.Context, searchKey, searchValue any) (*FederatedUser, error) { 36 + federatedUser := new(FederatedUser) 37 + has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(federatedUser) 38 + if err != nil { 39 + return nil, err 40 + } else if !has { 41 + return nil, nil 42 + } 43 + 44 + if res, err := validation.IsValid(*federatedUser); !res { 45 + return nil, err 46 + } 47 + 48 + return federatedUser, nil 49 + } 50 + 51 + func GetFederatedUserByKeyID(ctx context.Context, keyID string) (*FederatedUser, error) { 52 + return getFederatedUserFromDB(ctx, "key_id=?", keyID) 53 + } 54 + 55 + func GetFederatedUserByUserID(ctx context.Context, userID int64) (*FederatedUser, error) { 56 + return getFederatedUserFromDB(ctx, "user_id=?", userID) 27 57 } 28 58 29 59 func (user FederatedUser) Validate() []string {
-5
models/user/user.go
··· 311 311 return setting.AppURL + url.PathEscape(u.Name) 312 312 } 313 313 314 - // APActorID returns the IRI to the api endpoint of the user 315 - func (u *User) APActorID() string { 316 - return fmt.Sprintf("%vapi/v1/activitypub/user-id/%v", setting.AppURL, url.PathEscape(fmt.Sprintf("%v", u.ID))) 317 - } 318 - 319 314 // OrganisationLink returns the organization sub page link. 320 315 func (u *User) OrganisationLink() string { 321 316 return setting.AppSubURL + "/org/" + url.PathEscape(u.Name)
+12 -12
models/user/user_system.go
··· 73 73 } 74 74 75 75 const ( 76 - APActorUserID = -3 77 - APActorUserName = "actor" 78 - APActorEmail = "noreply@forgejo.org" 76 + APServerActorUserID = -3 77 + APServerActorUserName = "actor" 78 + APServerActorEmail = "noreply@forgejo.org" 79 79 ) 80 80 81 - func NewAPActorUser() *User { 81 + func NewAPServerActor() *User { 82 82 return &User{ 83 - ID: APActorUserID, 84 - Name: APActorUserName, 85 - LowerName: APActorUserName, 83 + ID: APServerActorUserID, 84 + Name: APServerActorUserName, 85 + LowerName: APServerActorUserName, 86 86 IsActive: true, 87 - Email: APActorEmail, 87 + Email: APServerActorEmail, 88 88 KeepEmailPrivate: true, 89 - LoginName: APActorUserName, 89 + LoginName: APServerActorUserName, 90 90 Type: UserTypeIndividual, 91 91 Visibility: structs.VisibleTypePublic, 92 92 } 93 93 } 94 94 95 - func APActorUserAPActorID() string { 95 + func APServerActorID() string { 96 96 path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor") 97 97 return path 98 98 } 99 99 100 - func (u *User) IsAPActor() bool { 101 - return u != nil && u.ID == APActorUserID 100 + func (u *User) IsAPServerActor() bool { 101 + return u != nil && u.ID == APServerActorUserID 102 102 }
+15 -3
models/user/user_test.go
··· 139 139 user := user_model.User{ID: 1} 140 140 url := user.APActorID() 141 141 expected := "https://try.gitea.io/api/v1/activitypub/user-id/1" 142 - if url != expected { 143 - t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url) 144 - } 142 + assert.Equal(t, expected, url) 143 + } 144 + 145 + func TestAPActorID_APActorID(t *testing.T) { 146 + user := user_model.User{ID: user_model.APServerActorUserID} 147 + url := user.APActorID() 148 + expected := "https://try.gitea.io/api/v1/activitypub/actor" 149 + assert.Equal(t, expected, url) 150 + } 151 + 152 + func TestAPActorKeyID(t *testing.T) { 153 + user := user_model.User{ID: 1} 154 + url := user.APActorKeyID() 155 + expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key" 156 + assert.Equal(t, expected, url) 145 157 } 146 158 147 159 func TestSearchUsers(t *testing.T) {
+8 -1
modules/activitypub/client.go
··· 191 191 return nil, err 192 192 } 193 193 defer response.Body.Close() 194 - body, err := io.ReadAll(response.Body) 194 + if response.ContentLength > setting.Federation.MaxSize { 195 + return nil, fmt.Errorf("Request returned %d bytes (max allowed incomming size: %d bytes)", response.ContentLength, setting.Federation.MaxSize) 196 + } else if response.ContentLength == -1 { 197 + log.Warn("Request to %v returned an unknown content length, response may be truncated to %d bytes", uri, setting.Federation.MaxSize) 198 + } 199 + 200 + body, err := io.ReadAll(io.LimitReader(response.Body, setting.Federation.MaxSize)) 195 201 if err != nil { 196 202 return nil, err 197 203 } 204 + 198 205 log.Debug("Client: got body: %v", charLimiter(string(body), 120)) 199 206 return body, nil 200 207 }
+6 -4
modules/setting/federation.go
··· 15 15 Enabled bool 16 16 ShareUserStatistics bool 17 17 MaxSize int64 18 - Algorithms []string 18 + SignatureAlgorithms []string 19 19 DigestAlgorithm string 20 20 GetHeaders []string 21 21 PostHeaders []string 22 + SignatureEnforced bool 22 23 }{ 23 24 Enabled: false, 24 25 ShareUserStatistics: true, 25 26 MaxSize: 4, 26 - Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, 27 + SignatureAlgorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, 27 28 DigestAlgorithm: "SHA-256", 28 29 GetHeaders: []string{"(request-target)", "Date", "Host"}, 29 30 PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"}, 31 + SignatureEnforced: true, 30 32 } 31 33 ) 32 34 ··· 44 46 // Get MaxSize in bytes instead of MiB 45 47 Federation.MaxSize = 1 << 20 * Federation.MaxSize 46 48 47 - HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms)) 48 - for i, alg := range Federation.Algorithms { 49 + HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.SignatureAlgorithms)) 50 + for i, alg := range Federation.SignatureAlgorithms { 49 51 HttpsigAlgs[i] = httpsig.Algorithm(alg) 50 52 } 51 53 }
+1 -1
modules/test/distant_federation_server_mock.go
··· 95 95 }) 96 96 } 97 97 for _, repository := range mock.Repositories { 98 - federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox/", repository.ID), 98 + federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repository.ID), 99 99 func(res http.ResponseWriter, req *http.Request) { 100 100 if req.Method != "POST" { 101 101 t.Errorf("POST expected at: %q", req.URL.EscapedPath())
+2 -2
routers/api/v1/activitypub/actor.go
··· 28 28 // "200": 29 29 // "$ref": "#/responses/ActivityPub" 30 30 31 - link := user_model.APActorUserAPActorID() 31 + link := user_model.APServerActorID() 32 32 actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) 33 33 34 34 actor.PreferredUsername = ap.NaturalLanguageValuesNew() ··· 46 46 actor.PublicKey.ID = ap.IRI(link + "#main-key") 47 47 actor.PublicKey.Owner = ap.IRI(link) 48 48 49 - publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser()) 49 + publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPServerActor()) 50 50 if err != nil { 51 51 ctx.ServerError("GetPublicKey", err) 52 52 return
+167 -30
routers/api/v1/activitypub/reqsignature.go
··· 6 6 import ( 7 7 "crypto" 8 8 "crypto/x509" 9 + "database/sql" 9 10 "encoding/pem" 10 11 "fmt" 11 - "io" 12 12 "net/http" 13 13 "net/url" 14 14 15 + "forgejo.org/models/db" 16 + "forgejo.org/models/forgefed" 17 + "forgejo.org/models/user" 15 18 "forgejo.org/modules/activitypub" 16 - "forgejo.org/modules/httplib" 19 + fm "forgejo.org/modules/forgefed" 17 20 "forgejo.org/modules/log" 18 21 "forgejo.org/modules/setting" 19 22 gitea_context "forgejo.org/services/context" 23 + "forgejo.org/services/federation" 20 24 21 25 "github.com/42wim/httpsig" 22 26 ap "github.com/go-ap/activitypub" 23 27 ) 24 28 25 - func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { 26 - person := ap.PersonNew(ap.IRI(keyID.String())) 29 + func decodePublicKeyPem(pubKeyPem string) ([]byte, error) { 30 + block, _ := pem.Decode([]byte(pubKeyPem)) 31 + if block == nil || block.Type != "PUBLIC KEY" { 32 + return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") 33 + } 34 + 35 + return block.Bytes, nil 36 + } 37 + 38 + func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) { 39 + dbUser, err := user.GetUserByFederatedURI(ctx, person.ID.String()) 40 + if err != nil { 41 + return nil, err 42 + } 43 + 44 + if dbUser != nil { 45 + federatedUser, err := user.GetFederatedUserByUserID(ctx, dbUser.ID) 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + if federatedUser != nil { 51 + return federatedUser, nil 52 + } 53 + } 54 + 55 + personID, err := fm.NewPersonID(person.ID.String(), string(federationHost.NodeInfo.SoftwareName)) 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + _, federatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID) 61 + if err != nil { 62 + return nil, err 63 + } 64 + 65 + return federatedUser, nil 66 + } 67 + 68 + func storePublicKey(ctx *gitea_context.APIContext, person *ap.Person, pubKeyBytes []byte) error { 69 + federationHost, err := federation.GetFederationHostForURI(ctx, person.ID.String()) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + if person.Type == ap.ActivityVocabularyType("Application") { 75 + federationHost.KeyID = sql.NullString{ 76 + String: person.PublicKey.ID.String(), 77 + Valid: true, 78 + } 79 + 80 + federationHost.PublicKey = sql.Null[sql.RawBytes]{ 81 + V: pubKeyBytes, 82 + Valid: true, 83 + } 84 + 85 + _, err = db.GetEngine(ctx).ID(federationHost.ID).Update(federationHost) 86 + if err != nil { 87 + return err 88 + } 89 + } else if person.Type == ap.ActivityVocabularyType("Person") { 90 + federatedUser, err := getFederatedUser(ctx, person, federationHost) 91 + if err != nil { 92 + return err 93 + } 94 + 95 + federatedUser.KeyID = sql.NullString{ 96 + String: person.PublicKey.ID.String(), 97 + Valid: true, 98 + } 99 + 100 + federatedUser.PublicKey = sql.Null[sql.RawBytes]{ 101 + V: pubKeyBytes, 102 + Valid: true, 103 + } 104 + 105 + _, err = db.GetEngine(ctx).ID(federatedUser.ID).Update(federatedUser) 106 + if err != nil { 107 + return err 108 + } 109 + } 110 + 111 + return nil 112 + } 113 + 114 + func getPublicKeyFromResponse(b []byte, keyID *url.URL) (person *ap.Person, pubKeyBytes []byte, p crypto.PublicKey, err error) { 115 + person = ap.PersonNew(ap.IRI(keyID.String())) 27 116 err = person.UnmarshalJSON(b) 28 117 if err != nil { 29 - return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) 118 + return nil, nil, nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) 30 119 } 120 + 31 121 pubKey := person.PublicKey 32 122 if pubKey.ID.String() != keyID.String() { 33 - return nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) 123 + return nil, nil, nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) 34 124 } 35 - pubKeyPem := pubKey.PublicKeyPem 36 - block, _ := pem.Decode([]byte(pubKeyPem)) 37 - if block == nil || block.Type != "PUBLIC KEY" { 38 - return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") 125 + 126 + pubKeyBytes, err = decodePublicKeyPem(pubKey.PublicKeyPem) 127 + if err != nil { 128 + return nil, nil, nil, err 39 129 } 40 - p, err = x509.ParsePKIXPublicKey(block.Bytes) 41 - return p, err 42 - } 43 130 44 - func fetch(iri *url.URL) (b []byte, err error) { 45 - req := httplib.NewRequest(iri.String(), http.MethodGet) 46 - req.Header("Accept", activitypub.ActivityStreamsContentType) 47 - req.Header("User-Agent", "Gitea/"+setting.AppVer) 48 - resp, err := req.Response() 131 + p, err = x509.ParsePKIXPublicKey(pubKeyBytes) 49 132 if err != nil { 50 - return nil, err 133 + return nil, nil, nil, err 51 134 } 52 - defer resp.Body.Close() 53 135 54 - if resp.StatusCode != http.StatusOK { 55 - return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) 56 - } 57 - b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) 58 - return b, err 136 + return person, pubKeyBytes, p, err 59 137 } 60 138 61 139 func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) { 140 + if !setting.Federation.SignatureEnforced { 141 + return true, nil 142 + } 143 + 62 144 r := ctx.Req 63 145 64 146 // 1. Figure out what key we need to verify ··· 66 148 if err != nil { 67 149 return false, err 68 150 } 151 + 69 152 ID := v.KeyId() 70 153 idIRI, err := url.Parse(ID) 71 154 if err != nil { 72 155 return false, err 73 156 } 157 + 158 + signatureAlgorithm := httpsig.Algorithm(setting.Federation.SignatureAlgorithms[0]) 159 + 74 160 // 2. Fetch the public key of the other actor 75 - b, err := fetch(idIRI) 161 + // Try if the signing actor is an already known federated user 162 + federationUser, err := user.GetFederatedUserByKeyID(ctx, idIRI.String()) 76 163 if err != nil { 77 164 return false, err 78 165 } 79 - pubKey, err := getPublicKeyFromResponse(b, idIRI) 166 + 167 + if federationUser != nil && federationUser.PublicKey.Valid { 168 + pubKey, err := x509.ParsePKIXPublicKey(federationUser.PublicKey.V) 169 + if err != nil { 170 + return false, err 171 + } 172 + 173 + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil 174 + return authenticated, err 175 + } 176 + 177 + // Try if the signing actor is an already known federation host 178 + federationHost, err := forgefed.FindFederationHostByKeyID(ctx, idIRI.String()) 80 179 if err != nil { 81 180 return false, err 82 181 } 83 - // 3. Verify the other actor's key 84 - algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) 85 - authenticated = v.Verify(pubKey, algo) == nil 182 + 183 + if federationHost != nil && federationHost.PublicKey.Valid { 184 + pubKey, err := x509.ParsePKIXPublicKey(federationHost.PublicKey.V) 185 + if err != nil { 186 + return false, err 187 + } 188 + 189 + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil 190 + return authenticated, err 191 + } 192 + 193 + // Fetch missing public key 194 + actionsUser := user.NewAPServerActor() 195 + clientFactory, err := activitypub.GetClientFactory(ctx) 196 + if err != nil { 197 + return false, err 198 + } 199 + 200 + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) 201 + if err != nil { 202 + return false, err 203 + } 204 + 205 + b, err := apClient.GetBody(idIRI.String()) 206 + if err != nil { 207 + return false, err 208 + } 209 + 210 + person, pubKeyBytes, pubKey, err := getPublicKeyFromResponse(b, idIRI) 211 + if err != nil { 212 + return false, err 213 + } 214 + 215 + authenticated = v.Verify(pubKey, signatureAlgorithm) == nil 216 + if authenticated { 217 + err = storePublicKey(ctx, person, pubKeyBytes) 218 + if err != nil { 219 + return false, err 220 + } 221 + } 222 + 86 223 return authenticated, err 87 224 } 88 225
+5 -5
routers/api/v1/api.go
··· 840 840 m.Group("/activitypub", func() { 841 841 // deprecated, remove in 1.20, use /user-id/{user-id} instead 842 842 m.Group("/user/{username}", func() { 843 - m.Get("", activitypub.Person) 843 + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person) 844 844 m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) 845 845 }, context.UserAssignmentAPI(), checkTokenPublicOnly()) 846 846 m.Group("/user-id/{user-id}", func() { 847 - m.Get("", activitypub.Person) 847 + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Person) 848 848 m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) 849 849 }, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) 850 850 m.Group("/actor", func() { 851 851 m.Get("", activitypub.Actor) 852 - m.Post("/inbox", activitypub.ActorInbox) 852 + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.ActorInbox) 853 853 }) 854 854 m.Group("/repository-id/{repository-id}", func() { 855 - m.Get("", activitypub.Repository) 855 + m.Get("", activitypub.ReqHTTPSignature(), activitypub.Repository) 856 856 m.Post("/inbox", 857 857 bind(forgefed.ForgeLike{}), 858 - // TODO: activitypub.ReqHTTPSignature(), 858 + activitypub.ReqHTTPSignature(), 859 859 activitypub.RepositoryInbox) 860 860 }, context.RepositoryIDAssignmentAPI()) 861 861 }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))
+1 -1
routers/web/repo/issue.go
··· 1313 1313 } 1314 1314 1315 1315 // Special user that can't have associated contributions and permissions in the repo. 1316 - if poster.IsGhost() || poster.IsActions() || poster.IsAPActor() { 1316 + if poster.IsGhost() || poster.IsActions() || poster.IsAPServerActor() { 1317 1317 return roleDescriptor, nil 1318 1318 } 1319 1319
+26 -9
services/federation/federation_service.go
··· 98 98 } 99 99 100 100 func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { 101 - actionsUser := user.NewActionsUser() 101 + actionsUser := user.NewAPServerActor() 102 102 clientFactory, err := activitypub.GetClientFactory(ctx) 103 103 if err != nil { 104 104 return nil, err 105 105 } 106 - client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.") 106 + 107 + client, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) 107 108 if err != nil { 108 109 return nil, err 109 110 } 111 + 110 112 body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI()) 111 113 if err != nil { 112 114 return nil, err 113 115 } 116 + 114 117 nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body) 115 118 if err != nil { 116 119 return nil, err 117 120 } 121 + 118 122 body, err = client.GetBody(nodeInfoWellKnown.Href) 119 123 if err != nil { 120 124 return nil, err 121 125 } 126 + 122 127 nodeInfo, err := forgefed.NewNodeInfo(body) 123 128 if err != nil { 124 129 return nil, err 125 130 } 131 + 126 132 result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host) 127 133 if err != nil { 128 134 return nil, err 129 135 } 136 + 130 137 err = forgefed.CreateFederationHost(ctx, &result) 131 138 if err != nil { 132 139 return nil, err 133 140 } 141 + 134 142 return &result, nil 135 143 } 136 144 ··· 155 163 } 156 164 157 165 func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) { 158 - // ToDo: Do we get a publicKeyId from server, repo or owner or repo? 159 - actionsUser := user.NewActionsUser() 166 + actionsUser := user.NewAPServerActor() 160 167 clientFactory, err := activitypub.GetClientFactory(ctx) 161 168 if err != nil { 162 169 return nil, nil, err 163 170 } 164 - client, err := clientFactory.WithKeys(ctx, actionsUser, "no idea where to get key material.") 171 + 172 + apClient, err := clientFactory.WithKeys(ctx, actionsUser, actionsUser.APActorKeyID()) 165 173 if err != nil { 166 174 return nil, nil, err 167 175 } 168 176 169 - body, err := client.GetBody(personID.AsURI()) 177 + body, err := apClient.GetBody(personID.AsURI()) 170 178 if err != nil { 171 179 return nil, nil, err 172 180 } ··· 176 184 if err != nil { 177 185 return nil, nil, err 178 186 } 187 + 179 188 if res, err := validation.IsValid(person); !res { 180 189 return nil, nil, err 181 190 } 191 + 182 192 log.Info("Fetched valid person:%q", person) 183 193 184 194 localFqdn, err := url.ParseRequestURI(setting.AppURL) 185 195 if err != nil { 186 196 return nil, nil, err 187 197 } 198 + 188 199 email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname()) 189 200 loginName := personID.AsLoginName() 190 201 name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix()) 191 202 fullName := person.Name.String() 203 + 192 204 if len(person.Name) == 0 { 193 205 fullName = name 194 206 } 207 + 195 208 password, err := password.Generate(32) 196 209 if err != nil { 197 210 return nil, nil, err 198 211 } 212 + 199 213 newUser := user.User{ 200 214 LowerName: strings.ToLower(name), 201 215 Name: name, ··· 209 223 IsAdmin: false, 210 224 NormalizedFederatedURI: personID.AsURI(), 211 225 } 226 + 212 227 federatedUser := user.FederatedUser{ 213 228 ExternalID: personID.ID, 214 229 FederationHostID: federationHostID, 215 230 } 231 + 216 232 err = user.CreateFederatedUser(ctx, &newUser, &federatedUser) 217 233 if err != nil { 218 234 return nil, nil, err 219 235 } 220 - log.Info("Created federatedUser:%q", federatedUser) 221 236 237 + log.Info("Created federatedUser:%q", federatedUser) 222 238 return &newUser, &federatedUser, nil 223 239 } 224 240 ··· 274 290 if err != nil { 275 291 return err 276 292 } 277 - apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorID()) 293 + 294 + apclient, err := apclientFactory.WithKeys(ctx, &doer, doer.APActorKeyID()) 278 295 if err != nil { 279 296 return err 280 297 } ··· 285 302 return err 286 303 } 287 304 288 - _, err = apclient.Post(json, fmt.Sprintf("%v/inbox/", activity.Object)) 305 + _, err = apclient.Post(json, fmt.Sprintf("%s/inbox", activity.Object)) 289 306 if err != nil { 290 307 log.Error("error %v while sending activity: %q", err, activity) 291 308 }
+54
tests/integration/activitypub_client_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "net/url" 8 + "testing" 9 + 10 + "forgejo.org/models/db" 11 + "forgejo.org/models/unittest" 12 + user_model "forgejo.org/models/user" 13 + "forgejo.org/modules/activitypub" 14 + "forgejo.org/modules/setting" 15 + "forgejo.org/modules/test" 16 + "forgejo.org/routers" 17 + 18 + "github.com/stretchr/testify/assert" 19 + "github.com/stretchr/testify/require" 20 + ) 21 + 22 + func TestActivityPubClientBodySize(t *testing.T) { 23 + defer test.MockVariableValue(&setting.Federation.Enabled, true)() 24 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 25 + 26 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 27 + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 28 + 29 + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) 30 + require.NoError(t, err) 31 + 32 + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) 33 + require.NoError(t, err) 34 + 35 + url := u.JoinPath("/api/v1/nodeinfo").String() 36 + 37 + // Request with normal MaxSize 38 + t.Run("NormalMaxSize", func(t *testing.T) { 39 + resp, err := apClient.GetBody(url) 40 + require.NoError(t, err) 41 + assert.Contains(t, string(resp), "forgejo") 42 + }) 43 + 44 + // Set MaxSize to something very low to always fail 45 + // Request with low MaxSize 46 + t.Run("LowMaxSize", func(t *testing.T) { 47 + defer test.MockVariableValue(&setting.Federation.MaxSize, 100)() 48 + 49 + _, err = apClient.GetBody(url) 50 + require.Error(t, err) 51 + assert.ErrorContains(t, err, "Request returned") 52 + }) 53 + }) 54 + }
+36 -22
tests/integration/api_activitypub_person_test.go
··· 26 26 func TestActivityPubPerson(t *testing.T) { 27 27 defer test.MockVariableValue(&setting.Federation.Enabled, true)() 28 28 defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 29 - defer tests.PrepareTestEnv(t)() 29 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 30 + userID := 2 31 + username := "user2" 32 + userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) 30 33 31 - userID := 2 32 - username := "user2" 33 - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/user-id/%v", userID)) 34 - resp := MakeRequest(t, req, http.StatusOK) 35 - assert.Contains(t, resp.Body.String(), "@context") 34 + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 36 35 37 - var person ap.Person 38 - err := person.UnmarshalJSON(resp.Body.Bytes()) 39 - require.NoError(t, err) 36 + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) 37 + require.NoError(t, err) 40 38 41 - assert.Equal(t, ap.PersonType, person.Type) 42 - assert.Equal(t, username, person.PreferredUsername.String()) 43 - keyID := person.GetID().String() 44 - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v$", userID), keyID) 45 - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/outbox$", userID), person.Outbox.GetID().String()) 46 - assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/inbox$", userID), person.Inbox.GetID().String()) 39 + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) 40 + require.NoError(t, err) 47 41 48 - pubKey := person.PublicKey 49 - assert.NotNil(t, pubKey) 50 - publicKeyID := keyID + "#main-key" 51 - assert.Equal(t, pubKey.ID.String(), publicKeyID) 42 + // Unsigned request 43 + t.Run("UnsignedRequest", func(t *testing.T) { 44 + req := NewRequest(t, "GET", userURL) 45 + MakeRequest(t, req, http.StatusBadRequest) 46 + }) 52 47 53 - pubKeyPem := pubKey.PublicKeyPem 54 - assert.NotNil(t, pubKeyPem) 55 - assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) 48 + t.Run("SignedRequestValidation", func(t *testing.T) { 49 + // Signed requset 50 + resp, err := apClient.GetBody(userURL) 51 + require.NoError(t, err) 52 + 53 + var person ap.Person 54 + err = person.UnmarshalJSON(resp) 55 + require.NoError(t, err) 56 + 57 + assert.Equal(t, ap.PersonType, person.Type) 58 + assert.Equal(t, username, person.PreferredUsername.String()) 59 + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", userID), person.GetID()) 60 + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/outbox$", userID), person.Outbox.GetID().String()) 61 + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", userID), person.Inbox.GetID().String()) 62 + 63 + assert.NotNil(t, person.PublicKey) 64 + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", userID), person.PublicKey.ID) 65 + 66 + assert.NotNil(t, person.PublicKey.PublicKeyPem) 67 + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem) 68 + }) 69 + }) 56 70 } 57 71 58 72 func TestActivityPubMissingPerson(t *testing.T) {
+29 -15
tests/integration/api_activitypub_repository_test.go
··· 28 28 func TestActivityPubRepository(t *testing.T) { 29 29 defer test.MockVariableValue(&setting.Federation.Enabled, true)() 30 30 defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 31 - defer tests.PrepareTestEnv(t)() 32 31 33 - repositoryID := 2 34 - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) 35 - resp := MakeRequest(t, req, http.StatusOK) 36 - assert.Contains(t, resp.Body.String(), "@context") 32 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 33 + repositoryID := 2 37 34 38 - var repository forgefed_modules.Repository 39 - err := repository.UnmarshalJSON(resp.Body.Bytes()) 40 - require.NoError(t, err) 35 + apServerActor := user.NewAPServerActor() 36 + 37 + cf, err := activitypub.GetClientFactory(db.DefaultContext) 38 + require.NoError(t, err) 39 + 40 + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) 41 + require.NoError(t, err) 41 42 42 - assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String()) 43 + resp, err := c.GetBody(fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID)) 44 + require.NoError(t, err) 45 + assert.Contains(t, string(resp), "@context") 46 + 47 + var repository forgefed_modules.Repository 48 + err = repository.UnmarshalJSON(resp) 49 + require.NoError(t, err) 50 + 51 + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%d$", repositoryID), repository.GetID().String()) 52 + }) 43 53 } 44 54 45 55 func TestActivityPubMissingRepository(t *testing.T) { ··· 48 58 defer tests.PrepareTestEnv(t)() 49 59 50 60 repositoryID := 9999999 51 - req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) 61 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)) 52 62 resp := MakeRequest(t, req, http.StatusNotFound) 53 63 assert.Contains(t, resp.Body.String(), "repository does not exist") 54 64 } ··· 62 72 defer federatedSrv.Close() 63 73 64 74 onGiteaRun(t, func(t *testing.T, u *url.URL) { 65 - actionsUser := user.NewActionsUser() 75 + apServerActor := user.NewAPServerActor() 66 76 repositoryID := 2 67 77 timeNow := time.Now().UTC() 68 78 69 79 cf, err := activitypub.GetClientFactory(db.DefaultContext) 70 80 require.NoError(t, err) 71 - c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") 81 + 82 + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) 72 83 require.NoError(t, err) 84 + 73 85 repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() 74 86 75 87 activity1 := []byte(fmt.Sprintf( ··· 139 151 defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 140 152 141 153 onGiteaRun(t, func(t *testing.T, u *url.URL) { 142 - actionsUser := user.NewActionsUser() 154 + apServerActor := user.NewAPServerActor() 143 155 repositoryID := 2 156 + 144 157 cf, err := activitypub.GetClientFactory(db.DefaultContext) 145 158 require.NoError(t, err) 146 - c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used") 159 + 160 + c, err := cf.WithKeys(db.DefaultContext, apServerActor, apServerActor.APActorKeyID()) 147 161 require.NoError(t, err) 148 162 149 - repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%v/inbox", repositoryID)).String() 163 + repoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() 150 164 activity := []byte(`{"type":"Wrong"}`) 151 165 resp, err := c.Post(activity, repoInboxURL) 152 166 require.NoError(t, err)
+82
tests/integration/api_federation_httpsig_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "net/url" 10 + "testing" 11 + 12 + "forgejo.org/models/db" 13 + "forgejo.org/models/forgefed" 14 + "forgejo.org/models/unittest" 15 + "forgejo.org/models/user" 16 + "forgejo.org/modules/activitypub" 17 + "forgejo.org/modules/setting" 18 + "forgejo.org/modules/test" 19 + "forgejo.org/routers" 20 + 21 + "github.com/stretchr/testify/assert" 22 + "github.com/stretchr/testify/require" 23 + ) 24 + 25 + func TestFederationHttpSigValidation(t *testing.T) { 26 + defer test.MockVariableValue(&setting.Federation.Enabled, true)() 27 + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() 28 + 29 + onGiteaRun(t, func(t *testing.T, u *url.URL) { 30 + userID := 2 31 + userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) 32 + 33 + user1 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 1}) 34 + 35 + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) 36 + require.NoError(t, err) 37 + 38 + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.APActorKeyID()) 39 + require.NoError(t, err) 40 + 41 + // Unsigned request 42 + t.Run("UnsignedRequest", func(t *testing.T) { 43 + req := NewRequest(t, "GET", userURL) 44 + MakeRequest(t, req, http.StatusBadRequest) 45 + }) 46 + 47 + // Signed request 48 + t.Run("SignedRequest", func(t *testing.T) { 49 + resp, err := apClient.Get(userURL) 50 + require.NoError(t, err) 51 + assert.Equal(t, http.StatusOK, resp.StatusCode) 52 + }) 53 + 54 + // HACK HACK HACK: the host part of the URL gets set to which IP forgejo is 55 + // listening on, NOT localhost, which is the Domain given to forgejo which 56 + // is then used for eg. the keyID all requests 57 + applicationKeyID := fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", setting.AppURL) 58 + actorKeyID := fmt.Sprintf("%sapi/v1/activitypub/user-id/1#main-key", setting.AppURL) 59 + 60 + // Check for cached public keys 61 + t.Run("ValidateCaches", func(t *testing.T) { 62 + host, err := forgefed.FindFederationHostByKeyID(db.DefaultContext, applicationKeyID) 63 + require.NoError(t, err) 64 + assert.NotNil(t, host) 65 + assert.True(t, host.PublicKey.Valid) 66 + 67 + user, err := user.GetFederatedUserByKeyID(db.DefaultContext, actorKeyID) 68 + require.NoError(t, err) 69 + assert.NotNil(t, user) 70 + assert.True(t, user.PublicKey.Valid) 71 + }) 72 + 73 + // Disable signature validation 74 + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() 75 + 76 + // Unsigned request 77 + t.Run("SignatureValidationDisabled", func(t *testing.T) { 78 + req := NewRequest(t, "GET", userURL) 79 + MakeRequest(t, req, http.StatusOK) 80 + }) 81 + }) 82 + }
+109
tests/integration/user_federationhost_xorm_test.go
··· 1 + // Copyright 2025 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "database/sql" 8 + "testing" 9 + 10 + "forgejo.org/models/db" 11 + "forgejo.org/models/forgefed" 12 + "forgejo.org/models/user" 13 + "forgejo.org/tests" 14 + 15 + "github.com/stretchr/testify/assert" 16 + "github.com/stretchr/testify/require" 17 + ) 18 + 19 + func TestStoreFederationHost(t *testing.T) { 20 + defer tests.PrepareTestEnv(t)() 21 + t.Run("ExplicitNull", func(t *testing.T) { 22 + federationHost := forgefed.FederationHost{ 23 + HostFqdn: "ExplicitNull", 24 + // Explicit null on KeyID and PublicKey 25 + KeyID: sql.NullString{Valid: false}, 26 + PublicKey: sql.Null[sql.RawBytes]{Valid: false}, 27 + } 28 + 29 + _, err := db.GetEngine(db.DefaultContext).Insert(&federationHost) 30 + require.NoError(t, err) 31 + 32 + dbFederationHost := new(forgefed.FederationHost) 33 + has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ExplicitNull").Get(dbFederationHost) 34 + require.NoError(t, err) 35 + assert.True(t, has) 36 + 37 + assert.False(t, dbFederationHost.KeyID.Valid) 38 + assert.False(t, dbFederationHost.PublicKey.Valid) 39 + }) 40 + 41 + t.Run("NotNull", func(t *testing.T) { 42 + federationHost := forgefed.FederationHost{ 43 + HostFqdn: "ImplicitNull", 44 + KeyID: sql.NullString{Valid: true, String: "meow"}, 45 + PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x23, 0x42}}, 46 + } 47 + 48 + _, err := db.GetEngine(db.DefaultContext).Insert(&federationHost) 49 + require.NoError(t, err) 50 + 51 + dbFederationHost := new(forgefed.FederationHost) 52 + has, err := db.GetEngine(db.DefaultContext).Where("host_fqdn=?", "ImplicitNull").Get(dbFederationHost) 53 + require.NoError(t, err) 54 + assert.True(t, has) 55 + 56 + assert.True(t, dbFederationHost.KeyID.Valid) 57 + assert.Equal(t, "meow", dbFederationHost.KeyID.String) 58 + 59 + assert.True(t, dbFederationHost.PublicKey.Valid) 60 + assert.Equal(t, sql.RawBytes{0x23, 0x42}, dbFederationHost.PublicKey.V) 61 + }) 62 + } 63 + 64 + func TestStoreFederatedUser(t *testing.T) { 65 + defer tests.PrepareTestEnv(t)() 66 + t.Run("ExplicitNull", func(t *testing.T) { 67 + federatedUser := user.FederatedUser{ 68 + UserID: 0, 69 + ExternalID: "ExplicitNull", 70 + FederationHostID: 0, 71 + KeyID: sql.NullString{Valid: false}, 72 + PublicKey: sql.Null[sql.RawBytes]{Valid: false}, 73 + } 74 + 75 + _, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser) 76 + require.NoError(t, err) 77 + 78 + dbFederatedUser := new(user.FederatedUser) 79 + has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 0).Get(dbFederatedUser) 80 + require.NoError(t, err) 81 + assert.True(t, has) 82 + 83 + assert.False(t, dbFederatedUser.KeyID.Valid) 84 + assert.False(t, dbFederatedUser.PublicKey.Valid) 85 + }) 86 + 87 + t.Run("NotNull", func(t *testing.T) { 88 + federatedUser := user.FederatedUser{ 89 + UserID: 1, 90 + ExternalID: "ImplicitNull", 91 + FederationHostID: 1, 92 + KeyID: sql.NullString{Valid: true, String: "woem"}, 93 + PublicKey: sql.Null[sql.RawBytes]{Valid: true, V: sql.RawBytes{0x42, 0x23}}, 94 + } 95 + 96 + _, err := db.GetEngine(db.DefaultContext).Insert(&federatedUser) 97 + require.NoError(t, err) 98 + 99 + dbFederatedUser := new(user.FederatedUser) 100 + has, err := db.GetEngine(db.DefaultContext).Where("user_id=?", 1).Get(dbFederatedUser) 101 + require.NoError(t, err) 102 + assert.True(t, has) 103 + 104 + assert.True(t, dbFederatedUser.KeyID.Valid) 105 + assert.Equal(t, "woem", dbFederatedUser.KeyID.String) 106 + assert.True(t, dbFederatedUser.PublicKey.Valid) 107 + assert.Equal(t, sql.RawBytes{0x42, 0x23}, dbFederatedUser.PublicKey.V) 108 + }) 109 + }