feat(auth): add keep me signed in login option (#4255)

* feat(auth): add keep me signed in login option

* fix(auth): address remember-login review feedback
This commit is contained in:
Ryker_Feng
2026-07-18 22:17:15 +08:00
committed by GitHub
parent e89edb39b1
commit a028dfd5fb
19 changed files with 905 additions and 38 deletions
+4
View File
@@ -270,6 +270,10 @@ section, when present, overrides the first two for backward compatibility.
The unified nginx endpoint is same-origin by default and does not emit browser CORS headers. If you run a split-origin or port-forwarded browser client, set `GATEWAY_CORS_ORIGINS` to comma-separated exact origins such as `http://localhost:3000`; the Gateway then applies the CORS allowlist and matching CSRF origin checks.
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). The Redis stream bridge (`stream_bridge.type: redis`) shares SSE delivery and `Last-Event-ID` replay across workers, with a rolling retained-buffer TTL (`stream_ttl_seconds`) as a cleanup safety net. Malformed reconnect IDs live-tail new events instead of replaying the retained buffer. It does not make run cancellation, request de-duplication, or IM channel state fully cross-worker by itself; use single-worker Gateway or explicit sticky routing/ownership before raising `GATEWAY_WORKERS`.
+4
View File
@@ -317,6 +317,10 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned.
Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login accepts a `remember_me` form flag, but the Gateway never stores passwords. `SessionCookiePolicy` persists the `HttpOnly access_token` cookie only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit operator opt-in for insecure persistence; public HTTP sandbox URLs degrade to session cookies. Session-creating handlers stamp the final `max_age` on `request.state`, and CSRF cookie creation mirrors that value so the double-submit cookie pair expires together, including explicit re-issue after password changes and OIDC callbacks. A small `HttpOnly` preference cookie preserves the user's remember choice across token re-issue paths. Logout clears all auth cookies and suppresses CSRF re-issue on the logout response.
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
**Routers**:
| Router | Endpoints |
+1
View File
@@ -32,6 +32,7 @@ class OIDCStatePayload(BaseModel):
nonce: str | None = Field(default=None, description="OIDC nonce, verified against the ID token nonce claim")
code_verifier: str | None = Field(default=None, description="PKCE code verifier, sent during token exchange")
next_path: str = Field(default="/workspace", description="Redirect target after successful auth")
remember_me: bool = Field(default=True, description="Whether the resulting DeerFlow session should be persistent")
issued_at: float = Field(default_factory=time.time, description="Unix timestamp of cookie creation")
+110
View File
@@ -0,0 +1,110 @@
"""Browser session cookie policy for Gateway authentication."""
import logging
import os
from dataclasses import dataclass
from ipaddress import ip_address
from fastapi import Request, Response
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.session_cookie_state import (
SESSION_COOKIE_ISSUED_STATE_ATTR,
SESSION_COOKIE_MAX_AGE_STATE_ATTR,
SESSION_COOKIE_SECURE_STATE_ATTR,
)
from app.gateway.csrf_middleware import is_secure_request
ACCESS_TOKEN_COOKIE_NAME = "access_token"
SESSION_PERSISTENCE_COOKIE_NAME = "deerflow_session_persistent"
ALLOW_INSECURE_PERSISTENT_COOKIE_ENV = "DEER_FLOW_AUTH_ALLOW_INSECURE_PERSISTENT_COOKIE"
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SessionCookiePolicy:
"""Resolved cookie settings for a session-creating auth response."""
secure: bool
max_age: int | None
reason: str
def _env_flag_enabled(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _request_hostname(request: Request) -> str:
"""Return the direct request host without trusting forwarded host headers."""
if request.url.hostname:
return request.url.hostname.lower()
return ""
def is_local_browser_origin(request: Request) -> bool:
"""Return True for loopback browser origins where HTTP persistence is acceptable."""
host = _request_hostname(request)
if host == "localhost" or host.endswith(".localhost"):
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _remember_me_from_cookie(request: Request, *, default: bool) -> bool:
value = request.cookies.get(SESSION_PERSISTENCE_COOKIE_NAME)
if value == "1":
return True
if value == "0":
return False
return default
def resolve_session_cookie_policy(request: Request, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
"""Resolve session cookie settings from user intent and deployment context."""
remember = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
secure = is_secure_request(request)
lifetime_seconds = get_auth_config().token_expiry_days * 24 * 3600
if not remember:
return SessionCookiePolicy(secure=secure, max_age=None, reason="session_requested")
if secure:
return SessionCookiePolicy(secure=True, max_age=lifetime_seconds, reason="secure_persistent")
if is_local_browser_origin(request):
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="localhost_persistent")
if _env_flag_enabled(ALLOW_INSECURE_PERSISTENT_COOKIE_ENV):
return SessionCookiePolicy(secure=False, max_age=lifetime_seconds, reason="operator_insecure_persistent")
return SessionCookiePolicy(secure=False, max_age=None, reason="public_http_session")
def set_session_cookie(response: Response, request: Request, token: str, *, remember_me: bool | None = None, default_remember_me: bool = True) -> SessionCookiePolicy:
"""Set the HttpOnly access-token cookie and stamp its lifetime on request state."""
resolved_remember_me = _remember_me_from_cookie(request, default=default_remember_me) if remember_me is None else remember_me
policy = resolve_session_cookie_policy(request, remember_me=resolved_remember_me, default_remember_me=default_remember_me)
response.set_cookie(
key=ACCESS_TOKEN_COOKIE_NAME,
value=token,
httponly=True,
secure=policy.secure,
samesite="lax",
max_age=policy.max_age,
)
response.set_cookie(
key=SESSION_PERSISTENCE_COOKIE_NAME,
value="1" if resolved_remember_me else "0",
httponly=True,
secure=policy.secure,
samesite="lax",
max_age=policy.max_age,
)
setattr(request.state, SESSION_COOKIE_MAX_AGE_STATE_ATTR, policy.max_age)
setattr(request.state, SESSION_COOKIE_SECURE_STATE_ATTR, policy.secure)
setattr(request.state, SESSION_COOKIE_ISSUED_STATE_ATTR, True)
logger.debug("Resolved auth session cookie policy: reason=%s secure=%s max_age=%s", policy.reason, policy.secure, policy.max_age)
return policy
@@ -0,0 +1,6 @@
"""Request-state keys shared by session and CSRF cookie handling."""
SESSION_COOKIE_ISSUED_STATE_ATTR = "deerflow_session_cookie_issued"
SESSION_COOKIE_MAX_AGE_STATE_ATTR = "deerflow_session_cookie_max_age"
SESSION_COOKIE_SECURE_STATE_ATTR = "deerflow_session_cookie_secure"
SKIP_AUTH_CSRF_COOKIE_STATE_ATTR = "deerflow_skip_auth_csrf_cookie"
+23 -5
View File
@@ -15,6 +15,7 @@ from starlette.responses import JSONResponse
from starlette.types import ASGIApp
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.session_cookie_state import SESSION_COOKIE_ISSUED_STATE_ATTR, SESSION_COOKIE_MAX_AGE_STATE_ATTR, SESSION_COOKIE_SECURE_STATE_ATTR, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR
from app.gateway.auth_disabled import is_auth_disabled
CSRF_COOKIE_NAME = "csrf_token"
@@ -181,6 +182,20 @@ def is_allowed_auth_origin(request: Request) -> bool:
return normalized_origin in _configured_cors_origins() or (request_origin is not None and normalized_origin == request_origin)
def auth_csrf_cookie_settings(request: Request) -> tuple[bool, int | None]:
"""Return ``(secure, max_age)`` for auth-created CSRF cookies."""
session_cookie_issued = getattr(request.state, SESSION_COOKIE_ISSUED_STATE_ATTR, False)
if session_cookie_issued:
return (
bool(getattr(request.state, SESSION_COOKIE_SECURE_STATE_ATTR, is_secure_request(request))),
getattr(request.state, SESSION_COOKIE_MAX_AGE_STATE_ATTR, None),
)
secure = is_secure_request(request)
max_age = get_auth_config().token_expiry_days * 24 * 3600 if secure else None
return secure, max_age
class CSRFMiddleware(BaseHTTPMiddleware):
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
@@ -214,23 +229,26 @@ class CSRFMiddleware(BaseHTTPMiddleware):
response = await call_next(request)
# For auth endpoints that set up session, also set CSRF cookie
if _is_auth and request.method == "POST":
# For auth endpoints that set up session, also set CSRF cookie.
# Session-creating handlers may stamp the final access-token max_age on
# request.state; mirroring it here keeps the double-submit cookie pair
# from diverging across HTTPS, localhost, and sandbox deployments.
if _is_auth and request.method == "POST" and not getattr(request.state, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR, False):
# Generate a new CSRF token for the session
csrf_token = generate_csrf_token()
is_https = is_secure_request(request)
secure, max_age = auth_csrf_cookie_settings(request)
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
secure=is_https,
secure=secure,
samesite="strict",
# Match the access_token cookie's lifetime (auth.py::_set_session_cookie)
# so the double-submit pair never diverges. A session-only csrf_token is
# evicted when iOS Safari terminates a home-screen PWA while the persistent
# access_token survives — leaving the user "logged in" but unable to make
# any state-changing request (403 "CSRF token missing").
max_age=get_auth_config().token_expiry_days * 24 * 3600 if is_https else None,
max_age=max_age,
)
return response
+26 -22
View File
@@ -9,7 +9,7 @@ import time
import urllib.parse
from ipaddress import ip_address, ip_network
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
from starlette.responses import RedirectResponse
@@ -31,8 +31,10 @@ from app.gateway.auth.oidc_state import (
get_state_cookie,
set_state_cookie,
)
from app.gateway.auth.session_cookie import ACCESS_TOKEN_COOKIE_NAME, SESSION_PERSISTENCE_COOKIE_NAME, set_session_cookie
from app.gateway.auth.session_cookie_state import SKIP_AUTH_CSRF_COOKIE_STATE_ATTR
from app.gateway.auth.user_provisioning import get_or_provision_oidc_user
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, generate_csrf_token, is_secure_request
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_csrf_cookie_settings, generate_csrf_token, is_secure_request
from app.gateway.deps import get_current_user_from_request, get_local_provider
from deerflow.config.auth_config import OIDCProviderConfig
@@ -126,6 +128,7 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
remember_me: bool = True
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@@ -136,6 +139,7 @@ class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8)
new_email: EmailStr | None = None
remember_me: bool | None = None
_strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@@ -149,18 +153,9 @@ class MessageResponse(BaseModel):
# ── Helpers ───────────────────────────────────────────────────────────────
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
def _set_session_cookie(response: Response, token: str, request: Request, *, remember_me: bool | None = None) -> None:
"""Set the access_token HttpOnly cookie on the response."""
config = get_auth_config()
is_https = is_secure_request(request)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=is_https,
samesite="lax",
max_age=config.token_expiry_days * 24 * 3600 if is_https else None,
)
set_session_cookie(response, request, token, remember_me=remember_me)
# ── Rate Limiting ────────────────────────────────────────────────────────
@@ -295,6 +290,7 @@ async def login_local(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
remember_me: bool = Form(default=True),
):
"""Local email/password login."""
client_ip = _get_client_ip(request)
@@ -311,7 +307,7 @@ async def login_local(
_record_login_success(client_ip)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
_set_session_cookie(response, token, request, remember_me=remember_me)
return LoginResponse(
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
@@ -335,7 +331,7 @@ async def register(request: Request, response: Response, body: RegisterRequest):
)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
_set_session_cookie(response, token, request, remember_me=body.remember_me)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
@@ -343,7 +339,11 @@ async def register(request: Request, response: Response, body: RegisterRequest):
@router.post("/logout", response_model=MessageResponse)
async def logout(request: Request, response: Response):
"""Logout current user by clearing the cookie."""
response.delete_cookie(key="access_token", secure=is_secure_request(request), samesite="lax")
is_https = is_secure_request(request)
response.delete_cookie(key=ACCESS_TOKEN_COOKIE_NAME, secure=is_https, samesite="lax")
response.delete_cookie(key=CSRF_COOKIE_NAME, secure=is_https, samesite="strict")
response.delete_cookie(key=SESSION_PERSISTENCE_COOKIE_NAME, secure=is_https, samesite="lax")
setattr(request.state, SKIP_AUTH_CSRF_COOKIE_STATE_ATTR, True)
return MessageResponse(message="Successfully logged out")
@@ -398,7 +398,8 @@ async def change_password(request: Request, response: Response, body: ChangePass
# Re-issue cookie with new token_version
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
_set_session_cookie(response, token, request, remember_me=body.remember_me)
_set_csrf_cookie(response, request)
return MessageResponse(message="Password changed successfully")
@@ -489,6 +490,7 @@ class InitializeAdminRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
remember_me: bool = True
_strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v)))
@@ -525,7 +527,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ
)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
_set_session_cookie(response, token, request, remember_me=body.remember_me)
return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, oauth_provider=user.oauth_provider)
@@ -552,17 +554,17 @@ async def close_oidc_service() -> None:
def _set_csrf_cookie(response: Response, request: Request) -> None:
"""Set the CSRF double-submit cookie (needed for GET-based OIDC callback)."""
csrf_token = generate_csrf_token()
is_https = is_secure_request(request)
secure, max_age = auth_csrf_cookie_settings(request)
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
secure=is_https,
secure=secure,
samesite="strict",
# Persist for the same lifetime as the access_token (see _set_session_cookie)
# so the double-submit pair is evicted together, never leaving a logged-in
# session whose csrf_token was dropped (e.g. iOS Safari PWA termination).
max_age=get_auth_config().token_expiry_days * 24 * 3600 if is_https else None,
max_age=max_age,
)
@@ -617,6 +619,7 @@ async def oauth_login(
request: Request,
provider: str,
next: str | None = None, # noqa: A002 (shadowing built-in is intentional — this is the query param name)
remember_me: bool = True,
):
"""Initiate OIDC login flow.
@@ -682,6 +685,7 @@ async def oauth_login(
nonce=nonce_value,
code_verifier=code_verifier,
next_path=redirect_path,
remember_me=remember_me,
)
redirect_response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND)
set_state_cookie(redirect_response, request, state_payload)
@@ -797,7 +801,7 @@ async def oauth_callback(
redirect_response = RedirectResponse(url=callback_redirect, status_code=status.HTTP_302_FOUND)
# Set session cookie (reuse existing helper)
_set_session_cookie(redirect_response, token, request)
_set_session_cookie(redirect_response, token, request, remember_me=state_payload.remember_me)
# Set CSRF cookie (callback is a GET, so CSRF middleware won't set it)
_set_csrf_cookie(redirect_response, request)
+372 -8
View File
@@ -634,6 +634,131 @@ def _get_set_cookie_headers(resp) -> list[str]:
return [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
def _get_response_set_cookie_headers(resp) -> list[str]:
return [v.decode("latin-1") for k, v in resp.raw_headers if k.lower() == b"set-cookie"]
def _make_request_scope(*, scheme: str = "http", host: str = "example.test", headers: dict[str, str] | None = None) -> dict:
raw_headers = [(b"host", host.encode("ascii"))]
for key, value in (headers or {}).items():
raw_headers.append((key.lower().encode("ascii"), value.encode("ascii")))
return {
"type": "http",
"method": "POST",
"path": "/api/v1/auth/login/local",
"headers": raw_headers,
"scheme": scheme,
"server": (host.split(":", 1)[0], 80 if scheme == "http" else 443),
"query_string": b"",
}
def test_session_cookie_policy_persists_on_https():
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="internal:8000", headers={"x-forwarded-proto": "https", "x-forwarded-host": "deerflow.example"}))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is True
assert policy.max_age == 7 * 24 * 3600
def test_session_cookie_policy_persists_on_localhost_http():
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="localhost:2026"))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is False
assert policy.max_age == 7 * 24 * 3600
def test_session_cookie_policy_persists_on_ipv4_loopback_range():
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="127.1.2.3:2026"))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is False
assert policy.max_age == 7 * 24 * 3600
def test_session_cookie_policy_degrades_public_http_to_session_cookie():
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="sandbox.example"))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is False
assert policy.max_age is None
@pytest.mark.parametrize(
"spoofed_headers",
[
{"x-forwarded-host": "localhost:2026"},
{"forwarded": 'for=192.0.2.1;host="localhost:2026";proto=http'},
],
)
def test_session_cookie_policy_ignores_forwarded_localhost_on_public_http(spoofed_headers):
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="sandbox.example", headers=spoofed_headers))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is False
assert policy.max_age is None
def test_session_cookie_policy_remember_me_false_is_session_cookie():
from starlette.requests import Request
from app.gateway.auth.session_cookie import resolve_session_cookie_policy
_setup_config()
request = Request(_make_request_scope(scheme="http", host="localhost:2026"))
policy = resolve_session_cookie_policy(request, remember_me=False)
assert policy.secure is False
assert policy.max_age is None
def test_session_cookie_policy_allows_operator_opt_in_for_public_http(monkeypatch):
from starlette.requests import Request
from app.gateway.auth.session_cookie import ALLOW_INSECURE_PERSISTENT_COOKIE_ENV, resolve_session_cookie_policy
_setup_config()
monkeypatch.setenv(ALLOW_INSECURE_PERSISTENT_COOKIE_ENV, "1")
request = Request(_make_request_scope(scheme="http", host="sandbox.example"))
policy = resolve_session_cookie_policy(request, remember_me=True)
assert policy.secure is False
assert policy.max_age == 7 * 24 * 3600
def test_register_http_cookie_httponly_true_secure_false():
"""HTTP register → access_token cookie is httponly=True, secure=False, no max_age."""
_setup_config()
@@ -666,6 +791,28 @@ def test_register_https_cookie_httponly_true_secure_true():
assert "max-age" in cookie_header.lower()
def test_register_remember_me_false_keeps_access_and_csrf_session_only():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
resp = client.post(
"/api/v1/auth/register",
json={"email": _unique_email("register-session"), "password": "Tr0ub4dor3a", "remember_me": False},
)
assert resp.status_code == 201
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
assert access_cookies and csrf_cookies and preference_cookies
assert "secure" in access_cookies[0].lower()
assert "secure" in csrf_cookies[0].lower()
assert "max-age" not in access_cookies[0].lower()
assert "max-age" not in csrf_cookies[0].lower()
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
def test_login_https_sets_secure_cookie():
"""HTTPS login → access_token cookie has secure flag."""
_setup_config()
@@ -684,6 +831,187 @@ def test_login_https_sets_secure_cookie():
assert "secure" in cookie_header.lower()
def test_login_remember_me_false_keeps_access_and_csrf_session_only():
"""remember_me=false should make both access_token and csrf_token session cookies."""
_setup_config()
client = TestClient(_make_auth_app(), base_url="http://localhost:2026")
email = _unique_email("remember-false")
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
resp = client.post(
"/api/v1/auth/login/local",
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
)
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
assert access_cookies, "access_token cookie not set on login"
assert csrf_cookies, "csrf_token cookie not set on login"
assert "max-age" not in access_cookies[0].lower()
assert "max-age" not in csrf_cookies[0].lower()
def test_login_remember_me_false_over_https_keeps_csrf_session_only():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
email = _unique_email("remember-false-https")
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
resp = client.post(
"/api/v1/auth/login/local",
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
)
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
assert access_cookies and csrf_cookies
assert "secure" in access_cookies[0].lower()
assert "secure" in csrf_cookies[0].lower()
assert "max-age" not in access_cookies[0].lower()
assert "max-age" not in csrf_cookies[0].lower()
def test_login_failure_uses_csrf_fallback_cookie_lifetime_on_https():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
resp = client.post(
"/api/v1/auth/login/local",
data={"username": "missing@example.com", "password": "wrong", "remember_me": "false"},
)
assert resp.status_code == 401
csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h]
assert csrf_cookies
assert "secure" in csrf_cookies[0].lower()
assert "max-age=604800" in csrf_cookies[0].lower()
def test_login_remember_me_true_keeps_access_and_csrf_max_age_in_lockstep_on_localhost():
"""localhost HTTP can persist, but access_token and csrf_token must share the same max_age."""
_setup_config()
client = TestClient(_make_auth_app(), base_url="http://localhost:2026")
email = _unique_email("remember-true")
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
resp = client.post(
"/api/v1/auth/login/local",
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "true"},
)
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
assert access_cookies and csrf_cookies
assert "max-age=604800" in access_cookies[0].lower()
assert "max-age=604800" in csrf_cookies[0].lower()
def test_change_password_preserves_session_only_preference():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
email = _unique_email("change-password-session")
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
client.post(
"/api/v1/auth/login/local",
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
)
csrf_token = client.cookies.get("csrf_token")
resp = client.post(
"/api/v1/auth/change-password",
json={"current_password": "Tr0ub4dor3a", "new_password": "An0therStrongPwd!"},
headers={"X-CSRF-Token": csrf_token},
)
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
assert access_cookies and preference_cookies
assert "max-age" not in access_cookies[0].lower()
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
def test_change_password_reissues_access_and_csrf_in_lockstep_when_preference_changes():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
email = _unique_email("change-password-persistent")
client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"})
client.post(
"/api/v1/auth/login/local",
data={"username": email, "password": "Tr0ub4dor3a", "remember_me": "false"},
)
csrf_token = client.cookies.get("csrf_token")
resp = client.post(
"/api/v1/auth/change-password",
json={
"current_password": "Tr0ub4dor3a",
"new_password": "An0therStrongPwd!",
"remember_me": True,
},
headers={"X-CSRF-Token": csrf_token},
)
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h.lower() for h in set_cookies if "access_token=" in h]
csrf_cookies = [h.lower() for h in set_cookies if "csrf_token=" in h]
assert access_cookies and csrf_cookies
assert "secure" in access_cookies[0]
assert "secure" in csrf_cookies[0]
assert "max-age=604800" in access_cookies[0]
assert "max-age=604800" in csrf_cookies[0]
def test_initialize_remember_me_false_keeps_access_and_csrf_session_only():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
resp = client.post(
"/api/v1/auth/initialize",
json={"email": _unique_email("init-session"), "password": "Tr0ub4dor3a", "remember_me": False},
)
assert resp.status_code == 201
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
assert access_cookies and csrf_cookies and preference_cookies
assert "secure" in access_cookies[0].lower()
assert "secure" in csrf_cookies[0].lower()
assert "max-age" not in access_cookies[0].lower()
assert "max-age" not in csrf_cookies[0].lower()
assert "deerflow_session_persistent=0" in preference_cookies[0].lower()
def test_logout_clears_access_and_csrf_without_reissuing_csrf():
_setup_config()
client = TestClient(_make_auth_app(), base_url="https://deerflow.example")
client.post(
"/api/v1/auth/register",
json={"email": _unique_email("logout-clear"), "password": "Tr0ub4dor3a"},
)
resp = client.post("/api/v1/auth/logout")
assert resp.status_code == 200
set_cookies = _get_set_cookie_headers(resp)
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
preference_cookies = [h for h in set_cookies if "deerflow_session_persistent=" in h]
assert access_cookies and "max-age=0" in access_cookies[0].lower()
assert csrf_cookies and "max-age=0" in csrf_cookies[0].lower()
assert preference_cookies and "max-age=0" in preference_cookies[0].lower()
def test_csrf_cookie_secure_on_https():
"""HTTPS register → csrf_token cookie has secure flag but NOT httponly."""
_setup_config()
@@ -764,8 +1092,8 @@ def test_csrf_cookie_session_only_on_http():
assert "max-age" not in csrf_cookies[0].lower()
def test_oidc_callback_csrf_cookie_persistent_on_https():
"""The OIDC-callback CSRF cookie helper is persistent over HTTPS too.
def test_oidc_callback_access_and_csrf_cookie_lifetime_match_on_https():
"""The OIDC-callback cookie helpers keep access/csrf attributes in lockstep.
``routers.auth._set_csrf_cookie`` is the second place a csrf_token cookie
is minted (GET OIDC callback, which CSRFMiddleware does not cover). It has
@@ -775,7 +1103,7 @@ def test_oidc_callback_csrf_cookie_persistent_on_https():
from starlette.requests import Request
from starlette.responses import Response
from app.gateway.routers.auth import _set_csrf_cookie
from app.gateway.routers.auth import _set_csrf_cookie, _set_session_cookie
_setup_config()
scope = {
@@ -788,8 +1116,44 @@ def test_oidc_callback_csrf_cookie_persistent_on_https():
"query_string": b"",
}
response = Response()
_set_csrf_cookie(response, Request(scope))
set_cookie = response.headers.get("set-cookie", "").lower()
assert "csrf_token=" in set_cookie
assert "secure" in set_cookie
assert "max-age" in set_cookie
request = Request(scope)
_set_session_cookie(response, "token", request, remember_me=True)
_set_csrf_cookie(response, request)
set_cookies = [h.lower() for h in _get_response_set_cookie_headers(response)]
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
assert access_cookies and csrf_cookies
assert "secure" in access_cookies[0]
assert "secure" in csrf_cookies[0]
assert "max-age=604800" in access_cookies[0]
assert "max-age=604800" in csrf_cookies[0]
def test_oidc_callback_access_and_csrf_cookie_stay_session_only():
from starlette.requests import Request
from starlette.responses import Response
from app.gateway.routers.auth import _set_csrf_cookie, _set_session_cookie
_setup_config()
scope = {
"type": "http",
"method": "GET",
"path": "/api/v1/auth/callback/example",
"headers": [(b"x-forwarded-proto", b"https")],
"scheme": "http",
"server": ("internal", 8000),
"query_string": b"",
}
response = Response()
request = Request(scope)
_set_session_cookie(response, "token", request, remember_me=False)
_set_csrf_cookie(response, request)
set_cookies = [h.lower() for h in _get_response_set_cookie_headers(response)]
access_cookies = [h for h in set_cookies if "access_token=" in h]
csrf_cookies = [h for h in set_cookies if "csrf_token=" in h]
assert access_cookies and csrf_cookies
assert "secure" in access_cookies[0]
assert "secure" in csrf_cookies[0]
assert "max-age" not in access_cookies[0]
assert "max-age" not in csrf_cookies[0]
+102
View File
@@ -0,0 +1,102 @@
# 管理页面保持登录方案
## 方案结论
实现“保持登录”,不实现“记住密码”。密码只随登录表单提交一次,不写入浏览器存储、日志、运行上下文或沙盒环境。
本次改造沿用现有认证链路:
`LoginPage -> /api/v1/auth/login/local -> LocalAuthProvider -> JWT -> HttpOnly access_token cookie -> AuthProvider / fetchWithAuth / AuthMiddleware`
新增点只挂在原链路上:
- 前端登录页新增“保持登录”勾选项。
- 登录表单新增 `remember_me` 字段。
- Gateway 新增统一会话 cookie 策略。
- CSRF cookie 使用与 access cookie 相同的最终生命周期。
## 背景与目标
Issue #4194 的用户诉求是“不要每次登录都填写账号密码”。现有 Gateway 已使用 `HttpOnly access_token` cookie 和双提交 `csrf_token` cookie。问题不应通过保存密码解决,而应通过更明确的会话持久化策略解决。
目标:
- 提升再次打开页面时的登录体验。
- 保持密码不落盘。
- 覆盖本地、HTTPS 反代、远程沙盒等部署形态。
- 避免 `access_token``csrf_token` 生命周期不一致导致“已登录但 POST 403”。
## 链路设计
### 前端
登录页新增两个状态:
- `rememberMe`:是否保持登录,默认 `true`
- remembered email:仅在用户选择保持登录时保存邮箱到 `localStorage`,便于下次预填。
登录成功后:
- `rememberMe=true`:保存邮箱和偏好。
- `rememberMe=false`:清除已保存邮箱和偏好。
前端不保存密码,不保存 token。
### Gateway
登录接口接收 `remember_me`
```text
username=<email>&password=<password>&remember_me=true|false
```
`SessionCookiePolicy` 统一决策:
| 场景 | 策略 |
| ---- | ---- |
| `remember_me=false` | session cookie |
| HTTPS 或可信反代后的 HTTPS | `Secure + Max-Age` |
| `localhost` / loopback HTTP | 允许 `Max-Age`,便于本地开发 |
| 公网 HTTP / 临时沙盒 HTTP | 降级为 session cookie |
| 显式运维开关允许公网 HTTP 持久化 | 非默认,需环境变量显式开启 |
Gateway 在设置 `access_token` 后,把最终 `max_age` 写入 `request.state`。CSRF middleware 设置 `csrf_token` 时读取同一个值,确保两枚 cookie 同寿命。
Gateway 还会写入一个 `HttpOnly` 的会话偏好 cookie,用于改密、管理员初始化、OIDC callback 等重新签发 session 的路径。这样用户在登录时取消“保持登录”后,后续重新签发 token 不会被静默升级成持久 cookie。
### 沙盒边界
认证 cookie 只属于浏览器和 Gateway
- 不传入 agent runtime context。
- 不注入 sandbox env。
- 不写入 checkpoint。
- 不影响 IM channel 内部鉴权链路。
远程沙盒如果每次使用不同公网域名,浏览器 cookie 无法跨域复用,这是浏览器隔离规则,不在本 issue 内解决。
### 非目标
本次不处理跨站 iframe 持久登录。该场景需要 `SameSite=None; Secure`、明确嵌入 allowlist 和额外 CSRF/点击劫持评估,应作为单独方案。
## 验证计划
- 后端 cookie policy 单测:
- HTTPS 持久化。
- `localhost` HTTP 持久化。
- 公网 HTTP 降级 session。
- `remember_me=false` 降级 session。
- 运维开关允许公网 HTTP 持久化。
- 后端 API 契约测试:
- 登录表单携带 `remember_me=false``access_token``Max-Age`
- `access_token``csrf_token``Max-Age` 一致。
- 失败登录、改密、初始化、OIDC helper 路径保留正确 cookie 生命周期。
- logout 清理 `access_token``csrf_token`
- 前端单测:
- 只保存邮箱和偏好。
- 关闭保持登录会清理邮箱。
- localStorage 异常时不阻塞登录页。
- 手工验证:
- `localhost:2026` 登录后重开浏览器仍可访问工作区。
- HTTPS 反代下 `Set-Cookie``Secure``Max-Age`
- 公网 HTTP 地址不产生持久 cookie。
+2
View File
@@ -70,6 +70,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat
5. TanStack Query manages server state; localStorage stores user settings
6. Components subscribe to thread state and render updates
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight.
Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to `ToolMessage.artifact.human_input`, `src/core/messages/human-input.ts` owns the runtime validators/types, and `src/components/workspace/messages/human-input-card.tsx` renders the reusable card. `MessageList` owns answered/latest/pending state for visible cards, but derives answered responses from raw `thread.messages` because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new `thread.error` reports an async stream failure. Page-level submit callbacks must send a normal human message and put `hide_from_ui: true` plus the response payload in the fourth `sendMessage(..., options)` argument as `options.additionalKwargs`; the third argument remains run context such as `{ agent_name }`. Composer entry points should disable normal bottom input while `hasOpenHumanInputRequest(...)` is true so users answer through the card and preserve response metadata.
+28 -3
View File
@@ -5,10 +5,15 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import {
loadRememberLoginPreference,
saveRememberLoginPreference,
} from "@/core/auth/remember-login";
import {
canCreateRegularAccount,
fetchSetupStatus,
@@ -59,6 +64,7 @@ export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(true);
const [isLogin, setIsLogin] = useState(true);
const [ssoProviders, setSsoProviders] = useState<
{ id: string; display_name: string; type: string }[]
@@ -99,6 +105,14 @@ export default function LoginPage() {
}
}, [isAuthenticated, redirectPath, router]);
useEffect(() => {
const preference = loadRememberLoginPreference();
setRememberMe(preference.rememberMe);
if (preference.email) {
setEmail(preference.email);
}
}, []);
// Fetch setup state and SSO providers
useEffect(() => {
let cancelled = false;
@@ -159,8 +173,12 @@ export default function LoginPage() {
? "/api/v1/auth/login/local"
: "/api/v1/auth/register";
const body = isLogin
? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`
: JSON.stringify({ email, password });
? new URLSearchParams({
password,
remember_me: String(rememberMe),
username: email,
})
: JSON.stringify({ email, password, remember_me: rememberMe });
const headers: HeadersInit = isLogin
? { "Content-Type": "application/x-www-form-urlencoded" }
@@ -185,6 +203,8 @@ export default function LoginPage() {
return;
}
saveRememberLoginPreference({ email, rememberMe });
// Both login and register set a cookie — redirect to workspace
router.push(redirectPath);
} catch {
@@ -258,6 +278,11 @@ export default function LoginPage() {
/>
</div>
<RememberSessionOption
checked={rememberMe}
onCheckedChange={setRememberMe}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
@@ -296,7 +321,7 @@ export default function LoginPage() {
className="w-full"
disabled={loading}
onClick={() => {
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}`;
window.location.href = `/api/v1/auth/oauth/${provider.id}?next=${encodeURIComponent(redirectPath)}&remember_me=${String(rememberMe)}`;
}}
>
{t.login.continueWith(provider.display_name)}
+15
View File
@@ -4,11 +4,13 @@ import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import { Input } from "@/components/ui/input";
import { getCsrfHeaders } from "@/core/api/fetcher";
import { useAuth } from "@/core/auth/AuthProvider";
import { loadRememberLoginPreference } from "@/core/auth/remember-login";
import {
fetchSetupStatus,
isSystemAlreadyInitializedError,
@@ -29,6 +31,9 @@ export default function SetupPage() {
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(
() => loadRememberLoginPreference().rememberMe,
);
// --- Change-password mode only ---
const [currentPassword, setCurrentPassword] = useState("");
@@ -82,6 +87,7 @@ export default function SetupPage() {
body: JSON.stringify({
email,
password: newPassword,
remember_me: rememberMe,
}),
});
@@ -131,6 +137,7 @@ export default function SetupPage() {
current_password: currentPassword,
new_password: newPassword,
new_email: email || undefined,
remember_me: rememberMe,
}),
});
@@ -221,6 +228,10 @@ export default function SetupPage() {
minLength={8}
/>
</div>
<RememberSessionOption
checked={rememberMe}
onCheckedChange={setRememberMe}
/>
{error && <p className="ms-1 text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating account…" : "Create Admin Account"}
@@ -283,6 +294,10 @@ export default function SetupPage() {
required
minLength={8}
/>
<RememberSessionOption
checked={rememberMe}
onCheckedChange={setRememberMe}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Setting up…" : "Complete Setup"}
@@ -0,0 +1,32 @@
"use client";
import { useI18n } from "@/core/i18n/hooks";
interface RememberSessionOptionProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
export function RememberSessionOption({
checked,
onCheckedChange,
}: RememberSessionOptionProps) {
const { t } = useI18n();
return (
<label className="text-muted-foreground flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={checked}
onChange={(event) => onCheckedChange(event.currentTarget.checked)}
className="border-input mt-1 h-4 w-4 rounded"
/>
<span>
<span className="text-foreground block font-medium">
{t.login.rememberMe}
</span>
<span>{t.login.rememberMeDescription}</span>
</span>
</label>
);
}
+47
View File
@@ -0,0 +1,47 @@
const REMEMBER_LOGIN_KEY = "deerflow.auth.remember_login";
const REMEMBERED_EMAIL_KEY = "deerflow.auth.remembered_email";
export interface RememberLoginPreference {
email: string;
rememberMe: boolean;
}
function getStorage(): Storage | null {
if (typeof globalThis.localStorage === "undefined") return null;
return globalThis.localStorage;
}
export function loadRememberLoginPreference(): RememberLoginPreference {
try {
const storage = getStorage();
if (!storage) {
return { email: "", rememberMe: true };
}
const rememberValue = storage.getItem(REMEMBER_LOGIN_KEY);
const rememberMe = rememberValue !== "0";
return {
email: rememberMe ? (storage.getItem(REMEMBERED_EMAIL_KEY) ?? "") : "",
rememberMe,
};
} catch {
return { email: "", rememberMe: true };
}
}
export function saveRememberLoginPreference({
email,
rememberMe,
}: RememberLoginPreference): void {
try {
const storage = getStorage();
if (!storage) return;
storage.setItem(REMEMBER_LOGIN_KEY, rememberMe ? "1" : "0");
if (rememberMe) {
storage.setItem(REMEMBERED_EMAIL_KEY, email);
} else {
storage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Login must not depend on localStorage availability.
}
}
+3
View File
@@ -828,6 +828,9 @@ export const enUS: Translations = {
emailPlaceholder: "you@example.com",
password: "Password",
passwordPlaceholder: "•••••••",
rememberMe: "Keep me signed in",
rememberMeDescription:
"Keep this browser session when possible. DeerFlow stores only your email, never your password.",
pleaseWait: "Please wait...",
signIn: "Sign In",
createAccount: "Create Account",
+2
View File
@@ -702,6 +702,8 @@ export interface Translations {
emailPlaceholder: string;
password: string;
passwordPlaceholder: string;
rememberMe: string;
rememberMeDescription: string;
pleaseWait: string;
signIn: string;
createAccount: string;
+3
View File
@@ -797,6 +797,9 @@ export const zhCN: Translations = {
emailPlaceholder: "you@example.com",
password: "密码",
passwordPlaceholder: "•••••••",
rememberMe: "保持登录",
rememberMeDescription:
"下次打开 DeerFlow 时尽量保持当前会话,仅保存邮箱,不保存密码。",
pleaseWait: "请稍候...",
signIn: "登录",
createAccount: "创建账号",
@@ -0,0 +1,32 @@
import { describe, expect, test } from "@rstest/core";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { I18nContext } from "@/core/i18n/context";
describe("RememberSessionOption", () => {
test("uses the active locale for setup and login copy", () => {
const markup = renderToStaticMarkup(
createElement(
I18nContext.Provider,
{
value: {
locale: "zh-CN",
setLocale: () => undefined,
},
},
createElement(RememberSessionOption, {
checked: true,
onCheckedChange: () => undefined,
}),
),
);
expect(markup).toContain("保持登录");
expect(markup).toContain(
"下次打开 DeerFlow 时尽量保持当前会话,仅保存邮箱,不保存密码。",
);
expect(markup).not.toContain("Keep me signed in");
});
});
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
import {
loadRememberLoginPreference,
saveRememberLoginPreference,
} from "@/core/auth/remember-login";
function makeStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: rs.fn((key: string) => values.get(key) ?? null),
setItem: rs.fn((key: string, value: string) => {
values.set(key, value);
}),
removeItem: rs.fn((key: string) => {
values.delete(key);
}),
values,
};
}
describe("remember login helpers", () => {
afterEach(() => {
rs.unstubAllGlobals();
});
test("loads default keep-signed-in preference without a saved email", () => {
const storage = makeStorage();
rs.stubGlobal("localStorage", storage);
expect(loadRememberLoginPreference()).toEqual({
email: "",
rememberMe: true,
});
});
test("saves only email and preference when enabled", () => {
const storage = makeStorage();
rs.stubGlobal("localStorage", storage);
saveRememberLoginPreference({
email: "admin@example.com",
rememberMe: true,
});
expect(storage.values.get("deerflow.auth.remember_login")).toBe("1");
expect(storage.values.get("deerflow.auth.remembered_email")).toBe(
"admin@example.com",
);
expect([...storage.values.values()]).not.toContain("password");
});
test("clears saved email when disabled", () => {
const storage = makeStorage({
"deerflow.auth.remember_login": "1",
"deerflow.auth.remembered_email": "admin@example.com",
});
rs.stubGlobal("localStorage", storage);
saveRememberLoginPreference({
email: "admin@example.com",
rememberMe: false,
});
expect(storage.values.get("deerflow.auth.remember_login")).toBe("0");
expect(storage.values.has("deerflow.auth.remembered_email")).toBe(false);
});
test("falls back safely when localStorage is unavailable", () => {
rs.stubGlobal("localStorage", {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
removeItem: () => {
throw new Error("blocked");
},
});
expect(loadRememberLoginPreference()).toEqual({
email: "",
rememberMe: true,
});
expect(() =>
saveRememberLoginPreference({
email: "admin@example.com",
rememberMe: true,
}),
).not.toThrow();
});
});