Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.gravity.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 3 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 <token>` 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.
Expand All @@ -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`).

Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Each issue is formatted as `- [ ] [GN-<number>]`. 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).
Expand All @@ -148,6 +148,7 @@ Each issue is formatted as `- [ ] [GN-<number>]`. 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`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-origin>/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.
Expand Down
3 changes: 0 additions & 3 deletions backend/cmd/gravity-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 3 additions & 7 deletions backend/internal/auth/session_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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")
ErrExpiredSessionToken = errors.New("session validator: token expired")
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"`
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand Down
54 changes: 29 additions & 25 deletions backend/internal/auth/session_validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand All @@ -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)
}
Expand All @@ -43,17 +49,16 @@ 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)
}
}

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
},
Expand All @@ -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)
}
Expand All @@ -83,40 +88,39 @@ 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,
})

claims, err := validator.ValidateRequest(request)
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)
}
}
41 changes: 17 additions & 24 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,48 +13,44 @@ 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
}

// 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 {
Expand All @@ -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")
}
Expand Down
Loading
Loading