diff --git a/cmd/cdropd/main.go b/cmd/cdropd/main.go index 0390046..2bdbae1 100644 --- a/cmd/cdropd/main.go +++ b/cmd/cdropd/main.go @@ -77,6 +77,7 @@ func main() { go transfer.RunSweeper(ctx, transfers) go relayMgr.RunReaper(ctx) go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour) + go httpapi.RunWebSessionReaper(ctx, queries) if cfg.ClipboardTTLSec > 0 { go clipboard.RunSweeper(ctx, clip) } diff --git a/internal/config/config.go b/internal/config/config.go index 24944d5..c78921e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,13 @@ type Config struct { HS256Secret string `koanf:"hs256_secret"` + // SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at + // rest in web_sessions (the "passwordless re-login" feature). Any-length + // high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only + // here (container env), never in the DB file, so an exfiltrated SQLite file + // can't be decrypted. Required in prod — refuse to boot without it. + SessionSecret string `koanf:"session_secret"` + // Shortcut tokens:iOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期 // (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。 // 需配 HS256Secret 才启用,否则签发端点返回 503。 @@ -127,6 +134,14 @@ func (c *Config) validate() error { "CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " + "(comma-separated OAuth client_ids); refusing to start") } + // SessionSecret keys at-rest encryption of browser refresh_tokens. Empty + // would either disable passwordless re-login or, worse, tempt a plaintext + // fallback; require it so the encryption path is always live in prod. + if c.SessionSecret == "" { + return errors.New( + "CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " + + "(keys web-session refresh_token encryption); refusing to start") + } default: return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a938ffb..1b02d95 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,9 +24,22 @@ func TestValidate_DevWithDevTokenOK(t *testing.T) { } func TestValidate_ProdOK(t *testing.T) { - c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop"} + c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop", SessionSecret: "s3cr3t"} if err := c.validate(); err != nil { - t.Fatalf("prod mode with audience should pass; got %v", err) + t.Fatalf("prod mode with audience + session secret should pass; got %v", err) + } +} + +func TestValidate_ProdRequiresSessionSecret(t *testing.T) { + // SessionSecret keys at-rest encryption of browser refresh_tokens; without it + // passwordless re-login can't run safely. Refuse to boot in prod. + c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web", SessionSecret: ""} + err := c.validate() + if err == nil { + t.Fatal("prod without CDROP_SESSION_SECRET must reject; got nil error") + } + if !strings.Contains(err.Error(), "CDROP_SESSION_SECRET") { + t.Errorf("error must mention CDROP_SESSION_SECRET; got %q", err.Error()) } } diff --git a/internal/db/bootstrap_test.go b/internal/db/bootstrap_test.go index 0dcd517..06b7d6c 100644 --- a/internal/db/bootstrap_test.go +++ b/internal/db/bootstrap_test.go @@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) { t.Fatalf("bootstrap: %v", err) } - want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions"} + want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions", "web_sessions"} rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") if err != nil { t.Fatalf("query tables: %v", err) diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql index 8ecd135..3c09320 100644 --- a/internal/db/migrations/0001_init.sql +++ b/internal/db/migrations/0001_init.sql @@ -49,3 +49,21 @@ CREATE TABLE IF NOT EXISTS clipboard_state ( -- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。 origin_ts INTEGER NOT NULL DEFAULT 0 ); + +-- web_sessions:浏览器端「免重登」。cookie 里只放不透明随机串,本表 id 存其 +-- SHA-256,故 DB 单独泄露也换不出可用 cookie;refresh_token 以 AES-256-GCM 落盘 +-- (密钥在容器环境、不在库内)。仅浏览器使用——桌面端 refresh_token 由 Go keyring +-- 持有、走自有 loopback flow,绝不入此表。device_name 随会话存,使 PWA 存储被清后 +-- 开机仍能恢复本机设备名。 +CREATE TABLE IF NOT EXISTS web_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + refresh_token TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at); diff --git a/internal/db/models.go b/internal/db/models.go index b4bdbad..1c0f05b 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -47,3 +47,14 @@ type TransferSession struct { FinishedAt *int64 `json:"finished_at"` FailReason *string `json:"fail_reason"` } + +type WebSession struct { + ID string `json:"id"` + UserID string `json:"user_id"` + RefreshToken string `json:"refresh_token"` + DeviceName string `json:"device_name"` + UserAgent string `json:"user_agent"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` +} diff --git a/internal/db/queries/web_sessions.sql b/internal/db/queries/web_sessions.sql new file mode 100644 index 0000000..ad0d7f6 --- /dev/null +++ b/internal/db/queries/web_sessions.sql @@ -0,0 +1,32 @@ +-- web_sessions: browser "passwordless re-login". The opaque cookie token is +-- never stored; id holds its SHA-256, so a DB leak yields no usable cookie. +-- refresh_token is AES-256-GCM ciphertext (key lives in the container env, not +-- the DB). 7-day sliding window: every refresh rotates the token and pushes +-- expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions. + +-- name: CreateWebSession :exec +INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: GetWebSession :one +SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at +FROM web_sessions +WHERE id = ?; + +-- name: RotateWebSession :exec +UPDATE web_sessions +SET refresh_token = ?, last_used_at = ?, expires_at = ? +WHERE id = ?; + +-- name: SetWebSessionDevice :exec +UPDATE web_sessions +SET device_name = ? +WHERE id = ?; + +-- name: DeleteWebSession :exec +DELETE FROM web_sessions +WHERE id = ?; + +-- name: DeleteExpiredWebSessions :execrows +DELETE FROM web_sessions +WHERE expires_at < ?; diff --git a/internal/db/web_sessions.sql.go b/internal/db/web_sessions.sql.go new file mode 100644 index 0000000..bbe0b7f --- /dev/null +++ b/internal/db/web_sessions.sql.go @@ -0,0 +1,130 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: web_sessions.sql + +package db + +import ( + "context" +) + +const createWebSession = `-- name: CreateWebSession :exec + +INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateWebSessionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + RefreshToken string `json:"refresh_token"` + DeviceName string `json:"device_name"` + UserAgent string `json:"user_agent"` + CreatedAt int64 `json:"created_at"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// web_sessions: browser "passwordless re-login". The opaque cookie token is +// never stored; id holds its SHA-256, so a DB leak yields no usable cookie. +// refresh_token is AES-256-GCM ciphertext (key lives in the container env, not +// the DB). 7-day sliding window: every refresh rotates the token and pushes +// expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions. +func (q *Queries) CreateWebSession(ctx context.Context, arg CreateWebSessionParams) error { + _, err := q.db.ExecContext(ctx, createWebSession, + arg.ID, + arg.UserID, + arg.RefreshToken, + arg.DeviceName, + arg.UserAgent, + arg.CreatedAt, + arg.LastUsedAt, + arg.ExpiresAt, + ) + return err +} + +const deleteExpiredWebSessions = `-- name: DeleteExpiredWebSessions :execrows +DELETE FROM web_sessions +WHERE expires_at < ? +` + +func (q *Queries) DeleteExpiredWebSessions(ctx context.Context, expiresAt int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteExpiredWebSessions, expiresAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deleteWebSession = `-- name: DeleteWebSession :exec +DELETE FROM web_sessions +WHERE id = ? +` + +func (q *Queries) DeleteWebSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteWebSession, id) + return err +} + +const getWebSession = `-- name: GetWebSession :one +SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at +FROM web_sessions +WHERE id = ? +` + +func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, error) { + row := q.db.QueryRowContext(ctx, getWebSession, id) + var i WebSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.RefreshToken, + &i.DeviceName, + &i.UserAgent, + &i.CreatedAt, + &i.LastUsedAt, + &i.ExpiresAt, + ) + return i, err +} + +const rotateWebSession = `-- name: RotateWebSession :exec +UPDATE web_sessions +SET refresh_token = ?, last_used_at = ?, expires_at = ? +WHERE id = ? +` + +type RotateWebSessionParams struct { + RefreshToken string `json:"refresh_token"` + LastUsedAt int64 `json:"last_used_at"` + ExpiresAt int64 `json:"expires_at"` + ID string `json:"id"` +} + +func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionParams) error { + _, err := q.db.ExecContext(ctx, rotateWebSession, + arg.RefreshToken, + arg.LastUsedAt, + arg.ExpiresAt, + arg.ID, + ) + return err +} + +const setWebSessionDevice = `-- name: SetWebSessionDevice :exec +UPDATE web_sessions +SET device_name = ? +WHERE id = ? +` + +type SetWebSessionDeviceParams struct { + DeviceName string `json:"device_name"` + ID string `json:"id"` +} + +func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDeviceParams) error { + _, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID) + return err +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index d10bdf4..0060e78 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -12,6 +12,8 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" + + "commilitia.net/cdrop/internal/db" ) // PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend @@ -62,11 +64,13 @@ type userResp struct { Avatar string `json:"avatar,omitempty"` } +// exchangeResp deliberately omits refresh_token: the durable credential never +// reaches the browser. It's encrypted into the server-side web_sessions row and +// represented to the client only by the HttpOnly session cookie set alongside. type exchangeResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - User userResp `json:"user"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + User userResp `json:"user"` } func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { @@ -112,43 +116,113 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { return } + // Stash the refresh_token server-side and hand the browser an opaque, + // HttpOnly cookie instead. Guarded so a deploy without the encryption key (or + // an IdP that returned no refresh_token) still logs the user in — they just + // don't get passwordless re-login and re-auth on the next access-token expiry. + if len(s.sessionKey) > 0 && tr.RefreshToken != "" { + if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil { + slog.Error("create web session failed", "err", err) + } + } + writeJSON(w, http.StatusOK, exchangeResp{ - AccessToken: tr.AccessToken, - RefreshToken: tr.RefreshToken, - ExpiresIn: tr.ExpiresIn, - User: user, + AccessToken: tr.AccessToken, + ExpiresIn: tr.ExpiresIn, + User: user, }) } -type refreshReq struct { - RefreshToken string `json:"refresh_token"` +// createWebSession encrypts the refresh_token, persists a new session row, and +// sets the session cookie on the response. +func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error { + raw, id, err := newSessionToken() + if err != nil { + return err + } + enc, err := s.encryptRefresh(refreshToken) + if err != nil { + return err + } + now := time.Now() + if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{ + ID: id, + UserID: userID, + RefreshToken: enc, + DeviceName: "", + UserAgent: truncate(r.UserAgent(), 256), + CreatedAt: now.Unix(), + LastUsedAt: now.Unix(), + ExpiresAt: now.Add(webSessionTTL).Unix(), + }); err != nil { + return err + } + setSessionCookie(w, raw) + return nil } +// refreshResp is what the browser sees: a fresh access_token plus the identity +// and device name recovered from the session. No refresh_token — it stays +// server-side. This same endpoint, called with the cookie and no body on app +// boot, IS the passwordless re-login path. type refreshResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + User userResp `json:"user"` + DeviceName string `json:"device_name"` } func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { - var req refreshReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) - return - } - if req.RefreshToken == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"}) - return - } if s.cfg.OIDCTokenURL == "" { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "OIDC token URL not configured"}) return } + if !s.sameOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) + return + } + + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + clearSessionCookie(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) + return + } + id := sessionID(c.Value) + + // Serialise concurrent refreshes of this session, then read the row INSIDE the + // lock: a racing tab may already have rotated the refresh_token, and we must + // spend the current one, not a stale copy the IdP would reject. + unlock := s.lockRefresh(id) + defer unlock() + + sess, err := s.queries.GetWebSession(r.Context(), id) + if err != nil { + // Unknown / deleted session → not authenticated. Clear the stale cookie. + clearSessionCookie(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) + return + } + now := time.Now() + if sess.ExpiresAt < now.Unix() { + _ = s.queries.DeleteWebSession(r.Context(), id) + clearSessionCookie(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"}) + return + } + refreshToken, err := s.decryptRefresh(sess.RefreshToken) + if err != nil { + // Corrupt ciphertext or rotated key — the session is unusable, drop it. + _ = s.queries.DeleteWebSession(r.Context(), id) + clearSessionCookie(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"}) + return + } form := url.Values{} form.Set("grant_type", "refresh_token") - form.Set("refresh_token", req.RefreshToken) + form.Set("refresh_token", refreshToken) form.Set("client_id", s.cfg.OIDCClientID) if s.cfg.OIDCClientSecret != "" { form.Set("client_secret", s.cfg.OIDCClientSecret) @@ -156,14 +230,47 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) if err != nil { + // The IdP rejected the refresh_token (expired / revoked) — the session is + // dead end to end, so destroy it rather than leave a row that always 401s. slog.Warn("oidc refresh failed", "err", err) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + _ = s.queries.DeleteWebSession(r.Context(), id) + clearSessionCookie(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"}) return } + + user, err := extractUser(tr.IDToken, tr.AccessToken) + if err != nil { + writeJSON(w, http.StatusBadGateway, + map[string]string{"error": "extract user: " + err.Error()}) + return + } + + // Rotate: Casdoor issues a new refresh_token on each grant; fall back to the + // existing one if it didn't. Slide the window forward and re-arm the cookie. + newRefresh := tr.RefreshToken + if newRefresh == "" { + newRefresh = refreshToken + } + if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil { + if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{ + RefreshToken: enc, + LastUsedAt: now.Unix(), + ExpiresAt: now.Add(webSessionTTL).Unix(), + ID: id, + }); err != nil { + slog.Error("rotate web session failed", "err", err) + } + } else { + slog.Error("encrypt rotated refresh token failed", "err", encErr) + } + setSessionCookie(w, c.Value) + writeJSON(w, http.StatusOK, refreshResp{ - AccessToken: tr.AccessToken, - RefreshToken: tr.RefreshToken, - ExpiresIn: tr.ExpiresIn, + AccessToken: tr.AccessToken, + ExpiresIn: tr.ExpiresIn, + User: user, + DeviceName: sess.DeviceName, }) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5f63c5f..11aa777 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "log/slog" "net/http" + "sync" "time" "github.com/go-chi/chi/v5" @@ -34,6 +35,19 @@ type Server struct { calls *calls.Provider // optional; nil → STUN-only fallback clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard mux *chi.Mux + + // sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when + // CDROP_SESSION_SECRET is unset (dev), which disables passwordless re-login. + // siteOrigin is the deployment's scheme://host, used for the CSRF Origin check. + sessionKey []byte + siteOrigin string + + // refreshLocks serialise concurrent refreshes of the same web session (all of + // a user's browser tabs share one cookie → one refresh_token). Striped so the + // lock set stays bounded; collisions just serialise unrelated sessions, which + // is harmless. Prevents a multi-tab race from spending a one-time-use + // refresh_token twice and logging the user out everywhere. + refreshLocks [refreshLockStripes]sync.Mutex } func New( @@ -58,6 +72,9 @@ func New( calls: cp, clipboard: clip, mux: chi.NewRouter(), + + sessionKey: deriveSessionKey(cfg.SessionSecret), + siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI), } s.routes() return s @@ -88,6 +105,11 @@ func (s *Server) routes() { r.Use(httprate.LimitByIP(60, time.Minute)) r.Post("/auth/exchange", s.handleAuthExchange) r.Post("/auth/refresh", s.handleAuthRefresh) + // Cookie-authenticated (no bearer): the HttpOnly session cookie is the + // only credential. logout drops the server session; device persists + // this browser's name into the session for PWA-eviction recovery. + r.Post("/auth/logout", s.handleAuthLogout) + r.Post("/auth/device", s.handleAuthDevice) }) // Protected routes. gzip / compress is intentionally NOT mounted — diff --git a/internal/httpapi/session.go b/internal/httpapi/session.go new file mode 100644 index 0000000..9341d3c --- /dev/null +++ b/internal/httpapi/session.go @@ -0,0 +1,260 @@ +package httpapi + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +// Browser "passwordless re-login" (web only — desktop persists its own +// refresh_token in the OS keyring and never touches these endpoints). +// +// The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in +// web_sessions, encrypted at rest. The browser only ever holds an opaque cookie +// token whose SHA-256 is the row's primary key — so a leaked DB yields neither a +// usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read +// it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped +// to /api/auth so it rides only these four endpoints, not every API call. + +const ( + sessionCookieName = "cdrop_session" + sessionCookiePath = "/api/auth" + + // webSessionTTL is the sliding inactivity window: each refresh pushes + // expires_at this far forward. The effective cap is min(this, the IdP's own + // refresh_token validity) — once Casdoor retires the refresh_token, refresh + // 401s and the user re-logs in regardless. + webSessionTTL = 7 * 24 * time.Hour + + // refreshLockStripes bounds the per-session refresh lock set (see Server). + refreshLockStripes = 256 +) + +// lockRefresh serialises refreshes of one session id, returning the unlock fn. +// Callers must re-read the session row after acquiring it: a concurrent refresh +// may have already rotated the refresh_token. +func (s *Server) lockRefresh(id string) func() { + idx := stripeIndex(id) + s.refreshLocks[idx].Lock() + return func() { s.refreshLocks[idx].Unlock() } +} + +// stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup. +func stripeIndex(id string) int { + var h uint32 = 2166136261 + for i := 0; i < len(id); i++ { + h = (h ^ uint32(id[i])) * 16777619 + } + return int(h % refreshLockStripes) +} + +// deriveSessionKey turns a config secret of any length into a 32-byte AES key +// (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled). +func deriveSessionKey(secret string) []byte { + if secret == "" { + return nil + } + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +// deriveSiteOrigin extracts scheme://host from the configured redirect_uri to +// give the CSRF Origin check a fixed expected value. Empty (dev) disables it. +func deriveSiteOrigin(redirectURI string) string { + if redirectURI == "" { + return "" + } + u, err := url.Parse(redirectURI) + if err != nil || u.Scheme == "" || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host +} + +// newSessionToken mints a fresh opaque cookie token plus its storage id. raw +// goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row +// never contains a usable cookie value. +func newSessionToken() (raw, id string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + raw = base64.RawURLEncoding.EncodeToString(b) + return raw, sessionID(raw), nil +} + +func sessionID(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// encryptRefresh seals a refresh_token with AES-256-GCM. Output is +// base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived). +func (s *Server) encryptRefresh(plain string) (string, error) { + if len(s.sessionKey) == 0 { + return "", errors.New("session key not configured") + } + gcm, err := newGCM(s.sessionKey) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ct := gcm.Seal(nonce, nonce, []byte(plain), nil) + return base64.StdEncoding.EncodeToString(ct), nil +} + +func (s *Server) decryptRefresh(enc string) (string, error) { + if len(s.sessionKey) == 0 { + return "", errors.New("session key not configured") + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", err + } + gcm, err := newGCM(s.sessionKey) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func setSessionCookie(w http.ResponseWriter, raw string) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: raw, + Path: sessionCookiePath, + MaxAge: int(webSessionTTL / time.Second), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: sessionCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the +// browser sends an Origin header (always, on fetch POST) it must match the +// deployment's own origin. Absent Origin (non-browser clients) is allowed, as is +// an unconfigured site origin (dev). +func (s *Server) sameOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" || s.siteOrigin == "" { + return true + } + return origin == s.siteOrigin +} + +// handleAuthLogout destroys the server-side session and clears the cookie. +// Cookie-authenticated (no bearer): the cookie is the only thing that proves +// which session to drop. +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { + if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil { + slog.Warn("web session delete failed", "err", err) + } + } + clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +type sessionDeviceReq struct { + DeviceName string `json:"device_name"` +} + +// handleAuthDevice persists this browser's device name into its session row so +// it survives PWA storage eviction: on the next boot, /auth/refresh hands the +// name back and the client re-hydrates selfDeviceName without a trip to /setup. +func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) + return + } + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"}) + return + } + var req sessionDeviceReq + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + name := jwtauth.SanitizeDeviceName(req.DeviceName) + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"}) + return + } + if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{ + DeviceName: name, + ID: sessionID(c.Value), + }); err != nil { + slog.Error("set web session device failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy +// deletion on access covers the hot path; this sweeps sessions that simply go +// idle and are never touched again. +func RunWebSessionReaper(ctx context.Context, q *db.Queries) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + n, err := q.DeleteExpiredWebSessions(ctx, time.Now().Unix()) + if err != nil { + slog.Warn("web session reaper failed", "err", err) + } else if n > 0 { + slog.Info("web sessions reaped", "count", n) + } + } + } +} diff --git a/internal/httpapi/session_test.go b/internal/httpapi/session_test.go new file mode 100644 index 0000000..c0d2c04 --- /dev/null +++ b/internal/httpapi/session_test.go @@ -0,0 +1,123 @@ +package httpapi + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestEncryptRefreshRoundTrip(t *testing.T) { + s := &Server{sessionKey: deriveSessionKey("a-strong-session-secret")} + plain := strings.Repeat("refresh.token.", 2000) // ~28 KB, mimics Casdoor's large JWT + + enc, err := s.encryptRefresh(plain) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if strings.Contains(enc, plain) { + t.Fatal("ciphertext leaks plaintext") + } + got, err := s.decryptRefresh(enc) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if got != plain { + t.Fatalf("roundtrip mismatch: len(got)=%d, len(want)=%d", len(got), len(plain)) + } +} + +func TestEncryptRefreshNonceIsRandom(t *testing.T) { + s := &Server{sessionKey: deriveSessionKey("secret")} + a, _ := s.encryptRefresh("same plaintext") + b, _ := s.encryptRefresh("same plaintext") + if a == b { + t.Fatal("two encryptions of the same plaintext are identical — nonce not random") + } +} + +func TestDecryptRefreshRejectsTamper(t *testing.T) { + s := &Server{sessionKey: deriveSessionKey("secret")} + enc, err := s.encryptRefresh("token") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + t.Fatalf("decode: %v", err) + } + raw[len(raw)-1] ^= 0xFF // flip a bit in the GCM tag + if _, err := s.decryptRefresh(base64.StdEncoding.EncodeToString(raw)); err == nil { + t.Fatal("tampered ciphertext must fail authentication") + } +} + +func TestDecryptRefreshRejectsWrongKey(t *testing.T) { + enc, err := (&Server{sessionKey: deriveSessionKey("key-one")}).encryptRefresh("token") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + if _, err := (&Server{sessionKey: deriveSessionKey("key-two")}).decryptRefresh(enc); err == nil { + t.Fatal("decryption under a different key must fail") + } +} + +func TestEncryptRefreshNoKey(t *testing.T) { + s := &Server{sessionKey: nil} + if _, err := s.encryptRefresh("token"); err == nil { + t.Fatal("encrypt without a key must error") + } +} + +func TestSessionIDDeterministicAndHashed(t *testing.T) { + raw, id, err := newSessionToken() + if err != nil { + t.Fatalf("newSessionToken: %v", err) + } + if id != sessionID(raw) { + t.Fatal("newSessionToken id must equal sessionID(raw)") + } + if id == raw || strings.Contains(id, raw) { + t.Fatal("stored id must be a hash of the cookie value, not the value itself") + } + // SHA-256 hex is always 64 chars. + if len(id) != 64 { + t.Fatalf("session id length: got %d, want 64", len(id)) + } +} + +func TestNewSessionTokenUnique(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 1000; i++ { + raw, _, err := newSessionToken() + if err != nil { + t.Fatalf("newSessionToken: %v", err) + } + if seen[raw] { + t.Fatal("duplicate session token generated") + } + seen[raw] = true + } +} + +func TestDeriveSessionKey(t *testing.T) { + if deriveSessionKey("") != nil { + t.Fatal("empty secret must yield a nil key (feature disabled)") + } + if got := len(deriveSessionKey("x")); got != 32 { + t.Fatalf("derived key length: got %d, want 32 (AES-256)", got) + } +} + +func TestDeriveSiteOrigin(t *testing.T) { + cases := []struct{ in, want string }{ + {"https://drop.example.net/oauth/callback", "https://drop.example.net"}, + {"http://localhost:5173/oauth/callback", "http://localhost:5173"}, + {"", ""}, + {"not a url", ""}, + } + for _, c := range cases { + if got := deriveSiteOrigin(c.in); got != c.want { + t.Errorf("deriveSiteOrigin(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/jwtauth/middleware.go b/internal/jwtauth/middleware.go index 29ecc8e..fe2f4c9 100644 --- a/internal/jwtauth/middleware.go +++ b/internal/jwtauth/middleware.go @@ -64,7 +64,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { return } - deviceName := sanitizeDeviceName(r.Header.Get("X-Device-Name")) + deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name")) deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type")) if claims.Scoped() { // The backend authoritatively knows a scoped token is the iOS @@ -284,14 +284,14 @@ func DeriveHS256Key(secret string) []byte { return sum[:] } -// sanitizeDeviceName enforces the global ASCII-only device-name policy. Device +// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device // names ride in the X-Device-Name HTTP header, which can't carry non-ASCII // reliably (and browser fetch rejects such header values outright), so the name // is restricted to printable ASCII everywhere. Here we keep only printable ASCII // (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also // validate the name up front for a clear error. Empty after sanitising → the // caller falls back to a UA-derived default. -func sanitizeDeviceName(raw string) string { +func SanitizeDeviceName(raw string) string { var b strings.Builder for _, r := range raw { if r >= 0x20 && r <= 0x7E { diff --git a/internal/jwtauth/middleware_test.go b/internal/jwtauth/middleware_test.go index c14613e..5b1689f 100644 --- a/internal/jwtauth/middleware_test.go +++ b/internal/jwtauth/middleware_test.go @@ -483,8 +483,8 @@ func TestSanitizeDeviceName(t *testing.T) { {"Mac Book", "MacBook"}, // NBSP dropped } for _, c := range cases { - if got := sanitizeDeviceName(c.in); got != c.want { - t.Errorf("sanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want) + if got := SanitizeDeviceName(c.in); got != c.want { + t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want) } } } diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts index 275054f..0512d6a 100644 --- a/web/src/features/auth/auth.ts +++ b/web/src/features/auth/auth.ts @@ -34,6 +34,12 @@ export function logout() // 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略 // ——只是体验降级回宽限期路径,不影响登出本身。 void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); + // 浏览器端:删除服务端 session 并清除 HttpOnly cookie(cookie 自动随同源请求), + // 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。 + if (!isDesktop() && useAppStore.getState().authMode === "prod") + { + void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ }); + } useAppStore.getState().clearAuth(); // 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。 if (isDesktop()) { clearDesktopSession(); } @@ -107,9 +113,10 @@ export async function loginProd(): Promise interface ExchangeResp { access_token: string; - refresh_token: string; expires_in: number; user: { id: string; name: string; avatar?: string }; + // refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚 + // HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。 } // completeOAuthLogin runs on /oauth/callback after the provider redirects back. @@ -156,20 +163,42 @@ export async function completeOAuthLogin(code: string, state: string): Promise +{ + const r = await fetch("/api/auth/refresh", { method: "POST" }); + if (!r.ok) { return null; } + const data = await r.json().catch(() => null) as CookieRefreshResp | null; + if (!data?.access_token || !data.user?.id) { return null; } + return { + accessToken: data.access_token, + user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, + deviceName: data.device_name ?? "", + }; +} + +// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。返回是否成功。 export async function refreshTokens(): Promise { const store = useAppStore.getState(); if (store.authMode !== "prod") { return false; } - // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故 - // 无需 store.refreshToken,直接无参调用。 + // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有), + // 直接无参调用。 if (isDesktop()) { const res = await desktopRefresh(); @@ -180,26 +209,45 @@ export async function refreshTokens(): Promise return true; } - const refreshToken = store.refreshToken; - if (!refreshToken) { return false; } + // 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。 + const res = await cookieRefresh(); + if (!res) { return false; } + const cur = useAppStore.getState(); + cur.setAuth({ accessToken: res.accessToken, user: res.user }); + // 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。 + if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } + return true; +} - const r = await fetch("/api/auth/refresh", { +// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token, +// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名), +// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。 +export async function bootstrapAuth(): Promise +{ + if (isDesktop()) { return; } + const store = useAppStore.getState(); + if (store.authMode !== "prod") { return; } + if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中) + + const res = await cookieRefresh(); + if (!res) { return; } + const cur = useAppStore.getState(); + cur.setAuth({ accessToken: res.accessToken, user: res.user }); + if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); } +} + +// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清 +// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走 +// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。 +export function syncWebDeviceName(name: string): void +{ + if (isDesktop()) { return; } + if (useAppStore.getState().authMode !== "prod") { return; } + void fetch("/api/auth/device", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - if (!r.ok) { return false; } - const data = await r.json() as { access_token: string; refresh_token?: string }; - if (!data.access_token) { return false; } - - const cur = useAppStore.getState(); - if (!cur.user) { return false; } - cur.setAuth({ - accessToken: data.access_token, - refreshToken: data.refresh_token ?? refreshToken, - user: cur.user, - }); - return true; + body: JSON.stringify({ device_name: name }), + }).catch(() => { /* ignore */ }); } // ---- PKCE helpers --------------------------------------------------------- diff --git a/web/src/main.tsx b/web/src/main.tsx index bc8a749..5299a17 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -13,6 +13,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import { ToastViewport } from "./ui/feedback"; import { routeTree } from "./routeTree.gen"; import { useAppStore, type ThemeMode } from "./store"; +import { bootstrapAuth } from "./features/auth/auth"; import { startCjkAutospace } from "./utils/cjkAutospace"; import { initDesktopMenuBridge, isDesktop, loadDesktopSettings } from "./net/desktop"; @@ -115,16 +116,28 @@ function App() // 时从注入的 window.__CDROP_BOOT__ 同步水合(store/helpers.ts),无需异步等待。 applyTheme(useAppStore.getState().theme); -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +function mount(): void +{ + ReactDOM.createRoot(document.getElementById("root")!).render( + + + , + ); + // CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续 + // 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制 + // 完会触发一次 runShim() 处理已挂载的子树。 + requestAnimationFrame(() => { startCjkAutospace(); }); +} -// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续 -// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制 -// 完会触发一次 runShim() 处理已挂载的子树。 -requestAnimationFrame(() => { startCjkAutospace(); }); +// 浏览器端「免重登」:首屏渲染前用 HttpOnly cookie 尝试静默续期。命中则直接以已 +// 登录态挂载,避免登录页 / 设置页闪现;未命中(401)即照常落到登录页。桌面 / dev / +// sessionStorage 已有令牌时 bootstrapAuth 立即返回,几乎无延迟。4s 超时兜底:万一 +// refresh 卡死,也不至于把整个应用永久挡在首屏外。 +const BOOT_AUTH_TIMEOUT_MS = 4000; +void Promise.race([ + bootstrapAuth().catch(() => { /* ignore:失败即落登录页 */ }), + new Promise((resolve) => { window.setTimeout(resolve, BOOT_AUTH_TIMEOUT_MS); }), +]).finally(mount); // PWA service worker:仅在浏览器 + prod 注册。dev 下会干扰 Vite HMR;桌面壳 // (Wails WebView,wails:// / loopback origin)已是原生应用且 SW 行为不可靠, diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx index 5316cb2..9f00879 100644 --- a/web/src/routes/settings.tsx +++ b/web/src/routes/settings.tsx @@ -9,7 +9,7 @@ import { } from "@mantine/core"; import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router"; import { LogOut, Trash2 } from "lucide-react"; -import { logout } from "../features/auth/auth"; +import { logout, syncWebDeviceName } from "../features/auth/auth"; import { DesktopSettings } from "../features/desktop/DesktopSettings"; import { isDesktop, persistDesktopDeviceName } from "../net/desktop"; import { apiFetch } from "../net/api"; @@ -96,6 +96,7 @@ function SettingsPage() } setSelfDeviceName(trimmedName); persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活 + syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除 toast.ok(t("settings.deviceName.success")); } catch (e) diff --git a/web/src/routes/setup.tsx b/web/src/routes/setup.tsx index d7f4034..0e2a13a 100644 --- a/web/src/routes/setup.tsx +++ b/web/src/routes/setup.tsx @@ -2,6 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { Check, Monitor } from "lucide-react"; import { useState } from "react"; import { AuthShell } from "../features/auth/AuthShell"; +import { syncWebDeviceName } from "../features/auth/auth"; import { persistDesktopDeviceName } from "../net/desktop"; import { useAppStore } from "../store"; import { t } from "../i18n"; @@ -37,6 +38,7 @@ function SetupPage() } setSelfDeviceName(trimmed); persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活 + syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除 navigate({ to: "/" }); }; diff --git a/web/src/store/slices/auth.ts b/web/src/store/slices/auth.ts index 914d248..8a7fac5 100644 --- a/web/src/store/slices/auth.ts +++ b/web/src/store/slices/auth.ts @@ -9,21 +9,19 @@ import { export type AuthSlice = Pick< AppState, - "authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth" + "authMode" | "accessToken" | "user" | "setAuth" | "clearAuth" >; const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod"; -// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责, -// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab -// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。 -// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有 -// (refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。 +// 跨重启的持久化:桌面由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责 +// (wails:// scheme 下 WebView 存储不存活);浏览器由服务端 HttpOnly session cookie +// 负责(开机 bootstrapAuth 静默续期,见 features/auth/auth.ts)。sessionStorage 仅 +// 用于浏览器同 tab 刷新存活。refresh_token 永不进 JS——只在服务端 / Go 进程。 export const createAuthSlice: StateCreator = (set) => ({ authMode: initialAuthMode, accessToken: readSessionAccess(), - refreshToken: null, user: readSessionUser(), setAuth: (a) => @@ -35,7 +33,6 @@ export const createAuthSlice: StateCreator = (set) } set({ accessToken: a.accessToken, - refreshToken: a.refreshToken ?? null, user: a.user, }); }, @@ -47,6 +44,6 @@ export const createAuthSlice: StateCreator = (set) window.sessionStorage.removeItem(ACCESS_TOKEN_KEY); window.sessionStorage.removeItem(USER_KEY); } - set({ accessToken: null, refreshToken: null, user: null }); + set({ accessToken: null, user: null }); }, }); diff --git a/web/src/store/types.ts b/web/src/store/types.ts index e2efc69..7aadbcb 100644 --- a/web/src/store/types.ts +++ b/web/src/store/types.ts @@ -2,8 +2,9 @@ import type { Locale } from "../i18n"; // Auth shape — see FRONTEND_DESIGN.md §6. // access_token lives in sessionStorage so a tab refresh keeps the user logged in -// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage — -// to keep XSS exposure as small as possible. +// (PROJECT_BRIEF.md §2). The refresh_token never touches JS at all: in the +// browser it lives server-side behind an HttpOnly session cookie (see +// features/auth/auth.ts), on desktop inside the Go process (OS keyring). export interface User { id: string; @@ -116,9 +117,8 @@ export interface AppState // ---- auth slice ---- authMode: AuthMode; accessToken: string | null; - refreshToken: string | null; user: User | null; - setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void; + setAuth: (a: { accessToken: string; user: User }) => void; clearAuth: () => void; // ---- device slice ----