From 3fceb4f2d0a05a72239cf0fe1e465ebd2e3121f9 Mon Sep 17 00:00:00 2001 From: Nawwar Elnarsh Date: Sun, 4 Oct 2026 00:45:53 +0200 Subject: [PATCH 1/2] fix(auth): type social identity email metadata parameters --- .github/workflows/verify.yml | 2 + docs/application-configuration.md | 11 ++ internal/httpapi/external_email_enrollment.go | 2 +- ...ernal_email_enrollment_integration_test.go | 49 +++++ ..._identity_registration_integration_test.go | 169 ++++++++++++++++++ internal/httpapi/google_auth.go | 4 +- web/app/page.tsx | 2 +- 7 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 internal/httpapi/external_identity_registration_integration_test.go diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 67ceefd..0a62f8a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -48,6 +48,8 @@ jobs: run: | go test -json -count=1 ./internal/httpapi ./internal/objectstorage | tee /tmp/integration-tests.json jq -e 'select(.Action == "pass" and .Test == "TestWorkspaceOwnershipAndClientScopeInvariants")' /tmp/integration-tests.json >/dev/null + jq -e 'select(.Action == "pass" and .Test == "TestExternalIdentityRegistrationPolicyAndLinking")' /tmp/integration-tests.json >/dev/null + jq -e 'select(.Action == "pass" and .Test == "TestExternalEmailEnrollmentResendCooldownIsAtomic/successful_enrollment_persists_email_metadata_and_rejects_replay")' /tmp/integration-tests.json >/dev/null jq -e 'select(.Action == "pass" and .Test == "TestMinIODirectTransferAndVisibility")' /tmp/integration-tests.json >/dev/null - run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./... - run: go run ./cmd/platform93 migrate diff --git a/docs/application-configuration.md b/docs/application-configuration.md index 2a90d1d..fe1d3f5 100644 --- a/docs/application-configuration.md +++ b/docs/application-configuration.md @@ -77,6 +77,17 @@ authentication enabled, and personal API keys and delegation disabled. Provider credentials and inheritance remain separate from both configuration objects. User-managed invitations are disabled and the custom-claim allowlist is empty. +In the admin, choose **Application > Settings > Access policy > Registration**. +Public registration is the default and applies to email and social authentication. +For social authentication, `automatic` creates a new user when no provider identity +exists and registration is public. Invite-only blocks this creation, but existing +linked identities can still sign in. Organization policy can additionally prohibit +public registration. The `sign_in` flow never creates an account. + +An existing account with the same email is not silently linked to a social provider. +The user must sign in to that account and explicitly link the provider. Provider +identities and account registration remain isolated to the selected application. + All configuration writes use the application ETag through `If-Match`. Public configuration writes replace the complete public object. Internal writes are strict partial updates and reject unknown keys. diff --git a/internal/httpapi/external_email_enrollment.go b/internal/httpapi/external_email_enrollment.go index ac1ff81..4b115b1 100644 --- a/internal/httpapi/external_email_enrollment.go +++ b/internal/httpapi/external_email_enrollment.go @@ -201,7 +201,7 @@ FROM external_auth_email_enrollments WHERE id=$1 AND application_id=$2 AND crede VALUES($1,$2,$3,$3,$4,$5,now())`, userID, applicationID, email, firstName, lastName) if err == nil { _, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata) -VALUES($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), applicationID, userID, provider, subject, email) +VALUES($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), applicationID, userID, provider, subject, email) } if err == nil { _, err = tx.Exec(r.Context(), `UPDATE external_auth_email_enrollments SET consumed_at=now() WHERE id=$1`, parts[0]) diff --git a/internal/httpapi/external_email_enrollment_integration_test.go b/internal/httpapi/external_email_enrollment_integration_test.go index 6786d8d..a6c6222 100644 --- a/internal/httpapi/external_email_enrollment_integration_test.go +++ b/internal/httpapi/external_email_enrollment_integration_test.go @@ -51,6 +51,9 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i t.Fatal(err) } defer func() { + _, _ = db.Exec(context.Background(), `DELETE FROM user_sessions WHERE application_id=$1`, applicationID) + _, _ = db.Exec(context.Background(), `DELETE FROM user_identities WHERE application_id=$1`, applicationID) + _, _ = db.Exec(context.Background(), `DELETE FROM users WHERE application_id=$1`, applicationID) _, _ = db.Exec(context.Background(), `DELETE FROM external_auth_email_enrollments WHERE id=$1`, enrollmentID) _, _ = db.Exec(context.Background(), `DELETE FROM auth_provider_configs WHERE id=$1`, providerID) _, _ = db.Exec(context.Background(), `DELETE FROM applications WHERE id=$1`, applicationID) @@ -126,4 +129,50 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i if attempts != 20 { t.Fatalf("attempts = %d, want the database maximum 20", attempts) } + + t.Run("successful_enrollment_persists_email_metadata_and_rejects_replay", func(t *testing.T) { + ciphertext, encryptErr := vault.Encrypt([]byte(`{"client_secret":"test-only-secret"}`), "auth-provider:"+providerID.String()) + if encryptErr != nil { + t.Fatal(encryptErr) + } + if _, err = db.Exec(context.Background(), `UPDATE auth_provider_configs SET config_ciphertext=$2 WHERE id=$1`, providerID, ciphertext); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(context.Background(), `UPDATE external_auth_email_enrollments SET attempts=0 WHERE id=$1`, enrollmentID); err != nil { + t.Fatal(err) + } + tx, beginErr := db.Begin(context.Background()) + if beginErr != nil { + t.Fatal(beginErr) + } + defer tx.Rollback(context.Background()) + if err = server.app.EnsureSigningKey(context.Background(), tx); err != nil { + t.Fatal(err) + } + if err = tx.Commit(context.Background()); err != nil { + t.Fatal(err) + } + verify := func() *httptest.ResponseRecorder { + request := requestWithRoute(t, http.MethodPost, "/", map[string]any{ + "enrollment": enrollmentID.String() + ":" + credential, + "code": "VALID123", + }, map[string]string{"application_id": applicationID.String()}, kernel.Actor{}) + response := httptest.NewRecorder() + server.verifyExternalEmailEnrollment(response, request) + return response + } + if response := verify(); response.Code != http.StatusOK { + t.Fatalf("successful enrollment returned %d: %s", response.Code, response.Body.String()) + } + var metadataEmail string + if err = db.QueryRow(context.Background(), `SELECT metadata->>'email' FROM user_identities WHERE application_id=$1 AND provider='microsoft'`, applicationID).Scan(&metadataEmail); err != nil { + t.Fatal(err) + } + if metadataEmail != "user@example.test" { + t.Fatalf("identity metadata email = %q", metadataEmail) + } + if response := verify(); response.Code != http.StatusUnauthorized { + t.Fatalf("enrollment replay returned %d", response.Code) + } + }) } diff --git a/internal/httpapi/external_identity_registration_integration_test.go b/internal/httpapi/external_identity_registration_integration_test.go new file mode 100644 index 0000000..fa0d1fa --- /dev/null +++ b/internal/httpapi/external_identity_registration_integration_test.go @@ -0,0 +1,169 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + + "github.com/supaapps/platform93/internal/database" + "github.com/supaapps/platform93/internal/kernel" + "github.com/supaapps/platform93/internal/platform" + "github.com/supaapps/platform93/internal/secure" +) + +func TestExternalIdentityRegistrationPolicyAndLinking(t *testing.T) { + databaseURL := os.Getenv("PLATFORM93_DATABASE_URL") + if databaseURL == "" { + t.Skip("PLATFORM93_DATABASE_URL is not configured") + } + if err := database.Migrate(databaseURL); err != nil { + t.Fatal(err) + } + ctx := context.Background() + db, err := database.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + vault, err := secure.NewVault(make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + server := &Server{app: platform.New(db, vault, "https://platform93.test")} + organizationID := kernel.NewID().String() + if _, err := db.Exec(ctx, `INSERT INTO organizations(id,name,slug) VALUES($1,'Social registration',$2)`, organizationID, "social-"+organizationID); err != nil { + t.Fatal(err) + } + applicationIDs := []string{} + defer func() { + for _, id := range applicationIDs { + _, _ = db.Exec(ctx, `DELETE FROM user_identities WHERE application_id=$1`, id) + _, _ = db.Exec(ctx, `DELETE FROM users WHERE application_id=$1`, id) + _, _ = db.Exec(ctx, `DELETE FROM applications WHERE id=$1`, id) + } + _, _ = db.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, organizationID) + }() + createApplication := func(config string) string { + t.Helper() + id := kernel.NewID().String() + internal := defaultApplicationInternalConfig() + if err := json.Unmarshal([]byte(config), &internal); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(internal) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(ctx, `INSERT INTO applications(id,organization_id,name,slug,internal_config) VALUES($1,$2,'Social registration',$3,$4)`, id, organizationID, "social-"+id, encoded); err != nil { + t.Fatal(err) + } + applicationIDs = append(applicationIDs, id) + return id + } + complete := func(applicationID, flow, subject, email string, requestedBy *string) (string, error) { + t.Helper() + request := requestWithRoute(t, http.MethodGet, "/", nil, map[string]string{"application_id": applicationID}, kernel.Actor{}) + return server.completeExternalIdentity(request, "google", "", flow, requestedBy, subject, email, "New", "User") + } + assertCounts := func(applicationID string, users, identities int) { + t.Helper() + var actualUsers, actualIdentities int + if err := db.QueryRow(ctx, `SELECT (SELECT count(*) FROM users WHERE application_id=$1),(SELECT count(*) FROM user_identities WHERE application_id=$1)`, applicationID).Scan(&actualUsers, &actualIdentities); err != nil { + t.Fatal(err) + } + if actualUsers != users || actualIdentities != identities { + t.Fatalf("users/identities = %d/%d, want %d/%d", actualUsers, actualIdentities, users, identities) + } + } + createUser := func(applicationID, email string) string { + t.Helper() + id := kernel.NewID().String() + if _, err := db.Exec(ctx, `INSERT INTO users(id,application_id,email,normalized_email) VALUES($1,$2,$3,$3)`, id, applicationID, email); err != nil { + t.Fatal(err) + } + return id + } + publicApplication := createApplication(`{}`) + var registeredUser string + t.Run("default public registration and normalized metadata", func(t *testing.T) { + var err error + registeredUser, err = complete(publicApplication, "automatic", "google-subject", " New.User@Example.Test ", nil) + if err != nil { + t.Fatalf("automatic registration failed: %v", err) + } + var normalized, metadataEmail string + var verified bool + if err := db.QueryRow(ctx, `SELECT u.normalized_email,u.email_verified_at IS NOT NULL,i.metadata->>'email' FROM users u JOIN user_identities i ON i.user_id=u.id WHERE u.id=$1 AND i.application_id=$2`, registeredUser, publicApplication).Scan(&normalized, &verified, &metadataEmail); err != nil { + t.Fatal(err) + } + if normalized != "new.user@example.test" || metadataEmail != normalized || !verified { + t.Fatal("registration did not persist a verified user and normalized identity email") + } + assertCounts(publicApplication, 1, 1) + }) + if registeredUser == "" { + return + } + t.Run("repeat login does not duplicate users", func(t *testing.T) { + id, err := complete(publicApplication, "automatic", "google-subject", "new.user@example.test", nil) + if err != nil || id != registeredUser { + t.Fatalf("repeat login = %s, %v; want existing user", id, err) + } + assertCounts(publicApplication, 1, 1) + }) + t.Run("same email requires explicit authenticated linking", func(t *testing.T) { + app := createApplication(`{}`) + existing := createUser(app, "existing@example.test") + if _, err := complete(app, "automatic", "existing-subject", "existing@example.test", nil); err == nil || err.Error() != "account_link_required" { + t.Fatalf("same-email automatic login = %v; want account_link_required", err) + } + assertCounts(app, 1, 0) + if _, err := complete(app, "link", "existing-subject", "existing@example.test", nil); err == nil || err.Error() != "authenticated_link_required" { + t.Fatalf("unauthenticated link = %v; want authenticated_link_required", err) + } + id, err := complete(app, "link", "existing-subject", " EXISTING@Example.Test ", &existing) + if err != nil || id != existing { + t.Fatalf("explicit link = %s, %v; want existing user", id, err) + } + var metadataEmail string + if err := db.QueryRow(ctx, `SELECT metadata->>'email' FROM user_identities WHERE user_id=$1`, existing).Scan(&metadataEmail); err != nil || metadataEmail != "existing@example.test" { + t.Fatalf("linked email metadata = %q, %v", metadataEmail, err) + } + assertCounts(app, 1, 1) + }) + t.Run("identities and links are application isolated", func(t *testing.T) { + app := createApplication(`{}`) + id, err := complete(app, "automatic", "google-subject", "new.user@example.test", nil) + if err != nil || id == registeredUser { + t.Fatalf("second application registration = %s, %v; want distinct user", id, err) + } + if _, err := complete(app, "link", "cross-application-subject", "new.user@example.test", ®isteredUser); err == nil || err.Error() != "account_unavailable" { + t.Fatalf("cross-application link = %v; want account_unavailable", err) + } + assertCounts(app, 1, 1) + }) + t.Run("invite only blocks registration but permits existing identities", func(t *testing.T) { + if _, err := db.Exec(ctx, `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"invite_only"') WHERE id=$1`, publicApplication); err != nil { + t.Fatal(err) + } + for _, flow := range []string{"automatic", "sign_up"} { + if _, err := complete(publicApplication, flow, "uninvited-subject", "uninvited@example.test", nil); err == nil || err.Error() != "registration_disabled" { + t.Fatalf("%s in invite-only app = %v; want registration_disabled", flow, err) + } + } + id, err := complete(publicApplication, "sign_in", "google-subject", "new.user@example.test", nil) + if err != nil || id != registeredUser { + t.Fatalf("existing identity sign-in = %s, %v", id, err) + } + assertCounts(publicApplication, 1, 1) + }) + t.Run("sign in never creates a new account", func(t *testing.T) { + app := createApplication(`{}`) + if _, err := complete(app, "sign_in", "unknown-subject", "unknown@example.test", nil); err == nil || err.Error() != "provider_identity_not_found" { + t.Fatalf("unknown sign-in = %v; want provider_identity_not_found", err) + } + assertCounts(app, 0, 0) + }) +} diff --git a/internal/httpapi/google_auth.go b/internal/httpapi/google_auth.go index 28e41f9..b391b94 100644 --- a/internal/httpapi/google_auth.go +++ b/internal/httpapi/google_auth.go @@ -296,7 +296,7 @@ WHERE application_id=$1 AND provider=$2 AND provider_subject=$3`, chi.URLParam(r } userID = *requestedBy _, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata) -VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized) +VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized) } else { if !strings.Contains(normalized, "@") { return "", fmt.Errorf("provider_email_verification_required") @@ -319,7 +319,7 @@ VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URL userID, chi.URLParam(r, "application_id"), email, normalized, firstName, lastName) if err == nil { _, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata) -VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized) +VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized) } } if err != nil || tx.Commit(r.Context()) != nil { diff --git a/web/app/page.tsx b/web/app/page.tsx index d410224..754cea7 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -2398,7 +2398,7 @@ function ApplicationConfigurationSettings({ application, setMessage, onChanged }
void saveInternal(event)}>
INTERNAL CONFIG

Authentication and access

- + From 1f52f2448680552bfd41a42b36177d6d6f03d60d Mon Sep 17 00:00:00 2001 From: Nawwar Elnarsh Date: Sun, 4 Oct 2026 00:50:54 +0200 Subject: [PATCH 2/2] fix(auth): recheck registration policy at enrollment completion --- internal/httpapi/external_email_enrollment.go | 4 ++ ...ernal_email_enrollment_integration_test.go | 49 ++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/internal/httpapi/external_email_enrollment.go b/internal/httpapi/external_email_enrollment.go index 4b115b1..102c82b 100644 --- a/internal/httpapi/external_email_enrollment.go +++ b/internal/httpapi/external_email_enrollment.go @@ -192,6 +192,10 @@ FROM external_auth_email_enrollments WHERE id=$1 AND application_id=$2 AND crede kernel.WriteProblem(w, r, http.StatusConflict, "account_link_required", "An account already uses this email. Sign in to that account and link the provider.") return } + if !s.registrationEnabled(r) { + kernel.WriteProblem(w, r, http.StatusForbidden, "registration_disabled", "Public registration is disabled for this application.") + return + } if limitErr := enforceUserLimit(r.Context(), tx, applicationID); limitErr != nil { kernel.WriteProblem(w, r, http.StatusConflict, "user_limit_reached", "The application user limit has been reached.") return diff --git a/internal/httpapi/external_email_enrollment_integration_test.go b/internal/httpapi/external_email_enrollment_integration_test.go index a6c6222..0d83cc8 100644 --- a/internal/httpapi/external_email_enrollment_integration_test.go +++ b/internal/httpapi/external_email_enrollment_integration_test.go @@ -51,13 +51,23 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i t.Fatal(err) } defer func() { - _, _ = db.Exec(context.Background(), `DELETE FROM user_sessions WHERE application_id=$1`, applicationID) - _, _ = db.Exec(context.Background(), `DELETE FROM user_identities WHERE application_id=$1`, applicationID) - _, _ = db.Exec(context.Background(), `DELETE FROM users WHERE application_id=$1`, applicationID) - _, _ = db.Exec(context.Background(), `DELETE FROM external_auth_email_enrollments WHERE id=$1`, enrollmentID) - _, _ = db.Exec(context.Background(), `DELETE FROM auth_provider_configs WHERE id=$1`, providerID) - _, _ = db.Exec(context.Background(), `DELETE FROM applications WHERE id=$1`, applicationID) - _, _ = db.Exec(context.Background(), `DELETE FROM organizations WHERE id=$1`, organizationID) + for _, statement := range []string{ + `DELETE FROM user_sessions WHERE application_id=$1`, + `DELETE FROM user_identities WHERE application_id=$1`, + `DELETE FROM users WHERE application_id=$1`, + `DELETE FROM external_auth_email_enrollments WHERE application_id=$1`, + `DELETE FROM auth_provider_configs WHERE application_id=$1`, + `DELETE FROM outbox WHERE event_id IN (SELECT id FROM domain_events WHERE application_id=$1)`, + `DELETE FROM domain_events WHERE application_id=$1`, + `DELETE FROM applications WHERE id=$1`, + } { + if _, cleanupErr := db.Exec(context.Background(), statement, applicationID); cleanupErr != nil { + t.Errorf("fixture cleanup failed: %v", cleanupErr) + } + } + if _, cleanupErr := db.Exec(context.Background(), `DELETE FROM organizations WHERE id=$1`, organizationID); cleanupErr != nil { + t.Errorf("organization cleanup failed: %v", cleanupErr) + } }() first, err := db.Begin(context.Background()) @@ -161,6 +171,31 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i server.verifyExternalEmailEnrollment(response, request) return response } + for _, policy := range []struct { + name, disable, restore string + id any + }{ + {"application", `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"invite_only"') WHERE id=$1`, `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"public"') WHERE id=$1`, applicationID}, + {"organization", `UPDATE organization_policies SET enabled_settings=jsonb_set(enabled_settings,'{public_registration}','false') WHERE organization_id=$1`, `UPDATE organization_policies SET enabled_settings=jsonb_set(enabled_settings,'{public_registration}','true') WHERE organization_id=$1`, organizationID}, + } { + if _, err = db.Exec(context.Background(), policy.disable, policy.id); err != nil { + t.Fatal(err) + } + if response := verify(); response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "registration_disabled") { + t.Fatalf("outstanding enrollment after %s policy change returned %d: %s", policy.name, response.Code, response.Body.String()) + } + var users, identities int + var consumed bool + if err = db.QueryRow(context.Background(), `SELECT (SELECT count(*) FROM users WHERE application_id=$1),(SELECT count(*) FROM user_identities WHERE application_id=$1),(SELECT consumed_at IS NOT NULL FROM external_auth_email_enrollments WHERE id=$2)`, applicationID, enrollmentID).Scan(&users, &identities, &consumed); err != nil { + t.Fatal(err) + } + if users != 0 || identities != 0 || consumed { + t.Fatalf("blocked enrollment changed state: users=%d identities=%d consumed=%t", users, identities, consumed) + } + if _, err = db.Exec(context.Background(), policy.restore, policy.id); err != nil { + t.Fatal(err) + } + } if response := verify(); response.Code != http.StatusOK { t.Fatalf("successful enrollment returned %d: %s", response.Code, response.Body.String()) }