From 3f3e49f8a99db07494f99e906883e4a9fd29b0c3 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 17 Sep 2026 22:21:59 +0300 Subject: [PATCH 1/9] Connect optional password login to shared Cosift authentication --- cmd/cosift/community.go | 2 +- deploy/community.env.example | 3 + internal/community/server.go | 4 +- internal/community/shared.go | 91 ++++++++- internal/community/shared_handlers_test.go | 4 +- internal/community/shared_password_test.go | 227 +++++++++++++++++++++ internal/sharedaccount/password_test.go | 111 ++++++++++ internal/sharedaccount/provider.go | 7 + internal/sharedaccount/remote.go | 19 +- 9 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 internal/community/shared_password_test.go create mode 100644 internal/sharedaccount/password_test.go diff --git a/cmd/cosift/community.go b/cmd/cosift/community.go index 4351492..081ea91 100644 --- a/cmd/cosift/community.go +++ b/cmd/cosift/community.go @@ -56,7 +56,7 @@ func runCommunity(ctx context.Context, args []string) error { defer client.Close() provider = client } - s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID")}) + s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID")}) if err != nil { return err } diff --git a/deploy/community.env.example b/deploy/community.env.example index 646f3ab..b38a715 100644 --- a/deploy/community.env.example +++ b/deploy/community.env.example @@ -11,6 +11,9 @@ COSIFT_ALLOW_TEST_PAYMENTS=0 # Default: standalone email/password accounts. See docs/SHARED-ACCOUNTS.md. COSIFT_AUTH_MODE=local +# Opt in only after cosift-auth with shared password support is deployed. +# Passwords remain in the upstream shared account store; OTP always remains. +COSIFT_SHARED_PASSWORD_ENABLED=0 # Shared mode requires ALL four values below; database never defaults silently. COSIFT_SHARED_PROJECT= COSIFT_SHARED_DATABASE= diff --git a/internal/community/server.go b/internal/community/server.go index 8e271a1..c043684 100644 --- a/internal/community/server.go +++ b/internal/community/server.go @@ -38,6 +38,7 @@ const dailyContributionLimit = 1000 type Config struct { Shared sharedaccount.Provider + SharedPasswordEnabled bool // Enable only after the upstream password service is deployed. DataDir string Backend string PublicURL string @@ -133,10 +134,11 @@ func Open(cfg Config) (*Server, error) { } mux := http.NewServeMux() mux.HandleFunc("GET /api/auth/config", func(w http.ResponseWriter, r *http.Request) { - respond(w, 200, map[string]bool{"shared": s.cfg.Shared != nil}) + respond(w, 200, map[string]bool{"shared": s.cfg.Shared != nil, "supports_password": s.sharedPasswordProvider() != nil}) }) mux.HandleFunc("POST /api/auth/start", s.sharedStart) mux.HandleFunc("POST /api/auth/verify", s.sharedFinish) + mux.HandleFunc("POST /api/auth/password", s.sharedPassword) mux.HandleFunc("POST /api/shared", s.auth(s.sharedTool)) mux.HandleFunc("GET /{$}", s.asset("index.html", "text/html; charset=utf-8")) mux.HandleFunc("GET /login", s.asset("index.html", "text/html; charset=utf-8")) diff --git a/internal/community/shared.go b/internal/community/shared.go index d8b951a..2a51728 100644 --- a/internal/community/shared.go +++ b/internal/community/shared.go @@ -195,20 +195,95 @@ func (s *Server) sharedFinish(w http.ResponseWriter, r *http.Request) { return } var in struct { - RequestID string `json:"request_id"` - Code string `json:"code"` + RequestID string `json:"request_id"` + Code string `json:"code"` + Password *string `json:"password"` } if decode(r, &in) != nil || len(in.RequestID) != 26 || len(in.Code) != 6 || strings.Trim(in.Code, "0123456789") != "" { sharedProblem(w, sharedaccount.ErrInvalid) return } - issued, err := s.cfg.Shared.Finish(sharedaccount.WithClientIP(r.Context(), s.clientIP(r)), in.RequestID, in.Code) + ctx := sharedaccount.WithClientIP(r.Context(), s.clientIP(r)) + var issued sharedaccount.Issued + var err error + if in.Password != nil { + provider := s.sharedPasswordProvider() + if provider == nil { + problem(w, 404, "shared password login is not enabled") + return + } + if len(*in.Password) < 12 || len(*in.Password) > 256 { + problem(w, 400, "password must contain 12–256 UTF-8 bytes") + return + } + issued, err = provider.FinishPassword(ctx, in.RequestID, in.Code, *in.Password) + } else { + issued, err = s.cfg.Shared.Finish(ctx, in.RequestID, in.Code) + } if err != nil { sharedProblem(w, err) return } - identity, err := s.cfg.Shared.Verify(r.Context(), issued.Token) - if err == nil && identity.UID != issued.UID { + s.sharedIssued(w, r, issued, "") +} + +func (s *Server) sharedPasswordProvider() sharedaccount.PasswordProvider { + if !s.cfg.SharedPasswordEnabled || s.cfg.Shared == nil { + return nil + } + provider, _ := s.cfg.Shared.(sharedaccount.PasswordProvider) + return provider +} + +func sharedPasswordProblem(w http.ResponseWriter, err error) { + if errors.Is(err, sharedaccount.ErrUnauthorized) || errors.Is(err, sharedaccount.ErrBanned) { + problem(w, 401, "invalid email or password") + return + } + sharedProblem(w, err) +} + +func (s *Server) sharedPassword(w http.ResponseWriter, r *http.Request) { + provider := s.sharedPasswordProvider() + if provider == nil { + problem(w, 404, "shared password login is not enabled") + return + } + if !s.allow("shared-password:"+s.clientIP(r), 10, time.Minute) { + sharedProblem(w, sharedaccount.ErrLimited) + return + } + var in struct { + Email string `json:"email"` + Password string `json:"password"` + } + if decode(r, &in) != nil { + sharedProblem(w, sharedaccount.ErrInvalid) + return + } + in.Email = strings.ToLower(strings.TrimSpace(in.Email)) + a, err := mail.ParseAddress(in.Email) + if err != nil || a.Address != in.Email || len(in.Email) > 254 || len(in.Password) < 12 || len(in.Password) > 256 { + sharedPasswordProblem(w, sharedaccount.ErrUnauthorized) + return + } + issued, err := provider.Password(sharedaccount.WithClientIP(r.Context(), s.clientIP(r)), in.Email, in.Password) + if err != nil { + sharedPasswordProblem(w, err) + return + } + s.sharedIssued(w, r, issued, in.Email) +} + +// Both entry points establish the same verified UID and canonical token cookie. +// Password sign-in also binds the verified email to the submitted account. +func (s *Server) sharedIssued(w http.ResponseWriter, r *http.Request, issued sharedaccount.Issued, email string) { + _, err := sharedaccount.Parse(issued.Token) + var identity sharedaccount.Identity + if err == nil { + identity, err = s.cfg.Shared.Verify(r.Context(), issued.Token) + } + if err == nil && (identity.UID != issued.UID || email != "" && identity.Email != email) { err = sharedaccount.ErrUnauthorized } var u User @@ -219,7 +294,11 @@ func (s *Server) sharedFinish(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.cfg.Shared.Revoke(ctx, issued.Token) - sharedProblem(w, err) + if email != "" { + sharedPasswordProblem(w, err) + } else { + sharedProblem(w, err) + } return } http.SetCookie(w, s.sharedCookie(issued.Token, int(sessionAge.Seconds()))) diff --git a/internal/community/shared_handlers_test.go b/internal/community/shared_handlers_test.go index fbd466e..c51e08b 100644 --- a/internal/community/shared_handlers_test.go +++ b/internal/community/shared_handlers_test.go @@ -76,7 +76,7 @@ func TestSharedLoginDisabledAndAdvertised(t *testing.T) { s := testServer(t, nil) w := request(t, s, "GET", "/api/auth/config", nil, nil) expect(t, w, 200) - if strings.TrimSpace(w.Body.String()) != `{"shared":false}` { + if strings.TrimSpace(w.Body.String()) != `{"shared":false,"supports_password":false}` { t.Fatal(w.Body.String()) } for _, path := range []string{"/api/auth/start", "/api/auth/verify"} { @@ -89,7 +89,7 @@ func TestSharedLoginDisabledAndAdvertised(t *testing.T) { s.cfg.Shared = &fakeShared{} w = request(t, s, "GET", "/api/auth/config", nil, nil) expect(t, w, 200) - if strings.TrimSpace(w.Body.String()) != `{"shared":true}` { + if strings.TrimSpace(w.Body.String()) != `{"shared":true,"supports_password":false}` { t.Fatal(w.Body.String()) } } diff --git a/internal/community/shared_password_test.go b/internal/community/shared_password_test.go new file mode 100644 index 0000000..e760406 --- /dev/null +++ b/internal/community/shared_password_test.go @@ -0,0 +1,227 @@ +package community + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/pilot-protocol/cosift/internal/sharedaccount" +) + +type passwordShared struct { + scriptedShared + passwordFn func(context.Context, string, string) (sharedaccount.Issued, error) + enrollFn func(context.Context, string, string, string) (sharedaccount.Issued, error) +} + +func (p *passwordShared) Password(ctx context.Context, email, password string) (sharedaccount.Issued, error) { + if p.passwordFn != nil { + return p.passwordFn(ctx, email, password) + } + return p.Finish(ctx, "", "") +} +func (p *passwordShared) FinishPassword(ctx context.Context, id, code, password string) (sharedaccount.Issued, error) { + if p.enrollFn != nil { + return p.enrollFn(ctx, id, code, password) + } + return p.Finish(ctx, id, code) +} +func passwordBody(email, password string) string { + body, _ := json.Marshal(map[string]string{"email": email, "password": password}) + return string(body) +} +func TestSharedPasswordCapabilityRequiresExplicitReadyProvider(t *testing.T) { + for _, tc := range []struct { + name string + provider sharedaccount.Provider + enabled, want bool + }{ + {"local", nil, true, false}, {"old OTP provider", &fakeShared{}, true, false}, + {"new provider default off", &passwordShared{}, false, false}, {"ready", &passwordShared{}, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t, nil) + s.cfg.Shared = tc.provider + s.cfg.SharedPasswordEnabled = tc.enabled + w := request(t, s, "GET", "/api/auth/config", nil, nil) + expect(t, w, 200) + var cfg map[string]bool + if json.Unmarshal(w.Body.Bytes(), &cfg) != nil || cfg["supports_password"] != tc.want { + t.Fatal(w.Body.String()) + } + if !tc.want { + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", ""), 404) + } + if tc.provider != nil && !tc.want { + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456","password":"password-valid-123"}`, "", ""), 404) + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456"}`, "", ""), 200) + } + }) + } +} +func TestSharedPasswordUsesExistingIdentityAndNeverStoresPassword(t *testing.T) { + s := testServer(t, nil) + s.cfg.PublicURL = "https://community.example.com" + s.cfg.SharedPasswordEnabled = true + p := &passwordShared{passwordFn: func(_ context.Context, email, password string) (sharedaccount.Issued, error) { + if email != "shared@example.com" || password != " password-valid-123 " { + t.Fatal("credentials changed before forwarding") + } + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + }} + s.cfg.Shared = p + u, err := s.sharedUser(context.Background(), sharedaccount.Identity{UID: "0123456789abcdef", Email: "shared@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`UPDATE users SET onboarded=1,interests='["engineering"]' WHERE id=?`, u.ID); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`INSERT INTO credit_ledger(id,user_id,delta,reason,created_at) VALUES('password-preservation',?,17,'fixture',0)`, u.ID); err != nil { + t.Fatal(err) + } + w := sharedJSON(s, "/api/auth/password", passwordBody(" SHARED@Example.com ", " password-valid-123 "), "", "") + expect(t, w, 200) + var got User + if err = json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.ID != u.ID || !got.Onboarded || len(got.Interests) != 1 { + t.Fatalf("identity changed: %+v %v", got, err) + } + if strings.Contains(w.Body.String(), sharedTestToken) || strings.Contains(w.Body.String(), "password-valid") { + t.Fatal("secret exposed") + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || cookies[0].Value != sharedTestToken || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatal("invalid shared cookie") + } + var users, sessions, balance int + var hash string + if err = s.db.QueryRow(`SELECT count(*),password_hash FROM users`).Scan(&users, &hash); err != nil || users != 1 || hash != "" { + t.Fatal("local account/password created", err) + } + if err = s.db.QueryRow(`SELECT count(*) FROM sessions`).Scan(&sessions); err != nil || sessions != 0 { + t.Fatal("local session created", err) + } + if err = s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance); err != nil || balance != 17 { + t.Fatal("ledger changed", err) + } + expect(t, bearerRequest(s, "/api/me", sharedTestToken), 200) + p.fakeShared.revoked = true + expect(t, bearerRequest(s, "/api/me", sharedTestToken), 401) +} +func TestSharedPasswordGenericFailuresAndIssuedTokenRevocation(t *testing.T) { + for _, tc := range []struct { + name string + issueErr, verifyErr error + identity *sharedaccount.Identity + status int + revoke bool + }{ + {name: "wrong or unknown", issueErr: sharedaccount.ErrUnauthorized, status: 401}, + {name: "banned", issueErr: sharedaccount.ErrBanned, status: 401}, + {name: "infrastructure", issueErr: errors.New("private upstream information"), status: 503}, + {name: "rate limit", issueErr: sharedaccount.ErrLimited, status: 429}, + {name: "revoked before use", verifyErr: sharedaccount.ErrUnauthorized, status: 401, revoke: true}, + {name: "banned before use", verifyErr: sharedaccount.ErrBanned, status: 401, revoke: true}, + {name: "UID mismatch", identity: &sharedaccount.Identity{UID: "fedcba9876543210", Email: "shared@example.com"}, status: 401, revoke: true}, + {name: "email mismatch", identity: &sharedaccount.Identity{UID: "0123456789abcdef", Email: "other@example.com"}, status: 401, revoke: true}, + {name: "verification unavailable", verifyErr: sharedaccount.ErrUnavailable, status: 503, revoke: true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + revoked := false + p := &passwordShared{passwordFn: func(context.Context, string, string) (sharedaccount.Issued, error) { + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, tc.issueErr + }} + p.verifyFn = func(context.Context, string) (sharedaccount.Identity, error) { + if tc.identity != nil { + return *tc.identity, tc.verifyErr + } + return sharedaccount.Identity{UID: "0123456789abcdef", Email: "shared@example.com"}, tc.verifyErr + } + p.revokeFn = func(_ context.Context, token string) error { + revoked = true + if token != sharedTestToken { + t.Error("wrong revoked token") + } + return nil + } + s.cfg.Shared = p + w := sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "") + expect(t, w, tc.status) + if tc.status == 401 && !strings.Contains(w.Body.String(), "invalid email or password") { + t.Fatal(w.Body.String()) + } + if revoked != tc.revoke || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private") { + t.Fatal("failed login leaked or changed credentials", w.Body.String()) + } + var n int + if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil || n != 0 { + t.Fatal("failed login created account", err) + } + }) + } +} +func TestSharedPasswordEnrollmentRequiresValidOTPAndPreservesOptionalFlow(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + enrolls, finishes := 0, 0 + p := &passwordShared{enrollFn: func(_ context.Context, id, code, password string) (sharedaccount.Issued, error) { + enrolls++ + if id != "01ARZ3NDEKTSV4RRFFQ69G5FAV" || password != "password-valid-123" { + t.Fatal("changed enrollment") + } + if code != "123456" { + return sharedaccount.Issued{}, sharedaccount.ErrUnauthorized + } + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + }} + p.finishFn = func(context.Context, string, string) (sharedaccount.Issued, error) { + finishes++ + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + } + s.cfg.Shared = p + for _, password := range []string{"", strings.Repeat("a", 11), strings.Repeat("a", 257), strings.Repeat("é", 129)} { + body, _ := json.Marshal(map[string]string{"request_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "code": "123456", "password": password}) + expect(t, sharedJSON(s, "/api/auth/verify", string(body), "", ""), 400) + } + if enrolls != 0 || finishes != 0 { + t.Fatal("invalid enrollment reached provider") + } + w := sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"000000","password":"password-valid-123"}`, "", "") + expect(t, w, 401) + if len(w.Result().Cookies()) != 0 { + t.Fatal("invalid OTP signed in") + } + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456","password":"password-valid-123"}`, "", ""), 200) + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456"}`, "", ""), 200) + if enrolls != 2 || finishes != 1 { + t.Fatal("OTP and password enrollment confused", enrolls, finishes) + } +} +func TestSharedPasswordInvalidCredentialsAndIPRateLimit(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + calls := 0 + s.cfg.Shared = &passwordShared{passwordFn: func(context.Context, string, string) (sharedaccount.Issued, error) { + calls++ + return sharedaccount.Issued{}, sharedaccount.ErrUnauthorized + }} + for _, body := range []string{passwordBody("invalid", "password-valid-123"), passwordBody("shared@example.com", "short"), passwordBody("shared@example.com", strings.Repeat("a", 257))} { + expect(t, sharedJSON(s, "/api/auth/password", body, "", "192.0.2.3:1"), 401) + } + if calls != 0 { + t.Fatal("invalid credentials reached upstream") + } + for i := 0; i < 10; i++ { + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.1:1"), 401) + } + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.1:2"), 429) + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.2:1"), 401) + if calls != 11 { + t.Fatal("rate limited request reached upstream", calls) + } +} diff --git a/internal/sharedaccount/password_test.go b/internal/sharedaccount/password_test.go new file mode 100644 index 0000000..47a0b36 --- /dev/null +++ b/internal/sharedaccount/password_test.go @@ -0,0 +1,111 @@ +package sharedaccount + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +func TestRemoteSharedPasswordWireAndOTPCompatibility(t *testing.T) { + const token = "ck_1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "" || r.Header.Get("X-Serverless-Authorization") != "Bearer infrastructure-fixture" || r.Header.Get("X-Forwarded-For") != "203.0.113.7" { + t.Error("credential or IP forwarding mismatch") + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + switch calls { + case 1: + if r.URL.Path != "/auth/verify" || body["request_id"] != "request" || body["code"] != "123456" || body["password"] != " password-valid-123 " { + t.Error("enrollment payload mismatch") + } + case 2: + if r.URL.Path != "/auth/password" || body["email"] != "shared@example.com" || body["password"] != " password-valid-123 " { + t.Error("login payload mismatch") + } + case 3: + if r.URL.Path != "/auth/verify" { + t.Error("OTP route changed") + } + if _, ok := body["password"]; ok { + t.Error("old OTP flow sent enrollment field") + } + default: + t.Error("invalid request reached upstream") + } + _ = json.NewEncoder(w).Encode(Issued{Token: token, UID: "0123456789abcdef"}) + })) + defer srv.Close() + remote, err := newRemote(context.Background(), srv.URL, "", false) + if err != nil { + t.Fatal(err) + } + remote.identity = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "infrastructure-fixture"}) + c := Client{auth: remote} + ctx := WithClientIP(context.Background(), "203.0.113.7") + if _, err = c.FinishPassword(ctx, "request", "123456", " password-valid-123 "); err != nil { + t.Fatal(err) + } + if _, err = c.Password(ctx, "shared@example.com", " password-valid-123 "); err != nil { + t.Fatal(err) + } + if _, err = c.Finish(ctx, "request", "123456"); err != nil { + t.Fatal(err) + } + if _, err = c.FinishPassword(ctx, "request", "123456", "short"); !errors.Is(err, ErrInvalid) { + t.Fatal(err) + } + if _, err = c.Password(ctx, "shared@example.com", strings.Repeat("é", 129)); !errors.Is(err, ErrUnauthorized) { + t.Fatal(err) + } + if calls != 3 { + t.Fatal(calls) + } +} +func TestRemoteSharedPasswordErrorContractAndMalformedCredentials(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + want error + }{ + {"wrong credentials", 401, `{"error":"Unauthorized","status":401,"detail":"invalid email or password"}`, ErrUnauthorized}, + {"Google HTML denial", 401, `private infrastructure`, ErrUnavailable}, + {"unrecognized denial", 401, `{"error":"Unauthorized","status":401,"detail":"private service error"}`, ErrUnavailable}, + {"limited", 429, `{}`, ErrLimited}, + {"unavailable", 503, `{}`, ErrUnavailable}, + {"bad token", 200, `{"token":"not-canonical","account_uid":"0123456789abcdef"}`, ErrUnavailable}, + {"bad UID", 200, `{"token":"ck_1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","account_uid":"bad"}`, ErrUnavailable}, + {"redirect", 307, `{}`, ErrUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Location", "/unexpected") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer srv.Close() + remote, err := newRemote(context.Background(), srv.URL, "", false) + if err != nil { + t.Fatal(err) + } + c := Client{auth: remote} + _, err = c.Password(context.Background(), "shared@example.com", "password-valid-123") + if !errors.Is(err, tc.want) || calls != 1 { + t.Fatalf("error=%v calls=%d", err, calls) + } + }) + } +} diff --git a/internal/sharedaccount/provider.go b/internal/sharedaccount/provider.go index 9234c89..d516b65 100644 --- a/internal/sharedaccount/provider.go +++ b/internal/sharedaccount/provider.go @@ -42,6 +42,13 @@ type Provider interface { Call(context.Context, string, string, map[string]any) (map[string]any, error) } +// PasswordProvider is optional so existing shared OTP providers remain usable. +// Password credentials are held and verified only by the authoritative service. +type PasswordProvider interface { + FinishPassword(context.Context, string, string, string) (Issued, error) + Password(context.Context, string, string) (Issued, error) +} + var tokenPattern = regexp.MustCompile(`^ck_([1-9][0-9]{0,5})_([A-Z2-7]{39})$`) var UIDPattern = regexp.MustCompile(`^[0-9a-f]{16}$`) diff --git a/internal/sharedaccount/remote.go b/internal/sharedaccount/remote.go index 5dfc700..358f377 100644 --- a/internal/sharedaccount/remote.go +++ b/internal/sharedaccount/remote.go @@ -91,7 +91,7 @@ func (r *remote) call(ctx context.Context, path, token string, body any, out any if problem.Status != res.StatusCode || problem.Error != http.StatusText(res.StatusCode) { return ErrUnavailable } - if res.StatusCode == 401 && (problem.Detail == "invalid or revoked token" || problem.Detail == "invalid or expired code") { + if res.StatusCode == 401 && (problem.Detail == "invalid or revoked token" || problem.Detail == "invalid or expired code" || path == "/auth/password" && problem.Detail == "invalid email or password") { return ErrUnauthorized } if res.StatusCode == 403 && problem.Detail == "account suspended" { @@ -126,8 +126,23 @@ func (c *Client) Start(ctx context.Context, email string) (Challenge, error) { return result, err } func (c *Client) Finish(ctx context.Context, requestID, code string) (Issued, error) { + return c.issue(ctx, "/auth/verify", map[string]string{"request_id": requestID, "code": code}) +} +func (c *Client) FinishPassword(ctx context.Context, requestID, code, password string) (Issued, error) { + if len(password) < 12 || len(password) > 256 { + return Issued{}, ErrInvalid + } + return c.issue(ctx, "/auth/verify", map[string]string{"request_id": requestID, "code": code, "password": password}) +} +func (c *Client) Password(ctx context.Context, email, password string) (Issued, error) { + if len(password) < 12 || len(password) > 256 { + return Issued{}, ErrUnauthorized + } + return c.issue(ctx, "/auth/password", map[string]string{"email": email, "password": password}) +} +func (c *Client) issue(ctx context.Context, path string, body map[string]string) (Issued, error) { var result Issued - err := c.auth.call(ctx, "/auth/verify", "", map[string]string{"request_id": requestID, "code": code}, &result) + err := c.auth.call(ctx, path, "", body, &result) if err == nil { if _, e := Parse(result.Token); e != nil || !UIDPattern.MatchString(result.UID) { err = ErrUnavailable From dc85912096667f9e43ba580c64b585781fe99580 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Thu, 17 Sep 2026 22:24:53 +0300 Subject: [PATCH 2/9] Keep OTP primary with optional password login and clear metered credit prices --- internal/community/web/app.js | 88 ++++++++++++++++++------ internal/community/web/index.html | 8 ++- internal/community/web/style.css | 3 + internal/community/webtests/app.test.cjs | 45 ++++++++++++ 4 files changed, 120 insertions(+), 24 deletions(-) diff --git a/internal/community/web/app.js b/internal/community/web/app.js index 383dac8..f34f9bc 100644 --- a/internal/community/web/app.js +++ b/internal/community/web/app.js @@ -14,6 +14,7 @@ const pendingRequests = new Set(); let searchRequest; let authBusy = false; let sharedAuth = false, authChallenge = null, authConfigured = false; +let supportsPassword = false, sharedLoginMode = "otp"; function resetAccount(nextUser = null) { accountGeneration++; for (const controller of pendingRequests) controller.abort(); @@ -205,25 +206,28 @@ $("auth-form").onsubmit = (event) => { resetAccount(); const form = new FormData(event.target); if (sharedAuth) { + if (sharedLoginMode === "password" && supportsPassword) { + user = await api("auth/password", "POST", {email: form.get("email"), password: form.get("password")}); + sharedLoginMode = "otp"; + event.target.reset(); + renderSharedAuth(); + await enterAfterLogin(); + return; + } if (!authChallenge) { authChallenge = await api("auth/start", "POST", {email: form.get("email")}); - $("code-field").hidden = false; - $("auth-restart").hidden = false; - event.target.elements.code.required = true; - event.target.elements.email.readOnly = true; - $("auth-submit").textContent = "Verify and sign in →"; + renderSharedAuth(); notify("If the address is eligible, a verification code is on its way. Check your email."); return; } - user = await api("auth/verify", "POST", {request_id: authChallenge.request_id, code: form.get("code")}); + const verification = {request_id: authChallenge.request_id, code: form.get("code")}; + if (sharedLoginMode === "setup" && supportsPassword) verification.password = form.get("password"); + user = await api("auth/verify", "POST", verification); authChallenge = null; - $("code-field").hidden = true; - $("auth-restart").hidden = true; - event.target.elements.code.required = false; - event.target.elements.email.readOnly = false; - $("auth-submit").textContent = "Email me a code →"; + sharedLoginMode = "otp"; event.target.reset(); - await enter(); + renderSharedAuth(); + await enterAfterLogin(); return; } user = await api(signingUp ? "register" : "login", "POST", { @@ -235,6 +239,50 @@ $("auth-form").onsubmit = (event) => { await enter(); }).finally(() => { authBusy = false; }); }; +function renderSharedAuth() { + const form = $("auth-form"), checkingCode = !!authChallenge; + const passwordLogin = sharedLoginMode === "password", settingPassword = sharedLoginMode === "setup"; + $("name-field").hidden = true; + form.elements.name.required = false; + $("auth-switch").hidden = true; + $("password-field").hidden = !(passwordLogin || settingPassword && checkingCode); + form.elements.password.required = !$("password-field").hidden; + form.elements.password.autocomplete = settingPassword ? "new-password" : "current-password"; + form.elements.password.placeholder = settingPassword ? "Choose a password (at least 12 characters)" : "Your password"; + $("code-field").hidden = !checkingCode; + form.elements.code.required = checkingCode; + form.elements.email.readOnly = checkingCode; + $("auth-restart").hidden = !checkingCode; + $("auth-password-switch").hidden = !supportsPassword; + $("auth-password-switch").textContent = sharedLoginMode === "otp" ? "Use email & password" : "Use an email code instead"; + $("auth-password-reset").hidden = !supportsPassword || !passwordLogin; + $("auth-title").textContent = settingPassword ? "Set your password" : "Sign in to Cosift"; + $("auth-description").textContent = settingPassword ? "Verify your email to set or reset your password. Your account and credits stay the same." + : passwordLogin ? "Use the password you set for your Cosift account." : "We’ll email you a sign-in code. No password needed."; + $("auth-submit").textContent = passwordLogin ? "Sign in →" : checkingCode ? settingPassword ? "Set password and sign in →" : "Verify and sign in →" : "Email me a code →"; +} +function selectSharedLogin(mode) { + if (authBusy || !sharedAuth || !supportsPassword) return; + sharedLoginMode = mode; + authChallenge = null; + $("auth-form").elements.password.value = ""; + $("auth-form").elements.code.value = ""; + renderSharedAuth(); +} +$("auth-password-switch").onclick = () => selectSharedLogin(sharedLoginMode === "otp" ? "password" : "otp"); +$("auth-password-reset").onclick = () => selectSharedLogin("setup"); +async function enterAfterLogin() { + showScreen("boot"); + try { await enter(); } + catch (e) { + if (user) { + $("boot-message").textContent = "You’re signed in, but your workspace couldn’t load. Please try again."; + $("boot-spinner").hidden = true; + $("boot-retry").hidden = false; + } + throw e; + } +} function onboarding() { selected = new Set(user.interests.filter((v) => topics.includes(v))); $("custom-interests").value = user.interests @@ -325,7 +373,7 @@ async function refreshCredits() { $("buy-credits").textContent = `Top up ${pack.credits.toLocaleString()} credits · ${price}`; $("buy-credits").hidden = !c.payments_enabled || !c.can_top_up; $("payment-info").hidden = false; - $("payment-info").textContent = "Extra requests: Search 1 credit · Answer 2 credits · Research 3 credits. Existing rate caps apply."; + $("payment-info").textContent = "Request costs: Search 1 credit · Answer 2 credits · Research 3 credits. Existing rate caps apply."; } } } @@ -794,7 +842,7 @@ async function refreshLimits() { `${modeLabels[mode]} ${limit.requests}/${duration(limit.window_seconds)}`).join(" · "); $("guest-policy").textContent = `Guests: one shared request every ${duration(interval)}. ${describe(requestPolicy.guest)}.`; $("request-limits").textContent = user - ? `${requestPolicy.member_free_requests_per_minute} free requests/min. Extra: Search 1 credit · Answer 2 · Research 3. ${describe(requestPolicy.member)}.` + ? `Search 1 credit · Answer 2 · Research 3. ${describe(requestPolicy.member)}.` : describe(requestPolicy.guest); } let startupPending = false; @@ -809,17 +857,11 @@ async function initialize() { try { const authConfig = await api("auth/config"); sharedAuth = authConfig.shared === true; + supportsPassword = sharedAuth && authConfig.supports_password === true; authConfigured = true; $("auth-submit").disabled = false; if (sharedAuth) { - $("name-field").hidden = true; - $("password-field").hidden = true; - $("auth-switch").hidden = true; - $("auth-form").elements.name.required = false; - $("auth-form").elements.password.required = false; - $("auth-title").textContent = "Sign in to Cosift."; - $("auth-description").textContent = "Use the same email as your connected agents. We’ll send you a verification code."; - $("auth-submit").textContent = "Email me a code →"; + renderSharedAuth(); $("interests-explanation").textContent = "Save interests to follow these topics across Cosift and your connected agents. Existing agent topics stay followed; remove them in Followed topics."; } try { user = await api("me"); } @@ -1007,6 +1049,8 @@ $("auth-restart").onclick = () => { $("code-field").hidden = true; $("auth-restart").hidden = true; $("auth-submit").textContent = "Email me a code →"; + form.elements.password.value = ""; + if (sharedAuth) renderSharedAuth(); }; // The application sends only sanitized pageviews, with no search/account payload. diff --git a/internal/community/web/index.html b/internal/community/web/index.html index 2b5ce14..0bb5f19 100644 --- a/internal/community/web/index.html +++ b/internal/community/web/index.html @@ -62,6 +62,10 @@

Sign in to Cosift

+
+ + +

Already have an account? @@ -298,7 +302,7 @@

Recent contributions