diff --git a/oauth_identities_test.go b/oauth_identities_test.go index 24cf3a6..971dab7 100644 --- a/oauth_identities_test.go +++ b/oauth_identities_test.go @@ -13,6 +13,7 @@ import ( "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" @@ -182,6 +183,66 @@ func TestOAuthIdentitiesDestroyWinter404BodiesAreByteIdentical(t *testing.T) { } } +func TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData(t *testing.T) { + app, db := sessionApp(t, "0s") + if err := lagoon.PublishEncryptionKeys(nil, bytes.Repeat([]byte("K"), 32), nil); err != nil { + t.Fatal(err) + } + user := insertUser(t, db, "oauth-secret-leak@example.test", "correct-horse") + const ( + plaintext = "secret-access-token-oauth-leak-probe" + refresh = "secret-refresh-token-oauth-leak-probe" + marker = "profile-distinctive-xyzzy-oauth" + ) + now := time.Now().UTC() + row := models.OAuthIdentity{ + UserID: user.ID, + Provider: "google", + ProviderID: "g-secret-leak", + AccessToken: lagoon.NewEncrypted(plaintext), + RefreshToken: lagoon.NewEncrypted(refresh), + ProfileData: lagoon.Jsonable[map[string]any]{ + Data: map[string]any{"email": "leak@example.test", "marker": marker}, + Valid: true, + }, + LinkedAt: &now, + } + if err := db.Create(&row).Error; err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil) + req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) + rec := httptest.NewRecorder() + controllers.OAuthIdentitiesIndex(app).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, needle := range []string{plaintext, refresh, marker, "access_token", "refresh_token", "profile_data", "[redacted]"} { + if strings.Contains(body, needle) { + t.Fatalf("GET list leaked %q: %s", needle, body) + } + } + var parsed struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Data) != 1 { + t.Fatalf("data = %#v", parsed.Data) + } + if len(parsed.Data[0]) != 2 { + t.Fatalf("row keys = %#v, want provider and linked_at only", parsed.Data[0]) + } + if parsed.Data[0]["provider"] != "google" { + t.Fatalf("provider = %#v", parsed.Data[0]["provider"]) + } + if _, ok := parsed.Data[0]["linked_at"]; !ok { + t.Fatalf("linked_at missing: %#v", parsed.Data[0]) + } +} + func TestOAuthIdentitiesUnauthorizedOnGoogle(t *testing.T) { app, _ := sessionApp(t, "0s") getReq := httptest.NewRequest(http.MethodGet, "/_fonoteka/api/v1/oauth-identities", nil) diff --git a/updates/oauth_identities_test.go b/updates/oauth_identities_test.go new file mode 100644 index 0000000..1e3ec1d --- /dev/null +++ b/updates/oauth_identities_test.go @@ -0,0 +1,88 @@ +package updates + +import ( + "testing" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" +) + +func TestOAuthIdentitiesMigration(t *testing.T) { + sqlDB := dedicatedDB(t, "user_oauth_identities") + db, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All()) + if err := m.Migrate(); err != nil { + t.Fatal(err) + } + table := (&models.OAuthIdentity{}).TableName() + if table != "golem15_user_oauth_identities" { + t.Fatalf("TableName = %q", table) + } + if !db.Migrator().HasTable(table) { + t.Fatal("golem15_user_oauth_identities missing") + } + for _, column := range []string{ + "id", "user_id", "provider", "provider_id", "access_token", "refresh_token", + "token_expires_at", "profile_data", "linked_at", "created_at", "updated_at", + } { + if !db.Migrator().HasColumn(&models.OAuthIdentity{}, column) { + t.Fatalf("golem15_user_oauth_identities.%s missing", column) + } + } + var udt string + if err := db.Raw(`SELECT udt_name FROM information_schema.columns WHERE table_name = 'golem15_user_oauth_identities' AND column_name = 'profile_data'`).Scan(&udt).Error; err != nil { + t.Fatal(err) + } + if udt != "jsonb" { + t.Fatalf("profile_data udt = %q, want jsonb", udt) + } + var indexes []string + if err := db.Raw(`SELECT indexname FROM pg_indexes WHERE tablename = 'golem15_user_oauth_identities'`).Scan(&indexes).Error; err != nil { + t.Fatal(err) + } + wantIdx := map[string]bool{ + "oauth_identities_user_provider_unique": false, + "oauth_identities_provider_identity_unique": false, + } + for _, name := range indexes { + if _, ok := wantIdx[name]; ok { + wantIdx[name] = true + } + } + for name, found := range wantIdx { + if !found { + t.Fatalf("missing unique index %s in %v", name, indexes) + } + } + var fk struct { + ForeignTable string + DeleteRule string + } + if err := db.Raw(` +SELECT ccu.table_name AS foreign_table, rc.delete_rule +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema +JOIN information_schema.referential_constraints rc + ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema +WHERE tc.table_name = 'golem15_user_oauth_identities' + AND tc.constraint_type = 'FOREIGN KEY' + AND kcu.column_name = 'user_id'`).Scan(&fk).Error; err != nil { + t.Fatal(err) + } + if fk.ForeignTable != "users" || fk.DeleteRule != "CASCADE" { + t.Fatalf("user_id FK = %+v, want users ON DELETE CASCADE", fk) + } + if err := m.RollbackMigration(oauthIdentityMigrations[0]); err != nil { + t.Fatal(err) + } + if db.Migrator().HasTable(table) { + t.Fatal("golem15_user_oauth_identities survived rollback") + } +}