diff --git a/cmd/pad/cmd_server.go b/cmd/pad/cmd_server.go index 014912ffd..0b7865764 100644 --- a/cmd/pad/cmd_server.go +++ b/cmd/pad/cmd_server.go @@ -974,6 +974,7 @@ func serveCmd() *cobra.Command { bypassEnv := os.Getenv("PAD_BYPASS_SETUP_TOKEN") bypassSetupToken := bypassEnv == "true" || bypassEnv == "1" srv.SetBypassSetupToken(bypassSetupToken && !cfg.IsCloudServer()) + srv.SetPushGateway(cfg.PushGatewayURL) if userCount, ucErr := s.UserCount(); ucErr != nil { slog.Warn("could not check user count for bootstrap token wiring", "error", ucErr) } else if userCount > 0 { diff --git a/docs/api.md b/docs/api.md index 3823011fe..728f944eb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -119,7 +119,9 @@ One browser tab's content writes to an item are ordered by the server, also **pe - **Pagination.** `next_cursor` is an opaque token, present only when more rows follow; omit it to start. It carries a position (`v`, sort, view, workspace filter, `as_of`, the sort key and the item id) and no authority: every page re-runs the whole filtered pipeline and takes the rows strictly after the key, and `as_of` (set on page 1) is the time defers are tested at, so one expiring between pages does not reshuffle them; an `as_of` later than the server's now is clamped to now. A malformed cursor, or one minted for a different sort, view or `workspace`, is `400 invalid_cursor`. A row's `marker` is the item's state now, to send back when snoozing it. - **`degraded`** lists, by slug, the admitted workspaces whose score provider failed (or whose attention decisions could not be read). It is set only on a provider ERROR, never from the decision provider's failing bit: that bit covers items the caller cannot see, and exposing it would leak one bit about them. The workspace's other rows still return. - `GET /api/v1/me/ui-dismissals`, `PUT /api/v1/me/ui-dismissals/{key}` — one-time UI suggestions the caller dismissed (TASK-3452: the first-time tutorial cards on /console and the workspace launchpad), stored on the user (`users.ui_dismissals`, a JSON set) so a dismissal holds on every device. Keys are a closed set (`models.UIDismissalKeys`); any other answers 400. A column rather than a table by ruling, until a third kind of dismissal appears. Web client only -- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `user`) +- `PUT|DELETE /api/v1/me/push-devices/{connection_id}` — register or remove the caller's push device (TASK-3938, PLAN-3908 phase 2). **Session-only**: a cookie or a `padsess_` bearer. An API token, an OAuth bearer (also with a session cookie riding along), a token with a cookie, and an app principal all get `403 session_required`; a workspace-pinned API token is refused before that by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`); no credential is `401`. After that gate, `404 push_unavailable` when the server has no gateway (the app stops trying that server until its next start, and treats any other 404 as a retry, so the error code is a contract), then `400 validation_error` unless `connection_id` matches `^[A-Za-z0-9-]{1,64}$`. PUT body is strict: `{platform: "ios"|"android", token, app_version?, environment?}`; `token` is non-empty, at most 4096 bytes and NUL-free, `app_version` at most 64 characters, an unknown or repeated member is `400`. `environment` is accepted for client compatibility and IGNORED: every token is an FCM token and FCM routes APNs itself. Success is `200 {id, connection_id, platform, app_version, updated_at}`; DELETE is `204`, also when nothing was registered, and removes only the device bound to the caller's own session under that connection id. **Register is one transactional replace**: it deletes every row on EITHER unique key (the caller's session's own row, whatever its token, and this user's row for this token, whatever its session, even one whose session is still alive), evicts, then inserts, so re-registering after a re-sign-in or an FCM token rotation leaves exactly one row, on the caller's session. **Cap: 20 devices per user.** At the cap the device with the oldest `updated_at` (ties: `created_at`, then id) is evicted SILENTLY (a debug log of the device id, never the token); replacing the caller's own row evicts nothing. A session that is gone or expired between admission and the write is `401`. **A device lives and dies with its session**: logout, session expiry and the expired-session sweep delete the row (`push_devices.session_id` cascades), and so does deleting the account. **A credential change** (password change, 2FA disable, provider unlink) rotates sessions in one transaction (`Store.RotateUserSessions`): the CALLER's devices are re-bound to the replacement session and survive, while devices on the user's OTHER sessions die with those sessions, since the change exists to cut off stolen sessions and their push tokens go with them. If that transaction fails every session is revoked and the call answers 500 (fail closed). Self-hosted servers send nothing unless they configure a gateway. +- `GET|PUT /api/v1/me/notification-settings` — the caller's recipient-side push filters (TASK-3938): `{push_enabled, notify_assigned, notify_comments, notify_replies, notify_reminders, notify_mentions, include_agent_activity}`, seven booleans. GET returns the defaults (everything on except `include_agent_activity`) when the user never saved any. PUT is a full replace: all seven keys are required, and a missing, unknown, null or non-boolean one is `400`, so nothing is silently reset. Available to any authenticated person, an API token included; an app principal is `403`, and so is a token pinned to one workspace, refused by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`) because these are account-level settings. Works without a push gateway. +- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `push_available`, `user`). `push_available` is a JSON bool (TASK-3938): true only when this server has a push gateway (`PAD_PUSH_GATEWAY_URL` non-empty), and the app enables push only on JSON `true`. It is present only for a SIGNED-IN caller: absent for an anonymous caller and on the pre-auth setup state, since whether a gateway exists is server configuration an anonymous caller does not need (an absent value reads as off) - `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance - `POST /api/v1/auth/register` — create account (admin-created or invitation-based after setup). An `invitation_code` signup claims the invitation in the same transaction as the membership (BUG-3438). If the invitation was declined, cancelled or replaced since the code was read: when the code was the signup's ADMISSION (registration is otherwise closed) the account is rolled back and the answer is `404 not_found` (`500` if the rollback itself fails, since the account then still holds the email); where the signup would have been admitted anyway (Pad Cloud self-serve, or an admin creating the account) the account is KEPT, joins nothing, and the 201 carries `invitation_not_joined: {code, message}` (`invitation_gone`, `inviter_scope_changed` when the inviter is now collection-restricted (BUG-3634) or no longer an owner (BUG-3770); `403` with that code in the admission case, or `invitation_not_applied` for another refused claim) instead of `accepted_invitation`. Additive, omitempty - `POST /api/v1/auth/login` — email/password login (returns session token) diff --git a/internal/config/config.go b/internal/config/config.go index d0eb3a07d..3754dd2ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,8 +86,13 @@ type Config struct { // production, set them to the canonical public URLs so the // metadata document matches the cert and the URL agents paste into // Claude Desktop. - MCPPublicURL string `toml:"mcp_public_url"` // Canonical URL clients paste into their MCP client (e.g. https://mcp.getpad.dev — matches mcp.stripe.com / mcp.linear.app convention). Published verbatim as RFC 9728 `resource` and used as the OAuth audience binding; pad mounts the transport internally at /mcp and pad-cloud's nginx router transparently rewrites mcp.* root → /mcp so external clients see a single canonical URL regardless of internal path. - AuthServerURL string `toml:"auth_server_url"` // Canonical URL of the OAuth authorization server (TASK-951), e.g. https://app.getpad.dev. Embedded in protected-resource metadata's authorization_servers field. + // PushGatewayURL is where the push dispatcher sends notifications + // (PAD_PUSH_GATEWAY_URL, TASK-3938). Non-empty means push is configured; + // empty means this server offers no push and the device routes answer 404 + // push_unavailable. + PushGatewayURL string `toml:"push_gateway_url"` + MCPPublicURL string `toml:"mcp_public_url"` // Canonical URL clients paste into their MCP client (e.g. https://mcp.getpad.dev — matches mcp.stripe.com / mcp.linear.app convention). Published verbatim as RFC 9728 `resource` and used as the OAuth audience binding; pad mounts the transport internally at /mcp and pad-cloud's nginx router transparently rewrites mcp.* root → /mcp so external clients see a single canonical URL regardless of internal path. + AuthServerURL string `toml:"auth_server_url"` // Canonical URL of the OAuth authorization server (TASK-951), e.g. https://app.getpad.dev. Embedded in protected-resource metadata's authorization_servers field. // OpenAIAppsChallenge is the domain-verification token OpenAI's plugin // dashboard issues, served verbatim at /.well-known/openai-apps-challenge // on cloud (TASK-3321 G4). Validated in ResolveMCPEndpoints. @@ -449,6 +454,9 @@ func Load() (*Config, error) { if v := os.Getenv("PAD_CLOUD_OUTBOUND_SECRET"); v != "" { cfg.CloudOutboundSecret = v } + if v := os.Getenv("PAD_PUSH_GATEWAY_URL"); v != "" { + cfg.PushGatewayURL = v + } if v := os.Getenv("PAD_MCP_PUBLIC_URL"); v != "" { cfg.MCPPublicURL = v } diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 757dd11ea..e6b556727 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -221,6 +221,11 @@ func (s *Server) sessionStatePayload(authenticated bool, user *models.User) map[ s.addBillingFields(payload) if authenticated { payload["user"] = sessionUserPayload(user) + // push_available: whether this server can send pushes (TASK-3938). A + // JSON bool for a SIGNED-IN caller only: the app reads it by type with + // its session, and whether a gateway exists is server configuration an + // anonymous caller does not need (absent reads as off). + payload["push_available"] = s.pushAvailable() } return payload } @@ -330,31 +335,26 @@ func (s *Server) validateSessionCookie(r *http.Request) *models.User { // should embed the returned token in their response body so both // transport styles stay authenticated. // -// On DeleteUserSessions error we log and continue; on CreateSession -// error we write a 500 response and return ok=false — the caller should -// return immediately. +// The rotation is one store transaction (RotateUserSessions): it fails +// closed, revoking every session when it cannot complete, and then this +// writes a 500 and returns ok=false — the caller should return immediately. +// The caller's own push devices move onto the replacement session; devices +// on the user's other sessions die with those sessions (TASK-3938). func (s *Server) rotateSessionsAfterCredentialChange(w http.ResponseWriter, r *http.Request, user *models.User) (string, bool) { + // TASK-3365: every session is revoked below, so the user's live + // connections re-check their credential now, not on their next tick. + defer s.invalidateUserAccess(user.ID) + info := s.requestSessionInfo(r) // The session being replaced dates the new one (BUG-3336). Every caller // is a session-only door, so a request without a readable session of // this user is not a state to mint from: every session is still revoked // and the caller signs in again, rather than receiving a session that // would read as freshly signed in. - // TASK-3365: every session is revoked below, so the user's live - // connections re-check their credential now, not on their next tick. - defer s.invalidateUserAccess(user.ID) - info := s.requestSessionInfo(r) - if err := s.store.DeleteUserSessions(user.ID); err != nil { - // Best-effort: even if deletion fails we must still mint a new - // session for the caller, but log loudly so the operator knows - // stale cookies may persist until expiry. - slog.Error("failed to invalidate sessions after credential change", - "user_id", user.ID, "error", err) - } - - // The new session inherits the replaced one's sign-in time, read above - // before the delete (BUG-3336): a credential change made with a session - // is not a fresh sign-in. if info == nil || info.User.ID != user.ID { + if err := s.store.DeleteUserSessions(user.ID); err != nil { + slog.Error("failed to invalidate sessions after credential change", + "user_id", user.ID, "error", err) + } writeError(w, http.StatusInternalServerError, "internal_error", "Credentials updated but failed to refresh session. Please sign in again.") return "", false @@ -372,14 +372,19 @@ func (s *Server) rotateSessionsAfterCredentialChange(w http.ResponseWriter, r *h if ttl <= 0 { ttl = webSessionTTL } - // Fenced on the epoch of the user the caller passes, which is the - // credential state this rotation follows (BUG-3382). - token, err := s.store.CreateSessionFenced(user.ID, user.CredentialEpoch, device, clientIP(r), r.UserAgent(), ttl, info.CreatedAt) + // One store transaction (TASK-3938): mint the replacement, fenced on the + // epoch of the user the caller passes, which is the credential state this + // rotation follows (BUG-3382); move the CALLER's push devices onto it; + // delete every other session, whose devices go with them. Any failure + // inside revokes every session, so nothing stays half-rotated. + token, err := s.store.RotateUserSessions(user.ID, user.CredentialEpoch, info.ID, device, clientIP(r), r.UserAgent(), ttl, info.CreatedAt) if errors.Is(err, store.ErrUserDisabled) { writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.") return "", false } if err != nil { + slog.Error("failed to rotate sessions after credential change", + "user_id", user.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Credentials updated but failed to refresh session. Please sign in again.") return "", false diff --git a/internal/server/handlers_push_devices.go b/internal/server/handlers_push_devices.go new file mode 100644 index 000000000..e05ce45b3 --- /dev/null +++ b/internal/server/handlers_push_devices.go @@ -0,0 +1,258 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "regexp" + "unicode/utf8" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// Push device registration and notification settings (TASK-3938, PLAN-3908 +// phase 2). The device routes are session-only: a device token is bound to the +// session that registered it and dies with it. The settings routes are +// account-level and open to any authenticated person, a PAT included. + +const ( + pushTokenMaxBytes = 4096 + pushAppVersionMaxRunes = 64 +) + +// pushConnectionIDPattern is what the app's connection ids look like (UUIDs). +var pushConnectionIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`) + +// SetPushGateway sets the push gateway URL (PAD_PUSH_GATEWAY_URL). Empty means +// this server offers no push. Called once at startup, before the server +// accepts connections. +func (s *Server) SetPushGateway(url string) { + s.pushGatewayURL = url +} + +// pushAvailable reports whether this server can send pushes. +func (s *Server) pushAvailable() bool { + return s.pushGatewayURL != "" +} + +// requirePushSession admits only a session credential, a cookie or a padsess_ +// bearer, and returns that session. Everything else is refused 403 +// session_required: an API token, an OAuth bearer (also when a cookie rides +// along, which requestSessionInfo alone would fall through to), an app +// principal. No credential is a 401. A workspace-pinned API token never +// reaches here: the auth middleware's BUG-3982 gate refuses it first. +func (s *Server) requirePushSession(w http.ResponseWriter, r *http.Request) *store.SessionInfo { + if appContextFrom(r) != nil { + writeError(w, http.StatusForbidden, "session_required", "Push devices are registered by a signed-in session") + return nil + } + switch authKind(r) { + case authKindSessionBearer, authKindSessionCookie: + case "": + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return nil + default: + writeError(w, http.StatusForbidden, "session_required", "Push devices are registered by a signed-in session, not a token") + return nil + } + info := s.requestSessionInfo(r) + if info == nil || info.ID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return nil + } + return info +} + +func writePushUnavailable(w http.ResponseWriter) { + writeError(w, http.StatusNotFound, "push_unavailable", "Push notifications are not available on this server") +} + +// strictPushBody decodes a request body into a T: the transport rules every +// decodeJSON caller gets (NUL, a repeated member, trailing bytes), then a +// second pass that refuses a member T does not declare. +func strictPushBody[T any](r *http.Request) (T, error) { + var out T + raw, err := readBodyForDecode(r, defaultJSONBodyLimit) + if err != nil { + return out, err + } + if err := decodeJSONBytes(raw, &out); err != nil { + return out, err + } + var strict T + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&strict); err != nil { + return out, err + } + return strict, nil +} + +type pushDeviceBody struct { + Platform string `json:"platform"` + Token string `json:"token"` + // Environment is accepted for client compatibility and ignored: every + // token is an FCM token, and FCM routes APNs itself. + Environment json.RawMessage `json:"environment"` + AppVersion string `json:"app_version"` +} + +type notificationSettingsBody struct { + PushEnabled *bool `json:"push_enabled"` + NotifyAssigned *bool `json:"notify_assigned"` + NotifyComments *bool `json:"notify_comments"` + NotifyReplies *bool `json:"notify_replies"` + NotifyReminders *bool `json:"notify_reminders"` + NotifyMentions *bool `json:"notify_mentions"` + IncludeAgentActivity *bool `json:"include_agent_activity"` +} + +// handlePutPushDevice answers PUT /api/v1/me/push-devices/{connection_id}. +func (s *Server) handlePutPushDevice(w http.ResponseWriter, r *http.Request) { + info := s.requirePushSession(w, r) + if info == nil { + return + } + if !s.pushAvailable() { + writePushUnavailable(w) + return + } + connectionID := chi.URLParam(r, "connection_id") + if !pushConnectionIDPattern.MatchString(connectionID) { + writeError(w, http.StatusBadRequest, "validation_error", "connection_id must be 1 to 64 letters, digits or hyphens") + return + } + in, err := strictPushBody[pushDeviceBody](r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if in.Platform != "ios" && in.Platform != "android" { + writeError(w, http.StatusBadRequest, "validation_error", "platform must be ios or android") + return + } + if in.Token == "" || len(in.Token) > pushTokenMaxBytes || !bindableText(in.Token) { + writeError(w, http.StatusBadRequest, "validation_error", "token must be a non-empty string of at most 4096 bytes") + return + } + if utf8.RuneCountInString(in.AppVersion) > pushAppVersionMaxRunes || !bindableText(in.AppVersion) { + writeError(w, http.StatusBadRequest, "validation_error", "app_version must be at most 64 characters") + return + } + dev, err := s.store.RegisterPushDevice(info.User.ID, info.ID, connectionID, in.Platform, in.Token, in.AppVersion) + if errors.Is(err, store.ErrPushSessionGone) { + writeError(w, http.StatusUnauthorized, "unauthorized", "Your session has ended. Sign in again.") + return + } + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "id": dev.ID, + "connection_id": dev.ConnectionID, + "platform": dev.Platform, + "app_version": dev.AppVersion, + "updated_at": dev.UpdatedAt, + }) +} + +// handleDeletePushDevice answers DELETE /api/v1/me/push-devices/{connection_id}. +// Idempotent: a device that is not registered is a success too. +func (s *Server) handleDeletePushDevice(w http.ResponseWriter, r *http.Request) { + info := s.requirePushSession(w, r) + if info == nil { + return + } + if !s.pushAvailable() { + writePushUnavailable(w) + return + } + connectionID := chi.URLParam(r, "connection_id") + if !pushConnectionIDPattern.MatchString(connectionID) { + writeError(w, http.StatusBadRequest, "validation_error", "connection_id must be 1 to 64 letters, digits or hyphens") + return + } + if err := s.store.DeletePushDevice(info.User.ID, info.ID, connectionID); err != nil { + writeInternalError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// notificationSettingsUser admits the caller for the account-level settings +// routes: any authenticated person, a PAT included. An app principal is +// refused. A PAT pinned to one workspace never reaches here: these routes are +// not in pinnedTokenExtraRoute, so the auth middleware's BUG-3982 gate refuses +// it first, and that gate is the one owner of the refusal. +func (s *Server) notificationSettingsUser(w http.ResponseWriter, r *http.Request) *models.User { + if appContextFrom(r) != nil { + writeError(w, http.StatusForbidden, "forbidden", "Notification settings belong to a person, not an app") + return nil + } + user := currentUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return nil + } + if user.IsApp() { + writeError(w, http.StatusForbidden, "forbidden", "Notification settings belong to a person, not an app") + return nil + } + return user +} + +// handleGetNotificationSettings answers GET /api/v1/me/notification-settings: +// the caller's settings, or the defaults when they never saved any. +func (s *Server) handleGetNotificationSettings(w http.ResponseWriter, r *http.Request) { + user := s.notificationSettingsUser(w, r) + if user == nil { + return + } + settings, err := s.store.GetNotificationSettings(user.ID) + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, settings) +} + +// handlePutNotificationSettings answers PUT /api/v1/me/notification-settings: +// a full replace. All seven booleans are required, and an unknown member is +// refused, so a client that is behind or ahead of the server fails loudly +// rather than silently resetting a choice. +func (s *Server) handlePutNotificationSettings(w http.ResponseWriter, r *http.Request) { + user := s.notificationSettingsUser(w, r) + if user == nil { + return + } + in, err := strictPushBody[notificationSettingsBody](r) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + if in.PushEnabled == nil || in.NotifyAssigned == nil || in.NotifyComments == nil || in.NotifyReplies == nil || + in.NotifyReminders == nil || in.NotifyMentions == nil || in.IncludeAgentActivity == nil { + writeError(w, http.StatusBadRequest, "validation_error", + "all seven settings are required: push_enabled, notify_assigned, notify_comments, notify_replies, notify_reminders, notify_mentions, include_agent_activity") + return + } + settings := models.NotificationSettings{ + PushEnabled: *in.PushEnabled, + NotifyAssigned: *in.NotifyAssigned, + NotifyComments: *in.NotifyComments, + NotifyReplies: *in.NotifyReplies, + NotifyReminders: *in.NotifyReminders, + NotifyMentions: *in.NotifyMentions, + IncludeAgentActivity: *in.IncludeAgentActivity, + } + if err := s.store.PutNotificationSettings(user.ID, settings); err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, settings) +} diff --git a/internal/server/handlers_push_devices_test.go b/internal/server/handlers_push_devices_test.go new file mode 100644 index 000000000..860ca4001 --- /dev/null +++ b/internal/server/handlers_push_devices_test.go @@ -0,0 +1,631 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// TASK-3938: push device registration and notification settings. Each test +// that touches SQL runs on SQLite and, under `make test-pg`, on Postgres +// (needsBackends). + +const pushTestGateway = "http://gateway.test/send" + +type pushEnv struct { + t *testing.T + srv *Server + user *models.User + token string // the bootstrap session's bearer +} + +func newPushEnv(t *testing.T, srv *Server, gateway bool) *pushEnv { + t.Helper() + if gateway { + srv.SetPushGateway(pushTestGateway) + } + tok := bootstrapFirstUser(t, srv, "push@example.com", "Push Person") + u, err := srv.store.GetUserByEmail("push@example.com") + if err != nil || u == nil { + t.Fatalf("GetUserByEmail: %v %v", u, err) + } + return &pushEnv{t: t, srv: srv, user: u, token: tok} +} + +// extraSession is a second live session of the user, as a second device has. +func (e *pushEnv) extraSession() (token, id string) { + e.t.Helper() + tok, err := e.srv.store.CreateSession(e.user.ID, "web", "", "", time.Hour) + if err != nil { + e.t.Fatal(err) + } + info, err := e.srv.store.ValidateSession(tok) + if err != nil || info == nil { + e.t.Fatalf("ValidateSession: %v %v", info, err) + } + return tok, info.ID +} + +func (e *pushEnv) sessionID(token string) string { + e.t.Helper() + info, err := e.srv.store.ValidateSession(token) + if err != nil || info == nil { + e.t.Fatalf("ValidateSession: %v %v", info, err) + } + return info.ID +} + +func (e *pushEnv) devices() map[string]string { + e.t.Helper() + ds, err := e.srv.store.ListPushDevices(e.user.ID) + if err != nil { + e.t.Fatal(err) + } + out := map[string]string{} + for _, d := range ds { + out[d.Token] = d.SessionID + } + return out +} + +// rawReq sends a request with an exact body. bearer and cookie are optional. +func rawReq(srv *Server, method, path, bearer, cookie, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + req.RemoteAddr = "127.0.0.1:1234" + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + if cookie != "" { + const csrf = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + req.AddCookie(&http.Cookie{Name: "pad_session", Value: cookie}) + req.AddCookie(&http.Cookie{Name: "pad_csrf", Value: csrf}) + req.Header.Set("X-CSRF-Token", csrf) + } + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + return rr +} + +func pushBody(token string) string { + return `{"platform":"android","token":"` + token + `","app_version":"1.4"}` +} + +func putDevice(srv *Server, bearer, conn, body string) *httptest.ResponseRecorder { + return rawReq(srv, "PUT", "/api/v1/me/push-devices/"+conn, bearer, "", body) +} + +// callPush runs a handler directly with a chosen auth kind and principal, +// the way the middleware chain would have left the request. +func callPush(h http.HandlerFunc, method, conn, body string, mutate func(r *http.Request) *http.Request) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/api/v1/me/push-devices/"+conn, strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("connection_id", conn) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + r = mutate(r) + rr := httptest.NewRecorder() + h(rr, r) + return rr +} + +func TestPushDevices_RegisterAndDelete(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + rr := putDevice(srv, e.token, "conn-1", pushBody("fcm-1")) + if rr.Code != http.StatusOK { + t.Fatalf("register: %d %s", rr.Code, rr.Body.String()) + } + if got := e.devices(); len(got) != 1 || got["fcm-1"] != e.sessionID(e.token) { + t.Fatalf("devices = %v, want fcm-1 on the caller's session", got) + } + // The cookie door works too. + cookieTok, _ := e.extraSession() + if rr := rawReq(srv, "PUT", "/api/v1/me/push-devices/conn-2", "", cookieTok, pushBody("fcm-2")); rr.Code != http.StatusOK { + t.Fatalf("cookie register: %d %s", rr.Code, rr.Body.String()) + } + if len(e.devices()) != 2 { + t.Fatalf("devices = %v, want 2", e.devices()) + } + // DELETE removes the caller's device only, and again is still a success. + for i := 0; i < 2; i++ { + if rr := rawReq(srv, "DELETE", "/api/v1/me/push-devices/conn-1", e.token, "", ""); rr.Code/100 != 2 { + t.Fatalf("delete #%d: %d %s", i, rr.Code, rr.Body.String()) + } + } + if got := e.devices(); len(got) != 1 || got["fcm-2"] == "" { + t.Fatalf("devices after delete = %v, want only fcm-2", got) + } + }) +} + +// Kills: a gate that lets a token, or a cookie riding with one, register. +func TestPushDevices_PATRejected(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + pat, err := srv.store.CreateAPIToken(e.user.ID, models.APITokenCreate{Name: "p"}, 0, 0) + if err != nil { + t.Fatal(err) + } + sessTok, _ := e.extraSession() + assert403 := func(name string, rr *httptest.ResponseRecorder) { + t.Helper() + if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "session_required") { + t.Errorf("%s: %d %s, want 403 session_required", name, rr.Code, rr.Body.String()) + } + } + assert403("PAT", putDevice(srv, pat.Token, "c1", pushBody("t1"))) + assert403("PAT DELETE", rawReq(srv, "DELETE", "/api/v1/me/push-devices/c1", pat.Token, "", "")) + // A PAT with a valid session cookie alongside is still a PAT. + assert403("PAT+cookie", rawReq(srv, "PUT", "/api/v1/me/push-devices/c1", pat.Token, sessTok, pushBody("t1"))) + // An OAuth bearer with a valid cookie alongside: requestSessionInfo would + // fall through to the cookie, so only the explicit gate refuses it. + oauth := callPush(srv.handlePutPushDevice, "PUT", "c1", pushBody("t1"), func(r *http.Request) *http.Request { + ctx := WithCurrentUser(r.Context(), e.user) + ctx = context.WithValue(ctx, ctxAuthKind, authKindOAuth) + r.AddCookie(&http.Cookie{Name: sessionCookieName(srv.secureCookies), Value: sessTok}) + return r.WithContext(ctx) + }) + assert403("OAuth+cookie", oauth) + // An app principal. + app := callPush(srv.handlePutPushDevice, "PUT", "c1", pushBody("t1"), func(r *http.Request) *http.Request { + ctx := context.WithValue(WithCurrentUser(r.Context(), e.user), appCtxKey{}, &appContext{}) + return r.WithContext(ctx) + }) + assert403("app token", app) + // A workspace-pinned PAT is refused earlier, by the BUG-3982 gate, and + // gets that gate's bytes rather than session_required. + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Pinned", OwnerID: e.user.ID}) + if err != nil { + t.Fatal(err) + } + pinned, err := srv.store.CreateAPIToken(e.user.ID, models.APITokenCreate{Name: "pinned", WorkspaceID: ws.ID}, 0, 0) + if err != nil { + t.Fatal(err) + } + want := httptest.NewRecorder() + writeTokenWorkspaceDeniedFor(want, &tokenPin{ID: ws.ID, Slug: ws.Slug}) + for _, m := range []string{"PUT", "DELETE"} { + if rr := rawReq(srv, m, "/api/v1/me/push-devices/c1", pinned.Token, "", pushBody("t1")); rr.Code != want.Code || rr.Body.String() != want.Body.String() { + t.Errorf("a pinned PAT %s: %d %s, want the BUG-3982 refusal %d %s", m, rr.Code, rr.Body.String(), want.Code, want.Body.String()) + } + } + if got := e.devices(); len(got) != 0 { + t.Fatalf("a refused request registered %v", got) + } + // No credential at all is a 401, not a 403. + if rr := callPush(srv.handlePutPushDevice, "PUT", "c1", pushBody("t1"), func(r *http.Request) *http.Request { return r }); rr.Code != http.StatusUnauthorized { + t.Errorf("no credential: %d, want 401", rr.Code) + } + if rr := rawReq(srv, "PUT", "/api/v1/me/push-devices/c1", "", "", pushBody("t1")); rr.Code < 400 { + t.Errorf("no credential over HTTP: %d, want a refusal", rr.Code) + } + // And the real session credential is accepted, so the 403s above are + // the gate and not a broken route. + if rr := putDevice(srv, sessTok, "c1", pushBody("t1")); rr.Code != http.StatusOK { + t.Fatalf("session bearer: %d %s", rr.Code, rr.Body.String()) + } + }) +} + +// Kills: answering 404 for the wrong reason, or without the token the app +// keys on; and checks the gate order (a token is refused before the 404). +func TestPushDevices_NoGateway404(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, false) + for _, m := range []string{"PUT", "DELETE"} { + body := "" + if m == "PUT" { + body = pushBody("t1") + } + rr := rawReq(srv, m, "/api/v1/me/push-devices/c1", e.token, "", body) + if rr.Code != http.StatusNotFound || !strings.Contains(rr.Body.String(), "push_unavailable") { + t.Errorf("%s with no gateway: %d %s, want 404 push_unavailable", m, rr.Code, rr.Body.String()) + } + } + if len(e.devices()) != 0 { + t.Fatal("a device was registered with no gateway") + } + pat, err := srv.store.CreateAPIToken(e.user.ID, models.APITokenCreate{Name: "p"}, 0, 0) + if err != nil { + t.Fatal(err) + } + if rr := putDevice(srv, pat.Token, "c1", pushBody("t1")); rr.Code != http.StatusForbidden { + t.Errorf("a PAT with no gateway: %d, want 403 (the kind gate runs first)", rr.Code) + } + }) +} + +func TestPushDevices_ConnectionIDValidation(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + for _, bad := range []string{strings.Repeat("a", 65), "a_b", "a.b", "a%20b", "%C3%A9", "a%00b"} { + for _, m := range []string{"PUT", "DELETE"} { + body := "" + if m == "PUT" { + body = pushBody("t1") + } + if rr := rawReq(srv, m, "/api/v1/me/push-devices/"+bad, e.token, "", body); rr.Code != http.StatusBadRequest { + t.Errorf("%s connection_id %q: %d %s, want 400", m, bad, rr.Code, rr.Body.String()) + } + } + } + if len(e.devices()) != 0 { + t.Fatal("an invalid connection_id registered a device") + } + for i, good := range []string{"3f1c2a9e-4b7d-4e0a-9a53-0c1d2e3f4a5b", strings.Repeat("A", 64), "x"} { + if rr := putDevice(srv, e.token, good, pushBody("t"+string(rune('a'+i)))); rr.Code != http.StatusOK { + t.Errorf("connection_id %q: %d %s, want 200", good, rr.Code, rr.Body.String()) + } + } + }) +} + +func TestPushDevices_RegisterBodyValidation(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + bad := map[string]string{ + "no platform": `{"token":"t"}`, + "platform web": `{"platform":"web","token":"t"}`, + "platform case": `{"platform":"Android","token":"t"}`, + "no token": `{"platform":"ios"}`, + "empty token": `{"platform":"ios","token":""}`, + "NUL in token": `{"platform":"ios","token":"a\u0000b"}`, + "NUL in version": `{"platform":"ios","token":"t","app_version":"1\u0000"}`, + "token too long": `{"platform":"ios","token":"` + strings.Repeat("x", 4097) + `"}`, + "version too long": `{"platform":"ios","token":"t","app_version":"` + strings.Repeat("9", 65) + `"}`, + "unknown member": `{"platform":"ios","token":"t","extra":1}`, + "token not string": `{"platform":"ios","token":5}`, + "repeated member": `{"platform":"ios","token":"a","token":"b"}`, + "not json": `nope`, + "empty body": ``, + "array": `[]`, + } + for name, body := range bad { + if rr := putDevice(srv, e.token, "c1", body); rr.Code != http.StatusBadRequest { + t.Errorf("%s: %d %s, want 400", name, rr.Code, rr.Body.String()) + } + } + if got := e.devices(); len(got) != 0 { + t.Fatalf("a refused body registered %v", got) + } + // environment is accepted, whatever it holds, and ignored. + for i, env := range []string{`"sandbox"`, `"production"`, `"anything"`, `null`, `5`} { + body := `{"platform":"ios","token":"env` + string(rune('a'+i)) + `","environment":` + env + `}` + if rr := putDevice(srv, e.token, "c1", body); rr.Code != http.StatusOK { + t.Errorf("environment %s: %d %s, want 200", env, rr.Code, rr.Body.String()) + } + } + // The token is stored as sent, platform and version with it. + ds, _ := srv.store.ListPushDevices(e.user.ID) + if len(ds) != 1 || ds[0].Platform != "ios" || ds[0].Token != "enve" { + t.Fatalf("devices = %+v", ds) + } + }) +} + +// Kills: a register against a session that ended before the write, and one +// that is simply gone, answering anything but 401. +func TestPushDevices_GoneSession401(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + tok, _ := e.extraSession() + if err := srv.store.DeleteSession(tok); err != nil { + t.Fatal(err) + } + if rr := putDevice(srv, tok, "c1", pushBody("t1")); rr.Code != http.StatusUnauthorized { + t.Fatalf("a gone session: %d %s, want 401", rr.Code, rr.Body.String()) + } + if len(e.devices()) != 0 { + t.Fatal("a gone session registered a device") + } + }) +} + +// Logout deletes the session, which deletes its device row. +func TestPushDevices_LogoutCascades_HTTP(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + other, _ := e.extraSession() + putDevice(srv, e.token, "c1", pushBody("t-out")) + putDevice(srv, other, "c2", pushBody("t-keep")) + if rr := rawReq(srv, "POST", "/api/v1/auth/logout", e.token, "", ""); rr.Code != http.StatusOK { + t.Fatalf("logout: %d %s", rr.Code, rr.Body.String()) + } + got := e.devices() + if _, ok := got["t-out"]; ok || len(got) != 1 { + t.Fatalf("devices after logout = %v, want only the other session's", got) + } + }) +} + +// --- rotation callers ------------------------------------------------------- + +const rotatedPassword = "another-strong-passphrase-42" + +func changePassword(srv *Server, bearer string) *httptest.ResponseRecorder { + return rawReq(srv, "PATCH", "/api/v1/auth/me", bearer, "", + `{"current_password":"correct-horse-battery-staple","new_password":"`+rotatedPassword+`"}`) +} + +// The password change: the caller's device survives on the replacement +// session, the other session's device is gone with its session. Kills: +// rebinding every session's devices, none, or deleting before the rebind. +func TestPushDevices_PasswordChange_RebindsCallerOnly(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, true) + other, _ := e.extraSession() + putDevice(srv, e.token, "c1", pushBody("t-caller")) + putDevice(srv, other, "c2", pushBody("t-other")) + rr := changePassword(srv, e.token) + if rr.Code != http.StatusOK { + t.Fatalf("password change: %d %s", rr.Code, rr.Body.String()) + } + var resp struct { + Token string `json:"token"` + } + parseJSON(t, rr, &resp) + if resp.Token == "" { + t.Fatal("no replacement token returned") + } + got := e.devices() + if len(got) != 1 || got["t-caller"] != e.sessionID(resp.Token) { + t.Fatalf("devices = %v, want only t-caller, on the replacement session", got) + } + if info, _ := srv.store.ValidateSession(e.token); info != nil { + t.Fatal("the replaced session is still valid") + } + if info, _ := srv.store.ValidateSession(other); info != nil { + t.Fatal("the other session is still valid") + } + }) +} + +// Each credential-change door runs the same rotation: with the fault seam +// armed, every caller fails closed (500, every session revoked, the devices +// with them). Kills: dropping the fail-closed fallback; a caller that +// bypasses RotateUserSessions (the seam would never fire and the sessions +// would survive). +func TestPushDevices_Rotation_InjectedFailure_RevokesAll(t *testing.T) { + type door struct { + name string + run func(t *testing.T, srv *Server) (userID string, rr *httptest.ResponseRecorder) + } + doors := []door{ + {"password change", func(t *testing.T, srv *Server) (string, *httptest.ResponseRecorder) { + e := newPushEnv(t, srv, true) + other, _ := e.extraSession() + putDevice(srv, e.token, "c1", pushBody("t1")) + putDevice(srv, other, "c2", pushBody("t2")) + return e.user.ID, changePassword(srv, e.token) + }}, + {"2fa disable", func(t *testing.T, srv *Server) (string, *httptest.ResponseRecorder) { + srv.SetPushGateway(pushTestGateway) + userID, secret, tok := passwordlessTOTPUser(t, srv, "nopw-3938@example.com") + other, err := srv.store.CreateSession(userID, "test", "192.0.2.1", testSessionUA, webSessionTTL) + if err != nil { + t.Fatal(err) + } + for i, tk := range []string{tok, other} { + info, _ := srv.store.ValidateSession(tk) + if _, err := srv.store.RegisterPushDevice(userID, info.ID, "c"+string(rune('1'+i)), "ios", "t"+string(rune('1'+i)), ""); err != nil { + t.Fatal(err) + } + } + return userID, doRequestWithCookie(srv, "POST", "/api/v1/auth/2fa/disable", map[string]any{"code": validTOTPCode(t, secret)}, tok) + }}, + {"cloud oauth unlink", func(t *testing.T, srv *Server) (string, *httptest.ResponseRecorder) { + srv.cloudMode = true + srv.SetPushGateway(pushTestGateway) + u, err := srv.store.CreateUser(models.UserCreate{Email: "unlink-3938@example.com", Name: "Unlink", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatal(err) + } + if err := srv.store.LinkOAuthProvider(u.ID, "github", "gh-3938"); err != nil { + t.Fatal(err) + } + tok, err := srv.store.CreateSession(u.ID, "test", "192.0.2.1", testSessionUA, webSessionTTL) + if err != nil { + t.Fatal(err) + } + other, _ := srv.store.CreateSession(u.ID, "test", "192.0.2.1", testSessionUA, webSessionTTL) + for i, tk := range []string{tok, other} { + info, _ := srv.store.ValidateSession(tk) + if _, err := srv.store.RegisterPushDevice(u.ID, info.ID, "c"+string(rune('1'+i)), "ios", "t"+string(rune('1'+i)), ""); err != nil { + t.Fatal(err) + } + } + return u.ID, doRequestWithCookie(srv, "POST", "/api/v1/auth/oauth-unlink", map[string]any{"provider": "github"}, tok) + }}, + } + for _, d := range doors { + t.Run(d.name, func(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + fired := 0 + restore := srv.store.SetRotateFaultHookForTesting(func() error { + fired++ + return errors.New("injected rotation failure") + }) + defer restore() + userID, rr := d.run(t, srv) + if fired == 0 { + t.Fatalf("the rotation fault seam never fired: this door does not go through RotateUserSessions (%d %s)", rr.Code, rr.Body.String()) + } + if rr.Code != http.StatusInternalServerError { + t.Fatalf("%d %s, want 500", rr.Code, rr.Body.String()) + } + ph := "?" + if srv.store.D().Driver() == store.DriverPostgres { + ph = "$1" + } + var n int + if err := srv.store.DB().QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = `+ph, userID).Scan(&n); err != nil || n != 0 { + t.Fatalf("%d sessions survived a failed rotation (%v), want 0", n, err) + } + ds, err := srv.store.ListPushDevices(userID) + if err != nil || len(ds) != 0 { + t.Fatalf("devices survived a failed rotation: %v %v", ds, err) + } + }) + }) + } +} + +// --- settings ---------------------------------------------------------------- + +func settingsJSON(t *testing.T, rr *httptest.ResponseRecorder) models.NotificationSettings { + t.Helper() + var s models.NotificationSettings + parseJSON(t, rr, &s) + return s +} + +const allSettings = `{"push_enabled":true,"notify_assigned":true,"notify_comments":false,"notify_replies":true,"notify_reminders":false,"notify_mentions":true,"include_agent_activity":true}` + +func TestNotificationSettings_DefaultsAndUnknownKey400(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, false) // settings do not need a gateway + path := "/api/v1/me/notification-settings" + + rr := rawReq(srv, "GET", path, e.token, "", "") + if rr.Code != http.StatusOK || settingsJSON(t, rr) != models.DefaultNotificationSettings() { + t.Fatalf("defaults: %d %s", rr.Code, rr.Body.String()) + } + + // Unknown key, missing key, wrong type, partial: all 400, none saved. + for name, body := range map[string]string{ + "unknown key": strings.Replace(allSettings, `"include_agent_activity":true`, `"include_agent_activity":true,"notify_bogus":true`, 1), + "missing key": strings.Replace(allSettings, `,"include_agent_activity":true`, ``, 1), + "missing first": strings.Replace(allSettings, `"push_enabled":true,`, ``, 1), + "empty object": `{}`, + "wrong type": strings.Replace(allSettings, `"push_enabled":true`, `"push_enabled":"yes"`, 1), + "null value": strings.Replace(allSettings, `"push_enabled":true`, `"push_enabled":null`, 1), + "not json": `x`, + } { + if rr := rawReq(srv, "PUT", path, e.token, "", body); rr.Code != http.StatusBadRequest { + t.Errorf("%s: %d %s, want 400", name, rr.Code, rr.Body.String()) + } + } + if got := settingsJSON(t, rawReq(srv, "GET", path, e.token, "", "")); got != models.DefaultNotificationSettings() { + t.Fatalf("a refused PUT changed the settings: %+v", got) + } + + // Round trip: a full replace, a false stays false. + rr = rawReq(srv, "PUT", path, e.token, "", allSettings) + want := models.NotificationSettings{PushEnabled: true, NotifyAssigned: true, NotifyReplies: true, NotifyMentions: true, IncludeAgentActivity: true} + if rr.Code != http.StatusOK || settingsJSON(t, rr) != want { + t.Fatalf("put: %d %s", rr.Code, rr.Body.String()) + } + if got := settingsJSON(t, rawReq(srv, "GET", path, e.token, "", "")); got != want { + t.Fatalf("round trip: %+v, want %+v", got, want) + } + // The cookie door and an unauthenticated caller. + if rr := rawReq(srv, "GET", path, "", "", ""); rr.Code != http.StatusUnauthorized { + t.Errorf("no credential: %d, want 401", rr.Code) + } + }) +} + +// Any authenticated user may use the settings, a PAT included; an app +// principal and a workspace-pinned PAT may not, the latter refused by the +// BUG-3982 gate. Kills: admitting these routes to a pinned token, or gating +// the settings on the session kind. +func TestNotificationSettings_Auth(t *testing.T) { + needsBackends(t, func(t *testing.T, srv *Server) { + e := newPushEnv(t, srv, false) + path := "/api/v1/me/notification-settings" + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Pinned", OwnerID: e.user.ID}) + if err != nil { + t.Fatal(err) + } + free, err := srv.store.CreateAPIToken(e.user.ID, models.APITokenCreate{Name: "free"}, 0, 0) + if err != nil { + t.Fatal(err) + } + pinned, err := srv.store.CreateAPIToken(e.user.ID, models.APITokenCreate{Name: "pinned", WorkspaceID: ws.ID}, 0, 0) + if err != nil { + t.Fatal(err) + } + if rr := rawReq(srv, "GET", path, free.Token, "", ""); rr.Code != http.StatusOK { + t.Errorf("an unpinned PAT GET: %d %s, want 200", rr.Code, rr.Body.String()) + } + if rr := rawReq(srv, "PUT", path, free.Token, "", allSettings); rr.Code != http.StatusOK { + t.Errorf("an unpinned PAT PUT: %d %s, want 200", rr.Code, rr.Body.String()) + } + // The refusal is BUG-3982's gate's, byte for byte, so a consumer keyed + // on it sees one answer at every pinned-token door. Kills: listing these + // routes in pinnedTokenExtraRoute. + want := httptest.NewRecorder() + writeTokenWorkspaceDeniedFor(want, &tokenPin{ID: ws.ID, Slug: ws.Slug}) + wantPinned := func(what string, rr *httptest.ResponseRecorder) { + t.Helper() + if rr.Code != want.Code || rr.Body.String() != want.Body.String() { + t.Errorf("a pinned PAT %s: %d %s, want the BUG-3982 refusal %d %s", what, rr.Code, rr.Body.String(), want.Code, want.Body.String()) + } + } + wantPinned("GET", rawReq(srv, "GET", path, pinned.Token, "", "")) + wantPinned("PUT", rawReq(srv, "PUT", path, pinned.Token, "", strings.Replace(allSettings, `"push_enabled":true`, `"push_enabled":false`, 1))) + if got := settingsJSON(t, rawReq(srv, "GET", path, e.token, "", "")); !got.PushEnabled { + t.Error("a refused pinned PUT changed the settings") + } + for _, h := range []http.HandlerFunc{srv.handleGetNotificationSettings, srv.handlePutNotificationSettings} { + r := httptest.NewRequest("PUT", path, strings.NewReader(allSettings)) + ctx := context.WithValue(WithCurrentUser(r.Context(), e.user), appCtxKey{}, &appContext{}) + rr := httptest.NewRecorder() + h(rr, r.WithContext(ctx)) + if rr.Code != http.StatusForbidden { + t.Errorf("an app principal: %d, want 403", rr.Code) + } + } + }) +} + +// push_available is a JSON bool on /auth/session for a SIGNED-IN caller, true +// only with a gateway, and is absent for an anonymous caller and on the +// pre-auth setup state. Kills: a string or number, an omitted false, emitting +// it to an anonymous caller. +func TestPushDevices_Session_PushAvailable_IsJSONBool(t *testing.T) { + for _, gateway := range []bool{false, true} { + srv := testServer(t) + if gateway { + srv.SetPushGateway(pushTestGateway) + } + // Pre-auth setup state first (no users yet): no push_available. + rr := doRequest(srv, "GET", "/api/v1/auth/session", nil) + var pre map[string]json.RawMessage + parseJSON(t, rr, &pre) + if _, ok := pre["push_available"]; ok { + t.Errorf("gateway=%v: setup state carries push_available", gateway) + } + tok := bootstrapFirstUser(t, srv, "pa@example.com", "PA") + var raw map[string]json.RawMessage + parseJSON(t, rawReq(srv, "GET", "/api/v1/auth/session", tok, "", ""), &raw) + got, ok := raw["push_available"] + want := "false" + if gateway { + want = "true" + } + if !ok || !bytes.Equal(bytes.TrimSpace(got), []byte(want)) { + t.Errorf("gateway=%v signed in: push_available = %s (present %v), want JSON %s", gateway, got, ok, want) + } + // After setup, an anonymous caller still gets the session payload, but + // not the gateway configuration (ruling b878d7f8). + var anon map[string]json.RawMessage + parseJSON(t, doRequest(srv, "GET", "/api/v1/auth/session", nil), &anon) + if v, ok := anon["push_available"]; ok { + t.Errorf("gateway=%v anonymous: carries push_available = %s, want it absent", gateway, v) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 127269357..38d806245 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -561,6 +561,10 @@ type Server struct { // public exposure should leave it off and use the logs-token path. bypassSetupToken bool + // pushGatewayURL is PAD_PUSH_GATEWAY_URL (TASK-3938); empty means this + // server offers no push. Set once at startup by SetPushGateway. + pushGatewayURL string + // restoreAckFault is a TEST SEAM (always nil in production). When non-nil, // handleRestoreItemVersion's collab commit closure invokes it AFTER the restore // transaction has durably committed; a non-nil return simulates a Postgres commit @@ -2096,6 +2100,12 @@ func (s *Server) setupRouter() { // The cross-workspace needs-you inbox (TASK-3920): outside the // /{slug} subrouter, so the handler admits each workspace itself. r.Get("/me/needs", s.handleMeNeeds) + // Push device registration (session-only) and the account's push + // settings (TASK-3938). + r.Put("/me/push-devices/{connection_id}", s.handlePutPushDevice) + r.Delete("/me/push-devices/{connection_id}", s.handleDeletePushDevice) + r.Get("/me/notification-settings", s.handleGetNotificationSettings) + r.Put("/me/notification-settings", s.handlePutNotificationSettings) r.Get("/me/ui-dismissals", s.handleListUIDismissals) r.Put("/me/ui-dismissals/{key}", s.handleDismissUI) r.Get("/tutorials", s.handleListTutorials) diff --git a/internal/server/testdata/compress_get_routes.txt b/internal/server/testdata/compress_get_routes.txt index ddcce1d04..7ed9af00a 100644 --- a/internal/server/testdata/compress_get_routes.txt +++ b/internal/server/testdata/compress_get_routes.txt @@ -56,6 +56,7 @@ compress /api/v1/library/entry compress /api/v1/mcp/tool-surface compress /api/v1/me/invitations compress /api/v1/me/needs +compress /api/v1/me/notification-settings compress /api/v1/me/ui-dismissals compress /api/v1/me/workspace-tabs/ skip /api/v1/oauth/clients/{id}/public-info diff --git a/internal/store/push_devices.go b/internal/store/push_devices.go new file mode 100644 index 000000000..1c6586237 --- /dev/null +++ b/internal/store/push_devices.go @@ -0,0 +1,145 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "time" +) + +// MaxPushDevicesPerUser caps the devices one user keeps registered. A register +// that would exceed it evicts the oldest, silently (TASK-3938). +const MaxPushDevicesPerUser = 20 + +// ErrPushSessionGone is returned by RegisterPushDevice when the session it was +// asked to bind no longer exists or has expired. The handler answers 401: the +// caller's credential died between admission and the write. +var ErrPushSessionGone = errors.New("session is gone or expired") + +// PushDevice is one registered push token, bound to the session that +// registered it. The row dies with the session or the user. +type PushDevice struct { + ID string + UserID string + SessionID string + ConnectionID string + Platform string + Token string + AppVersion string + CreatedAt string + UpdatedAt string +} + +// RegisterPushDevice binds a push token to a session in ONE transaction: it +// deletes every row on EITHER unique key (this session's own row, whatever its +// token, and this user's row for this token, whatever its session), evicts the +// oldest devices while the user is at the cap, then inserts. A single-key +// upsert cannot clear the other key and collides with it (TASK-3938). +// +// On Postgres the user row is locked first so two concurrent registers +// serialize and the cap holds. A session that is gone or expired is +// ErrPushSessionGone. +func (s *Store) RegisterPushDevice(userID, sessionID, connectionID, platform, token, appVersion string) (*PushDevice, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf("register push device: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if s.dialect.Driver() == DriverPostgres { + var locked string + if err := tx.QueryRow(s.q(`SELECT id FROM users WHERE id = ? FOR UPDATE`), userID).Scan(&locked); err != nil { + return nil, fmt.Errorf("register push device: lock user: %w", err) + } + } + + // The session must be live under the same transaction that binds to it. + // FOR SHARE on Postgres keeps a concurrent logout from deleting it before + // the insert commits (the FK would refuse the insert, but as a 500). + sessQ := `SELECT expires_at FROM sessions WHERE id = ? AND user_id = ?` + if s.dialect.Driver() == DriverPostgres { + sessQ += ` FOR SHARE` + } + var expiresAt string + switch err := tx.QueryRow(s.q(sessQ), sessionID, userID).Scan(&expiresAt); { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrPushSessionGone + case err != nil: + return nil, fmt.Errorf("register push device: read session: %w", err) + } + if parseTime(expiresAt).Before(time.Now().UTC()) { + return nil, ErrPushSessionGone + } + + if _, err := tx.Exec(s.q(`DELETE FROM push_devices WHERE session_id = ? OR (user_id = ? AND token = ?)`), + sessionID, userID, token); err != nil { + return nil, fmt.Errorf("register push device: clear keys: %w", err) + } + + for { + var count int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM push_devices WHERE user_id = ?`), userID).Scan(&count); err != nil { + return nil, fmt.Errorf("register push device: count: %w", err) + } + if count < MaxPushDevicesPerUser { + break + } + var oldest string + if err := tx.QueryRow(s.q(`SELECT id FROM push_devices WHERE user_id = ? ORDER BY updated_at, created_at, id LIMIT 1`), + userID).Scan(&oldest); err != nil { + return nil, fmt.Errorf("register push device: find oldest: %w", err) + } + if _, err := tx.Exec(s.q(`DELETE FROM push_devices WHERE id = ?`), oldest); err != nil { + return nil, fmt.Errorf("register push device: evict: %w", err) + } + // The device id only: a push token is a credential and is never logged. + slog.Debug("evicted the oldest push device at the cap", "user_id", userID, "device_id", oldest) + } + + ts := now() + d := &PushDevice{ + ID: newID(), UserID: userID, SessionID: sessionID, ConnectionID: connectionID, + Platform: platform, Token: token, AppVersion: appVersion, CreatedAt: ts, UpdatedAt: ts, + } + if _, err := tx.Exec(s.q(` + INSERT INTO push_devices (id, user_id, session_id, connection_id, platform, token, app_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + d.ID, d.UserID, d.SessionID, d.ConnectionID, d.Platform, d.Token, d.AppVersion, d.CreatedAt, d.UpdatedAt); err != nil { + return nil, fmt.Errorf("register push device: insert: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("register push device: commit: %w", err) + } + return d, nil +} + +// DeletePushDevice removes the device bound to this session under this +// connection id. Absent is not an error: the call is idempotent. +func (s *Store) DeletePushDevice(userID, sessionID, connectionID string) error { + if _, err := s.db.Exec(s.q(`DELETE FROM push_devices WHERE user_id = ? AND session_id = ? AND connection_id = ?`), + userID, sessionID, connectionID); err != nil { + return fmt.Errorf("delete push device: %w", err) + } + return nil +} + +// ListPushDevices returns userID's devices, oldest first by updated_at. +func (s *Store) ListPushDevices(userID string) ([]PushDevice, error) { + rows, err := s.db.Query(s.q(` + SELECT id, user_id, session_id, connection_id, platform, token, app_version, created_at, updated_at + FROM push_devices WHERE user_id = ? ORDER BY updated_at, created_at, id`), userID) + if err != nil { + return nil, fmt.Errorf("list push devices: %w", err) + } + defer rows.Close() + var out []PushDevice + for rows.Next() { + var d PushDevice + if err := rows.Scan(&d.ID, &d.UserID, &d.SessionID, &d.ConnectionID, &d.Platform, &d.Token, &d.AppVersion, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, fmt.Errorf("list push devices: %w", err) + } + out = append(out, d) + } + return out, rows.Err() +} diff --git a/internal/store/push_devices_test.go b/internal/store/push_devices_test.go new file mode 100644 index 000000000..2a63b4243 --- /dev/null +++ b/internal/store/push_devices_test.go @@ -0,0 +1,416 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +// TASK-3938. These run on SQLite, and on Postgres whenever PAD_TEST_POSTGRES_URL +// is set (testStore switches), so `make test-pg` is the Postgres half. + +// pushSession makes a live session for the user and returns its id and token. +func pushSession(t *testing.T, s *Store, userID string) (id, token string) { + t.Helper() + tok, err := s.CreateSession(userID, "web", "", "", time.Hour) + if err != nil { + t.Fatal(err) + } + info, err := s.ValidateSession(tok) + if err != nil || info == nil { + t.Fatalf("ValidateSession: %v %v", info, err) + } + return info.ID, tok +} + +func register(t *testing.T, s *Store, userID, sessionID, token string) *PushDevice { + t.Helper() + d, err := s.RegisterPushDevice(userID, sessionID, "conn-"+sessionID[:8], "android", token, "1.0") + if err != nil { + t.Fatalf("RegisterPushDevice(%s): %v", token, err) + } + return d +} + +func deviceTokens(t *testing.T, s *Store, userID string) map[string]string { + t.Helper() + ds, err := s.ListPushDevices(userID) + if err != nil { + t.Fatal(err) + } + out := map[string]string{} + for _, d := range ds { + out[d.Token] = d.SessionID + } + return out +} + +// Kills: a ValidateSession that does not select the id. +func TestPushDevices_ValidateSession_ReturnsID(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "vid@example.com") + id, tok := pushSession(t, s, u.ID) + sum := sha256.Sum256([]byte(tok)) + var want string + if err := s.db.QueryRow(s.q(`SELECT id FROM sessions WHERE token_hash = ?`), hex.EncodeToString(sum[:])).Scan(&want); err != nil { + t.Fatal(err) + } + if id == "" || id != want { + t.Fatalf("SessionInfo.ID = %q, want the row id %q", id, want) + } +} + +// Kills: a plain INSERT, or a (user, token)-only delete: the old session is +// ALIVE and still holds the token. +func TestPushDevices_Register_SameTokenNewSession(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "a@example.com") + oldSess, _ := pushSession(t, s, u.ID) + newSess, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, oldSess, "tok-A") + register(t, s, u.ID, newSess, "tok-A") + got := deviceTokens(t, s, u.ID) + if len(got) != 1 || got["tok-A"] != newSess { + t.Fatalf("devices = %v, want exactly tok-A on the new session", got) + } +} + +// Kills: a (user, token)-only delete, which leaves the session's old row and +// collides on session_id. +func TestPushDevices_Register_NewTokenSameSession(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "b@example.com") + sess, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, sess, "tok-A") + register(t, s, u.ID, sess, "tok-B") + got := deviceTokens(t, s, u.ID) + if len(got) != 1 || got["tok-B"] != sess { + t.Fatalf("devices = %v, want exactly tok-B on the session", got) + } +} + +// The ruling's case (c): the caller's session holds token A, another session +// of the same user holds token B, and the caller now registers B. Kills +// dropping EITHER arm of the delete. +func TestPushDevices_Register_BothKeys(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "c@example.com") + caller, _ := pushSession(t, s, u.ID) + other, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, caller, "tok-A") + register(t, s, u.ID, other, "tok-B") + register(t, s, u.ID, caller, "tok-B") + got := deviceTokens(t, s, u.ID) + if len(got) != 1 || got["tok-B"] != caller { + t.Fatalf("devices = %v, want exactly tok-B on the caller's session", got) + } +} + +// Another user holding the same token string is not touched. +func TestPushDevices_Register_OtherUsersTokenUntouched(t *testing.T) { + t.Parallel() + s := testStore(t) + u1, u2 := notifyUser(t, s, "u1@example.com"), notifyUser(t, s, "u2@example.com") + s1, _ := pushSession(t, s, u1.ID) + s2, _ := pushSession(t, s, u2.ID) + register(t, s, u1.ID, s1, "shared") + register(t, s, u2.ID, s2, "shared") + if len(deviceTokens(t, s, u1.ID)) != 1 || len(deviceTokens(t, s, u2.ID)) != 1 { + t.Fatal("registering one user's token removed another user's") + } +} + +// Kills: no eviction (the 21st device is simply added), evicting the newest, +// or evicting by something other than updated_at. +func TestPushDevices_Cap20_EvictsOldest(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "cap@example.com") + sessions := make([]string, MaxPushDevicesPerUser) + for i := range sessions { + sessions[i], _ = pushSession(t, s, u.ID) + register(t, s, u.ID, sessions[i], fmt.Sprintf("tok-%02d", i)) + } + // Dates 0..19 by updated_at, oldest first, but with tok-07 the oldest: a + // register order that is not the age order. + base := time.Now().UTC().Add(-time.Hour) + for i := range sessions { + at := base.Add(time.Duration(i) * time.Minute).Format(time.RFC3339) + if i == 7 { + at = base.Add(-time.Hour).Format(time.RFC3339) + } + s.mustExec(t, `UPDATE push_devices SET updated_at = ?, created_at = ? WHERE token = ?`, at, at, fmt.Sprintf("tok-%02d", i)) + } + extra, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, extra, "tok-new") + got := deviceTokens(t, s, u.ID) + if len(got) != MaxPushDevicesPerUser { + t.Fatalf("%d devices, want the cap %d", len(got), MaxPushDevicesPerUser) + } + if _, ok := got["tok-07"]; ok { + t.Fatal("the oldest device (tok-07) survived the eviction") + } + if _, ok := got["tok-new"]; !ok { + t.Fatal("the new device was not registered") + } + // Replacing an existing device at the cap evicts nothing. + register(t, s, u.ID, extra, "tok-new-2") + if got := deviceTokens(t, s, u.ID); len(got) != MaxPushDevicesPerUser { + t.Fatalf("a replace at the cap left %d devices", len(got)) + } else if _, ok := got["tok-00"]; !ok { + t.Fatal("a replace at the cap evicted another device") + } +} + +// Concurrent registers at 19 of 20 devices must not overshoot the cap. Without +// the Postgres user-row lock every transaction counts 19 and inserts, leaving +// 19+N rows. SQLite serializes writers, so there it only proves no call fails +// with a constraint error. +func TestPushDevices_Cap20_ConcurrentRegisters(t *testing.T) { + t.Parallel() + s := testStore(t) + const workers, rounds = 16, 4 + u := notifyUser(t, s, "race@example.com") + seedSessions := make([]string, MaxPushDevicesPerUser-1) + for i := range seedSessions { + seedSessions[i], _ = pushSession(t, s, u.ID) + } + sessions := make([]string, workers) + for i := range sessions { + sessions[i], _ = pushSession(t, s, u.ID) + } + if s.dialect.Driver() == DriverPostgres { + // Widen the count-to-insert window so the race is not left to + // scheduler luck: each insert dwells 40ms. It is enabled only around + // the concurrent step, so seeding stays fast. The database is this + // test's own clone. + s.mustExec(t, `CREATE FUNCTION push_devices_slow() RETURNS trigger AS $$ BEGIN PERFORM pg_sleep(0.04); RETURN NEW; END $$ LANGUAGE plpgsql`) + s.mustExec(t, `CREATE TRIGGER push_devices_slow BEFORE INSERT ON push_devices FOR EACH ROW EXECUTE FUNCTION push_devices_slow()`) + s.mustExec(t, `ALTER TABLE push_devices DISABLE TRIGGER push_devices_slow`) + } + slow := func(on bool) { + if s.dialect.Driver() != DriverPostgres { + return + } + verb := "DISABLE" + if on { + verb = "ENABLE" + } + s.mustExec(t, `ALTER TABLE push_devices `+verb+` TRIGGER push_devices_slow`) + } + for r := 0; r < rounds; r++ { + // Back to 19 devices, one per seed session. + s.mustExec(t, `DELETE FROM push_devices WHERE user_id = ?`, u.ID) + for i, sid := range seedSessions { + register(t, s, u.ID, sid, fmt.Sprintf("seed-%d-%02d", r, i)) + } + slow(true) + start := make(chan struct{}) + errs := make([]error, workers) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, errs[i] = s.RegisterPushDevice(u.ID, sessions[i], "conn-race", "android", fmt.Sprintf("race-%d-%02d", r, i), "1.0") + }(i) + } + close(start) + wg.Wait() + slow(false) + for i, err := range errs { + if err != nil { + t.Errorf("round %d worker %d: %v", r, i, err) + } + } + if got := deviceTokens(t, s, u.ID); len(got) != MaxPushDevicesPerUser { + t.Fatalf("round %d: %d devices after %d concurrent registers, want exactly %d", r, len(got), workers, MaxPushDevicesPerUser) + } + } +} + +// Ties on updated_at break by created_at, then id. +func TestPushDevices_Cap20_TieBreaksByCreatedAt(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "tie@example.com") + at := time.Now().UTC().Add(-time.Hour) + for i := 0; i < MaxPushDevicesPerUser; i++ { + sess, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, sess, fmt.Sprintf("tok-%02d", i)) + s.mustExec(t, `UPDATE push_devices SET updated_at = ?, created_at = ? WHERE token = ?`, + at.Format(time.RFC3339), at.Add(time.Duration(19-i)*time.Second).Format(time.RFC3339), fmt.Sprintf("tok-%02d", i)) + } + extra, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, extra, "tok-new") + // Same updated_at everywhere; tok-19 has the earliest created_at. + if _, ok := deviceTokens(t, s, u.ID)["tok-19"]; ok { + t.Fatal("the tie was not broken by created_at: tok-19 survived") + } +} + +// Kills: removing push_devices.session_id's cascade; the logout path is +// DeleteSession. +func TestPushDevices_LogoutCascades(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "logout@example.com") + sess, tok := pushSession(t, s, u.ID) + keep, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, sess, "tok-out") + register(t, s, u.ID, keep, "tok-keep") + if err := s.DeleteSession(tok); err != nil { + t.Fatal(err) + } + got := deviceTokens(t, s, u.ID) + if _, ok := got["tok-out"]; ok || len(got) != 1 { + t.Fatalf("devices after logout = %v, want only the other session's", got) + } +} + +// Kills: a sweep that leaves the device of an expired session. +func TestPushDevices_ExpiryCascades(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "expiry@example.com") + sess, _ := pushSession(t, s, u.ID) + live, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, sess, "tok-exp") + register(t, s, u.ID, live, "tok-live") + s.mustExec(t, `UPDATE sessions SET expires_at = ? WHERE id = ?`, time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), sess) + if err := s.CleanExpiredSessions(); err != nil { + t.Fatal(err) + } + got := deviceTokens(t, s, u.ID) + if _, ok := got["tok-exp"]; ok || len(got) != 1 { + t.Fatalf("devices after the sweep = %v, want only the live session's", got) + } +} + +// Kills: binding a device to a session that is gone or expired. +func TestPushDevices_Register_GoneOrExpiredSession(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "gone@example.com") + if _, err := s.RegisterPushDevice(u.ID, "no-such-session", "c1", "ios", "t", ""); !errors.Is(err, ErrPushSessionGone) { + t.Fatalf("gone session: %v, want ErrPushSessionGone", err) + } + sess, _ := pushSession(t, s, u.ID) + s.mustExec(t, `UPDATE sessions SET expires_at = ? WHERE id = ?`, time.Now().UTC().Add(-time.Minute).Format(time.RFC3339), sess) + if _, err := s.RegisterPushDevice(u.ID, sess, "c1", "ios", "t", ""); !errors.Is(err, ErrPushSessionGone) { + t.Fatalf("expired session: %v, want ErrPushSessionGone", err) + } + // Another user's session is not the caller's. + other := notifyUser(t, s, "other-gone@example.com") + os, _ := pushSession(t, s, other.ID) + if _, err := s.RegisterPushDevice(u.ID, os, "c1", "ios", "t", ""); !errors.Is(err, ErrPushSessionGone) { + t.Fatalf("another user's session: %v, want ErrPushSessionGone", err) + } + if len(deviceTokens(t, s, u.ID))+len(deviceTokens(t, s, other.ID)) != 0 { + t.Fatal("a refused register left a row") + } +} + +// DeletePushDevice is scoped to the caller's session and connection id, and +// idempotent. +func TestPushDevices_Delete_ScopedAndIdempotent(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "del@example.com") + a, _ := pushSession(t, s, u.ID) + b, _ := pushSession(t, s, u.ID) + da := register(t, s, u.ID, a, "tok-a") + register(t, s, u.ID, b, "tok-b") + if err := s.DeletePushDevice(u.ID, b, da.ConnectionID); err != nil { + t.Fatal(err) + } + if len(deviceTokens(t, s, u.ID)) != 2 { + t.Fatal("deleting through another session's id removed a device") + } + for i := 0; i < 2; i++ { + if err := s.DeletePushDevice(u.ID, a, da.ConnectionID); err != nil { + t.Fatal(err) + } + } + if got := deviceTokens(t, s, u.ID); len(got) != 1 { + t.Fatalf("devices = %v, want only tok-b", got) + } +} + +// The caller's device moves to the replacement session; another session's +// device is gone; the old session is gone. Kills: rebinding every session's +// devices, rebinding none, deleting before rebinding. +func TestPushDevices_PasswordChange_RebindsCallerOnly(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "rot@example.com") + caller, _ := pushSession(t, s, u.ID) + other, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, caller, "tok-caller") + register(t, s, u.ID, other, "tok-other") + tok, err := s.RotateUserSessions(u.ID, u.CredentialEpoch, caller, "web", "", "", time.Hour, time.Time{}) + if err != nil { + t.Fatal(err) + } + info, err := s.ValidateSession(tok) + if err != nil || info == nil { + t.Fatalf("the replacement session does not validate: %v %v", info, err) + } + got := deviceTokens(t, s, u.ID) + if len(got) != 1 || got["tok-caller"] != info.ID { + t.Fatalf("devices = %v, want only tok-caller on the replacement %s", got, info.ID) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`), u.ID).Scan(&n); err != nil || n != 1 { + t.Fatalf("%d sessions after rotation (%v), want 1", n, err) + } +} + +// Kills: dropping the fail-closed fallback: a failed rotation rolls back and +// would leave every old session alive. +func TestPushDevices_Rotation_InjectedFailure_RevokesAll(t *testing.T) { + s := testStore(t) + u := notifyUser(t, s, "rotfail@example.com") + caller, _ := pushSession(t, s, u.ID) + other, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, caller, "tok-caller") + register(t, s, u.ID, other, "tok-other") + restore := s.SetRotateFaultHookForTesting(func() error { return errors.New("injected") }) + defer restore() + if tok, err := s.RotateUserSessions(u.ID, u.CredentialEpoch, caller, "web", "", "", time.Hour, time.Time{}); err == nil || tok != "" { + t.Fatalf("an injected failure returned %q, %v", tok, err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`), u.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("%d sessions after a failed rotation (%v), want 0: it must fail closed", n, err) + } + if got := deviceTokens(t, s, u.ID); len(got) != 0 { + t.Fatalf("devices survived the revoke-all: %v", got) + } +} + +// The credential-epoch fence and the active-user check survive the move into +// the transaction, and a refusal revokes too. +func TestPushDevices_Rotation_KeepsTheFence(t *testing.T) { + t.Parallel() + s := testStore(t) + u := notifyUser(t, s, "fence@example.com") + caller, _ := pushSession(t, s, u.ID) + register(t, s, u.ID, caller, "tok") + if _, err := s.RotateUserSessions(u.ID, u.CredentialEpoch+1, caller, "web", "", "", time.Hour, time.Time{}); !errors.Is(err, ErrCredentialsChanged) { + t.Fatalf("a stale epoch: %v, want ErrCredentialsChanged", err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`), u.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("%d sessions after a fenced rotation (%v), want 0", n, err) + } +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 7435a1d08..8c4ddd5b2 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "log/slog" "time" "github.com/PerpetualSoftware/pad/internal/models" @@ -15,6 +16,9 @@ import ( // SessionInfo holds the result of session validation, including the // authenticated user and session binding metadata for IP/UA verification. type SessionInfo struct { + // ID is the session row's id (sessions.id), what rows that live and die + // with the session (push_devices) reference. Never the token. + ID string User *models.User IPAddress string UAHash string @@ -90,10 +94,31 @@ func (s *Store) CreateSessionFenced(userID string, epoch int64, deviceInfo, ipAd } func (s *Store) createSession(userID string, epoch int64, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) { + // Only for an account that is not disabled, decided under the users row + // (BUG-3349, requireActiveUserTx): a request admitted before a disable, + // minting after it, would otherwise leave a session its revoke never saw. + tx, err := s.db.Begin() + if err != nil { + return "", fmt.Errorf("insert session: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + plaintext, _, err := s.createSessionTx(tx, userID, epoch, deviceInfo, ipAddress, userAgent, ttl, issuedAt) + if err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("insert session: commit: %w", err) + } + return plaintext, nil +} + +// createSessionTx is createSession's body on a caller-owned transaction: it +// returns the plaintext token and the new row's id, and commits nothing. +func (s *Store) createSessionTx(tx *sql.Tx, userID string, epoch int64, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, string, error) { // Generate 32 random bytes → hex → prefix raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("generate session token: %w", err) + return "", "", fmt.Errorf("generate session token: %w", err) } plaintext := "padsess_" + hex.EncodeToString(raw) @@ -118,19 +143,11 @@ func (s *Store) createSession(userID string, epoch int64, deviceInfo, ipAddress, } expiresAt := expires.Format(time.RFC3339) - // Only for an account that is not disabled, decided under the users row - // (BUG-3349, requireActiveUserTx): a request admitted before a disable, - // minting after it, would otherwise leave a session its revoke never saw. - tx, err := s.db.Begin() - if err != nil { - return "", fmt.Errorf("insert session: begin: %w", err) - } - defer func() { _ = tx.Rollback() }() if err := s.requireActiveUserTx(tx, userID); err != nil { if errors.Is(err, ErrUserDisabled) { - return "", err + return "", "", err } - return "", fmt.Errorf("insert session: %w", err) + return "", "", fmt.Errorf("insert session: %w", err) } // The epoch is read under requireActiveUserTx's lock (FOR SHARE on // Postgres; SQLite serializes writers), which a credential change's @@ -138,23 +155,78 @@ func (s *Store) createSession(userID string, epoch int64, deviceInfo, ipAddress, if epoch >= 0 { var current int64 if err := tx.QueryRow(s.q(`SELECT credential_epoch FROM users WHERE id = ?`), userID).Scan(¤t); err != nil { - return "", fmt.Errorf("insert session: read credential epoch: %w", err) + return "", "", fmt.Errorf("insert session: read credential epoch: %w", err) } if current != epoch { - return "", ErrCredentialsChanged + return "", "", ErrCredentialsChanged } } if _, err := tx.Exec(s.q(` INSERT INTO sessions (id, user_id, token_hash, device_info, ip_address, ua_hash, expires_at, created_at, renew_ttl_seconds, kind) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), id, userID, tokenHash, deviceInfo, ipAddress, uaHash, expiresAt, ts, int64(ttl/time.Second), SessionKindFor(deviceInfo)); err != nil { - return "", fmt.Errorf("insert session: %w", err) + return "", "", fmt.Errorf("insert session: %w", err) + } + return plaintext, id, nil +} + +// RotateUserSessions is a credential change's session rotation (password +// change, 2FA disable, provider unlink) as ONE transaction: it mints the +// replacement session (the same epoch fence and active-user check as +// CreateSessionFenced), moves the push devices bound to oldSessionID, the +// caller's own session, onto it, then deletes every other session of the +// user. A device on any OTHER session dies with that session (the cascade): +// the change exists to cut off stolen sessions, and their push tokens go with +// them (TASK-3938). An empty oldSessionID rebinds nothing. +// +// It fails closed. When the transaction fails for any reason, every session +// of the user is revoked as DeleteUserSessions always did, and the original +// error is returned: the caller answers 500 and the user signs in again, +// where a half-rotated state would leave the sessions the change meant to cut +// off. ErrUserDisabled and ErrCredentialsChanged get the same revoke-all. +func (s *Store) RotateUserSessions(userID string, epoch int64, oldSessionID, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) { + token, err := s.rotateUserSessions(userID, epoch, oldSessionID, deviceInfo, ipAddress, userAgent, ttl, issuedAt) + if err == nil { + return token, nil + } + if delErr := s.DeleteUserSessions(userID); delErr != nil { + slog.Error("failed to revoke sessions after a failed rotation", + "user_id", userID, "rotation_error", err, "error", delErr) + } + return "", err +} + +func (s *Store) rotateUserSessions(userID string, epoch int64, oldSessionID, deviceInfo, ipAddress, userAgent string, ttl time.Duration, issuedAt time.Time) (string, error) { + if epoch < 0 { + return "", fmt.Errorf("rotate sessions: negative credential epoch %d", epoch) + } + tx, err := s.db.Begin() + if err != nil { + return "", fmt.Errorf("rotate sessions: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + token, replacementID, err := s.createSessionTx(tx, userID, epoch, deviceInfo, ipAddress, userAgent, ttl, issuedAt) + if err != nil { + return "", err + } + if oldSessionID != "" { + if _, err := tx.Exec(s.q(`UPDATE push_devices SET session_id = ? WHERE session_id = ? AND user_id = ?`), + replacementID, oldSessionID, userID); err != nil { + return "", fmt.Errorf("rotate sessions: rebind push devices: %w", err) + } + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE user_id = ? AND id <> ?`), userID, replacementID); err != nil { + return "", fmt.Errorf("rotate sessions: delete other sessions: %w", err) + } + if s.rotateFault != nil { + if err := s.rotateFault(); err != nil { + return "", fmt.Errorf("rotate sessions: %w", err) + } } if err := tx.Commit(); err != nil { - return "", fmt.Errorf("insert session: commit: %w", err) + return "", fmt.Errorf("rotate sessions: commit: %w", err) } - - return plaintext, nil + return token, nil } // ValidateSession hashes the provided token, looks it up, checks expiry, @@ -164,11 +236,11 @@ func (s *Store) ValidateSession(token string) (*SessionInfo, error) { hash := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(hash[:]) - var userID, expiresAt, ipAddress, uaHash, createdAt, kind string + var sessionID, userID, expiresAt, ipAddress, uaHash, createdAt, kind string var renewSeconds int64 err := s.db.QueryRow(s.q(` - SELECT user_id, expires_at, ip_address, ua_hash, created_at, kind, renew_ttl_seconds FROM sessions WHERE token_hash = ? - `), tokenHash).Scan(&userID, &expiresAt, &ipAddress, &uaHash, &createdAt, &kind, &renewSeconds) + SELECT id, user_id, expires_at, ip_address, ua_hash, created_at, kind, renew_ttl_seconds FROM sessions WHERE token_hash = ? + `), tokenHash).Scan(&sessionID, &userID, &expiresAt, &ipAddress, &uaHash, &createdAt, &kind, &renewSeconds) if err == sql.ErrNoRows { return nil, nil } @@ -192,6 +264,7 @@ func (s *Store) ValidateSession(token string) (*SessionInfo, error) { } return &SessionInfo{ + ID: sessionID, User: user, IPAddress: ipAddress, UAHash: uaHash, diff --git a/internal/store/store.go b/internal/store/store.go index b4b532260..9f2735499 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -170,6 +170,11 @@ type Store struct { // revoke or a second refresh can land (BUG-3678). beforeRotateRefresh func(requestID string) + // rotateFault is a TEST-ONLY seam, nil in production. When set, + // RotateUserSessions calls it after its writes and before its COMMIT; a + // non-nil return fails the transaction (TASK-3938). + rotateFault func() error + // afterItemRestoreOrMoveCommit is a TEST-ONLY seam, nil in production. // restoreItemOnce and MoveItemWithPreCheck call it right after their // COMMIT and before they return. It exists for BUG-2717: both used to diff --git a/internal/store/testing_helpers.go b/internal/store/testing_helpers.go index e3694db2d..3e5b02c80 100644 --- a/internal/store/testing_helpers.go +++ b/internal/store/testing_helpers.go @@ -335,3 +335,16 @@ func (s *Store) SetBeforeVersionsReadHookForTesting(hook func(documentID string) s.beforeVersionsRead = hook return func() { s.beforeVersionsRead = prev } } + +// SetRotateFaultHookForTesting makes RotateUserSessions fail its transaction +// with hook's error, after the writes and before the commit, for the lifetime +// of the returned restore function. It exists to prove the fail-closed +// fallback: a failed rotation must revoke every session (TASK-3938). +// +// Production code MUST NOT call this. The "ForTesting" suffix is the grep +// signal. Set it only while no other request is in flight against this Store. +func (s *Store) SetRotateFaultHookForTesting(hook func() error) (restore func()) { + prev := s.rotateFault + s.rotateFault = hook + return func() { s.rotateFault = prev } +}