From aa33fa9856bfb996c0a0951fe1ebc17a31d9c3d2 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 3 Jan 2026 13:45:14 -0800 Subject: [PATCH 1/2] Fix GN-428 remove configurable TAuth issuer --- .env.gravity.example | 1 - ARCHITECTURE.md | 7 ++- README.md | 2 +- backend/cmd/gravity-api/main.go | 3 -- backend/internal/auth/session_validator.go | 10 ++-- .../internal/auth/session_validator_test.go | 54 ++++++++++--------- backend/internal/config/config.go | 41 ++++++-------- .../server/realtime_integration_test.go | 32 ++++++----- .../tests/integration/auth_and_sync_test.go | 43 +++++++++------ frontend/tests/helpers/backendHarness.js | 21 +++----- 10 files changed, 105 insertions(+), 109 deletions(-) diff --git a/.env.gravity.example b/.env.gravity.example index 86e74e5..c76b803 100644 --- a/.env.gravity.example +++ b/.env.gravity.example @@ -2,6 +2,5 @@ GRAVITY_HTTP_ADDRESS=0.0.0.0:8080 GRAVITY_DATABASE_PATH=/data/gravity.db GRAVITY_LOG_LEVEL=info GRAVITY_TAUTH_SIGNING_SECRET=qqq -GRAVITY_TAUTH_ISSUER=mprlab-auth GRAVITY_TAUTH_COOKIE_NAME=app_session GRAVITY_GOOGLE_CLIENT_ID=qqq.apps.googleusercontent.com diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 438c7af..bc20a47 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,7 +92,7 @@ EasyMDE produces markdown, marked renders it to HTML, and DOMPurify sanitises th ### Backend (Go) - HTTP API (Gin): `/notes` (snapshot), `/notes/sync` (ops queue), `/notes/stream` (SSE). -- Auth: accept the `app_session` cookie minted by TAuth (or a fallback `Authorization: Bearer ` header) and validate HS256 signatures using the shared TAuth signing secret + issuer. No Gravity-managed `/auth/google` endpoint remains. +- Auth: accept the `app_session` cookie minted by TAuth (or a fallback `Authorization: Bearer ` header) and validate HS256 signatures using the shared TAuth signing secret and the fixed `tauth` issuer. No Gravity-managed `/auth/google` endpoint remains. - Data: GORM + SQLite (CGO-free driver) with `notes` and append-only `note_changes` tables for idempotency and audit. - Conflict strategy: `(client_edit_seq, updated_at)` precedence; server `version` remains monotonic per note. - Layout: Cobra CLI under `cmd/`, domain packages in `internal/`, zap for logging, configuration via Viper. @@ -104,8 +104,7 @@ EasyMDE produces markdown, marked renders it to HTML, and DOMPurify sanitises th #### Configuration -- `GRAVITY_TAUTH_SIGNING_SECRET` — HS256 secret shared with TAuth; used to validate session cookies (required). -- `GRAVITY_TAUTH_ISSUER` — Optional override for the expected issuer embedded in the TAuth JWT (defaults to `mprlab-auth`). +- `GRAVITY_TAUTH_SIGNING_SECRET` — HS256 secret shared with TAuth; used to validate session cookies (required). The issuer is fixed to `tauth` and not configurable. - `GRAVITY_TAUTH_COOKIE_NAME` — Optional override for the cookie carrying the session JWT (defaults to `app_session`). - Optional overrides: `GRAVITY_HTTP_ADDRESS` (default `0.0.0.0:8080`), `GRAVITY_DATABASE_PATH` (default `gravity.db`), `GRAVITY_LOG_LEVEL` (default `info`). @@ -158,7 +157,7 @@ When serving from an alternate hostname, add a new profile or override the URLs #### Authentication Contract - **Browser responsibilities:** Gravity’s frontend loads `authBaseUrl/tauth.js`, asks `/auth/nonce` for a nonce, exchanges Google credentials at `/auth/google`, and retries requests via `/auth/refresh` when the backend returns `401`. All network calls simply include the `app_session` cookie; no Google tokens touch the Gravity API. -- **Backend responsibilities:** the API validates `app_session` with the shared HS256 secret/issuer, stores no refresh tokens, and trusts the canonical `user_id` resolved by the `user_identities` table. A one-time migration strips the legacy `google:` prefix from existing note rows and backfills the identity mapping automatically. +- **Backend responsibilities:** the API validates `app_session` with the shared HS256 signing secret and fixed `tauth` issuer, stores no refresh tokens, and trusts the canonical `user_id` resolved by the `user_identities` table. A one-time migration strips the legacy `google:` prefix from existing note rows and backfills the identity mapping automatically. - **Logout propagation:** triggering **Sign out** in the UI invokes `/auth/logout`, revokes refresh tokens inside TAuth, and dispatches `gravity:auth-sign-out` so the browser returns to the anonymous notebook. - **Future providers:** because every `(provider, subject)` pair maps to the same Gravity user, we can add Apple/email sign-in later without rewriting stored notes. diff --git a/README.md b/README.md index 67c4a2e..b0e33e7 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Runtime configuration files under `frontend/data/` now include `authBaseUrl`, so ### Authentication Contract - Gravity no longer exchanges Google credentials itself. The browser loads `https:///tauth.js`, fetches a nonce from `/auth/nonce`, and lets TAuth exchange the Google credential at `/auth/google`. -- TAuth mints two cookies: `app_session` (short-lived HS256 JWT) and `app_refresh` (long-lived refresh token). Every request from the UI includes `app_session` automatically, so the Gravity backend simply validates the JWT using `GRAVITY_TAUTH_SIGNING_SECRET` / `GRAVITY_TAUTH_ISSUER`. No bearer tokens or local storage is used. +- TAuth mints two cookies: `app_session` (short-lived HS256 JWT) and `app_refresh` (long-lived refresh token). Every request from the UI includes `app_session` automatically, so the Gravity backend validates the JWT using `GRAVITY_TAUTH_SIGNING_SECRET` and the fixed `tauth` issuer. No bearer tokens or local storage is used. - To keep the multi-tenant TAuth flow working, the backend’s CORS preflight now whitelists the `X-TAuth-Tenant` header (in addition to `Authorization`, `Content-Type`, etc.), so browsers can send the tenant hint while relying on cookie authentication. - When a request returns `401`, the browser calls `/auth/refresh` on the TAuth origin; a fresh `app_session` cookie is minted and the original request is retried. - Signing out in the UI calls `/auth/logout`, revokes the refresh token, clears both cookies, and returns Gravity to the anonymous notebook. diff --git a/backend/cmd/gravity-api/main.go b/backend/cmd/gravity-api/main.go index 34be6bd..bd45b1f 100644 --- a/backend/cmd/gravity-api/main.go +++ b/backend/cmd/gravity-api/main.go @@ -52,14 +52,12 @@ func setupFlags(cmd *cobra.Command) { cmd.PersistentFlags().String("database-path", defaults.GetString("database.path"), "SQLite database path") cmd.PersistentFlags().String("log-level", defaults.GetString("log.level"), "Log level (debug, info, warn, error)") cmd.PersistentFlags().String("tauth-signing-secret", defaults.GetString("tauth.signing_secret"), "Shared HS256 signing secret from TAuth") - cmd.PersistentFlags().String("tauth-issuer", defaults.GetString("tauth.issuer"), "Expected issuer for TAuth session tokens") cmd.PersistentFlags().String("tauth-cookie-name", defaults.GetString("tauth.cookie_name"), "Cookie name carrying the TAuth session token") bindFlag(cmd, "http.address", "http-address") bindFlag(cmd, "database.path", "database-path") bindFlag(cmd, "log.level", "log-level") bindFlag(cmd, "tauth.signing_secret", "tauth-signing-secret") - bindFlag(cmd, "tauth.issuer", "tauth-issuer") bindFlag(cmd, "tauth.cookie_name", "tauth-cookie-name") } @@ -108,7 +106,6 @@ func runServer(ctx context.Context) error { sessionValidator, err := auth.NewSessionValidator(auth.SessionValidatorConfig{ SigningSecret: []byte(appConfig.TAuthSigningKey), - Issuer: appConfig.TAuthIssuer, CookieName: appConfig.TAuthCookieName, }) if err != nil { diff --git a/backend/internal/auth/session_validator.go b/backend/internal/auth/session_validator.go index 11ea823..df019bf 100644 --- a/backend/internal/auth/session_validator.go +++ b/backend/internal/auth/session_validator.go @@ -12,7 +12,6 @@ import ( var ( ErrMissingSessionSigningKey = errors.New("session validator: signing key required") - ErrMissingSessionIssuer = errors.New("session validator: issuer required") ErrMissingSessionCookieName = errors.New("session validator: cookie name required") ErrMissingSessionToken = errors.New("session validator: token required") ErrInvalidSessionToken = errors.New("session validator: invalid token") @@ -20,6 +19,8 @@ var ( ErrMissingSessionSubject = errors.New("session validator: subject required") ) +const defaultSessionIssuer = "tauth" + // SessionClaims mirror the payload emitted by TAuth. type SessionClaims struct { UserID string `json:"user_id"` @@ -33,7 +34,6 @@ type SessionClaims struct { // SessionValidatorConfig describes how to validate HS256 session cookies. type SessionValidatorConfig struct { SigningSecret []byte - Issuer string CookieName string Clock func() time.Time } @@ -51,10 +51,6 @@ func NewSessionValidator(cfg SessionValidatorConfig) (*SessionValidator, error) if len(cfg.SigningSecret) == 0 { return nil, ErrMissingSessionSigningKey } - issuer := strings.TrimSpace(cfg.Issuer) - if issuer == "" { - return nil, ErrMissingSessionIssuer - } cookieName := strings.TrimSpace(cfg.CookieName) if cookieName == "" { return nil, ErrMissingSessionCookieName @@ -65,7 +61,7 @@ func NewSessionValidator(cfg SessionValidatorConfig) (*SessionValidator, error) } return &SessionValidator{ signingSecret: append([]byte(nil), cfg.SigningSecret...), - issuer: issuer, + issuer: defaultSessionIssuer, cookieName: cookieName, clock: clock, }, nil diff --git a/backend/internal/auth/session_validator_test.go b/backend/internal/auth/session_validator_test.go index f46a499..25783aa 100644 --- a/backend/internal/auth/session_validator_test.go +++ b/backend/internal/auth/session_validator_test.go @@ -9,12 +9,18 @@ import ( "github.com/golang-jwt/jwt/v5" ) +const ( + testSessionSigningSecret = "secret" + testSessionCookieName = "app_session" + testSessionUserID = "user-123" + testSessionUserEmail = "user@example.com" +) + func TestSessionValidatorValidateToken(t *testing.T) { clockNow := time.Date(2024, 9, 1, 12, 0, 0, 0, time.UTC) validator, err := NewSessionValidator(SessionValidatorConfig{ - SigningSecret: []byte("secret"), - Issuer: "mprlab-auth", - CookieName: "app_session", + SigningSecret: []byte(testSessionSigningSecret), + CookieName: testSessionCookieName, Clock: func() time.Time { return clockNow }, @@ -24,17 +30,17 @@ func TestSessionValidatorValidateToken(t *testing.T) { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{ - UserID: "user-123", - UserEmail: "user@example.com", + UserID: testSessionUserID, + UserEmail: testSessionUserEmail, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "mprlab-auth", - Subject: "user-123", + Issuer: defaultSessionIssuer, + Subject: testSessionUserID, IssuedAt: jwt.NewNumericDate(clockNow.Add(-time.Minute)), NotBefore: jwt.NewNumericDate(clockNow.Add(-time.Minute)), ExpiresAt: jwt.NewNumericDate(clockNow.Add(time.Hour)), }, }) - signed, err := token.SignedString([]byte("secret")) + signed, err := token.SignedString([]byte(testSessionSigningSecret)) if err != nil { t.Fatalf("failed to sign token: %v", err) } @@ -43,7 +49,7 @@ func TestSessionValidatorValidateToken(t *testing.T) { if err != nil { t.Fatalf("unexpected validation failure: %v", err) } - if claims.UserID != "user-123" { + if claims.UserID != testSessionUserID { t.Fatalf("unexpected user id: %s", claims.UserID) } } @@ -51,9 +57,8 @@ func TestSessionValidatorValidateToken(t *testing.T) { func TestSessionValidatorValidateTokenExpired(t *testing.T) { clockNow := time.Date(2024, 9, 1, 12, 0, 0, 0, time.UTC) validator, err := NewSessionValidator(SessionValidatorConfig{ - SigningSecret: []byte("secret"), - Issuer: "mprlab-auth", - CookieName: "app_session", + SigningSecret: []byte(testSessionSigningSecret), + CookieName: testSessionCookieName, Clock: func() time.Time { return clockNow }, @@ -63,15 +68,15 @@ func TestSessionValidatorValidateTokenExpired(t *testing.T) { } token := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{ - UserID: "user-123", + UserID: testSessionUserID, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "mprlab-auth", - Subject: "user-123", + Issuer: defaultSessionIssuer, + Subject: testSessionUserID, IssuedAt: jwt.NewNumericDate(clockNow.Add(-2 * time.Hour)), ExpiresAt: jwt.NewNumericDate(clockNow.Add(-time.Hour)), }, }) - signed, err := token.SignedString([]byte("secret")) + signed, err := token.SignedString([]byte(testSessionSigningSecret)) if err != nil { t.Fatalf("failed to sign token: %v", err) } @@ -83,32 +88,31 @@ func TestSessionValidatorValidateTokenExpired(t *testing.T) { func TestSessionValidatorValidateRequestUsesCookie(t *testing.T) { validator, err := NewSessionValidator(SessionValidatorConfig{ - SigningSecret: []byte("secret"), - Issuer: "mprlab-auth", - CookieName: "app_session", + SigningSecret: []byte(testSessionSigningSecret), + CookieName: testSessionCookieName, }) if err != nil { t.Fatalf("failed to construct validator: %v", err) } token := jwt.NewWithClaims(jwt.SigningMethodHS256, SessionClaims{ - UserID: "user-123", + UserID: testSessionUserID, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "mprlab-auth", - Subject: "user-123", + Issuer: defaultSessionIssuer, + Subject: testSessionUserID, IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)), NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, }) - signed, err := token.SignedString([]byte("secret")) + signed, err := token.SignedString([]byte(testSessionSigningSecret)) if err != nil { t.Fatalf("failed to sign token: %v", err) } request := httptest.NewRequest(http.MethodGet, "/notes", http.NoBody) request.AddCookie(&http.Cookie{ - Name: "app_session", + Name: testSessionCookieName, Value: signed, }) @@ -116,7 +120,7 @@ func TestSessionValidatorValidateRequestUsesCookie(t *testing.T) { if err != nil { t.Fatalf("validation failed: %v", err) } - if claims.UserID != "user-123" { + if claims.UserID != testSessionUserID { t.Fatalf("unexpected user id: %s", claims.UserID) } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0ea53b1..6f6b1f9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -13,14 +13,12 @@ const ( defaultDatabasePath = "gravity.db" defaultLogLevel = "info" defaultCookieName = "app_session" - defaultIssuer = "mprlab-auth" ) // AppConfig captures runtime configuration for the API server. type AppConfig struct { HTTPAddress string TAuthSigningKey string - TAuthIssuer string TAuthCookieName string DatabasePath string LogLevel string @@ -28,33 +26,31 @@ type AppConfig struct { // NewViper returns a viper instance with defaults and env bindings configured. func NewViper() *viper.Viper { - v := viper.New() - ApplyDefaults(v) - return v + configViper := viper.New() + ApplyDefaults(configViper) + return configViper } // ApplyDefaults configures defaults and env bindings on the provided viper instance. -func ApplyDefaults(v *viper.Viper) { - v.SetEnvPrefix(envPrefix) - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() +func ApplyDefaults(configViper *viper.Viper) { + configViper.SetEnvPrefix(envPrefix) + configViper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + configViper.AutomaticEnv() - v.SetDefault("http.address", defaultHTTPAddress) - v.SetDefault("database.path", defaultDatabasePath) - v.SetDefault("log.level", defaultLogLevel) - v.SetDefault("tauth.cookie_name", defaultCookieName) - v.SetDefault("tauth.issuer", defaultIssuer) + configViper.SetDefault("http.address", defaultHTTPAddress) + configViper.SetDefault("database.path", defaultDatabasePath) + configViper.SetDefault("log.level", defaultLogLevel) + configViper.SetDefault("tauth.cookie_name", defaultCookieName) } // Load parses runtime configuration from viper. -func Load(v *viper.Viper) (AppConfig, error) { +func Load(configViper *viper.Viper) (AppConfig, error) { cfg := AppConfig{ - HTTPAddress: v.GetString("http.address"), - TAuthSigningKey: v.GetString("tauth.signing_secret"), - TAuthIssuer: v.GetString("tauth.issuer"), - TAuthCookieName: v.GetString("tauth.cookie_name"), - DatabasePath: v.GetString("database.path"), - LogLevel: v.GetString("log.level"), + HTTPAddress: configViper.GetString("http.address"), + TAuthSigningKey: configViper.GetString("tauth.signing_secret"), + TAuthCookieName: configViper.GetString("tauth.cookie_name"), + DatabasePath: configViper.GetString("database.path"), + LogLevel: configViper.GetString("log.level"), } if err := cfg.validate(); err != nil { @@ -71,9 +67,6 @@ func (c AppConfig) validate() error { if strings.TrimSpace(c.DatabasePath) == "" { return fmt.Errorf("database.path is required") } - if strings.TrimSpace(c.TAuthIssuer) == "" { - return fmt.Errorf("tauth.issuer is required") - } if strings.TrimSpace(c.TAuthCookieName) == "" { return fmt.Errorf("tauth.cookie_name is required") } diff --git a/backend/internal/server/realtime_integration_test.go b/backend/internal/server/realtime_integration_test.go index 42fe08c..53442bc 100644 --- a/backend/internal/server/realtime_integration_test.go +++ b/backend/internal/server/realtime_integration_test.go @@ -18,6 +18,15 @@ import ( "gorm.io/gorm" ) +const ( + sessionSigningSecret = "test-signing-secret" + sessionCookieName = "app_session" + sessionIssuer = "tauth" + sessionUserID = "user-123" + sessionNoteID = "note-1" + jsonContentType = "application/json" +) + func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { db, err := gorm.Open(githubsqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { @@ -36,9 +45,8 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { t.Fatalf("failed to construct notes service: %v", err) } sessionValidator, err := auth.NewSessionValidator(auth.SessionValidatorConfig{ - SigningSecret: []byte("test-signing-secret"), - Issuer: "mprlab-auth", - CookieName: "app_session", + SigningSecret: []byte(sessionSigningSecret), + CookieName: sessionCookieName, }) if err != nil { t.Fatalf("failed to construct session validator: %v", err) @@ -47,7 +55,7 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { dispatcher := NewRealtimeDispatcher() handler, err := NewHTTPHandler(Dependencies{ SessionValidator: sessionValidator, - SessionCookie: "app_session", + SessionCookie: sessionCookieName, NotesService: noteService, Logger: zap.NewExample(), Realtime: dispatcher, @@ -59,7 +67,7 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { server := httptest.NewServer(handler) t.Cleanup(server.Close) - sessionToken := mustMintSessionToken(t, "test-signing-secret", "mprlab-auth", "user-123", time.Now()) + sessionToken := mustMintSessionToken(t, sessionSigningSecret, sessionUserID, time.Now()) streamRequest, err := http.NewRequest(http.MethodGet, server.URL+"/notes/stream?access_token="+sessionToken, http.NoBody) if err != nil { @@ -78,13 +86,13 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { streamReader := bufio.NewReader(streamResp.Body) - payload := `{"operations":[{"note_id":"note-1","operation":"upsert","client_edit_seq":1,"client_time_s":1700000000,"created_at_s":1700000000,"updated_at_s":1700000000,"payload":{"noteId":"note-1","markdownText":"hello world","createdAtIso":"2023-01-01T00:00:00Z","updatedAtIso":"2023-01-01T00:00:00Z","lastActivityIso":"2023-01-01T00:00:00Z"}}]}` + payload := `{"operations":[{"note_id":"` + sessionNoteID + `","operation":"upsert","client_edit_seq":1,"client_time_s":1700000000,"created_at_s":1700000000,"updated_at_s":1700000000,"payload":{"noteId":"` + sessionNoteID + `","markdownText":"hello world","createdAtIso":"2023-01-01T00:00:00Z","updatedAtIso":"2023-01-01T00:00:00Z","lastActivityIso":"2023-01-01T00:00:00Z"}}]}` syncReq, err := http.NewRequest(http.MethodPost, server.URL+"/notes/sync", bytes.NewBufferString(payload)) if err != nil { t.Fatalf("failed to construct sync request: %v", err) } - syncReq.AddCookie(&http.Cookie{Name: "app_session", Value: sessionToken}) - syncReq.Header.Set("Content-Type", "application/json") + syncReq.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken}) + syncReq.Header.Set("Content-Type", jsonContentType) syncResp, err := http.DefaultClient.Do(syncReq) if err != nil { t.Fatalf("sync request failed: %v", err) @@ -102,7 +110,7 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { t.Fatalf("failed to decode sync response: %v", err) } _ = syncResp.Body.Close() - if len(syncPayload.Results) != 1 || !syncPayload.Results[0].Accepted || syncPayload.Results[0].NoteID != "note-1" { + if len(syncPayload.Results) != 1 || !syncPayload.Results[0].Accepted || syncPayload.Results[0].NoteID != sessionNoteID { t.Fatalf("unexpected sync results: %#v", syncPayload) } @@ -148,7 +156,7 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { if err := json.Unmarshal([]byte(dataJSON), &payload); err != nil { t.Fatalf("failed to decode event payload: %v", err) } - if len(payload.NoteIDs) == 0 || payload.NoteIDs[0] != "note-1" { + if len(payload.NoteIDs) == 0 || payload.NoteIDs[0] != sessionNoteID { t.Fatalf("unexpected note identifiers: %#v", payload.NoteIDs) } return @@ -156,12 +164,12 @@ func TestRealtimeStreamEmitsNoteChangeEvents(t *testing.T) { } } -func mustMintSessionToken(t *testing.T, signingSecret, issuer, userID string, now time.Time) string { +func mustMintSessionToken(t *testing.T, signingSecret, userID string, now time.Time) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: issuer, + Issuer: sessionIssuer, Subject: userID, IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)), diff --git a/backend/tests/integration/auth_and_sync_test.go b/backend/tests/integration/auth_and_sync_test.go index 9602d18..a931e93 100644 --- a/backend/tests/integration/auth_and_sync_test.go +++ b/backend/tests/integration/auth_and_sync_test.go @@ -18,6 +18,16 @@ import ( "gorm.io/gorm" ) +const ( + sessionSigningSecret = "integration-secret" + sessionCookieName = "app_session" + sessionIssuer = "tauth" + sessionUserID = "user-abc" + sessionNoteID = "note-1" + sessionClientDevice = "web" + jsonContentType = "application/json" +) + func TestAuthAndSyncFlow(t *testing.T) { gin.SetMode(gin.TestMode) @@ -39,9 +49,8 @@ func TestAuthAndSyncFlow(t *testing.T) { t.Fatalf("failed to build notes service: %v", err) } sessionValidator, err := auth.NewSessionValidator(auth.SessionValidatorConfig{ - SigningSecret: []byte("integration-secret"), - Issuer: "mprlab-auth", - CookieName: "app_session", + SigningSecret: []byte(sessionSigningSecret), + CookieName: sessionCookieName, }) if err != nil { t.Fatalf("failed to construct session validator: %v", err) @@ -49,7 +58,7 @@ func TestAuthAndSyncFlow(t *testing.T) { handler, err := server.NewHTTPHandler(server.Dependencies{ SessionValidator: sessionValidator, - SessionCookie: "app_session", + SessionCookie: sessionCookieName, NotesService: notesService, Logger: zap.NewNop(), }) @@ -60,24 +69,24 @@ func TestAuthAndSyncFlow(t *testing.T) { testServer := httptest.NewServer(handler) defer testServer.Close() - sessionToken := mustMintSessionToken(t, "integration-secret", "mprlab-auth", "user-abc", time.Now()) + sessionToken := mustMintSessionToken(t, sessionSigningSecret, sessionUserID, time.Now()) sessionCookie := &http.Cookie{ - Name: "app_session", + Name: sessionCookieName, Value: sessionToken, } syncRequest := map[string]any{ "operations": []any{ map[string]any{ - "note_id": "note-1", + "note_id": sessionNoteID, "operation": "upsert", "client_edit_seq": 1, - "client_device": "web", + "client_device": sessionClientDevice, "client_time_s": 1700000000, "created_at_s": 1700000000, "updated_at_s": 1700000000, "payload": map[string]any{ - "noteId": "note-1", + "noteId": sessionNoteID, "markdownText": "hello", }, }, @@ -86,7 +95,7 @@ func TestAuthAndSyncFlow(t *testing.T) { syncBody, _ := json.Marshal(syncRequest) syncReq, _ := http.NewRequest(http.MethodPost, testServer.URL+"/notes/sync", bytes.NewReader(syncBody)) syncReq.AddCookie(sessionCookie) - syncReq.Header.Set("Content-Type", "application/json") + syncReq.Header.Set("Content-Type", jsonContentType) syncResp, err := http.DefaultClient.Do(syncReq) if err != nil { @@ -134,7 +143,7 @@ func TestAuthAndSyncFlow(t *testing.T) { if len(snapshotPayload.Notes) != 1 { t.Fatalf("expected single note in snapshot, got %d", len(snapshotPayload.Notes)) } - if snapshotPayload.Notes[0].NoteID != "note-1" { + if snapshotPayload.Notes[0].NoteID != sessionNoteID { t.Fatalf("unexpected note id in snapshot: %s", snapshotPayload.Notes[0].NoteID) } if snapshotPayload.Notes[0].IsDeleted { @@ -147,14 +156,14 @@ func TestAuthAndSyncFlow(t *testing.T) { staleRequest := map[string]any{ "operations": []any{ map[string]any{ - "note_id": "note-1", + "note_id": sessionNoteID, "operation": "upsert", "client_edit_seq": 0, - "client_device": "web", + "client_device": sessionClientDevice, "client_time_s": 1700000001, "updated_at_s": 1700000001, "payload": map[string]any{ - "noteId": "note-1", + "noteId": sessionNoteID, "markdownText": "stale", }, }, @@ -163,7 +172,7 @@ func TestAuthAndSyncFlow(t *testing.T) { staleBody, _ := json.Marshal(staleRequest) staleReq, _ := http.NewRequest(http.MethodPost, testServer.URL+"/notes/sync", bytes.NewReader(staleBody)) staleReq.AddCookie(sessionCookie) - staleReq.Header.Set("Content-Type", "application/json") + staleReq.Header.Set("Content-Type", jsonContentType) staleResp, err := http.DefaultClient.Do(staleReq) if err != nil { @@ -188,12 +197,12 @@ func TestAuthAndSyncFlow(t *testing.T) { } } -func mustMintSessionToken(t *testing.T, signingSecret, issuer, userID string, now time.Time) string { +func mustMintSessionToken(t *testing.T, signingSecret, userID string, now time.Time) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ - Issuer: issuer, + Issuer: sessionIssuer, Subject: userID, IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), NotBefore: jwt.NewNumericDate(now.Add(-time.Minute)), diff --git a/frontend/tests/helpers/backendHarness.js b/frontend/tests/helpers/backendHarness.js index 4ceedac..171f430 100644 --- a/frontend/tests/helpers/backendHarness.js +++ b/frontend/tests/helpers/backendHarness.js @@ -1,3 +1,5 @@ +// @ts-check + import { spawn } from "node:child_process"; import { createHmac } from "node:crypto"; import { once } from "node:events"; @@ -12,7 +14,6 @@ import { readRuntimeContext } from "./runtimeContext.js"; const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", ".."); const BACKEND_DIR = path.join(REPO_ROOT, "backend"); const DEFAULT_SESSION_SIGNING_SECRET = "gravity-test-session-secret"; -const DEFAULT_SESSION_ISSUER = "mprlab-auth"; const DEFAULT_SESSION_COOKIE = "app_session"; const DEFAULT_LOG_LEVEL = "info"; const HEADER_COOKIE = "Cookie"; @@ -25,7 +26,6 @@ let backendBinaryPromise = null; * Start a Gravity backend instance for integration tests. * @param {{ * signingSecret?: string, - * issuer?: string, * cookieName?: string, * logLevel?: string * }} [options] @@ -46,7 +46,6 @@ let sharedBackendRefs = 0; export async function startTestBackend(options = {}) { const normalizedOptions = { signingSecret: options.signingSecret ?? DEFAULT_SESSION_SIGNING_SECRET, - issuer: options.issuer ?? DEFAULT_SESSION_ISSUER, cookieName: options.cookieName ?? DEFAULT_SESSION_COOKIE, logLevel: options.logLevel ?? DEFAULT_LOG_LEVEL }; @@ -75,7 +74,6 @@ export async function startTestBackend(options = {}) { address: backendAddress, databasePath, signingSecret: normalizedOptions.signingSecret, - issuer: normalizedOptions.issuer, cookieName: normalizedOptions.cookieName, logLevel: normalizedOptions.logLevel }); @@ -88,7 +86,6 @@ export async function startTestBackend(options = {}) { baseUrl: `http://${backendAddress}`, googleClientId: "gravity-test-client", signingSecret: normalizedOptions.signingSecret, - issuer: normalizedOptions.issuer, cookieName: normalizedOptions.cookieName, signingKeyPem: deriveDummyPem(normalizedOptions.signingSecret), signingKeyId: "tauth-session-secret", @@ -102,7 +99,6 @@ export async function startTestBackend(options = {}) { createSessionToken(userId, expiresInSeconds = 5 * 60) { return mintSessionToken({ userId, - issuer: normalizedOptions.issuer, signingSecret: normalizedOptions.signingSecret, expiresInSeconds }); @@ -148,7 +144,6 @@ function attemptRuntimeContextBackend(normalizedOptions) { const { baseUrl, signingSecret, - issuer, cookieName, signingKeyPem, signingKeyId, @@ -157,19 +152,17 @@ function attemptRuntimeContextBackend(normalizedOptions) { if ( typeof baseUrl !== "string" || typeof signingSecret !== "string" - || typeof issuer !== "string" || typeof cookieName !== "string" ) { return null; } - if (normalizedOptions.signingSecret !== signingSecret || normalizedOptions.issuer !== issuer || normalizedOptions.cookieName !== cookieName) { + if (normalizedOptions.signingSecret !== signingSecret || normalizedOptions.cookieName !== cookieName) { return null; } return { baseUrl, signingSecret, - issuer, cookieName, googleClientId: typeof googleClientId === "string" ? googleClientId : "gravity-test-client", signingKeyPem: typeof signingKeyPem === "string" ? signingKeyPem : deriveDummyPem(signingSecret), @@ -184,7 +177,6 @@ function attemptRuntimeContextBackend(normalizedOptions) { createSessionToken(userId, expiresInSeconds = 5 * 60) { return mintSessionToken({ userId, - issuer, signingSecret, expiresInSeconds }); @@ -301,14 +293,13 @@ export function fetchBackendNotes({ backendUrl, sessionToken, cookieName, timeou }); } -async function startBackendProcess({ binaryPath, address, databasePath, signingSecret, issuer, cookieName, logLevel }) { +async function startBackendProcess({ binaryPath, address, databasePath, signingSecret, cookieName, logLevel }) { const [host, port] = address.split(":"); const env = { ...process.env, GRAVITY_HTTP_ADDRESS: `0.0.0.0:${port}`, GRAVITY_DATABASE_PATH: databasePath, GRAVITY_TAUTH_SIGNING_SECRET: signingSecret, - GRAVITY_TAUTH_ISSUER: issuer, GRAVITY_TAUTH_COOKIE_NAME: cookieName, GRAVITY_LOG_LEVEL: logLevel }; @@ -439,7 +430,7 @@ function runCommand(command, args, options) { }); } -function mintSessionToken({ userId, issuer, signingSecret, expiresInSeconds }) { +function mintSessionToken({ userId, signingSecret, expiresInSeconds }) { const header = { alg: "HS256", typ: "JWT" @@ -451,7 +442,7 @@ function mintSessionToken({ userId, issuer, signingSecret, expiresInSeconds }) { user_display_name: "Gravity Test User", user_avatar_url: "https://example.com/avatar.png", user_roles: ["user"], - iss: issuer, + iss: "tauth", sub: userId, iat: issuedAtSeconds, nbf: issuedAtSeconds - 30, From 24f5ba873e1089fe93519dc155664250e69d4597 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Sat, 3 Jan 2026 15:00:19 -0800 Subject: [PATCH 2/2] Fix GN-428 backend 401 sign-out --- CHANGELOG.md | 1 + ISSUES.md | 3 +- frontend/js/app.js | 294 ++++++++++-------- frontend/js/constants.js | 1 + frontend/js/core/backendClient.js | 68 +++- frontend/js/core/syncManager.js | 7 +- frontend/tests/backendClient.test.js | 38 +++ .../fullstack.endtoend.puppeteer.test.js | 4 +- .../tests/persistence.sync.puppeteer.test.js | 6 +- 9 files changed, 287 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6aa8bf..2bce4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and are grouped by the date the work landed on `master`. - Spellcheck replacement regression coverage now simulates replacement input events instead of `execCommand` to avoid flakes (GN-427). - Hardened sync payload validation to require noteId + markdownText, enforce note id matching, and rollback on audit/id failures (GN-427). - Sync deletes now treat JSON null payloads as empty so delete operations are not rejected during validation (GN-431). +- Backend 401 responses now trigger a frontend sign-out so invalid sessions cannot keep the UI authenticated; sync integration tests attach backend cookies before sign-in (GN-428). - Conflict-aware LWW sync now preserves local edits on rejected operations, tracks conflicts, and avoids overwriting local changes during snapshots (GN-429). - Html view interactions now reserve the chevron toggle for expansion while single clicks anywhere else enter inline edit mode (GN-109). - Inline editor now wraps selected text with matching backtick fences and escalates when the selection already contains backticks, covering GN-106 with new regression tests. diff --git a/ISSUES.md b/ISSUES.md index cc9764c..b41df3e 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -133,7 +133,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-427] Harden sync payload validation to require noteId/markdownText, enforce note id matching, and rollback on audit/id failures. (Resolved by validating ChangeEnvelope payloads, rejecting invalid sync operations, and adding rollback/normalization coverage.) - [x] [GN-431] Treat JSON null sync payloads as empty for delete operations so deletes are not rejected as invalid changes. (Resolved by normalizing delete payloads when JSON null is provided and adding handler coverage.) -- [ ] [GN-428] CRITICAL: Gravity keeps the UI authenticated even when its backend rejects the session token. +- [x] [GN-428] CRITICAL: Gravity keeps the UI authenticated even when its backend rejects the session token. When Gravity returns 401/invalid token (e.g., issuer/signing key mismatch), the frontend stays logged in because it only keys off TAuth `/me`. We need a client-side 401 handler that clears auth state (trigger tauth.js logout or force re-auth) whenever Gravity API calls fail token validation, so users are not shown authenticated UI with failing backend access. Repro (local multi-tenant demo): - Introduce a session validator mismatch (e.g., set GRAVITY_TAUTH_ISSUER to a non-tauth value or change GRAVITY_TAUTH_SIGNING_SECRET). @@ -148,6 +148,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - The frontend should not show authenticated UI when backend rejects the session. Backend alignment (do this in Gravity so issuer config is never required): - `tools/gravity/backend/internal/config/config.go`: remove `tauth.issuer` default + validation (stop requiring GRAVITY_TAUTH_ISSUER). + (Resolved by dispatching auth sign-out requests on backend 401 responses, wiring app sign-out handling, and ensuring backend persistence tests attach session cookies before sign-in.) - `tools/gravity/backend/cmd/gravity-api/main.go`: drop the `tauth-issuer` flag and viper binding. - `tools/gravity/backend/internal/auth/session_validator.go`: default issuer to `tauth` when empty (match TAuth), keep whitespace invalid. - `tools/gravity/.env.gravity.example`: remove `GRAVITY_TAUTH_ISSUER`. diff --git a/frontend/js/app.js b/frontend/js/app.js index 01054b1..8858c70 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -30,6 +30,7 @@ import { EVENT_NOTIFICATION_REQUEST, EVENT_AUTH_SIGN_IN, EVENT_AUTH_SIGN_OUT, + EVENT_AUTH_SIGN_OUT_REQUEST, EVENT_AUTH_ERROR, EVENT_AUTH_CREDENTIAL_RECEIVED, EVENT_SYNC_SNAPSHOT_APPLIED, @@ -458,16 +459,19 @@ function gravityApp(appConfig) { /** * Handle a local sign-out request from the UI. + * @param {string} [reason] * @returns {void} */ - handleAuthSignOutRequest() { + handleAuthSignOutRequest(reason = "manual") { this.avatarMenu?.close({ focusTrigger: false }); this.realtimeSync?.disconnect(); if (this.tauthSession) { void this.tauthSession.signOut(); } if (this.authController) { - this.authController.signOut("manual"); + this.authController.signOut(reason); + } else { + this.dispatchAuthSignOut(reason); } this.authControls?.showSignedOut(); this.avatarMenu?.setEnabled(false); @@ -561,149 +565,185 @@ function gravityApp(appConfig) { } }); - root.addEventListener(EVENT_NOTES_IMPORTED, (event) => { - const { records, shouldRender } = extractImportDetail(event); - if (shouldRender !== false) { - const nextRecords = GravityStore.loadAllNotes(); - initializeNotesState(nextRecords); - this.renderNotes(nextRecords); - } - for (const record of records) { - const persisted = GravityStore.getById(record.noteId) ?? record; - this.syncManager?.recordLocalUpsert(persisted); - } - const message = records.length > 0 - ? MESSAGE_NOTES_IMPORTED - : MESSAGE_NOTES_SKIPPED; - this.emitNotification(message); - }); + root.addEventListener(EVENT_NOTES_IMPORTED, (event) => { + const { records, shouldRender } = extractImportDetail(event); + if (shouldRender !== false) { + const nextRecords = GravityStore.loadAllNotes(); + initializeNotesState(nextRecords); + this.renderNotes(nextRecords); + } + for (const record of records) { + const persisted = GravityStore.getById(record.noteId) ?? record; + this.syncManager?.recordLocalUpsert(persisted); + } + const message = records.length > 0 + ? MESSAGE_NOTES_IMPORTED + : MESSAGE_NOTES_SKIPPED; + this.emitNotification(message); + }); - root.addEventListener(EVENT_AUTH_CREDENTIAL_RECEIVED, (event) => { - const detail = /** @type {{ credential?: string|null }} */ (event?.detail ?? {}); - const credential = typeof detail?.credential === "string" ? detail.credential : ""; - if (!credential) { - return; - } - void this.exchangeCredentialWithTAuth(credential); - }); + root.addEventListener(EVENT_AUTH_CREDENTIAL_RECEIVED, (event) => { + const detail = /** @type {{ credential?: string|null }} */ (event?.detail ?? {}); + const credential = typeof detail?.credential === "string" ? detail.credential : ""; + if (!credential) { + return; + } + void this.exchangeCredentialWithTAuth(credential); + }); - root.addEventListener(EVENT_AUTH_SIGN_IN, (event) => { - const detail = /** @type {{ user?: { id?: string, email?: string|null, name?: string|null, pictureUrl?: string|null } }} */ (event?.detail ?? {}); - const user = detail?.user; - if (!user || !user.id) { - return; - } - if (this.authUser?.id === user.id || this.pendingSignInUserId === user.id) { - return; - } - this.pendingSignInUserId = user.id; + root.addEventListener(EVENT_AUTH_SIGN_OUT_REQUEST, (event) => { + const detail = /** @type {{ reason?: string }} */ (event?.detail ?? {}); + const reason = typeof detail.reason === "string" && detail.reason.length > 0 + ? detail.reason + : "backend-unauthorized"; + this.handleAuthSignOutRequest(reason); + }); - const applyGuestState = () => { + root.addEventListener(EVENT_AUTH_SIGN_IN, (event) => { + const detail = /** @type {{ user?: { id?: string, email?: string|null, name?: string|null, pictureUrl?: string|null } }} */ (event?.detail ?? {}); + const user = detail?.user; + if (!user || !user.id) { + return; + } + if (this.authUser?.id === user.id || this.pendingSignInUserId === user.id) { + return; + } + this.pendingSignInUserId = user.id; + + const applyGuestState = () => { + this.authUser = null; + this.authControls?.showSignedOut(); + this.avatarMenu?.setEnabled(false); + this.avatarMenu?.close({ focusTrigger: false }); + GravityStore.setUserScope(null); + this.initializeNotes(); + this.setGuestExportVisibility(true); + this.authNonceToken = null; + this.realtimeSync?.disconnect(); + }; + + const applySignedInState = () => { + this.authUser = { + id: user.id, + email: typeof user.email === "string" ? user.email : null, + name: typeof user.name === "string" ? user.name : null, + pictureUrl: typeof user.pictureUrl === "string" ? user.pictureUrl : null + }; + this.authControls?.clearError(); + this.authControls?.showSignedIn(this.authUser); + this.avatarMenu?.setEnabled(true); + this.avatarMenu?.close({ focusTrigger: false }); + GravityStore.setUserScope(this.authUser.id); + this.initializeNotes(); + this.setGuestExportVisibility(false); + }; + + const attemptSignIn = async () => { + GravityStore.setUserScope(user.id); + try { + const result = this.syncManager && typeof this.syncManager.handleSignIn === "function" + ? await this.syncManager.handleSignIn({ + userId: user.id + }) + : { + authenticated: true, + queueFlushed: false, + snapshotApplied: false + }; + if (!result?.authenticated) { + applyGuestState(); + this.authControls?.showError(ERROR_AUTHENTICATION_GENERIC); + return; + } + applySignedInState(); + this.realtimeSync?.connect({ + baseUrl: appConfig.backendBaseUrl + }); + } catch (error) { + logging.error(error); + applyGuestState(); + this.authControls?.showError(ERROR_AUTHENTICATION_GENERIC); + } finally { + if (this.pendingSignInUserId === user.id) { + this.pendingSignInUserId = null; + } + } + }; + + void attemptSignIn(); + }); + + root.addEventListener(EVENT_AUTH_SIGN_OUT, () => { this.authUser = null; + this.authControls?.clearError(); this.authControls?.showSignedOut(); this.avatarMenu?.setEnabled(false); this.avatarMenu?.close({ focusTrigger: false }); GravityStore.setUserScope(null); this.initializeNotes(); + this.syncManager?.handleSignOut(); this.setGuestExportVisibility(true); - this.authNonceToken = null; this.realtimeSync?.disconnect(); - }; + if (typeof window !== "undefined" && this.syncIntervalHandle !== null) { + window.clearInterval(this.syncIntervalHandle); + this.syncIntervalHandle = null; + } + }); - const applySignedInState = () => { - this.authUser = { - id: user.id, - email: typeof user.email === "string" ? user.email : null, - name: typeof user.name === "string" ? user.name : null, - pictureUrl: typeof user.pictureUrl === "string" ? user.pictureUrl : null - }; - this.authControls?.clearError(); - this.authControls?.showSignedIn(this.authUser); - this.avatarMenu?.setEnabled(true); - this.avatarMenu?.close({ focusTrigger: false }); - GravityStore.setUserScope(this.authUser.id); - this.initializeNotes(); - this.setGuestExportVisibility(false); - }; + root.addEventListener(EVENT_AUTH_ERROR, (event) => { + const detail = /** @type {{ error?: unknown, reason?: unknown }} */ (event?.detail ?? {}); + const errorMessage = typeof detail.error === "string" + ? detail.error + : typeof detail.reason === "string" + ? String(detail.reason) + : ERROR_AUTHENTICATION_GENERIC; + this.authControls?.showError(errorMessage); + }); - const attemptSignIn = async () => { - GravityStore.setUserScope(user.id); - try { - const result = this.syncManager && typeof this.syncManager.handleSignIn === "function" - ? await this.syncManager.handleSignIn({ - userId: user.id - }) - : { - authenticated: true, - queueFlushed: false, - snapshotApplied: false - }; - if (!result?.authenticated) { - applyGuestState(); - this.authControls?.showError(ERROR_AUTHENTICATION_GENERIC); - return; - } - applySignedInState(); - this.realtimeSync?.connect({ - baseUrl: appConfig.backendBaseUrl - }); - } catch (error) { - logging.error(error); - applyGuestState(); - this.authControls?.showError(ERROR_AUTHENTICATION_GENERIC); - } finally { - if (this.pendingSignInUserId === user.id) { - this.pendingSignInUserId = null; - } + root.addEventListener(EVENT_NOTIFICATION_REQUEST, (event) => { + const detail = /** @type {{ message?: string, durationMs?: number }|undefined} */ (event?.detail); + if (!detail || typeof detail.message !== "string" || detail.message.length === 0) { + return; } - }; + const duration = typeof detail.durationMs === "number" && Number.isFinite(detail.durationMs) + ? detail.durationMs + : NOTIFICATION_DEFAULT_DURATION_MS; + this.emitNotification(detail.message, duration); + }); - void attemptSignIn(); - }); + root.addEventListener(EVENT_SYNC_SNAPSHOT_APPLIED, () => { + const refreshedRecords = GravityStore.loadAllNotes(); + initializeNotesState(refreshedRecords); + this.renderNotes(refreshedRecords); + }); + }, - root.addEventListener(EVENT_AUTH_SIGN_OUT, () => { - this.authUser = null; - this.authControls?.clearError(); - this.authControls?.showSignedOut(); - this.avatarMenu?.setEnabled(false); - this.avatarMenu?.close({ focusTrigger: false }); - GravityStore.setUserScope(null); - this.initializeNotes(); - this.syncManager?.handleSignOut(); - this.setGuestExportVisibility(true); - this.realtimeSync?.disconnect(); - if (typeof window !== "undefined" && this.syncIntervalHandle !== null) { - window.clearInterval(this.syncIntervalHandle); - this.syncIntervalHandle = null; - } - }); - - root.addEventListener(EVENT_AUTH_ERROR, (event) => { - const detail = /** @type {{ error?: unknown, reason?: unknown }} */ (event?.detail ?? {}); - const errorMessage = typeof detail.error === "string" - ? detail.error - : typeof detail.reason === "string" - ? String(detail.reason) - : ERROR_AUTHENTICATION_GENERIC; - this.authControls?.showError(errorMessage); - }); - - root.addEventListener(EVENT_NOTIFICATION_REQUEST, (event) => { - const detail = /** @type {{ message?: string, durationMs?: number }|undefined} */ (event?.detail); - if (!detail || typeof detail.message !== "string" || detail.message.length === 0) { + /** + * Emit a sign-out event when auth controllers cannot dispatch one. + * @param {string} reason + * @returns {void} + */ + dispatchAuthSignOut(reason) { + const target = this.$el ?? (typeof document !== "undefined" ? document.body : null); + if (!target || typeof target.dispatchEvent !== "function") { return; } - const duration = typeof detail.durationMs === "number" && Number.isFinite(detail.durationMs) - ? detail.durationMs - : NOTIFICATION_DEFAULT_DURATION_MS; - this.emitNotification(detail.message, duration); - }); - - root.addEventListener(EVENT_SYNC_SNAPSHOT_APPLIED, () => { - const refreshedRecords = GravityStore.loadAllNotes(); - initializeNotesState(refreshedRecords); - this.renderNotes(refreshedRecords); - }); + const detail = { reason }; + try { + if (typeof CustomEvent === "function") { + target.dispatchEvent(new CustomEvent(EVENT_AUTH_SIGN_OUT, { bubbles: true, detail })); + return; + } + } catch (error) { + logging.error(error); + } + try { + const fallbackEvent = new Event(EVENT_AUTH_SIGN_OUT); + /** @type {any} */ (fallbackEvent).detail = detail; + target.dispatchEvent(fallbackEvent); + } catch (error) { + logging.error(error); + } }, /** diff --git a/frontend/js/constants.js b/frontend/js/constants.js index 247cddb..be09d66 100644 --- a/frontend/js/constants.js +++ b/frontend/js/constants.js @@ -81,6 +81,7 @@ export const EVENT_NOTES_IMPORTED = "gravity:notes-imported"; export const EVENT_NOTIFICATION_REQUEST = "gravity:notify"; export const EVENT_AUTH_SIGN_IN = "gravity:auth-sign-in"; export const EVENT_AUTH_SIGN_OUT = "gravity:auth-sign-out"; +export const EVENT_AUTH_SIGN_OUT_REQUEST = "gravity:auth-sign-out-request"; export const EVENT_AUTH_ERROR = "gravity:auth-error"; export const EVENT_AUTH_CREDENTIAL_RECEIVED = "gravity:auth-credential"; export const EVENT_SYNC_SNAPSHOT_APPLIED = "gravity:sync-snapshot-applied"; diff --git a/frontend/js/core/backendClient.js b/frontend/js/core/backendClient.js index 7773948..3038a6f 100644 --- a/frontend/js/core/backendClient.js +++ b/frontend/js/core/backendClient.js @@ -1,19 +1,26 @@ // @ts-check +import { EVENT_AUTH_SIGN_OUT_REQUEST } from "../constants.js?build=2026-01-01T22:43:21Z"; import { logging } from "../utils/logging.js?build=2026-01-01T22:43:21Z"; import { encodeUrlBlanks } from "../utils/url.js?build=2026-01-01T22:43:21Z"; +const HTTP_STATUS_UNAUTHORIZED = 401; +const AUTH_SIGN_OUT_REASON = "backend-unauthorized"; + /** * @typedef {{ operation: "upsert"|"delete", note_id: string, client_edit_seq: number, client_device?: string, client_time_s?: number, created_at_s?: number, updated_at_s?: number, payload?: unknown }} SyncOperation */ /** * Create a client for interacting with the Gravity backend service. - * @param {{ baseUrl?: string, fetchImplementation?: typeof fetch }} options + * @param {{ baseUrl?: string, fetchImplementation?: typeof fetch, eventTarget?: EventTarget|null }} options */ export function createBackendClient(options = {}) { const normalizedBase = normalizeBaseUrl(options.baseUrl ?? ""); const resolveFetch = createFetchResolver(options.fetchImplementation); + const defaultEventTarget = resolveEventTarget(typeof document !== "undefined" ? document.body : null); + const authEventTarget = resolveEventTarget(options.eventTarget) ?? defaultEventTarget; + let unauthorizedDispatched = false; return Object.freeze({ /** @@ -29,6 +36,7 @@ export function createBackendClient(options = {}) { body: JSON.stringify({ operations: params.operations }) }) ); + handleUnauthorizedResponse(response); const payload = await parseJson(response); if (!response.ok) { throw new Error(payload?.error ?? "Failed to sync operations."); @@ -47,6 +55,7 @@ export function createBackendClient(options = {}) { method: "GET" }) ); + handleUnauthorizedResponse(response); const payload = await parseJson(response); if (!response.ok) { throw new Error(payload?.error ?? "Failed to load snapshot."); @@ -54,6 +63,52 @@ export function createBackendClient(options = {}) { return payload; } }); + + function handleUnauthorizedResponse(response) { + if (!response || typeof response.status !== "number") { + return; + } + if (response.ok === true) { + unauthorizedDispatched = false; + return; + } + if (response.status !== HTTP_STATUS_UNAUTHORIZED) { + return; + } + if (unauthorizedDispatched) { + return; + } + unauthorizedDispatched = true; + dispatchAuthSignOutRequest({ + reason: AUTH_SIGN_OUT_REASON, + status: response.status, + url: typeof response.url === "string" ? response.url : "" + }); + } + + function dispatchAuthSignOutRequest(detail) { + if (!authEventTarget || typeof authEventTarget.dispatchEvent !== "function") { + return; + } + try { + if (typeof CustomEvent === "function") { + authEventTarget.dispatchEvent(new CustomEvent(EVENT_AUTH_SIGN_OUT_REQUEST, { + bubbles: true, + detail + })); + return; + } + } catch (error) { + logging.error(error); + } + try { + const fallbackEvent = new Event(EVENT_AUTH_SIGN_OUT_REQUEST); + /** @type {any} */ (fallbackEvent).detail = detail; + authEventTarget.dispatchEvent(fallbackEvent); + } catch (error) { + logging.error(error); + } + } } function createFetchResolver(customFetch) { @@ -130,3 +185,14 @@ function normalizeBaseUrl(value) { const encoded = encodeUrlBlanks(trimmed); return encoded.replace(/\/+$/u, ""); } + +/** + * @param {EventTarget|null|undefined} value + * @returns {EventTarget|null} + */ +function resolveEventTarget(value) { + if (value && typeof value.dispatchEvent === "function") { + return value; + } + return null; +} diff --git a/frontend/js/core/syncManager.js b/frontend/js/core/syncManager.js index 886fa4c..60af861 100644 --- a/frontend/js/core/syncManager.js +++ b/frontend/js/core/syncManager.js @@ -43,9 +43,6 @@ export function createSyncManager(options) { if (!options || typeof options !== TYPE_OBJECT) { throw new Error(ERROR_MESSAGES.MISSING_OPTIONS); } - const backendClient = options.backendClient ?? createBackendClient({ - baseUrl: assertBaseUrl(options.backendBaseUrl) - }); const metadataStore = options.metadataStore ?? createSyncMetadataStore(); const queueStore = options.queueStore ?? createSyncQueue(); const clock = typeof options.clock === "function" ? options.clock : () => new Date(); @@ -57,6 +54,10 @@ export function createSyncManager(options) { ? globalThis.document : null; const syncEventTarget = options.eventTarget ?? defaultEventTarget; + const backendClient = options.backendClient ?? createBackendClient({ + baseUrl: assertBaseUrl(options.backendBaseUrl), + eventTarget: syncEventTarget + }); /** @type {{ userId: string|null, metadata: Record, queue: PendingOperation[], flushing: boolean }} */ const state = { diff --git a/frontend/tests/backendClient.test.js b/frontend/tests/backendClient.test.js index 01b3ddb..fec1ecc 100644 --- a/frontend/tests/backendClient.test.js +++ b/frontend/tests/backendClient.test.js @@ -1,7 +1,10 @@ +// @ts-check + import assert from "node:assert/strict"; import test from "node:test"; import { createBackendClient } from "../js/core/backendClient.js"; +import { EVENT_AUTH_SIGN_OUT_REQUEST } from "../js/constants.js"; class StubResponse { constructor(status, body, headers = {}) { @@ -81,3 +84,38 @@ test("custom fetch is preferred over apiFetch", async () => { assert.deepEqual(result, { results: [] }); assert.equal(customCalls, 1); }); + +test("backend client dispatches sign-out requests on unauthorized responses", async () => { + const events = []; + const eventTarget = new EventTarget(); + eventTarget.addEventListener(EVENT_AUTH_SIGN_OUT_REQUEST, (event) => { + events.push(event?.detail ?? null); + }); + + const responses = [ + new StubResponse(401, { error: "unauthorized" }), + new StubResponse(200, { notes: [] }), + new StubResponse(401, { error: "unauthorized" }) + ]; + + const client = createBackendClient({ + baseUrl: "https://api.example.com", + eventTarget, + fetchImplementation: async () => { + const response = responses.shift(); + if (!response) { + throw new Error("unexpected request"); + } + return response; + } + }); + + await assert.rejects(() => client.fetchSnapshot()); + const snapshot = await client.fetchSnapshot(); + assert.deepEqual(snapshot, { notes: [] }); + await assert.rejects(() => client.fetchSnapshot()); + + assert.equal(events.length, 2); + assert.equal(events[0]?.reason, "backend-unauthorized"); + assert.equal(events[0]?.status, 401); +}); diff --git a/frontend/tests/fullstack.endtoend.puppeteer.test.js b/frontend/tests/fullstack.endtoend.puppeteer.test.js index 4ffaf56..1ee6868 100644 --- a/frontend/tests/fullstack.endtoend.puppeteer.test.js +++ b/frontend/tests/fullstack.endtoend.puppeteer.test.js @@ -1,3 +1,5 @@ +// @ts-check + import assert from "node:assert/strict"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -59,8 +61,8 @@ test.describe("Full stack integration", () => { llmProxyUrl: "" }); try { - await dispatchSignIn(page, credential, userId); await attachBackendSessionCookie(page, backendContext, userId); + await dispatchSignIn(page, credential, userId); await waitForSyncManagerUser(page, userId); const noteId = "fullstack-sync-note"; diff --git a/frontend/tests/persistence.sync.puppeteer.test.js b/frontend/tests/persistence.sync.puppeteer.test.js index 7528305..12ee525 100644 --- a/frontend/tests/persistence.sync.puppeteer.test.js +++ b/frontend/tests/persistence.sync.puppeteer.test.js @@ -1,3 +1,5 @@ +// @ts-check + import assert from "node:assert/strict"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -52,8 +54,8 @@ test.describe("Backend persistence", () => { let contextB = null; try { - await dispatchSignIn(pageA, credentialA, TEST_USER_ID); await attachBackendSessionCookie(pageA, backendContext, TEST_USER_ID); + await dispatchSignIn(pageA, credentialA, TEST_USER_ID); await waitForSyncManagerUser(pageA, TEST_USER_ID); await waitForPendingOperations(pageA); @@ -85,8 +87,8 @@ test.describe("Backend persistence", () => { llmProxyUrl: "" }); - await dispatchSignIn(pageB, credentialB, TEST_USER_ID); await attachBackendSessionCookie(pageB, backendContext, TEST_USER_ID); + await dispatchSignIn(pageB, credentialB, TEST_USER_ID); await waitForSyncManagerUser(pageB, TEST_USER_ID); await waitForPendingOperations(pageB); await pageB.waitForSelector(".auth-avatar:not([hidden])");