From b2704525a07cf76adc53794730551a8217706896 Mon Sep 17 00:00:00 2001 From: greatmengqi Date: Thu, 9 Apr 2026 11:29:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20release-validation=20pass=20for?= =?UTF-8?q?=202.0-rc=20=E2=80=94=2012=20blockers=20+=20simplify=20follow-u?= =?UTF-8?q?ps=20(#2008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): introduce backend auth module Port RFC-001 authentication core from PR #1728: - JWT token handling (create_access_token, decode_token, TokenPayload) - Password hashing (bcrypt) with verify_password - SQLite UserRepository with base interface - Provider Factory pattern (LocalAuthProvider) - CLI reset_admin tool - Auth-specific errors (AuthErrorCode, TokenError, AuthErrorResponse) Deps: - bcrypt>=4.0.0 - pyjwt>=2.9.0 - email-validator>=2.0.0 - backend/uv.toml pins public PyPI index Tests: 12 pure unit tests (test_auth_config.py, test_auth_errors.py). Scope note: authz.py, test_auth.py, and test_auth_type_system.py are deferred to commit 2 because they depend on middleware and deps wiring that is not yet in place. Commit 1 stays "pure new files only" as the spec mandates. * feat(auth): wire auth end-to-end (middleware + frontend replacement) Backend: - Port auth_middleware, csrf_middleware, langgraph_auth, routers/auth - Port authz decorator (owner_filter_key defaults to 'owner_id') - Merge app.py: register AuthMiddleware + CSRFMiddleware + CORS, add _ensure_admin_user lifespan hook, _migrate_orphaned_threads helper, register auth router - Merge deps.py: add get_local_provider, get_current_user_from_request, get_optional_user_from_request; keep get_current_user as thin str|None adapter for feedback router - langgraph.json: add auth path pointing to langgraph_auth.py:auth - Rename metadata['user_id'] -> metadata['owner_id'] in langgraph_auth (both metadata write and LangGraph filter dict) + test fixtures Frontend: - Delete better-auth library and api catch-all route - Remove better-auth npm dependency and env vars (BETTER_AUTH_SECRET, BETTER_AUTH_GITHUB_*) from env.js - Port frontend/src/core/auth/* (AuthProvider, gateway-config, proxy-policy, server-side getServerSideUser, types) - Port frontend/src/core/api/fetcher.ts - Port (auth)/layout, (auth)/login, (auth)/setup pages - Rewrite workspace/layout.tsx as server component that calls getServerSideUser and wraps in AuthProvider - Port workspace/workspace-content.tsx for the client-side sidebar logic Tests: - Port 5 auth test files (test_auth, test_auth_middleware, test_auth_type_system, test_ensure_admin, test_langgraph_auth) - 176 auth tests PASS After this commit: login/logout/registration flow works, but persistence layer does not yet filter by owner_id. Commit 4 closes that gap. * feat(auth): account settings page + i18n - Port account-settings-page.tsx (change password, change email, logout) - Wire into settings-dialog.tsx as new "account" section with UserIcon, rendered first in the section list - Add i18n keys: - en-US/zh-CN: settings.sections.account ("Account" / "账号") - en-US/zh-CN: button.logout ("Log out" / "退出登录") - types.ts: matching type declarations * feat(auth): enforce owner_id across 2.0-rc persistence layer Add request-scoped contextvar-based owner filtering to threads_meta, runs, run_events, and feedback repositories. Router code is unchanged — isolation is enforced at the storage layer so that any caller that forgets to pass owner_id still gets filtered results, and new routes cannot accidentally leak data. Core infrastructure ------------------- - deerflow/runtime/user_context.py (new): - ContextVar[CurrentUser | None] with default None - runtime_checkable CurrentUser Protocol (structural subtype with .id) - set/reset/get/require helpers - AUTO sentinel + resolve_owner_id(value, method_name) for sentinel three-state resolution: AUTO reads contextvar, explicit str overrides, explicit None bypasses the filter (for migration/CLI) Repository changes ------------------ - ThreadMetaRepository: create/get/search/update_*/delete gain owner_id=AUTO kwarg; read paths filter by owner, writes stamp it, mutations check ownership before applying - RunRepository: put/get/list_by_thread/delete gain owner_id=AUTO kwarg - FeedbackRepository: create/get/list_by_run/list_by_thread/delete gain owner_id=AUTO kwarg - DbRunEventStore: list_messages/list_events/list_messages_by_run/ count_messages/delete_by_thread/delete_by_run gain owner_id=AUTO kwarg. Write paths (put/put_batch) read contextvar softly: when a request-scoped user is available, owner_id is stamped; background worker writes without a user context pass None which is valid (orphan row to be bound by migration) Schema ------ - persistence/models/run_event.py: RunEventRow.owner_id = Mapped[ str | None] = mapped_column(String(64), nullable=True, index=True) - No alembic migration needed: 2.0 ships fresh, Base.metadata.create_all picks up the new column automatically Middleware ---------- - auth_middleware.py: after cookie check, call get_optional_user_from_ request to load the real User, stamp it into request.state.user AND the contextvar via set_current_user, reset in a try/finally. Public paths and unauthenticated requests continue without contextvar, and @require_auth handles the strict 401 path Test infrastructure ------------------- - tests/conftest.py: @pytest.fixture(autouse=True) _auto_user_context sets a default SimpleNamespace(id="test-user-autouse") on every test unless marked @pytest.mark.no_auto_user. Keeps existing 20+ persistence tests passing without modification - pyproject.toml [tool.pytest.ini_options]: register no_auto_user marker so pytest does not emit warnings for opt-out tests - tests/test_user_context.py: 6 tests covering three-state semantics, Protocol duck typing, and require/optional APIs - tests/test_thread_meta_repo.py: one test updated to pass owner_id= None explicitly where it was previously relying on the old default Test results ------------ - test_user_context.py: 6 passed - test_auth*.py + test_langgraph_auth.py + test_ensure_admin.py: 127 - test_run_event_store / test_run_repository / test_thread_meta_repo / test_feedback: 92 passed - Full backend suite: 1905 passed, 2 failed (both @requires_llm flaky integration tests unrelated to auth), 1 skipped * feat(auth): extend orphan migration to 2.0-rc persistence tables _ensure_admin_user now runs a three-step pipeline on every boot: Step 1 (fatal): admin user exists / is created / password is reset Step 2 (non-fatal): LangGraph store orphan threads → admin Step 3 (non-fatal): SQL persistence tables → admin - threads_meta - runs - run_events - feedback Each step is idempotent. The fatal/non-fatal split mirrors PR #1728's original philosophy: admin creation failure blocks startup (the system is unusable without an admin), whereas migration failures log a warning and let the service proceed (a partial migration is recoverable; a missing admin is not). Key helpers ----------- - _iter_store_items(store, namespace, *, page_size=500): async generator that cursor-paginates across LangGraph store pages. Fixes PR #1728's hardcoded limit=1000 bug that would silently lose orphans beyond the first page. - _migrate_orphaned_threads(store, admin_user_id): Rewritten to use _iter_store_items. Returns the migrated count so the caller can log it; raises only on unhandled exceptions. - _migrate_orphan_sql_tables(admin_user_id): Imports the 4 ORM models lazily, grabs the shared session factory, runs one UPDATE per table in a single transaction, commits once. No-op when no persistence backend is configured (in-memory dev). Tests: test_ensure_admin.py (8 passed) * test(auth): port AUTH test plan docs + lint/format pass - Port backend/docs/AUTH_TEST_PLAN.md and AUTH_UPGRADE.md from PR #1728 - Rename metadata.user_id → metadata.owner_id in AUTH_TEST_PLAN.md (4 occurrences from the original PR doc) - ruff auto-fix UP037 in sentinel type annotations: drop quotes around "str | None | _AutoSentinel" now that from __future__ import annotations makes them implicit string forms - ruff format: 2 files (app/gateway/app.py, runtime/user_context.py) Note on test coverage additions: - conftest.py autouse fixture was already added in commit 4 (had to be co-located with the repository changes to keep pre-existing persistence tests passing) - cross-user isolation E2E tests (test_owner_isolation.py) deferred — enforcement is already proven by the 98-test repository suite via the autouse fixture + explicit _AUTO sentinel exercises - New test cases (TC-API-17..20, TC-ATK-13, TC-MIG-01..07) listed in AUTH_TEST_PLAN.md are deferred to a follow-up PR — they are manual-QA test cases rather than pytest code, and the spec-level coverage is already met by test_user_context.py + the 98-test repository suite. Final test results: - Auth suite (test_auth*, test_langgraph_auth, test_ensure_admin, test_user_context): 186 passed - Persistence suite (test_run_event_store, test_run_repository, test_thread_meta_repo, test_feedback): 98 passed - Lint: ruff check + ruff format both clean * test(auth): add cross-user isolation test suite 10 tests exercising the storage-layer owner filter by manually switching the user_context contextvar between two users. Verifies the safety invariant: After a repository write with owner_id=A, a subsequent read with owner_id=B must not return the row, and vice versa. Covers all 4 tables that own user-scoped data: TC-API-17 threads_meta — read, search, update, delete cross-user TC-API-18 runs — get, list_by_thread, delete cross-user TC-API-19 run_events — list_messages, list_events, count_messages, delete_by_thread (CRITICAL: raw conversation content leak vector) TC-API-20 feedback — get, list_by_run, delete cross-user Plus two meta-tests verifying the sentinel pattern itself: - AUTO + unset contextvar raises RuntimeError - explicit owner_id=None bypasses the filter (migration escape hatch) Architecture note ----------------- These tests bypass the HTTP layer by design. The full chain (cookie → middleware → contextvar → repository) is covered piecewise: - test_auth_middleware.py: middleware sets contextvar from cookies - test_owner_isolation.py: repositories enforce isolation when contextvar is set to different users Together they prove the end-to-end safety property without the ceremony of spinning up a full TestClient + in-memory DB for every router endpoint. Tests pass: 231 (full auth + persistence + isolation suite) Lint: clean * refactor(auth): migrate user repository to SQLAlchemy ORM Move the users table into the shared persistence engine so auth matches the pattern of threads_meta, runs, run_events, and feedback — one engine, one session factory, one schema init codepath. New files --------- - persistence/user/__init__.py, persistence/user/model.py: UserRow ORM class with partial unique index on (oauth_provider, oauth_id) - Registered in persistence/models/__init__.py so Base.metadata.create_all() picks it up Modified -------- - auth/repositories/sqlite.py: rewritten as async SQLAlchemy, identical constructor pattern to the other four repositories (def __init__(self, session_factory) + self._sf = session_factory) - auth/config.py: drop users_db_path field — storage is configured through config.database like every other table - deps.py/get_local_provider: construct SQLiteUserRepository with the shared session factory, fail fast if engine is not initialised - tests/test_auth.py: rewrite test_sqlite_round_trip_new_fields to use the shared engine (init_engine + close_engine in a tempdir) - tests/test_auth_type_system.py: add per-test autouse fixture that spins up a scratch engine and resets deps._cached_* singletons * refactor(auth): remove SQL orphan migration (unused in supported scenarios) The _migrate_orphan_sql_tables helper existed to bind NULL owner_id rows in threads_meta, runs, run_events, and feedback to the admin on first boot. But in every supported upgrade path, it's a no-op: 1. Fresh install: create_all builds fresh tables, no legacy rows 2. No-auth → with-auth (no existing persistence DB): persistence tables are created fresh by create_all, no legacy rows 3. No-auth → with-auth (has existing persistence DB from #1930): NOT a supported upgrade path — "有 DB 到有 DB" schema evolution is out of scope; users wipe DB or run manual ALTER So the SQL orphan migration never has anything to do in the supported matrix. Delete the function, simplify _ensure_admin_user from a 3-step pipeline to a 2-step one (admin creation + LangGraph store orphan migration only). LangGraph store orphan migration stays: it serves the real "no-auth → with-auth" upgrade path where a user's existing LangGraph thread metadata has no owner_id field and needs to be stamped with the newly-created admin's id. Tests: 284 passed (auth + persistence + isolation) Lint: clean * security(auth): write initial admin password to 0600 file instead of logs CodeQL py/clear-text-logging-sensitive-data flagged 3 call sites that logged the auto-generated admin password to stdout via logger.info(). Production log aggregators (ELK/Splunk/etc) would have captured those cleartext secrets. Replace with a shared helper that writes to .deer-flow/admin_initial_credentials.txt with mode 0600, and log only the path. New file -------- - app/gateway/auth/credential_file.py: write_initial_credentials() helper. Takes email, password, and a "initial"/"reset" label. Creates .deer-flow/ if missing, writes a header comment plus the email+password, chmods 0o600, returns the absolute Path. Modified -------- - app/gateway/app.py: both _ensure_admin_user paths (fresh creation + needs_setup password reset) now write to file and log the path - app/gateway/auth/reset_admin.py: rewritten to use the shared ORM repo (SQLiteUserRepository with session_factory) and the credential_file helper. The previous implementation was broken after the earlier ORM refactor — it still imported _get_users_conn and constructed SQLiteUserRepository() without a session factory. No tests changed — the three password-log sites are all exercised via existing test_ensure_admin.py which checks that startup succeeds, not that a specific string appears in logs. CodeQL alerts 272, 283, 284: all resolved. * security(auth): strict JWT validation in middleware (fix junk cookie bypass) AUTH_TEST_PLAN test 7.5.8 expects junk cookies to be rejected with 401. The previous middleware behaviour was "presence-only": check that some access_token cookie exists, then pass through. In combination with my Task-12 decision to skip @require_auth decorators on routes, this created a gap where a request with any cookie-shaped string (e.g. access_token=not-a-jwt) would bypass authentication on routes that do not touch the repository (/api/models, /api/mcp/config, /api/memory, /api/skills, …). Fix: middleware now calls get_current_user_from_request() strictly and catches the resulting HTTPException to render a 401 with the proper fine-grained error code (token_invalid, token_expired, user_not_found, …). On success it stamps request.state.user and the contextvar so repository-layer owner filters work downstream. The 4 old "_with_cookie_passes" tests in test_auth_middleware.py were written for the presence-only behaviour; they asserted that a junk cookie would make the handler return 200. They are renamed to "_with_junk_cookie_rejected" and their assertions flipped to 401. The negative path (no cookie → 401 not_authenticated) is unchanged. Verified: no cookie → 401 not_authenticated junk cookie → 401 token_invalid (the fixed bug) expired cookie → 401 token_expired Tests: 284 passed (auth + persistence + isolation) Lint: clean * security(auth): wire @require_permission(owner_check=True) on isolation routes Apply the require_permission decorator to all 28 routes that take a {thread_id} path parameter. Combined with the strict middleware (previous commit), this gives the double-layer protection that AUTH_TEST_PLAN test 7.5.9 documents: Layer 1 (AuthMiddleware): cookie + JWT validation, rejects junk cookies and stamps request.state.user Layer 2 (@require_permission with owner_check=True): per-resource ownership verification via ThreadMetaStore.check_access — returns 404 if a different user owns the thread The decorator's owner_check branch is rewritten to use the SQL thread_meta_repo (the 2.0-rc persistence layer) instead of the LangGraph store path that PR #1728 used (_store_get / get_store in routers/threads.py). The inject_record convenience is dropped — no caller in 2.0 needs the LangGraph blob, and the SQL repo has a different shape. Routes decorated (28 total): - threads.py: delete, patch, get, get-state, post-state, post-history - thread_runs.py: post-runs, post-runs-stream, post-runs-wait, list_runs, get_run, cancel_run, join_run, stream_existing_run, list_thread_messages, list_run_messages, list_run_events, thread_token_usage - feedback.py: create, list, stats, delete - uploads.py: upload (added Request param), list, delete - artifacts.py: get_artifact - suggestions.py: generate (renamed body parameter to avoid conflict with FastAPI Request) Test fixes: - test_suggestions_router.py: bypass the decorator via __wrapped__ (the unit tests cover parsing logic, not auth — no point spinning up a thread_meta_repo just to test JSON unwrapping) - test_auth_middleware.py 4 fake-cookie tests: already updated in the previous commit (745bf432) Tests: 293 passed (auth + persistence + isolation + suggestions) Lint: clean * security(auth): defense-in-depth fixes from release validation pass Eight findings caught while running the AUTH_TEST_PLAN end-to-end against the deployed sg_dev stack. Each is a pre-condition for shipping release/2.0-rc that the previous PRs missed. Backend hardening - routers/auth.py: rate limiter X-Real-IP now requires AUTH_TRUSTED_PROXIES whitelist (CIDR/IP allowlist). Without nginx in front, the previous code honored arbitrary X-Real-IP, letting an attacker rotate the header to fully bypass the per-IP login lockout. - routers/auth.py: 36-entry common-password blocklist via Pydantic field_validator on RegisterRequest + ChangePasswordRequest. The shared _validate_strong_password helper keeps the constraint in one place. - routers/threads.py: ThreadCreateRequest + ThreadPatchRequest strip server-reserved metadata keys (owner_id, user_id) via Pydantic field_validator so a forged value can never round-trip back to other clients reading the same thread. The actual ownership invariant stays on the threads_meta row; this closes the metadata-blob echo gap. - authz.py + thread_meta/sql.py: require_permission gains a require_existing flag plumbed through check_access(require_existing=True). Destructive routes (DELETE/PATCH/state-update/runs/feedback) now treat a missing thread_meta row as 404 instead of "untracked legacy thread, allow", closing the cross-user delete-idempotence gap where any user could successfully DELETE another user's deleted thread. - repositories/sqlite.py + base.py: update_user raises UserNotFoundError on a vanished row instead of silently returning the input. Concurrent delete during password reset can no longer look like a successful update. - runtime/user_context.py: resolve_owner_id() coerces User.id (UUID) to str at the contextvar boundary so SQLAlchemy String(64) columns can bind it. The whole 2.0-rc isolation pipeline was previously broken end-to-end (POST /api/threads → 500 "type 'UUID' is not supported"). - persistence/engine.py: SQLAlchemy listener enables PRAGMA journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON on every new SQLite connection. TC-UPG-06 in the test plan expects WAL; previous code shipped with the default 'delete' journal. - auth_middleware.py: stamp request.state.auth = AuthContext(...) so @require_permission's short-circuit fires; previously every isolation request did a duplicate JWT decode + users SELECT. Also unifies the 401 payload through AuthErrorResponse(...).model_dump(). - app.py: _ensure_admin_user restructure removes the noqa F821 scoping bug where 'password' was referenced outside the branch that defined it. New _announce_credentials helper absorbs the duplicate log block in the fresh-admin and reset-admin branches. * fix(frontend+nginx): rollout CSRF on every state-changing client path The frontend was 100% broken in gateway-pro mode for any user trying to open a specific chat thread. Three cumulative bugs each silently masked the next. LangGraph SDK CSRF gap (api-client.ts) - The Client constructor took only apiUrl, no defaultHeaders, no fetch interceptor. The SDK's internal fetch never sent X-CSRF-Token, so every state-changing /api/langgraph-compat/* call (runs/stream, threads/search, threads/{tid}/history, ...) hit CSRFMiddleware and got 403 before reaching the auth check. UI symptom: empty thread page with no error message; the SPA's hooks swallowed the rejection. - Fix: pass an onRequest hook that injects X-CSRF-Token from the csrf_token cookie per request. Reading the cookie per call (not at construction time) handles login / logout / password-change cookie rotation transparently. The SDK's prepareFetchOptions calls onRequest for both regular requests AND streaming/SSE/reconnect, so the same hook covers runs.stream and runs.joinStream. Raw fetch CSRF gap (7 files) - Audit: 11 frontend fetch sites, only 2 included CSRF (login/setup + account-settings change-password). The other 7 routed through raw fetch() with no header — suggestions, memory, agents, mcp, skills, uploads, and the local thread cleanup hook all 403'd silently. - Fix: enhance fetcher.ts:fetchWithAuth to auto-inject X-CSRF-Token on POST/PUT/DELETE/PATCH from a single shared readCsrfCookie() helper. Convert all 7 raw fetch() callers to fetchWithAuth so the contract is centrally enforced. api-client.ts and fetcher.ts share readCsrfCookie + STATE_CHANGING_METHODS to avoid drift. nginx routing + buffering (nginx.local.conf) - The auth feature shipped without updating the nginx config: per-API explicit location blocks but no /api/v1/auth/, /api/feedback, /api/runs. The frontend's client-side fetches to /api/v1/auth/login/local 404'd from the Next.js side because nginx routed /api/* to the frontend. - Fix: add catch-all `location /api/` that proxies to the gateway. nginx longest-prefix matching keeps the explicit blocks (/api/models, /api/threads regex, /api/langgraph/, ...) winning for their paths. - Fix: disable proxy_buffering + proxy_request_buffering for the frontend `location /` block. Without it, nginx tries to spool large Next.js chunks into /var/lib/nginx/proxy (root-owned) and fails with Permission denied → ERR_INCOMPLETE_CHUNKED_ENCODING → ChunkLoadError. * test(auth): release-validation test infra and new coverage Test fixtures and unit tests added during the validation pass. Router test helpers (NEW: tests/_router_auth_helpers.py) - make_authed_test_app(): builds a FastAPI test app with a stub middleware that stamps request.state.user + request.state.auth and a permissive thread_meta_repo mock. TestClient-based router tests (test_artifacts_router, test_threads_router) use it instead of bare FastAPI() so the new @require_permission(owner_check=True) decorators short-circuit cleanly. - call_unwrapped(): walks the __wrapped__ chain to invoke the underlying handler without going through the authz wrappers. Direct-call tests (test_uploads_router) use it. Typed with ParamSpec so the wrapped signature flows through. Backend test additions - test_auth.py: 7 tests for the new _get_client_ip trust model (no proxy / trusted proxy / untrusted peer / XFF rejection / invalid CIDR / no client). 5 tests for the password blocklist (literal, case-insensitive, strong password accepted, change-password binding, short-password length-check still fires before blocklist). test_update_user_raises_when_row_concurrently_deleted: closes a shipped-without-coverage gap on the new UserNotFoundError contract. - test_thread_meta_repo.py: 4 tests for check_access(require_existing=True) — strict missing-row denial, strict owner match, strict owner mismatch, strict null-owner still allowed (shared rows survive the tightening). - test_ensure_admin.py: 3 tests for _migrate_orphaned_threads / _iter_store_items pagination, covering the TC-UPG-02 upgrade story end-to-end via mock store. Closes the gap where the cursor pagination was untested even though the previous PR rewrote it. - test_threads_router.py: 5 tests for _strip_reserved_metadata (owner_id removal, user_id removal, safe-keys passthrough, empty input, both-stripped). - test_auth_type_system.py: replace "password123" fixtures with Tr0ub4dor3a / AnotherStr0ngPwd! so the new password blocklist doesn't reject the test data. * docs(auth): refresh TC-DOCKER-05 + document Docker validation gap - AUTH_TEST_PLAN.md TC-DOCKER-05: the previous expectation ("admin password visible in docker logs") was stale after the simplify pass that moved credentials to a 0600 file. The grep "Password:" check would have silently failed and given a false sense of coverage. New expectation matches the actual file-based path: 0600 file in DEER_FLOW_HOME, log shows the path (not the secret), reverse-grep asserts no leaked password in container logs. - NEW: docs/AUTH_TEST_DOCKER_GAP.md documents the only un-executed block in the test plan (TC-DOCKER-01..06). Reason: sg_dev validation host has no Docker daemon installed. The doc maps each Docker case to an already-validated bare-metal equivalent (TC-1.1, TC-REENT-01, TC-API-02 etc.) so the gap is auditable, and includes pre-flight reproduction steps for whoever has Docker available. --------- Co-authored-by: greatmengqi --- backend/app/gateway/app.py | 158 +- backend/app/gateway/auth/__init__.py | 42 + backend/app/gateway/auth/config.py | 57 + backend/app/gateway/auth/credential_file.py | 48 + backend/app/gateway/auth/errors.py | 44 + backend/app/gateway/auth/jwt.py | 55 + backend/app/gateway/auth/local_provider.py | 87 + backend/app/gateway/auth/models.py | 41 + backend/app/gateway/auth/password.py | 33 + backend/app/gateway/auth/providers.py | 24 + .../app/gateway/auth/repositories/__init__.py | 0 backend/app/gateway/auth/repositories/base.py | 97 + .../app/gateway/auth/repositories/sqlite.py | 122 ++ backend/app/gateway/auth/reset_admin.py | 91 + backend/app/gateway/auth_middleware.py | 117 ++ backend/app/gateway/authz.py | 262 +++ backend/app/gateway/csrf_middleware.py | 112 + backend/app/gateway/deps.py | 99 +- backend/app/gateway/langgraph_auth.py | 106 + backend/app/gateway/routers/artifacts.py | 2 + backend/app/gateway/routers/auth.py | 418 ++++ backend/app/gateway/routers/feedback.py | 5 + backend/app/gateway/routers/suggestions.py | 14 +- backend/app/gateway/routers/thread_runs.py | 13 + backend/app/gateway/routers/threads.py | 32 +- backend/app/gateway/routers/uploads.py | 11 +- backend/docs/AUTH_TEST_DOCKER_GAP.md | 77 + backend/docs/AUTH_TEST_PLAN.md | 1801 +++++++++++++++++ backend/docs/AUTH_UPGRADE.md | 129 ++ backend/langgraph.json | 3 + .../harness/deerflow/persistence/engine.py | 19 + .../deerflow/persistence/feedback/sql.py | 81 +- .../deerflow/persistence/models/__init__.py | 4 +- .../deerflow/persistence/models/run_event.py | 4 + .../harness/deerflow/persistence/run/sql.py | 50 +- .../deerflow/persistence/thread_meta/sql.py | 119 +- .../deerflow/persistence/user/__init__.py | 12 + .../deerflow/persistence/user/model.py | 59 + .../deerflow/runtime/events/store/db.py | 98 +- .../harness/deerflow/runtime/user_context.py | 148 ++ backend/pyproject.toml | 8 + backend/tests/_router_auth_helpers.py | 134 ++ backend/tests/conftest.py | 44 + backend/tests/test_artifacts_router.py | 12 +- backend/tests/test_auth.py | 654 ++++++ backend/tests/test_auth_config.py | 54 + backend/tests/test_auth_errors.py | 75 + backend/tests/test_auth_middleware.py | 222 ++ backend/tests/test_auth_type_system.py | 701 +++++++ backend/tests/test_ensure_admin.py | 319 +++ backend/tests/test_langgraph_auth.py | 312 +++ backend/tests/test_owner_isolation.py | 465 +++++ backend/tests/test_suggestions_router.py | 16 +- backend/tests/test_thread_meta_repo.py | 43 +- backend/tests/test_threads_router.py | 40 +- backend/tests/test_uploads_router.py | 15 +- backend/tests/test_user_context.py | 69 + backend/uv.lock | 253 ++- backend/uv.toml | 1 + docker/nginx/nginx.local.conf | 36 + frontend/package.json | 1 - frontend/pnpm-lock.yaml | 183 -- frontend/src/app/(auth)/layout.tsx | 45 + frontend/src/app/(auth)/login/page.tsx | 183 ++ frontend/src/app/(auth)/setup/page.tsx | 115 ++ frontend/src/app/api/auth/[...all]/route.ts | 5 - frontend/src/app/workspace/layout.tsx | 91 +- .../src/app/workspace/workspace-content.tsx | 50 + .../src/components/workspace/input-box.tsx | 24 +- .../settings/account-settings-page.tsx | 132 ++ .../workspace/settings/settings-dialog.tsx | 10 + frontend/src/core/agents/api.ts | 7 +- frontend/src/core/api/api-client.ts | 26 + frontend/src/core/api/fetcher.ts | 104 + frontend/src/core/auth/AuthProvider.tsx | 165 ++ frontend/src/core/auth/gateway-config.ts | 34 + frontend/src/core/auth/proxy-policy.ts | 55 + frontend/src/core/auth/server.ts | 57 + frontend/src/core/auth/types.ts | 72 + frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + frontend/src/core/mcp/api.ts | 16 +- frontend/src/core/memory/api.ts | 37 +- frontend/src/core/skills/api.ts | 18 +- frontend/src/core/threads/hooks.ts | 3 +- frontend/src/core/uploads/api.ts | 5 +- frontend/src/env.js | 10 - frontend/src/server/better-auth/client.ts | 5 - frontend/src/server/better-auth/config.ts | 9 - frontend/src/server/better-auth/index.ts | 1 - frontend/src/server/better-auth/server.ts | 8 - 92 files changed, 9160 insertions(+), 484 deletions(-) create mode 100644 backend/app/gateway/auth/__init__.py create mode 100644 backend/app/gateway/auth/config.py create mode 100644 backend/app/gateway/auth/credential_file.py create mode 100644 backend/app/gateway/auth/errors.py create mode 100644 backend/app/gateway/auth/jwt.py create mode 100644 backend/app/gateway/auth/local_provider.py create mode 100644 backend/app/gateway/auth/models.py create mode 100644 backend/app/gateway/auth/password.py create mode 100644 backend/app/gateway/auth/providers.py create mode 100644 backend/app/gateway/auth/repositories/__init__.py create mode 100644 backend/app/gateway/auth/repositories/base.py create mode 100644 backend/app/gateway/auth/repositories/sqlite.py create mode 100644 backend/app/gateway/auth/reset_admin.py create mode 100644 backend/app/gateway/auth_middleware.py create mode 100644 backend/app/gateway/authz.py create mode 100644 backend/app/gateway/csrf_middleware.py create mode 100644 backend/app/gateway/langgraph_auth.py create mode 100644 backend/app/gateway/routers/auth.py create mode 100644 backend/docs/AUTH_TEST_DOCKER_GAP.md create mode 100644 backend/docs/AUTH_TEST_PLAN.md create mode 100644 backend/docs/AUTH_UPGRADE.md create mode 100644 backend/packages/harness/deerflow/persistence/user/__init__.py create mode 100644 backend/packages/harness/deerflow/persistence/user/model.py create mode 100644 backend/packages/harness/deerflow/runtime/user_context.py create mode 100644 backend/tests/_router_auth_helpers.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_auth_config.py create mode 100644 backend/tests/test_auth_errors.py create mode 100644 backend/tests/test_auth_middleware.py create mode 100644 backend/tests/test_auth_type_system.py create mode 100644 backend/tests/test_ensure_admin.py create mode 100644 backend/tests/test_langgraph_auth.py create mode 100644 backend/tests/test_owner_isolation.py create mode 100644 backend/tests/test_user_context.py create mode 100644 backend/uv.toml create mode 100644 frontend/src/app/(auth)/layout.tsx create mode 100644 frontend/src/app/(auth)/login/page.tsx create mode 100644 frontend/src/app/(auth)/setup/page.tsx delete mode 100644 frontend/src/app/api/auth/[...all]/route.ts create mode 100644 frontend/src/app/workspace/workspace-content.tsx create mode 100644 frontend/src/components/workspace/settings/account-settings-page.tsx create mode 100644 frontend/src/core/api/fetcher.ts create mode 100644 frontend/src/core/auth/AuthProvider.tsx create mode 100644 frontend/src/core/auth/gateway-config.ts create mode 100644 frontend/src/core/auth/proxy-policy.ts create mode 100644 frontend/src/core/auth/server.ts create mode 100644 frontend/src/core/auth/types.ts delete mode 100644 frontend/src/server/better-auth/client.ts delete mode 100644 frontend/src/server/better-auth/config.ts delete mode 100644 frontend/src/server/better-auth/index.ts delete mode 100644 frontend/src/server/better-auth/server.ts diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 01606a8cb..1d25e0113 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -1,15 +1,21 @@ import logging +import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from datetime import UTC from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.gateway.auth_middleware import AuthMiddleware from app.gateway.config import get_gateway_config +from app.gateway.csrf_middleware import CSRFMiddleware from app.gateway.deps import langgraph_runtime from app.gateway.routers import ( agents, artifacts, assistants_compat, + auth, channels, feedback, mcp, @@ -34,6 +40,125 @@ logging.basicConfig( logger = logging.getLogger(__name__) +async def _ensure_admin_user(app: FastAPI) -> None: + """Auto-create the admin user on first boot if no users exist. + + After admin creation, migrate orphan threads from the LangGraph + store (metadata.owner_id unset) to the admin account. This is the + "no-auth → with-auth" upgrade path: users who ran DeerFlow without + authentication have existing LangGraph thread data that needs an + owner assigned. + + No SQL persistence migration is needed: the four owner_id columns + (threads_meta, runs, run_events, feedback) only come into existence + alongside the auth module via create_all, so freshly created tables + never contain NULL-owner rows. "Existing persistence DB + new auth" + is not a supported upgrade path — fresh install or wipe-and-retry. + + Multi-worker safe: relies on SQLite UNIQUE constraint to resolve + races during admin creation. Only the worker that successfully + creates/updates the admin prints the password; losers silently skip. + """ + import secrets + + from app.gateway.auth.credential_file import write_initial_credentials + from app.gateway.deps import get_local_provider + + def _announce_credentials(email: str, password: str, *, label: str, headline: str) -> None: + """Write the password to a 0600 file and log the path (never the secret).""" + cred_path = write_initial_credentials(email, password, label=label) + logger.info("=" * 60) + logger.info(" %s", headline) + logger.info(" Credentials written to: %s (mode 0600)", cred_path) + logger.info(" Change it after login: Settings -> Account") + logger.info("=" * 60) + + provider = get_local_provider() + user_count = await provider.count_users() + + admin = None + + if user_count == 0: + password = secrets.token_urlsafe(16) + try: + admin = await provider.create_user(email="admin@deerflow.dev", password=password, system_role="admin", needs_setup=True) + except ValueError: + return # Another worker already created the admin. + _announce_credentials(admin.email, password, label="initial", headline="Admin account created on first boot") + else: + # Admin exists but setup never completed — reset password so operator + # can always find it in the console without needing the CLI. + # Multi-worker guard: if admin was created less than 30s ago, another + # worker just created it and will print the password — skip reset. + admin = await provider.get_user_by_email("admin@deerflow.dev") + if admin and admin.needs_setup: + import time + + age = time.time() - admin.created_at.replace(tzinfo=UTC).timestamp() + if age >= 30: + from app.gateway.auth.password import hash_password_async + + password = secrets.token_urlsafe(16) + admin.password_hash = await hash_password_async(password) + admin.token_version += 1 + await provider.update_user(admin) + _announce_credentials(admin.email, password, label="reset", headline="Admin account setup incomplete — password reset") + + if admin is None: + return # Nothing to bind orphans to. + + admin_id = str(admin.id) + + # LangGraph store orphan migration — non-fatal. + # This covers the "no-auth → with-auth" upgrade path for users + # whose existing LangGraph thread metadata has no owner_id set. + store = getattr(app.state, "store", None) + if store is not None: + try: + migrated = await _migrate_orphaned_threads(store, admin_id) + if migrated: + logger.info("Migrated %d orphan LangGraph thread(s) to admin", migrated) + except Exception: + logger.exception("LangGraph thread migration failed (non-fatal)") + + +async def _iter_store_items(store, namespace, *, page_size: int = 500): + """Paginated async iterator over a LangGraph store namespace. + + Replaces the old hardcoded ``limit=1000`` call with a cursor-style + loop so that environments with more than one page of orphans do + not silently lose data. Terminates when a page is empty OR when a + short page arrives (indicating the last page). + """ + offset = 0 + while True: + batch = await store.asearch(namespace, limit=page_size, offset=offset) + if not batch: + return + for item in batch: + yield item + if len(batch) < page_size: + return + offset += page_size + + +async def _migrate_orphaned_threads(store, admin_user_id: str) -> int: + """Migrate LangGraph store threads with no owner_id to the given admin. + + Uses cursor pagination so all orphans are migrated regardless of + count. Returns the number of rows migrated. + """ + migrated = 0 + async for item in _iter_store_items(store, ("threads",)): + metadata = item.value.get("metadata", {}) + if not metadata.get("owner_id"): + metadata["owner_id"] = admin_user_id + item.value["metadata"] = metadata + await store.aput(("threads",), item.key, item.value) + migrated += 1 + return migrated + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" @@ -53,6 +178,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async with langgraph_runtime(app): logger.info("LangGraph runtime initialised") + # Ensure admin user exists (auto-create on first boot) + # Must run AFTER langgraph_runtime so app.state.store is available for thread migration + await _ensure_admin_user(app) + # Start IM channel service if any channels are configured try: from app.channels.service import start_channel_service @@ -164,7 +293,31 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an ], ) - # CORS is handled by nginx - no need for FastAPI middleware + # Auth: reject unauthenticated requests to non-public paths (fail-closed safety net) + app.add_middleware(AuthMiddleware) + + # CSRF: Double Submit Cookie pattern for state-changing requests + app.add_middleware(CSRFMiddleware) + + # CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware. + # In production, nginx handles CORS and no middleware is needed. + cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "") + if cors_origins_env: + cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] + # Validate: wildcard origin with credentials is a security misconfiguration + for origin in cors_origins: + if origin == "*": + logger.error("GATEWAY_CORS_ORIGINS contains wildcard '*' with allow_credentials=True. This is a security misconfiguration — browsers will reject the response. Use explicit scheme://host:port origins instead.") + cors_origins = [o for o in cors_origins if o != "*"] + break + if cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) # Include routers # Models API is mounted at /api/models @@ -200,6 +353,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Assistants compatibility API (LangGraph Platform stub) app.include_router(assistants_compat.router) + # Auth API is mounted at /api/v1/auth + app.include_router(auth.router) + # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback app.include_router(feedback.router) diff --git a/backend/app/gateway/auth/__init__.py b/backend/app/gateway/auth/__init__.py new file mode 100644 index 000000000..4e9b71c42 --- /dev/null +++ b/backend/app/gateway/auth/__init__.py @@ -0,0 +1,42 @@ +"""Authentication module for DeerFlow. + +This module provides: +- JWT-based authentication +- Provider Factory pattern for extensible auth methods +- UserRepository interface for storage backends (SQLite) +""" + +from app.gateway.auth.config import AuthConfig, get_auth_config, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import TokenPayload, create_access_token, decode_token +from app.gateway.auth.local_provider import LocalAuthProvider +from app.gateway.auth.models import User, UserResponse +from app.gateway.auth.password import hash_password, verify_password +from app.gateway.auth.providers import AuthProvider +from app.gateway.auth.repositories.base import UserRepository + +__all__ = [ + # Config + "AuthConfig", + "get_auth_config", + "set_auth_config", + # Errors + "AuthErrorCode", + "AuthErrorResponse", + "TokenError", + # JWT + "TokenPayload", + "create_access_token", + "decode_token", + # Password + "hash_password", + "verify_password", + # Models + "User", + "UserResponse", + # Providers + "AuthProvider", + "LocalAuthProvider", + # Repository + "UserRepository", +] diff --git a/backend/app/gateway/auth/config.py b/backend/app/gateway/auth/config.py new file mode 100644 index 000000000..01f0870fd --- /dev/null +++ b/backend/app/gateway/auth/config.py @@ -0,0 +1,57 @@ +"""Authentication configuration for DeerFlow.""" + +import logging +import os +import secrets + +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +load_dotenv() + +logger = logging.getLogger(__name__) + + +class AuthConfig(BaseModel): + """JWT and auth-related configuration. Parsed once at startup. + + Note: the ``users`` table now lives in the shared persistence + database managed by ``deerflow.persistence.engine``. The old + ``users_db_path`` config key has been removed — user storage is + configured through ``config.database`` like every other table. + """ + + jwt_secret: str = Field( + ..., + description="Secret key for JWT signing. MUST be set via AUTH_JWT_SECRET.", + ) + token_expiry_days: int = Field(default=7, ge=1, le=30) + oauth_github_client_id: str | None = Field(default=None) + oauth_github_client_secret: str | None = Field(default=None) + + +_auth_config: AuthConfig | None = None + + +def get_auth_config() -> AuthConfig: + """Get the global AuthConfig instance. Parses from env on first call.""" + global _auth_config + if _auth_config is None: + jwt_secret = os.environ.get("AUTH_JWT_SECRET") + if not jwt_secret: + jwt_secret = secrets.token_urlsafe(32) + os.environ["AUTH_JWT_SECRET"] = jwt_secret + logger.warning( + "⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. " + "Sessions will be invalidated on restart. " + "For production, add AUTH_JWT_SECRET to your .env file: " + 'python -c "import secrets; print(secrets.token_urlsafe(32))"' + ) + _auth_config = AuthConfig(jwt_secret=jwt_secret) + return _auth_config + + +def set_auth_config(config: AuthConfig) -> None: + """Set the global AuthConfig instance (for testing).""" + global _auth_config + _auth_config = config diff --git a/backend/app/gateway/auth/credential_file.py b/backend/app/gateway/auth/credential_file.py new file mode 100644 index 000000000..100ca3b04 --- /dev/null +++ b/backend/app/gateway/auth/credential_file.py @@ -0,0 +1,48 @@ +"""Write initial admin credentials to a restricted file instead of logs. + +Logging secrets to stdout/stderr is a well-known CodeQL finding +(py/clear-text-logging-sensitive-data) — in production those logs +get collected into ELK/Splunk/etc and become a secret sprawl +source. This helper writes the credential to a 0600 file that only +the process user can read, and returns the path so the caller can +log **the path** (not the password) for the operator to pick up. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from deerflow.config.paths import get_paths + +_CREDENTIAL_FILENAME = "admin_initial_credentials.txt" + + +def write_initial_credentials(email: str, password: str, *, label: str = "initial") -> Path: + """Write the admin email + password to ``{base_dir}/admin_initial_credentials.txt``. + + The file is created **atomically** with mode 0600 via ``os.open`` + so the password is never world-readable, even for the single syscall + window between ``write_text`` and ``chmod``. + + ``label`` distinguishes "initial" (fresh creation) from "reset" + (password reset) in the file header so an operator picking up the + file after a restart can tell which event produced it. + + Returns the absolute :class:`Path` to the file. + """ + target = get_paths().base_dir / _CREDENTIAL_FILENAME + target.parent.mkdir(parents=True, exist_ok=True) + + content = ( + f"# DeerFlow admin {label} credentials\n# This file is generated on first boot or password reset.\n# Change the password after login via Settings -> Account,\n# then delete this file.\n#\nemail: {email}\npassword: {password}\n" + ) + + # Atomic 0600 create-or-truncate. O_TRUNC (not O_EXCL) so the + # reset-password path can rewrite an existing file without a + # separate unlink-then-create dance. + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + + return target.resolve() diff --git a/backend/app/gateway/auth/errors.py b/backend/app/gateway/auth/errors.py new file mode 100644 index 000000000..55ae004db --- /dev/null +++ b/backend/app/gateway/auth/errors.py @@ -0,0 +1,44 @@ +"""Typed error definitions for auth module. + +AuthErrorCode: exhaustive enum of all auth failure conditions. +TokenError: exhaustive enum of JWT decode failures. +AuthErrorResponse: structured error payload for HTTP responses. +""" + +from enum import StrEnum + +from pydantic import BaseModel + + +class AuthErrorCode(StrEnum): + """Exhaustive list of auth error conditions.""" + + INVALID_CREDENTIALS = "invalid_credentials" + TOKEN_EXPIRED = "token_expired" + TOKEN_INVALID = "token_invalid" + USER_NOT_FOUND = "user_not_found" + EMAIL_ALREADY_EXISTS = "email_already_exists" + PROVIDER_NOT_FOUND = "provider_not_found" + NOT_AUTHENTICATED = "not_authenticated" + + +class TokenError(StrEnum): + """Exhaustive list of JWT decode failure reasons.""" + + EXPIRED = "expired" + INVALID_SIGNATURE = "invalid_signature" + MALFORMED = "malformed" + + +class AuthErrorResponse(BaseModel): + """Structured error response — replaces bare `detail` strings.""" + + code: AuthErrorCode + message: str + + +def token_error_to_code(err: TokenError) -> AuthErrorCode: + """Map TokenError to AuthErrorCode — single source of truth.""" + if err == TokenError.EXPIRED: + return AuthErrorCode.TOKEN_EXPIRED + return AuthErrorCode.TOKEN_INVALID diff --git a/backend/app/gateway/auth/jwt.py b/backend/app/gateway/auth/jwt.py new file mode 100644 index 000000000..3853692b7 --- /dev/null +++ b/backend/app/gateway/auth/jwt.py @@ -0,0 +1,55 @@ +"""JWT token creation and verification.""" + +from datetime import UTC, datetime, timedelta + +import jwt +from pydantic import BaseModel + +from app.gateway.auth.config import get_auth_config +from app.gateway.auth.errors import TokenError + + +class TokenPayload(BaseModel): + """JWT token payload.""" + + sub: str # user_id + exp: datetime + iat: datetime | None = None + ver: int = 0 # token_version — must match User.token_version + + +def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str: + """Create a JWT access token. + + Args: + user_id: The user's UUID as string + expires_delta: Optional custom expiry, defaults to 7 days + token_version: User's current token_version for invalidation + + Returns: + Encoded JWT string + """ + config = get_auth_config() + expiry = expires_delta or timedelta(days=config.token_expiry_days) + + now = datetime.now(UTC) + payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version} + return jwt.encode(payload, config.jwt_secret, algorithm="HS256") + + +def decode_token(token: str) -> TokenPayload | TokenError: + """Decode and validate a JWT token. + + Returns: + TokenPayload if valid, or a specific TokenError variant. + """ + config = get_auth_config() + try: + payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"]) + return TokenPayload(**payload) + except jwt.ExpiredSignatureError: + return TokenError.EXPIRED + except jwt.InvalidSignatureError: + return TokenError.INVALID_SIGNATURE + except jwt.PyJWTError: + return TokenError.MALFORMED diff --git a/backend/app/gateway/auth/local_provider.py b/backend/app/gateway/auth/local_provider.py new file mode 100644 index 000000000..e051f982b --- /dev/null +++ b/backend/app/gateway/auth/local_provider.py @@ -0,0 +1,87 @@ +"""Local email/password authentication provider.""" + +from app.gateway.auth.models import User +from app.gateway.auth.password import hash_password_async, verify_password_async +from app.gateway.auth.providers import AuthProvider +from app.gateway.auth.repositories.base import UserRepository + + +class LocalAuthProvider(AuthProvider): + """Email/password authentication provider using local database.""" + + def __init__(self, repository: UserRepository): + """Initialize with a UserRepository. + + Args: + repository: UserRepository implementation (SQLite) + """ + self._repo = repository + + async def authenticate(self, credentials: dict) -> User | None: + """Authenticate with email and password. + + Args: + credentials: dict with 'email' and 'password' keys + + Returns: + User if authentication succeeds, None otherwise + """ + email = credentials.get("email") + password = credentials.get("password") + + if not email or not password: + return None + + user = await self._repo.get_user_by_email(email) + if user is None: + return None + + if user.password_hash is None: + # OAuth user without local password + return None + + if not await verify_password_async(password, user.password_hash): + return None + + return user + + async def get_user(self, user_id: str) -> User | None: + """Get user by ID.""" + return await self._repo.get_user_by_id(user_id) + + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: + """Create a new local user. + + Args: + email: User email address + password: Plain text password (will be hashed) + system_role: Role to assign ("admin" or "user") + needs_setup: If True, user must complete setup on first login + + Returns: + Created User instance + """ + password_hash = await hash_password_async(password) if password else None + user = User( + email=email, + password_hash=password_hash, + system_role=system_role, + needs_setup=needs_setup, + ) + return await self._repo.create_user(user) + + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + """Get user by OAuth provider and ID.""" + return await self._repo.get_user_by_oauth(provider, oauth_id) + + async def count_users(self) -> int: + """Return total number of registered users.""" + return await self._repo.count_users() + + async def update_user(self, user: User) -> User: + """Update an existing user.""" + return await self._repo.update_user(user) + + async def get_user_by_email(self, email: str) -> User | None: + """Get user by email.""" + return await self._repo.get_user_by_email(email) diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py new file mode 100644 index 000000000..d8f9b954a --- /dev/null +++ b/backend/app/gateway/auth/models.py @@ -0,0 +1,41 @@ +"""User Pydantic models for authentication.""" + +from datetime import UTC, datetime +from typing import Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +def _utc_now() -> datetime: + """Return current UTC time (timezone-aware).""" + return datetime.now(UTC) + + +class User(BaseModel): + """Internal user representation.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(default_factory=uuid4, description="Primary key") + email: EmailStr = Field(..., description="Unique email address") + password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users") + system_role: Literal["admin", "user"] = Field(default="user") + created_at: datetime = Field(default_factory=_utc_now) + + # OAuth linkage (optional) + oauth_provider: str | None = Field(None, description="e.g. 'github', 'google'") + oauth_id: str | None = Field(None, description="User ID from OAuth provider") + + # Auth lifecycle + needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") + token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") + + +class UserResponse(BaseModel): + """Response model for user info endpoint.""" + + id: str + email: str + system_role: Literal["admin", "user"] + needs_setup: bool = False diff --git a/backend/app/gateway/auth/password.py b/backend/app/gateway/auth/password.py new file mode 100644 index 000000000..588b7a643 --- /dev/null +++ b/backend/app/gateway/auth/password.py @@ -0,0 +1,33 @@ +"""Password hashing utilities using bcrypt directly.""" + +import asyncio + +import bcrypt + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +async def hash_password_async(password: str) -> str: + """Hash a password using bcrypt (non-blocking). + + Wraps the blocking bcrypt operation in a thread pool to avoid + blocking the event loop during password hashing. + """ + return await asyncio.to_thread(hash_password, password) + + +async def verify_password_async(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash (non-blocking). + + Wraps the blocking bcrypt operation in a thread pool to avoid + blocking the event loop during password verification. + """ + return await asyncio.to_thread(verify_password, plain_password, hashed_password) diff --git a/backend/app/gateway/auth/providers.py b/backend/app/gateway/auth/providers.py new file mode 100644 index 000000000..25e782ce3 --- /dev/null +++ b/backend/app/gateway/auth/providers.py @@ -0,0 +1,24 @@ +"""Auth provider abstraction.""" + +from abc import ABC, abstractmethod + + +class AuthProvider(ABC): + """Abstract base class for authentication providers.""" + + @abstractmethod + async def authenticate(self, credentials: dict) -> "User | None": + """Authenticate user with given credentials. + + Returns User if authentication succeeds, None otherwise. + """ + ... + + @abstractmethod + async def get_user(self, user_id: str) -> "User | None": + """Retrieve user by ID.""" + ... + + +# Import User at runtime to avoid circular imports +from app.gateway.auth.models import User # noqa: E402 diff --git a/backend/app/gateway/auth/repositories/__init__.py b/backend/app/gateway/auth/repositories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py new file mode 100644 index 000000000..57bc2c23b --- /dev/null +++ b/backend/app/gateway/auth/repositories/base.py @@ -0,0 +1,97 @@ +"""User repository interface for abstracting database operations.""" + +from abc import ABC, abstractmethod + +from app.gateway.auth.models import User + + +class UserNotFoundError(LookupError): + """Raised when a user repository operation targets a non-existent row. + + Subclass of :class:`LookupError` so callers that already catch + ``LookupError`` for "missing entity" can keep working unchanged, + while specific call sites can pin to this class to distinguish + "concurrent delete during update" from other lookups. + """ + + +class UserRepository(ABC): + """Abstract interface for user data storage. + + Implement this interface to support different storage backends + (SQLite) + """ + + @abstractmethod + async def create_user(self, user: User) -> User: + """Create a new user. + + Args: + user: User object to create + + Returns: + Created User with ID assigned + + Raises: + ValueError: If email already exists + """ + ... + + @abstractmethod + async def get_user_by_id(self, user_id: str) -> User | None: + """Get user by ID. + + Args: + user_id: User UUID as string + + Returns: + User if found, None otherwise + """ + ... + + @abstractmethod + async def get_user_by_email(self, email: str) -> User | None: + """Get user by email. + + Args: + email: User email address + + Returns: + User if found, None otherwise + """ + ... + + @abstractmethod + async def update_user(self, user: User) -> User: + """Update an existing user. + + Args: + user: User object with updated fields + + Returns: + Updated User + + Raises: + UserNotFoundError: If no row exists for ``user.id``. This is + a hard failure (not a no-op) so callers cannot mistake a + concurrent-delete race for a successful update. + """ + ... + + @abstractmethod + async def count_users(self) -> int: + """Return total number of registered users.""" + ... + + @abstractmethod + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + """Get user by OAuth provider and ID. + + Args: + provider: OAuth provider name (e.g. 'github', 'google') + oauth_id: User ID from the OAuth provider + + Returns: + User if found, None otherwise + """ + ... diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py new file mode 100644 index 000000000..da2d8b9f7 --- /dev/null +++ b/backend/app/gateway/auth/repositories/sqlite.py @@ -0,0 +1,122 @@ +"""SQLAlchemy-backed UserRepository implementation. + +Uses the shared async session factory from +``deerflow.persistence.engine`` — the ``users`` table lives in the +same database as ``threads_meta``, ``runs``, ``run_events``, and +``feedback``. + +Constructor takes the session factory directly (same pattern as the +other four repositories in ``deerflow.persistence.*``). Callers +construct this after ``init_engine_from_config()`` has run. +""" + +from __future__ import annotations + +from datetime import UTC +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.gateway.auth.models import User +from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository +from deerflow.persistence.user.model import UserRow + + +class SQLiteUserRepository(UserRepository): + """Async user repository backed by the shared SQLAlchemy engine.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + # ── Converters ──────────────────────────────────────────────────── + + @staticmethod + def _row_to_user(row: UserRow) -> User: + return User( + id=UUID(row.id), + email=row.email, + password_hash=row.password_hash, + system_role=row.system_role, # type: ignore[arg-type] + # SQLite loses tzinfo on read; reattach UTC so downstream + # code can compare timestamps reliably. + created_at=row.created_at if row.created_at.tzinfo else row.created_at.replace(tzinfo=UTC), + oauth_provider=row.oauth_provider, + oauth_id=row.oauth_id, + needs_setup=row.needs_setup, + token_version=row.token_version, + ) + + @staticmethod + def _user_to_row(user: User) -> UserRow: + return UserRow( + id=str(user.id), + email=user.email, + password_hash=user.password_hash, + system_role=user.system_role, + created_at=user.created_at, + oauth_provider=user.oauth_provider, + oauth_id=user.oauth_id, + needs_setup=user.needs_setup, + token_version=user.token_version, + ) + + # ── CRUD ────────────────────────────────────────────────────────── + + async def create_user(self, user: User) -> User: + """Insert a new user. Raises ``ValueError`` on duplicate email.""" + row = self._user_to_row(user) + async with self._sf() as session: + session.add(row) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise ValueError(f"Email already registered: {user.email}") from exc + return user + + async def get_user_by_id(self, user_id: str) -> User | None: + async with self._sf() as session: + row = await session.get(UserRow, user_id) + return self._row_to_user(row) if row is not None else None + + async def get_user_by_email(self, email: str) -> User | None: + stmt = select(UserRow).where(UserRow.email == email) + async with self._sf() as session: + result = await session.execute(stmt) + row = result.scalar_one_or_none() + return self._row_to_user(row) if row is not None else None + + async def update_user(self, user: User) -> User: + async with self._sf() as session: + row = await session.get(UserRow, str(user.id)) + if row is None: + # Hard fail on concurrent delete: callers (reset_admin, + # password change handlers, _ensure_admin_user) all + # fetched the user just before this call, so a missing + # row here means the row vanished underneath us. Silent + # success would let the caller log "password reset" for + # a row that no longer exists. + raise UserNotFoundError(f"User {user.id} no longer exists") + row.email = user.email + row.password_hash = user.password_hash + row.system_role = user.system_role + row.oauth_provider = user.oauth_provider + row.oauth_id = user.oauth_id + row.needs_setup = user.needs_setup + row.token_version = user.token_version + await session.commit() + return user + + async def count_users(self) -> int: + stmt = select(func.count()).select_from(UserRow) + async with self._sf() as session: + return await session.scalar(stmt) or 0 + + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + stmt = select(UserRow).where(UserRow.oauth_provider == provider, UserRow.oauth_id == oauth_id) + async with self._sf() as session: + result = await session.execute(stmt) + row = result.scalar_one_or_none() + return self._row_to_user(row) if row is not None else None diff --git a/backend/app/gateway/auth/reset_admin.py b/backend/app/gateway/auth/reset_admin.py new file mode 100644 index 000000000..7b7da74d0 --- /dev/null +++ b/backend/app/gateway/auth/reset_admin.py @@ -0,0 +1,91 @@ +"""CLI tool to reset an admin password. + +Usage: + python -m app.gateway.auth.reset_admin + python -m app.gateway.auth.reset_admin --email admin@example.com + +Writes the new password to ``.deer-flow/admin_initial_credentials.txt`` +(mode 0600) instead of printing it, so CI / log aggregators never see +the cleartext secret. +""" + +from __future__ import annotations + +import argparse +import asyncio +import secrets +import sys + +from sqlalchemy import select + +from app.gateway.auth.credential_file import write_initial_credentials +from app.gateway.auth.password import hash_password +from app.gateway.auth.repositories.sqlite import SQLiteUserRepository +from deerflow.persistence.user.model import UserRow + + +async def _run(email: str | None) -> int: + from deerflow.config import get_app_config + from deerflow.persistence.engine import ( + close_engine, + get_session_factory, + init_engine_from_config, + ) + + config = get_app_config() + await init_engine_from_config(config.database) + try: + sf = get_session_factory() + if sf is None: + print("Error: persistence engine not available (check config.database).", file=sys.stderr) + return 1 + + repo = SQLiteUserRepository(sf) + + if email: + user = await repo.get_user_by_email(email) + else: + # Find first admin via direct SELECT — repository does not + # expose a "first admin" helper and we do not want to add + # one just for this CLI. + async with sf() as session: + stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1) + row = (await session.execute(stmt)).scalar_one_or_none() + if row is None: + user = None + else: + user = await repo.get_user_by_id(row.id) + + if user is None: + if email: + print(f"Error: user '{email}' not found.", file=sys.stderr) + else: + print("Error: no admin user found.", file=sys.stderr) + return 1 + + new_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(new_password) + user.token_version += 1 + user.needs_setup = True + await repo.update_user(user) + + cred_path = write_initial_credentials(user.email, new_password, label="reset") + print(f"Password reset for: {user.email}") + print(f"Credentials written to: {cred_path} (mode 0600)") + print("Next login will require setup (new email + password).") + return 0 + finally: + await close_engine() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Reset admin password") + parser.add_argument("--email", help="Admin email (default: first admin found)") + args = parser.parse_args() + + exit_code = asyncio.run(_run(args.email)) + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py new file mode 100644 index 000000000..b45591a30 --- /dev/null +++ b/backend/app/gateway/auth_middleware.py @@ -0,0 +1,117 @@ +"""Global authentication middleware — fail-closed safety net. + +Rejects unauthenticated requests to non-public paths with 401. When a +request passes the cookie check, resolves the JWT payload to a real +``User`` object and stamps it into both ``request.state.user`` and the +``deerflow.runtime.user_context`` contextvar so that repository-layer +owner filtering works automatically via the sentinel pattern. + +Fine-grained permission checks remain in authz.py decorators. +""" + +from collections.abc import Callable + +from fastapi import HTTPException, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.types import ASGIApp + +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.authz import _ALL_PERMISSIONS, AuthContext +from deerflow.runtime.user_context import reset_current_user, set_current_user + +# Paths that never require authentication. +_PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( + "/health", + "/docs", + "/redoc", + "/openapi.json", +) + +# Exact auth paths that are public (login/register/status check). +# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public. +_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( + { + "/api/v1/auth/login/local", + "/api/v1/auth/register", + "/api/v1/auth/logout", + "/api/v1/auth/setup-status", + } +) + + +def _is_public(path: str) -> bool: + stripped = path.rstrip("/") + if stripped in _PUBLIC_EXACT_PATHS: + return True + return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) + + +class AuthMiddleware(BaseHTTPMiddleware): + """Strict auth gate: reject requests without a valid session. + + Two-stage check for non-public paths: + + 1. Cookie presence — return 401 NOT_AUTHENTICATED if missing + 2. JWT validation via ``get_optional_user_from_request`` — return 401 + TOKEN_INVALID if the token is absent, malformed, expired, or the + signed user does not exist / is stale + + On success, stamps ``request.state.user`` and the + ``deerflow.runtime.user_context`` contextvar so that repository-layer + owner filters work downstream without every route needing a + ``@require_auth`` decorator. Routes that need per-resource + authorization (e.g. "user A cannot read user B's thread by guessing + the URL") should additionally use ``@require_permission(..., + owner_check=True)`` for explicit enforcement — but authentication + itself is fully handled here. + """ + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if _is_public(request.url.path): + return await call_next(request) + + # Non-public path: require session cookie + if not request.cookies.get("access_token"): + return JSONResponse( + status_code=401, + content={ + "detail": AuthErrorResponse( + code=AuthErrorCode.NOT_AUTHENTICATED, + message="Authentication required", + ).model_dump() + }, + ) + + # Strict JWT validation: reject junk/expired tokens with 401 + # right here instead of silently passing through. This closes + # the "junk cookie bypass" gap (AUTH_TEST_PLAN test 7.5.8): + # without this, non-isolation routes like /api/models would + # accept any cookie-shaped string as authentication. + # + # We call the *strict* resolver so that fine-grained error + # codes (token_expired, token_invalid, user_not_found, …) + # propagate from AuthErrorCode, not get flattened into one + # generic code. BaseHTTPMiddleware doesn't let HTTPException + # bubble up, so we catch and render it as JSONResponse here. + from app.gateway.deps import get_current_user_from_request + + try: + user = await get_current_user_from_request(request) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + # Stamp both request.state.user (for the contextvar pattern) + # and request.state.auth (so @require_permission's "auth is + # None" branch short-circuits instead of running the entire + # JWT-decode + DB-lookup pipeline a second time per request). + request.state.user = user + request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) + token = set_current_user(user) + try: + return await call_next(request) + finally: + reset_current_user(token) diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py new file mode 100644 index 000000000..fa2e5f2d5 --- /dev/null +++ b/backend/app/gateway/authz.py @@ -0,0 +1,262 @@ +"""Authorization decorators and context for DeerFlow. + +Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blob/main/libs/sdk-py/langgraph_sdk/auth/__init__.py + +**Usage:** + +1. Use ``@require_auth`` on routes that need authentication +2. Use ``@require_permission("resource", "action", filter_key=...)`` for permission checks +3. The decorator chain processes from bottom to top + +**Example:** + + @router.get("/{thread_id}") + @require_auth + @require_permission("threads", "read", owner_check=True) + async def get_thread(thread_id: str, request: Request): + # User is authenticated and has threads:read permission + ... + +**Permission Model:** + +- threads:read - View thread +- threads:write - Create/update thread +- threads:delete - Delete thread +- runs:create - Run agent +- runs:read - View run +- runs:cancel - Cancel run +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar + +from fastapi import HTTPException, Request + +if TYPE_CHECKING: + from app.gateway.auth.models import User + +P = ParamSpec("P") +T = TypeVar("T") + + +# Permission constants +class Permissions: + """Permission constants for resource:action format.""" + + # Threads + THREADS_READ = "threads:read" + THREADS_WRITE = "threads:write" + THREADS_DELETE = "threads:delete" + + # Runs + RUNS_CREATE = "runs:create" + RUNS_READ = "runs:read" + RUNS_CANCEL = "runs:cancel" + + +class AuthContext: + """Authentication context for the current request. + + Stored in request.state.auth after require_auth decoration. + + Attributes: + user: The authenticated user, or None if anonymous + permissions: List of permission strings (e.g., "threads:read") + """ + + __slots__ = ("user", "permissions") + + def __init__(self, user: User | None = None, permissions: list[str] | None = None): + self.user = user + self.permissions = permissions or [] + + @property + def is_authenticated(self) -> bool: + """Check if user is authenticated.""" + return self.user is not None + + def has_permission(self, resource: str, action: str) -> bool: + """Check if context has permission for resource:action. + + Args: + resource: Resource name (e.g., "threads") + action: Action name (e.g., "read") + + Returns: + True if user has permission + """ + permission = f"{resource}:{action}" + return permission in self.permissions + + def require_user(self) -> User: + """Get user or raise 401. + + Raises: + HTTPException 401 if not authenticated + """ + if not self.user: + raise HTTPException(status_code=401, detail="Authentication required") + return self.user + + +def get_auth_context(request: Request) -> AuthContext | None: + """Get AuthContext from request state.""" + return getattr(request.state, "auth", None) + + +_ALL_PERMISSIONS: list[str] = [ + Permissions.THREADS_READ, + Permissions.THREADS_WRITE, + Permissions.THREADS_DELETE, + Permissions.RUNS_CREATE, + Permissions.RUNS_READ, + Permissions.RUNS_CANCEL, +] + + +async def _authenticate(request: Request) -> AuthContext: + """Authenticate request and return AuthContext. + + Delegates to deps.get_optional_user_from_request() for the JWT→User pipeline. + Returns AuthContext with user=None for anonymous requests. + """ + from app.gateway.deps import get_optional_user_from_request + + user = await get_optional_user_from_request(request) + if user is None: + return AuthContext(user=None, permissions=[]) + + # In future, permissions could be stored in user record + return AuthContext(user=user, permissions=_ALL_PERMISSIONS) + + +def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]: + """Decorator that authenticates the request and sets AuthContext. + + Must be placed ABOVE other decorators (executes after them). + + Usage: + @router.get("/{thread_id}") + @require_auth # Bottom decorator (executes first after permission check) + @require_permission("threads", "read") + async def get_thread(thread_id: str, request: Request): + auth: AuthContext = request.state.auth + ... + + Raises: + ValueError: If 'request' parameter is missing + """ + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") + if request is None: + raise ValueError("require_auth decorator requires 'request' parameter") + + # Authenticate and set context + auth_context = await _authenticate(request) + request.state.auth = auth_context + + return await func(*args, **kwargs) + + return wrapper + + +def require_permission( + resource: str, + action: str, + owner_check: bool = False, + require_existing: bool = False, +) -> Callable[[Callable[P, T]], Callable[P, T]]: + """Decorator that checks permission for resource:action. + + Must be used AFTER @require_auth. + + Args: + resource: Resource name (e.g., "threads", "runs") + action: Action name (e.g., "read", "write", "delete") + owner_check: If True, validates that the current user owns the resource. + Requires 'thread_id' path parameter and performs ownership check. + require_existing: Only meaningful with ``owner_check=True``. If True, a + missing ``threads_meta`` row counts as a denial (404) + instead of "untracked legacy thread, allow". Use on + **destructive / mutating** routes (DELETE, PATCH, + state-update) so a deleted thread can't be re-targeted + by another user via the missing-row code path. + + Usage: + # Read-style: legacy untracked threads are allowed + @require_permission("threads", "read", owner_check=True) + async def get_thread(thread_id: str, request: Request): + ... + + # Destructive: thread row MUST exist and be owned by caller + @require_permission("threads", "delete", owner_check=True, require_existing=True) + async def delete_thread(thread_id: str, request: Request): + ... + + Raises: + HTTPException 401: If authentication required but user is anonymous + HTTPException 403: If user lacks permission + HTTPException 404: If owner_check=True but user doesn't own the thread + ValueError: If owner_check=True but 'thread_id' parameter is missing + """ + + def decorator(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") + if request is None: + raise ValueError("require_permission decorator requires 'request' parameter") + + auth: AuthContext = getattr(request.state, "auth", None) + if auth is None: + auth = await _authenticate(request) + request.state.auth = auth + + if not auth.is_authenticated: + raise HTTPException(status_code=401, detail="Authentication required") + + # Check permission + if not auth.has_permission(resource, action): + raise HTTPException( + status_code=403, + detail=f"Permission denied: {resource}:{action}", + ) + + # Owner check for thread-specific resources. + # + # 2.0-rc moved thread metadata into the SQL persistence layer + # (``threads_meta`` table). We verify ownership via + # ``ThreadMetaStore.check_access``: it returns True for + # missing rows (untracked legacy thread) and for rows whose + # ``owner_id`` is NULL (shared / pre-auth data), so this is + # strict-deny rather than strict-allow — only an *existing* + # row with a *different* owner_id triggers 404. + if owner_check: + thread_id = kwargs.get("thread_id") + if thread_id is None: + raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter") + + from app.gateway.deps import get_thread_meta_repo + + thread_meta_repo = get_thread_meta_repo(request) + allowed = await thread_meta_repo.check_access( + thread_id, + str(auth.user.id), + require_existing=require_existing, + ) + if not allowed: + raise HTTPException( + status_code=404, + detail=f"Thread {thread_id} not found", + ) + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py new file mode 100644 index 000000000..fc96878b6 --- /dev/null +++ b/backend/app/gateway/csrf_middleware.py @@ -0,0 +1,112 @@ +"""CSRF protection middleware for FastAPI. + +Per RFC-001: +State-changing operations require CSRF protection. +""" + +import secrets +from collections.abc import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.types import ASGIApp + +CSRF_COOKIE_NAME = "csrf_token" +CSRF_HEADER_NAME = "X-CSRF-Token" +CSRF_TOKEN_LENGTH = 64 # bytes + + +def is_secure_request(request: Request) -> bool: + """Detect whether the original client request was made over HTTPS.""" + return request.headers.get("x-forwarded-proto", request.url.scheme) == "https" + + +def generate_csrf_token() -> str: + """Generate a secure random CSRF token.""" + return secrets.token_urlsafe(CSRF_TOKEN_LENGTH) + + +def should_check_csrf(request: Request) -> bool: + """Determine if a request needs CSRF validation. + + CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH). + GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. + """ + if request.method not in ("POST", "PUT", "DELETE", "PATCH"): + return False + + path = request.url.path.rstrip("/") + # Exempt /api/v1/auth/me endpoint + if path == "/api/v1/auth/me": + return False + return True + + +_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset( + { + "/api/v1/auth/login/local", + "/api/v1/auth/logout", + "/api/v1/auth/register", + } +) + + +def is_auth_endpoint(request: Request) -> bool: + """Check if the request is to an auth endpoint. + + Auth endpoints don't need CSRF validation on first call (no token). + """ + return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS + + +class CSRFMiddleware(BaseHTTPMiddleware): + """Middleware that implements CSRF protection using Double Submit Cookie pattern.""" + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + _is_auth = is_auth_endpoint(request) + + if should_check_csrf(request) and not _is_auth: + cookie_token = request.cookies.get(CSRF_COOKIE_NAME) + header_token = request.headers.get(CSRF_HEADER_NAME) + + if not cookie_token or not header_token: + return JSONResponse( + status_code=403, + content={"detail": "CSRF token missing. Include X-CSRF-Token header."}, + ) + + if not secrets.compare_digest(cookie_token, header_token): + return JSONResponse( + status_code=403, + content={"detail": "CSRF token mismatch."}, + ) + + response = await call_next(request) + + # For auth endpoints that set up session, also set CSRF cookie + if _is_auth and request.method == "POST": + # Generate a new CSRF token for the session + csrf_token = generate_csrf_token() + is_https = is_secure_request(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, + samesite="strict", + ) + + return response + + +def get_csrf_token(request: Request) -> str | None: + """Get the CSRF token from the current request's cookies. + + This is useful for server-side rendering where you need to embed + token in forms or headers. + """ + return request.cookies.get(CSRF_COOKIE_NAME) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index bdcea365c..5ea7f6751 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -11,11 +11,16 @@ from __future__ import annotations from collections.abc import AsyncGenerator from contextlib import AsyncExitStack, asynccontextmanager +from typing import TYPE_CHECKING from fastapi import FastAPI, HTTPException, Request from deerflow.runtime import RunContext, RunManager +if TYPE_CHECKING: + from app.gateway.auth.local_provider import LocalAuthProvider + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + @asynccontextmanager async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: @@ -127,10 +132,94 @@ def get_run_context(request: Request) -> RunContext: ) -async def get_current_user(request: Request) -> str | None: - """Extract user identity from request. +# --------------------------------------------------------------------------- +# Auth helpers (used by authz.py and auth middleware) +# --------------------------------------------------------------------------- - Phase 2: always returns None (no authentication). - Phase 3: extract user_id from JWT / session / API key header. +# Cached singletons to avoid repeated instantiation per request +_cached_local_provider: LocalAuthProvider | None = None +_cached_repo: SQLiteUserRepository | None = None + + +def get_local_provider() -> LocalAuthProvider: + """Get or create the cached LocalAuthProvider singleton. + + Must be called after ``init_engine_from_config()`` — the shared + session factory is required to construct the user repository. """ - return None + global _cached_local_provider, _cached_repo + if _cached_repo is None: + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + from deerflow.persistence.engine import get_session_factory + + sf = get_session_factory() + if sf is None: + raise RuntimeError("get_local_provider() called before init_engine_from_config(); cannot access users table") + _cached_repo = SQLiteUserRepository(sf) + if _cached_local_provider is None: + from app.gateway.auth.local_provider import LocalAuthProvider + + _cached_local_provider = LocalAuthProvider(repository=_cached_repo) + return _cached_local_provider + + +async def get_current_user_from_request(request: Request): + """Get the current authenticated user from the request cookie. + + Raises HTTPException 401 if not authenticated. + """ + from app.gateway.auth import decode_token + from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code + + access_token = request.cookies.get("access_token") + if not access_token: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(), + ) + + payload = decode_token(access_token) + if isinstance(payload, TokenError): + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(), + ) + + provider = get_local_provider() + user = await provider.get_user(payload.sub) + if user is None: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(), + ) + + # Token version mismatch → password was changed, token is stale + if user.token_version != payload.ver: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(), + ) + + return user + + +async def get_optional_user_from_request(request: Request): + """Get optional authenticated user from request. + + Returns None if not authenticated. + """ + try: + return await get_current_user_from_request(request) + except HTTPException: + return None + + +async def get_current_user(request: Request) -> str | None: + """Extract user_id from request cookie, or None if not authenticated. + + Thin adapter that returns the string id for callers that only need + identification (e.g., ``feedback.py``). Full-user callers should use + ``get_current_user_from_request`` or ``get_optional_user_from_request``. + """ + user = await get_optional_user_from_request(request) + return str(user.id) if user else None diff --git a/backend/app/gateway/langgraph_auth.py b/backend/app/gateway/langgraph_auth.py new file mode 100644 index 000000000..25d3b434c --- /dev/null +++ b/backend/app/gateway/langgraph_auth.py @@ -0,0 +1,106 @@ +"""LangGraph Server auth handler — shares JWT logic with Gateway. + +Loaded by LangGraph Server via langgraph.json ``auth.path``. +Reuses the same ``decode_token`` / ``get_auth_config`` as Gateway, +so both modes validate tokens with the same secret and rules. + +Two layers: + 1. @auth.authenticate — validates JWT cookie, extracts user_id, + and enforces CSRF on state-changing methods (POST/PUT/DELETE/PATCH) + 2. @auth.on — returns metadata filter so each user only sees own threads +""" + +import secrets + +from langgraph_sdk import Auth + +from app.gateway.auth.errors import TokenError +from app.gateway.auth.jwt import decode_token +from app.gateway.deps import get_local_provider + +auth = Auth() + +# Methods that require CSRF validation (state-changing per RFC 7231). +_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"}) + + +def _check_csrf(request) -> None: + """Enforce Double Submit Cookie CSRF check for state-changing requests. + + Mirrors Gateway's CSRFMiddleware logic so that LangGraph routes + proxied directly by nginx have the same CSRF protection. + """ + method = getattr(request, "method", "") or "" + if method.upper() not in _CSRF_METHODS: + return + + cookie_token = request.cookies.get("csrf_token") + header_token = request.headers.get("x-csrf-token") + + if not cookie_token or not header_token: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="CSRF token missing. Include X-CSRF-Token header.", + ) + + if not secrets.compare_digest(cookie_token, header_token): + raise Auth.exceptions.HTTPException( + status_code=403, + detail="CSRF token mismatch.", + ) + + +@auth.authenticate +async def authenticate(request): + """Validate the session cookie, decode JWT, and check token_version. + + Same validation chain as Gateway's get_current_user_from_request: + cookie → decode JWT → DB lookup → token_version match + Also enforces CSRF on state-changing methods. + """ + # CSRF check before authentication so forged cross-site requests + # are rejected early, even if the cookie carries a valid JWT. + _check_csrf(request) + + token = request.cookies.get("access_token") + if not token: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Not authenticated", + ) + + payload = decode_token(token) + if isinstance(payload, TokenError): + raise Auth.exceptions.HTTPException( + status_code=401, + detail=f"Token error: {payload.value}", + ) + + user = await get_local_provider().get_user(payload.sub) + if user is None: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="User not found", + ) + if user.token_version != payload.ver: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Token revoked (password changed)", + ) + + return payload.sub + + +@auth.on +async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict): + """Inject owner_id metadata on writes; filter by owner_id on reads. + + Gateway stores thread ownership as ``metadata.owner_id``. + This handler ensures LangGraph Server enforces the same isolation. + """ + # On create/update: stamp owner_id into metadata + metadata = value.setdefault("metadata", {}) + metadata["owner_id"] = ctx.user.identity + + # Return filter dict — LangGraph applies it to search/read/delete + return {"owner_id": ctx.user.identity} diff --git a/backend/app/gateway/routers/artifacts.py b/backend/app/gateway/routers/artifacts.py index a58fd5c0b..78ea5fa00 100644 --- a/backend/app/gateway/routers/artifacts.py +++ b/backend/app/gateway/routers/artifacts.py @@ -7,6 +7,7 @@ from urllib.parse import quote from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, PlainTextResponse, Response +from app.gateway.authz import require_permission from app.gateway.path_utils import resolve_thread_virtual_path logger = logging.getLogger(__name__) @@ -81,6 +82,7 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte summary="Get Artifact File", description="Retrieve an artifact file generated by the AI agent. Text and binary files can be viewed inline, while active web content is always downloaded.", ) +@require_permission("threads", "read", owner_check=True) async def get_artifact(thread_id: str, path: str, request: Request, download: bool = False) -> Response: """Get an artifact file by its path. diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py new file mode 100644 index 000000000..804a52ce9 --- /dev/null +++ b/backend/app/gateway/routers/auth.py @@ -0,0 +1,418 @@ +"""Authentication endpoints.""" + +import logging +import os +import time +from ipaddress import ip_address, ip_network + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.gateway.auth import ( + UserResponse, + create_access_token, +) +from app.gateway.auth.config import get_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.csrf_middleware import is_secure_request +from app.gateway.deps import get_current_user_from_request, get_local_provider + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + + +# ── Request/Response Models ────────────────────────────────────────────── + + +class LoginResponse(BaseModel): + """Response model for login — token only lives in HttpOnly cookie.""" + + expires_in: int # seconds + needs_setup: bool = False + + +# Top common-password blocklist. Drawn from the public SecLists "10k worst +# passwords" set, lowercased + length>=8 only (shorter ones already fail +# the min_length check). Kept tight on purpose: this is the **lower bound** +# defense, not a full HIBP / passlib check, and runs in-process per request. +_COMMON_PASSWORDS: frozenset[str] = frozenset( + { + "password", + "password1", + "password12", + "password123", + "password1234", + "12345678", + "123456789", + "1234567890", + "qwerty12", + "qwertyui", + "qwerty123", + "abc12345", + "abcd1234", + "iloveyou", + "letmein1", + "welcome1", + "welcome123", + "admin123", + "administrator", + "passw0rd", + "p@ssw0rd", + "monkey12", + "trustno1", + "sunshine", + "princess", + "football", + "baseball", + "superman", + "batman123", + "starwars", + "dragon123", + "master123", + "shadow12", + "michael1", + "jennifer", + "computer", + } +) + + +def _password_is_common(password: str) -> bool: + """Case-insensitive blocklist check. + + Lowercases the input so trivial mutations like ``Password`` / + ``PASSWORD`` are also rejected. Does not normalize digit substitutions + (``p@ssw0rd`` is included as a literal entry instead) — keeping the + rule cheap and predictable. + """ + return password.lower() in _COMMON_PASSWORDS + + +def _validate_strong_password(value: str) -> str: + """Pydantic field-validator body shared by Register + ChangePassword. + + Constraint = function, not type-level mixin. The two request models + have no "is-a" relationship; they only share the password-strength + rule. Lifting it into a free function lets each model bind it via + ``@field_validator(field_name)`` without inheritance gymnastics. + """ + if _password_is_common(value): + raise ValueError("Password is too common; choose a stronger password.") + return value + + +class RegisterRequest(BaseModel): + """Request model for user registration.""" + + email: EmailStr + password: str = Field(..., min_length=8) + + _strong_password = field_validator("password")(classmethod(lambda cls, v: _validate_strong_password(v))) + + +class ChangePasswordRequest(BaseModel): + """Request model for password change (also handles setup flow).""" + + current_password: str + new_password: str = Field(..., min_length=8) + new_email: EmailStr | None = None + + _strong_password = field_validator("new_password")(classmethod(lambda cls, v: _validate_strong_password(v))) + + +class MessageResponse(BaseModel): + """Generic message response.""" + + message: str + + +# ── Helpers ─────────────────────────────────────────────────────────────── + + +def _set_session_cookie(response: Response, token: str, request: Request) -> 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, + ) + + +# ── Rate Limiting ──────────────────────────────────────────────────────── +# In-process dict — not shared across workers. Sufficient for single-worker deployments. + +_MAX_LOGIN_ATTEMPTS = 5 +_LOCKOUT_SECONDS = 300 # 5 minutes + +# ip → (fail_count, lock_until_timestamp) +_login_attempts: dict[str, tuple[int, float]] = {} + + +def _trusted_proxies() -> list: + """Parse ``AUTH_TRUSTED_PROXIES`` env var into a list of ip_network objects. + + Comma-separated CIDR or single-IP entries. Empty / unset = no proxy is + trusted (direct mode). Invalid entries are skipped with a logger warning. + Read live so env-var overrides take effect immediately and tests can + ``monkeypatch.setenv`` without poking a module-level cache. + """ + raw = os.getenv("AUTH_TRUSTED_PROXIES", "").strip() + if not raw: + return [] + nets = [] + for entry in raw.split(","): + entry = entry.strip() + if not entry: + continue + try: + nets.append(ip_network(entry, strict=False)) + except ValueError: + logger.warning("AUTH_TRUSTED_PROXIES: ignoring invalid entry %r", entry) + return nets + + +def _get_client_ip(request: Request) -> str: + """Extract the real client IP for rate limiting. + + Trust model: + + - The TCP peer (``request.client.host``) is always the baseline. It is + whatever the kernel reports as the connecting socket — unforgeable + by the client itself. + - ``X-Real-IP`` is **only** honored if the TCP peer is in the + ``AUTH_TRUSTED_PROXIES`` allowlist (set via env var, comma-separated + CIDR or single IPs). When set, the gateway is assumed to be behind a + reverse proxy (nginx, Cloudflare, ALB, …) that overwrites + ``X-Real-IP`` with the original client address. + - With no ``AUTH_TRUSTED_PROXIES`` set, ``X-Real-IP`` is silently + ignored — closing the bypass where any client could rotate the + header to dodge per-IP rate limits in dev / direct-gateway mode. + + ``X-Forwarded-For`` is intentionally NOT used because it is naturally + client-controlled at the *first* hop and the trust chain is harder to + audit per-request. + """ + peer_host = request.client.host if request.client else None + + trusted = _trusted_proxies() + if trusted and peer_host: + try: + peer_ip = ip_address(peer_host) + if any(peer_ip in net for net in trusted): + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + except ValueError: + # peer_host wasn't a parseable IP (e.g. "unknown") — fall through + pass + + return peer_host or "unknown" + + +def _check_rate_limit(ip: str) -> None: + """Raise 429 if the IP is currently locked out.""" + record = _login_attempts.get(ip) + if record is None: + return + fail_count, lock_until = record + if fail_count >= _MAX_LOGIN_ATTEMPTS: + if time.time() < lock_until: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again later.", + ) + del _login_attempts[ip] + + +_MAX_TRACKED_IPS = 10000 + + +def _record_login_failure(ip: str) -> None: + """Record a failed login attempt for the given IP.""" + # Evict expired lockouts when dict grows too large + if len(_login_attempts) >= _MAX_TRACKED_IPS: + now = time.time() + expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t] + for k in expired: + del _login_attempts[k] + # If still too large, evict cheapest-to-lose half: below-threshold + # IPs (lock_until=0.0) sort first, then earliest-expiring lockouts. + if len(_login_attempts) >= _MAX_TRACKED_IPS: + by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1]) + for k, _ in by_time[: len(by_time) // 2]: + del _login_attempts[k] + + record = _login_attempts.get(ip) + if record is None: + _login_attempts[ip] = (1, 0.0) + else: + new_count = record[0] + 1 + lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0 + _login_attempts[ip] = (new_count, lock_until) + + +def _record_login_success(ip: str) -> None: + """Clear failure counter for the given IP on successful login.""" + _login_attempts.pop(ip, None) + + +# ── Endpoints ───────────────────────────────────────────────────────────── + + +@router.post("/login/local", response_model=LoginResponse) +async def login_local( + request: Request, + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), +): + """Local email/password login.""" + client_ip = _get_client_ip(request) + _check_rate_limit(client_ip) + + user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password}) + + if user is None: + _record_login_failure(client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(), + ) + + _record_login_success(client_ip) + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return LoginResponse( + expires_in=get_auth_config().token_expiry_days * 24 * 3600, + needs_setup=user.needs_setup, + ) + + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(request: Request, response: Response, body: RegisterRequest): + """Register a new user account (always 'user' role). + + Admin is auto-created on first boot. This endpoint creates regular users. + Auto-login by setting the session cookie. + """ + try: + user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="user") + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(), + ) + + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role) + + +@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") + return MessageResponse(message="Successfully logged out") + + +@router.post("/change-password", response_model=MessageResponse) +async def change_password(request: Request, response: Response, body: ChangePasswordRequest): + """Change password for the currently authenticated user. + + Also handles the first-boot setup flow: + - If new_email is provided, updates email (checks uniqueness) + - If user.needs_setup is True and new_email is given, clears needs_setup + - Always increments token_version to invalidate old sessions + - Re-issues session cookie with new token_version + """ + from app.gateway.auth.password import hash_password_async, verify_password_async + + user = await get_current_user_from_request(request) + + if user.password_hash is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump()) + + if not await verify_password_async(body.current_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Current password is incorrect").model_dump()) + + provider = get_local_provider() + + # Update email if provided + if body.new_email is not None: + existing = await provider.get_user_by_email(body.new_email) + if existing and str(existing.id) != str(user.id): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump()) + user.email = body.new_email + + # Update password + bump version + user.password_hash = await hash_password_async(body.new_password) + user.token_version += 1 + + # Clear setup flag if this is the setup flow + if user.needs_setup and body.new_email is not None: + user.needs_setup = False + + await provider.update_user(user) + + # 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) + + return MessageResponse(message="Password changed successfully") + + +@router.get("/me", response_model=UserResponse) +async def get_me(request: Request): + """Get current authenticated user info.""" + user = await get_current_user_from_request(request) + return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup) + + +@router.get("/setup-status") +async def setup_status(): + """Check if admin account exists. Always False after first boot.""" + user_count = await get_local_provider().count_users() + return {"needs_setup": user_count == 0} + + +# ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── + + +@router.get("/oauth/{provider}") +async def oauth_login(provider: str): + """Initiate OAuth login flow. + + Redirects to the OAuth provider's authorization URL. + Currently a placeholder - requires OAuth provider implementation. + """ + if provider not in ["github", "google"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported OAuth provider: {provider}", + ) + + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="OAuth login not yet implemented", + ) + + +@router.get("/callback/{provider}") +async def oauth_callback(provider: str, code: str, state: str): + """OAuth callback endpoint. + + Handles the OAuth provider's callback after user authorization. + Currently a placeholder. + """ + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="OAuth callback not yet implemented", + ) diff --git a/backend/app/gateway/routers/feedback.py b/backend/app/gateway/routers/feedback.py index 579b29a9e..2bf631d01 100644 --- a/backend/app/gateway/routers/feedback.py +++ b/backend/app/gateway/routers/feedback.py @@ -12,6 +12,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field +from app.gateway.authz import require_permission from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store logger = logging.getLogger(__name__) @@ -53,6 +54,7 @@ class FeedbackStatsResponse(BaseModel): @router.post("/{thread_id}/runs/{run_id}/feedback", response_model=FeedbackResponse) +@require_permission("threads", "write", owner_check=True, require_existing=True) async def create_feedback( thread_id: str, run_id: str, @@ -85,6 +87,7 @@ async def create_feedback( @router.get("/{thread_id}/runs/{run_id}/feedback", response_model=list[FeedbackResponse]) +@require_permission("threads", "read", owner_check=True) async def list_feedback( thread_id: str, run_id: str, @@ -96,6 +99,7 @@ async def list_feedback( @router.get("/{thread_id}/runs/{run_id}/feedback/stats", response_model=FeedbackStatsResponse) +@require_permission("threads", "read", owner_check=True) async def feedback_stats( thread_id: str, run_id: str, @@ -107,6 +111,7 @@ async def feedback_stats( @router.delete("/{thread_id}/runs/{run_id}/feedback/{feedback_id}") +@require_permission("threads", "delete", owner_check=True, require_existing=True) async def delete_feedback( thread_id: str, run_id: str, diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index ac54e674d..0da5e4322 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -1,10 +1,11 @@ import json import logging -from fastapi import APIRouter +from fastapi import APIRouter, Request from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field +from app.gateway.authz import require_permission from deerflow.models import create_chat_model logger = logging.getLogger(__name__) @@ -98,12 +99,13 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str: summary="Generate Follow-up Questions", description="Generate short follow-up questions a user might ask next, based on recent conversation context.", ) -async def generate_suggestions(thread_id: str, request: SuggestionsRequest) -> SuggestionsResponse: - if not request.messages: +@require_permission("threads", "read", owner_check=True) +async def generate_suggestions(thread_id: str, body: SuggestionsRequest, request: Request) -> SuggestionsResponse: + if not body.messages: return SuggestionsResponse(suggestions=[]) - n = request.n - conversation = _format_conversation(request.messages) + n = body.n + conversation = _format_conversation(body.messages) if not conversation: return SuggestionsResponse(suggestions=[]) @@ -120,7 +122,7 @@ async def generate_suggestions(thread_id: str, request: SuggestionsRequest) -> S user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions" try: - model = create_chat_model(name=request.model_name, thinking_enabled=False) + model = create_chat_model(name=body.model_name, thinking_enabled=False) response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)]) raw = _extract_response_text(response.content) suggestions = _parse_json_string_list(raw) or [] diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index a26bdfbf3..904f94ff0 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field +from app.gateway.authz import require_permission from app.gateway.deps import get_checkpointer, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge from app.gateway.services import sse_consumer, start_run from deerflow.runtime import RunRecord, serialize_channel_values @@ -93,6 +94,7 @@ def _record_to_response(record: RunRecord) -> RunResponse: @router.post("/{thread_id}/runs", response_model=RunResponse) +@require_permission("runs", "create", owner_check=True, require_existing=True) async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse: """Create a background run (returns immediately).""" record = await start_run(body, thread_id, request) @@ -100,6 +102,7 @@ async def create_run(thread_id: str, body: RunCreateRequest, request: Request) - @router.post("/{thread_id}/runs/stream") +@require_permission("runs", "create", owner_check=True, require_existing=True) async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse: """Create a run and stream events via SSE. @@ -127,6 +130,7 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) - @router.post("/{thread_id}/runs/wait", response_model=dict) +@require_permission("runs", "create", owner_check=True, require_existing=True) async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict: """Create a run and block until it completes, returning the final state.""" record = await start_run(body, thread_id, request) @@ -152,6 +156,7 @@ async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> @router.get("/{thread_id}/runs", response_model=list[RunResponse]) +@require_permission("runs", "read", owner_check=True) async def list_runs(thread_id: str, request: Request) -> list[RunResponse]: """List all runs for a thread.""" run_mgr = get_run_manager(request) @@ -160,6 +165,7 @@ async def list_runs(thread_id: str, request: Request) -> list[RunResponse]: @router.get("/{thread_id}/runs/{run_id}", response_model=RunResponse) +@require_permission("runs", "read", owner_check=True) async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse: """Get details of a specific run.""" run_mgr = get_run_manager(request) @@ -170,6 +176,7 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse: @router.post("/{thread_id}/runs/{run_id}/cancel") +@require_permission("runs", "cancel", owner_check=True, require_existing=True) async def cancel_run( thread_id: str, run_id: str, @@ -207,6 +214,7 @@ async def cancel_run( @router.get("/{thread_id}/runs/{run_id}/join") +@require_permission("runs", "read", owner_check=True) async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse: """Join an existing run's SSE stream.""" bridge = get_stream_bridge(request) @@ -227,6 +235,7 @@ async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingRe @router.api_route("/{thread_id}/runs/{run_id}/stream", methods=["GET", "POST"], response_model=None) +@require_permission("runs", "read", owner_check=True) async def stream_existing_run( thread_id: str, run_id: str, @@ -274,6 +283,7 @@ async def stream_existing_run( @router.get("/{thread_id}/messages") +@require_permission("runs", "read", owner_check=True) async def list_thread_messages( thread_id: str, request: Request, @@ -287,6 +297,7 @@ async def list_thread_messages( @router.get("/{thread_id}/runs/{run_id}/messages") +@require_permission("runs", "read", owner_check=True) async def list_run_messages(thread_id: str, run_id: str, request: Request) -> list[dict]: """Return displayable messages for a specific run.""" event_store = get_run_event_store(request) @@ -294,6 +305,7 @@ async def list_run_messages(thread_id: str, run_id: str, request: Request) -> li @router.get("/{thread_id}/runs/{run_id}/events") +@require_permission("runs", "read", owner_check=True) async def list_run_events( thread_id: str, run_id: str, @@ -308,6 +320,7 @@ async def list_run_events( @router.get("/{thread_id}/token-usage") +@require_permission("threads", "read", owner_check=True) async def thread_token_usage(thread_id: str, request: Request) -> dict: """Thread-level token usage aggregation.""" run_store = get_run_store(request) diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 487bf5413..c7c7b4053 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -18,8 +18,9 @@ import uuid from typing import Any from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator +from app.gateway.authz import require_permission from app.gateway.deps import get_checkpointer from app.gateway.utils import sanitize_log_param from deerflow.config.paths import Paths, get_paths @@ -29,6 +30,22 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/threads", tags=["threads"]) +# Metadata keys that the server controls; clients are not allowed to set +# them. Pydantic ``@field_validator("metadata")`` strips them on every +# inbound model below so a malicious client cannot reflect a forged +# owner identity through the API surface. Defense-in-depth — the +# row-level invariant is still ``threads_meta.owner_id`` populated from +# the auth contextvar; this list closes the metadata-blob echo gap. +_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"}) + + +def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]: + """Return ``metadata`` with server-controlled keys removed.""" + if not metadata: + return metadata or {} + return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS} + + # --------------------------------------------------------------------------- # Response / request models # --------------------------------------------------------------------------- @@ -60,6 +77,8 @@ class ThreadCreateRequest(BaseModel): assistant_id: str | None = Field(default=None, description="Associate thread with an assistant") metadata: dict[str, Any] = Field(default_factory=dict, description="Initial metadata") + _strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v))) + class ThreadSearchRequest(BaseModel): """Request body for searching threads.""" @@ -88,6 +107,8 @@ class ThreadPatchRequest(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata to merge") + _strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v))) + class ThreadStateUpdateRequest(BaseModel): """Request body for updating thread state (human-in-the-loop resume).""" @@ -165,6 +186,7 @@ def _derive_thread_status(checkpoint_tuple) -> str: @router.delete("/{thread_id}", response_model=ThreadDeleteResponse) +@require_permission("threads", "delete", owner_check=True, require_existing=True) async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteResponse: """Delete local persisted filesystem data for a thread. @@ -211,6 +233,8 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe thread_meta_repo = get_thread_meta_repo(request) thread_id = body.thread_id or str(uuid.uuid4()) now = time.time() + # ``body.metadata`` is already stripped of server-reserved keys by + # ``ThreadCreateRequest._strip_reserved`` — see the model definition. # Idempotency: return existing record when already present existing_record = await thread_meta_repo.get(thread_id) @@ -293,6 +317,7 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th @router.patch("/{thread_id}", response_model=ThreadResponse) +@require_permission("threads", "write", owner_check=True, require_existing=True) async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse: """Merge metadata into a thread record.""" from app.gateway.deps import get_thread_meta_repo @@ -302,6 +327,7 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques if record is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") + # ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``. try: await thread_meta_repo.update_metadata(thread_id, body.metadata) except Exception: @@ -320,6 +346,7 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques @router.get("/{thread_id}", response_model=ThreadResponse) +@require_permission("threads", "read", owner_check=True) async def get_thread(thread_id: str, request: Request) -> ThreadResponse: """Get thread info. @@ -376,6 +403,7 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse: @router.get("/{thread_id}/state", response_model=ThreadStateResponse) +@require_permission("threads", "read", owner_check=True) async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse: """Get the latest state snapshot for a thread. @@ -425,6 +453,7 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo @router.post("/{thread_id}/state", response_model=ThreadStateResponse) +@require_permission("threads", "write", owner_check=True, require_existing=True) async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: """Update thread state (e.g. for human-in-the-loop resume or title rename). @@ -514,6 +543,7 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re @router.post("/{thread_id}/history", response_model=list[HistoryEntry]) +@require_permission("threads", "read", owner_check=True) async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]: """Get checkpoint history for a thread. diff --git a/backend/app/gateway/routers/uploads.py b/backend/app/gateway/routers/uploads.py index 9d9d0c9bc..3de297355 100644 --- a/backend/app/gateway/routers/uploads.py +++ b/backend/app/gateway/routers/uploads.py @@ -4,9 +4,10 @@ import logging import os import stat -from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi import APIRouter, File, HTTPException, Request, UploadFile from pydantic import BaseModel +from app.gateway.authz import require_permission from deerflow.config.paths import get_paths from deerflow.sandbox.sandbox_provider import get_sandbox_provider from deerflow.uploads.manager import ( @@ -54,8 +55,10 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None: @router.post("", response_model=UploadResponse) +@require_permission("threads", "write", owner_check=True, require_existing=True) async def upload_files( thread_id: str, + request: Request, files: list[UploadFile] = File(...), ) -> UploadResponse: """Upload multiple files to a thread's uploads directory.""" @@ -133,7 +136,8 @@ async def upload_files( @router.get("/list", response_model=dict) -async def list_uploaded_files(thread_id: str) -> dict: +@require_permission("threads", "read", owner_check=True) +async def list_uploaded_files(thread_id: str, request: Request) -> dict: """List all files in a thread's uploads directory.""" try: uploads_dir = get_uploads_dir(thread_id) @@ -151,7 +155,8 @@ async def list_uploaded_files(thread_id: str) -> dict: @router.delete("/{filename}") -async def delete_uploaded_file(thread_id: str, filename: str) -> dict: +@require_permission("threads", "delete", owner_check=True, require_existing=True) +async def delete_uploaded_file(thread_id: str, filename: str, request: Request) -> dict: """Delete a file from a thread's uploads directory.""" try: uploads_dir = get_uploads_dir(thread_id) diff --git a/backend/docs/AUTH_TEST_DOCKER_GAP.md b/backend/docs/AUTH_TEST_DOCKER_GAP.md new file mode 100644 index 000000000..adf4916a3 --- /dev/null +++ b/backend/docs/AUTH_TEST_DOCKER_GAP.md @@ -0,0 +1,77 @@ +# Docker Test Gap (Section 七 7.4) + +This file documents the only **un-executed** test cases from +`backend/docs/AUTH_TEST_PLAN.md` after the full release validation pass. + +## Why this gap exists + +The release validation environment (sg_dev: `10.251.229.92`) **does not have +a Docker daemon installed**. The TC-DOCKER cases are container-runtime +behavior tests that need an actual Docker engine to spin up +`docker/docker-compose.yaml` services. + +```bash +$ ssh sg_dev "which docker; docker --version" +# (empty) +# bash: docker: command not found +``` + +All other test plan sections were executed against either: +- The local dev box (Mac, all services running locally), or +- The deployed sg_dev instance (gateway + frontend + nginx via SSH tunnel) + +## Cases not executed + +| Case | Title | What it covers | Why not run | +|---|---|---|---| +| TC-DOCKER-01 | `users.db` volume persistence | Verify the `DEER_FLOW_HOME` bind mount survives container restart | needs `docker compose up` | +| TC-DOCKER-02 | Session persistence across container restart | `AUTH_JWT_SECRET` env var keeps cookies valid after `docker compose down && up` | needs `docker compose down/up` | +| TC-DOCKER-03 | Per-worker rate limiter divergence | Confirms in-process `_login_attempts` dict doesn't share state across `gunicorn` workers (4 by default in the compose file); known limitation, documented | needs multi-worker container | +| TC-DOCKER-04 | IM channels skip AuthMiddleware | Verify Feishu/Slack/Telegram dispatchers run in-container against `http://langgraph:2024` without going through nginx | needs `docker logs` | +| TC-DOCKER-05 | Admin credentials surfacing | **Updated post-simplify** — was "log scrape", now "0600 credential file in `DEER_FLOW_HOME`". The file-based behavior is already validated by TC-1.1 + TC-UPG-13 on sg_dev (non-Docker), so the only Docker-specific gap is verifying the volume mount carries the file out to the host | needs container + host volume | +| TC-DOCKER-06 | Gateway-mode Docker deploy | `./scripts/deploy.sh --gateway` produces a 3-container topology (no `langgraph` container); same auth flow as standard mode | needs `docker compose --profile gateway` | + +## Coverage already provided by non-Docker tests + +The **auth-relevant** behavior in each Docker case is already exercised by +the test cases that ran on sg_dev or local: + +| Docker case | Auth behavior covered by | +|---|---| +| TC-DOCKER-01 (volume persistence) | TC-REENT-01 on sg_dev (admin row survives gateway restart) — same SQLite file, just no container layer between | +| TC-DOCKER-02 (session persistence) | TC-API-02/03/06 (cookie roundtrip), plus TC-REENT-04 (multi-cookie) — JWT verification is process-state-free, container restart is equivalent to `pkill uvicorn && uv run uvicorn` | +| TC-DOCKER-03 (per-worker rate limit) | TC-GW-04 + TC-REENT-09 (single-worker rate limit + 5min expiry). The cross-worker divergence is an architectural property of the in-memory dict; no auth code path differs | +| TC-DOCKER-04 (IM channels skip auth) | Code-level only: `app/channels/manager.py` uses `langgraph_sdk` directly with no cookie handling. The langgraph_auth handler is bypassed by going through SDK, not HTTP | +| TC-DOCKER-05 (credential surfacing) | TC-1.1 on sg_dev (file at `~/deer-flow/backend/.deer-flow/admin_initial_credentials.txt`, mode 0600, password 22 chars) — the only Docker-unique step is whether the bind mount projects this path onto the host, which is a `docker compose` config check, not a runtime behavior change | +| TC-DOCKER-06 (gateway-mode container) | Section 七 7.2 covered by TC-GW-01..05 + Section 二 (gateway-mode auth flow on sg_dev) — same Gateway code, container is just a packaging change | + +## Reproduction steps when Docker becomes available + +Anyone with `docker` + `docker compose` installed can reproduce the gap by +running the test plan section verbatim. Pre-flight: + +```bash +# Required on the host +docker --version # >=24.x +docker compose version # plugin >=2.x + +# Required env var (otherwise sessions reset on every container restart) +echo "AUTH_JWT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" \ + >> .env + +# Optional: pin DEER_FLOW_HOME to a stable host path +echo "DEER_FLOW_HOME=$HOME/deer-flow-data" >> .env +``` + +Then run TC-DOCKER-01..06 from the test plan as written. + +## Decision log + +- **Not blocking the release.** The auth-relevant behavior in every Docker + case has an already-validated equivalent on bare metal. The gap is purely + about *container packaging* details (bind mounts, multi-worker, log + collection), not about whether the auth code paths work. +- **TC-DOCKER-05 was updated in place** in `AUTH_TEST_PLAN.md` to reflect + the post-simplify reality (credentials file → 0600 file, no log leak). + The old "grep 'Password:' in docker logs" expectation would have failed + silently and given a false sense of coverage. diff --git a/backend/docs/AUTH_TEST_PLAN.md b/backend/docs/AUTH_TEST_PLAN.md new file mode 100644 index 000000000..15b20494a --- /dev/null +++ b/backend/docs/AUTH_TEST_PLAN.md @@ -0,0 +1,1801 @@ +# Auth 模块测试计划 + +## 测试矩阵 + +| 模式 | 启动命令 | Auth 层 | 端口 | +|------|---------|---------|------| +| 标准模式 | `make dev` | Gateway AuthMiddleware + LangGraph auth | 2026 (nginx) | +| Gateway 模式 | `make dev-pro` | Gateway AuthMiddleware(全量) | 2026 (nginx) | +| 直连 Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 | +| 直连 LangGraph | `cd backend && make dev` | LangGraph auth | 2024 | + +每种模式下都需执行以下测试。 + +--- + +## 一、环境准备 + +### 1.1 首次启动(干净数据库) + +```bash +# 清除已有数据 +rm -f backend/.deer-flow/users.db + +# 选择模式启动 +make dev # 标准模式 +# 或 +make dev-pro # Gateway 模式 +``` + +**验证点:** +- [ ] 控制台输出 admin 邮箱和随机密码 +- [ ] 密码格式为 `secrets.token_urlsafe(16)` 的 22 字符字符串 +- [ ] 邮箱为 `admin@deerflow.dev` +- [ ] 提示 `Change it after login: Settings -> Account` + +### 1.2 非首次启动 + +```bash +# 不清除数据库,直接启动 +make dev +``` + +**验证点:** +- [ ] 控制台不输出密码 +- [ ] 如果 admin 仍 `needs_setup=True`,控制台有 warning 提示 + +### 1.3 环境变量配置 + +| 变量 | 验证 | +|------|------| +| `AUTH_JWT_SECRET` 未设 | 启动时 warning,自动生成临时密钥 | +| `AUTH_JWT_SECRET` 已设 | 无 warning,重启后 session 保持 | + +--- + +## 二、接口流程测试 + +> 以下用 `BASE=http://localhost:2026` 为例。标准模式和 Gateway 模式都用此地址。 +> 直连测试替换为对应端口。 +> +> **CSRF token 提取**:多处用到从 cookie jar 提取 CSRF token,统一使用: +> ```bash +> CSRF=$(python3 -c " +> import http.cookiejar +> cj = http.cookiejar.MozillaCookieJar('cookies.txt'); cj.load() +> print(next(c.value for c in cj if c.name == 'csrf_token')) +> ") +> ``` +> 或简写(多数场景够用):`CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')` + +### 2.1 注册 + 登录 + 会话 + +#### TC-API-01: Setup 状态查询 + +```bash +curl -s $BASE/api/v1/auth/setup-status | jq . +``` + +**预期:** 返回 `{"needs_setup": false}`(admin 在启动时已自动创建,`count_users() > 0`)。仅在启动完成前的极短窗口内可能返回 `true`。 + +#### TC-API-02: Admin 首次登录 + +```bash +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<控制台密码>" \ + -c cookies.txt | jq . +``` + +**预期:** +- 状态码 200 +- Body: `{"expires_in": 604800, "needs_setup": true}` +- `cookies.txt` 包含 `access_token`(HttpOnly)和 `csrf_token`(非 HttpOnly) + +#### TC-API-03: 获取当前用户 + +```bash +curl -s $BASE/api/v1/auth/me -b cookies.txt | jq . +``` + +**预期:** `{"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}` + +#### TC-API-04: Setup 流程(改邮箱 + 改密码) + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"<控制台密码>","new_password":"NewPass123!","new_email":"admin@example.com"}' | jq . +``` + +**预期:** +- 状态码 200 +- `{"message": "Password changed successfully"}` +- 再调 `/auth/me` 邮箱变为 `admin@example.com`,`needs_setup` 变为 `false` + +#### TC-API-05: 普通用户注册 + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user1@example.com","password":"UserPass1!"}' \ + -c user_cookies.txt | jq . +``` + +**预期:** 状态码 201,`system_role` 为 `"user"`,自动登录(cookie 已设) + +#### TC-API-06: 登出 + +```bash +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt | jq . +``` + +**预期:** `{"message": "Successfully logged out"}`,后续用 cookies.txt 访问 `/auth/me` 返回 401 + +### 2.2 多租户隔离 + +#### TC-API-07: 用户 A 创建 Thread + +```bash +# 以 user1 登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user1@example.com&password=UserPass1!" \ + -c user1.txt + +CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}') + +# 创建 thread +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF1" \ + -d '{"metadata":{}}' | jq .thread_id +# 记录 THREAD_ID +``` + +#### TC-API-08: 用户 B 无法访问用户 A 的 Thread + +```bash +# 注册并登录 user2 +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user2@example.com","password":"UserPass2!"}' \ + -c user2.txt + +# 尝试访问 user1 的 thread +curl -s $BASE/api/threads/$THREAD_ID -b user2.txt +``` + +**预期:** 状态码 404(不是 403,避免泄露 thread 存在性) + +#### TC-API-09: 用户 B 搜索 Thread 看不到用户 A 的 + +```bash +CSRF2=$(grep csrf_token user2.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads/search \ + -b user2.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF2" \ + -d '{}' | jq length +``` + +**预期:** 返回 0 或仅包含 user2 自己的 thread + +### 2.3 标准模式 LangGraph Server 隔离 + +> 仅在标准模式下测试。Gateway 模式不跑 LangGraph Server。 + +#### TC-API-10: LangGraph 端点需要 cookie + +```bash +# 不带 cookie 访问 LangGraph 接口 +curl -s -w "%{http_code}" $BASE/api/langgraph/threads +``` + +**预期:** 401 + +#### TC-API-11: LangGraph 带 cookie 可访问 + +```bash +curl -s $BASE/api/langgraph/threads -b user1.txt | jq length +``` + +**预期:** 200,返回 user1 的 thread 列表 + +#### TC-API-12: LangGraph 隔离 — 用户只看到自己的 + +```bash +# user2 查 LangGraph threads +curl -s $BASE/api/langgraph/threads -b user2.txt | jq length +``` + +**预期:** 不包含 user1 的 thread + +### 2.4 Token 失效 + +#### TC-API-13: 改密码后旧 token 立即失效 + +```bash +# 保存当前 cookie +cp user1.txt user1_old.txt + +# 改密码 +CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF1" \ + -d '{"current_password":"UserPass1!","new_password":"NewUserPass1!"}' \ + -c user1.txt + +# 用旧 cookie 访问 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b user1_old.txt +``` + +**预期:** 401(token_version 不匹配) + +#### TC-API-14: 改密码后新 cookie 可用 + +```bash +curl -s $BASE/api/v1/auth/me -b user1.txt | jq .email +``` + +**预期:** 200,返回用户信息 + +### 2.5 错误响应格式 + +#### TC-API-15: 结构化错误响应 + +```bash +# 错误密码登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" | jq .detail +``` + +**预期:** +```json +{"code": "invalid_credentials", "message": "Incorrect email or password"} +``` + +#### TC-API-16: 重复邮箱注册 + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user1@example.com","password":"AnyPass123"}' -w "\n%{http_code}" +``` + +**预期:** 400,`{"code": "email_already_exists", ...}` + +--- + +## 三、攻击测试 + +### 3.1 暴力破解防护 + +#### TC-ATK-01: IP 限速 + +```bash +# 连续 6 次错误密码 +for i in $(seq 1 6); do + echo "Attempt $i:" + curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong$i" -w " HTTP %{http_code}\n" +done +``` + +**预期:** 前 5 次返回 401,第 6 次返回 429 `"Too many login attempts. Try again later."` + +#### TC-ATK-02: 限速后正确密码也被拒 + +```bash +# 紧接上一步 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n" +``` + +**预期:** 429(锁定 5 分钟) + +#### TC-ATK-03: 成功登录清除限速 + +```bash +# 等待锁定过期后(或重启服务),用正确密码登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -w " HTTP %{http_code}\n" +``` + +**预期:** 200,计数器重置 + +### 3.2 CSRF 防护 + +#### TC-ATK-04: 无 CSRF token 的 POST 请求 + +```bash +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -d '{"metadata":{}}' -w "\nHTTP %{http_code}" +``` + +**预期:** 403 `"CSRF token missing"` + +#### TC-ATK-05: 错误 CSRF token + +```bash +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: fake-token" \ + -d '{"metadata":{}}' -w "\nHTTP %{http_code}" +``` + +**预期:** 403 `"CSRF token mismatch"` + +### 3.3 Cookie 安全 + +> HTTP 与 HTTPS 行为差异通过 `X-Forwarded-Proto: https` 模拟。 +> **注意:** 经 nginx 代理时,nginx 的 `proxy_set_header X-Forwarded-Proto $scheme` 会覆盖 +> 客户端发的值(`$scheme` = nginx 监听端口的 scheme),因此 HTTPS 模拟必须**直连 Gateway(端口 8001)**。 +> 每个 case 需在 **login** 和 **register** 两个端点各验证一次。 + +#### TC-ATK-06: HTTP 模式 Cookie 属性 + +```bash +# 登录 +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie +``` + +**预期:** +- `access_token`: `HttpOnly; Path=/; SameSite=lax`,无 `Secure`,无 `Max-Age` +- `csrf_token`: `Path=/; SameSite=strict`,无 `HttpOnly`(JS 需要读取),无 `Secure` + +```bash +# 注册 +curl -s -D - -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"cookie-http@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie +``` + +**预期:** 同上 + +#### TC-ATK-07: HTTPS 模式 Cookie 属性 + +> **必须直连 Gateway**(`GW=http://localhost:8001`),经 nginx 会被 `$scheme` 覆盖。 + +```bash +GW=http://localhost:8001 + +# 登录(模拟 HTTPS) +curl -s -D - -X POST $GW/api/v1/auth/login/local \ + -H "X-Forwarded-Proto: https" \ + -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie +``` + +**预期:** +- `access_token`: `HttpOnly; Secure; Path=/; SameSite=lax; Max-Age=604800` +- `csrf_token`: `Secure; Path=/; SameSite=strict`,无 `HttpOnly` + +```bash +# 注册(模拟 HTTPS) +curl -s -D - -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -H "X-Forwarded-Proto: https" \ + -d '{"email":"cookie-https@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie +``` + +**预期:** 同上 + +#### TC-ATK-07a: HTTP/HTTPS 差异对比 + +> 直连 Gateway 执行,避免 nginx 覆盖 `X-Forwarded-Proto`。 + +```bash +GW=http://localhost:8001 + +for proto in "" "https"; do + HEADER="" + LABEL="HTTP" + if [ -n "$proto" ]; then + HEADER="-H X-Forwarded-Proto:$proto" + LABEL="HTTPS" + fi + echo "=== $LABEL ===" + EMAIL="compare-${LABEL,,}-$(date +%s)@example.com" + curl -s -D - -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" $HEADER \ + -d "{\"email\":\"$EMAIL\",\"password\":\"Compare1!\"}" 2>/dev/null | grep -i set-cookie | while read line; do + if echo "$line" | grep -q "access_token="; then + echo " access_token:" + echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)" + echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)" + echo " Max-Age: $(echo "$line" | grep -oi "max-age=[0-9]*" || echo NONE)" + echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")" + fi + if echo "$line" | grep -q "csrf_token="; then + echo " csrf_token:" + echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)" + echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)" + echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")" + fi + done +done +``` + +**预期对比表:** + +| 属性 | HTTP access_token | HTTPS access_token | HTTP csrf_token | HTTPS csrf_token | +|------|------|------|------|------| +| HttpOnly | Yes | Yes | No | No | +| Secure | No | **Yes** | No | **Yes** | +| SameSite | Lax | Lax | Strict | Strict | +| Max-Age | 无(session cookie) | **604800**(7天) | 无 | 无 | + +### 3.4 越权访问 + +#### TC-ATK-08: 无 cookie 访问受保护接口 + +```bash +for path in /api/models /api/mcp/config /api/memory /api/skills \ + /api/agents /api/channels; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**预期:** 全部 401 + +#### TC-ATK-09: 伪造 JWT + +```bash +# 用不同 secret 签名的 token +FAKE_TOKEN=$(python3 -c " +import jwt +print(jwt.encode({'sub':'admin-id','ver':0,'exp':9999999999}, 'wrong-secret', algorithm='HS256')) +") + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me \ + --cookie "access_token=$FAKE_TOKEN" +``` + +**预期:** 401(签名验证失败) + +#### TC-ATK-10: 过期 JWT + +```bash +# 不依赖环境变量,直接用一个已过期的、随机 secret 签名的 token +# 无论 secret 是否匹配,过期 token 都会被拒绝 +EXPIRED_TOKEN=$(python3 -c " +import jwt, time +print(jwt.encode({'sub':'x','ver':0,'exp':int(time.time())-100}, 'any-secret-32chars-placeholder!!', algorithm='HS256')) +") + +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me \ + --cookie "access_token=$EXPIRED_TOKEN" +``` + +**预期:** 401(过期 or 签名不匹配,均被拒绝) + +### 3.5 密码安全 + +#### TC-ATK-11: 密码长度不足 + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"short@example.com","password":"1234567"}' -w "\nHTTP %{http_code}" +``` + +**预期:** 422(Pydantic validation: min_length=8) + +#### TC-ATK-12: 密码不以明文存储 + +```bash +# 检查数据库 +sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;" +``` + +**预期:** `password_hash` 以 `$2b$` 开头(bcrypt 格式) + +--- + +## 四、UI 操作测试 + +> 浏览器中操作,验证前后端联动。 + +### 4.1 首次登录流程 + +#### TC-UI-01: 访问首页跳转登录 + +1. 打开 `http://localhost:2026/workspace` +2. **预期:** 自动跳转到 `/login` + +#### TC-UI-02: Login 页面 + +1. 输入 admin 邮箱和控制台密码 +2. 点击 Login +3. **预期:** 跳转到 `/setup`(因为 `needs_setup=true`) + +#### TC-UI-03: Setup 页面 + +1. 输入新邮箱、控制台密码(current)、新密码、确认密码 +2. 点击 Complete Setup +3. **预期:** 跳转到 `/workspace` +4. 刷新页面不跳回 `/setup` + +#### TC-UI-04: Setup 密码不匹配 + +1. 新密码和确认密码不一致 +2. 点击 Complete Setup +3. **预期:** 显示 "Passwords do not match" 错误 + +### 4.2 日常使用 + +#### TC-UI-05: 创建对话 + +1. 在 workspace 发送一条消息 +2. **预期:** 左侧栏出现新 thread + +#### TC-UI-06: 对话持久化 + +1. 创建对话后刷新页面 +2. **预期:** 对话列表和内容仍然存在 + +#### TC-UI-07: 登出 + +1. 点击头像 → Logout +2. **预期:** 跳转到首页 `/` +3. 直接访问 `/workspace` → 跳转到 `/login` + +### 4.3 多用户隔离 + +#### TC-UI-08: 用户 A 看不到用户 B 的对话 + +1. 用户 A 在浏览器 1 登录,创建一个对话并发消息 +2. 用户 B 在浏览器 2(或隐身窗口)注册并登录 +3. **预期:** 用户 B 的 workspace 左侧栏为空,看不到用户 A 的对话 + +#### TC-UI-09: 直接 URL 访问他人 Thread + +1. 复制用户 A 的 thread URL +2. 在用户 B 的浏览器中访问 +3. **预期:** 404 或空白页,不显示对话内容 + +### 4.4 Session 管理 + +#### TC-UI-10: Tab 切换 Session 检查 + +1. 登录 workspace +2. 切换到其他 tab 等待 60+ 秒 +3. 切回 workspace tab +4. **预期:** 静默检查 session,页面正常(控制台无 401 刷屏) + +#### TC-UI-11: Session 过期后 Tab 切回 + +1. 登录 workspace +2. 在另一个 tab 改密码(使当前 session 失效) +3. 切回 workspace tab +4. **预期:** 自动跳转到 `/login` + +#### TC-UI-12: 改密码后 Settings 页面 + +1. 进入 Settings → Account +2. 修改密码 +3. **预期:** 成功提示,页面不需要重新登录(cookie 已自动更新) + +### 4.5 注册流程 + +#### TC-UI-13: 从登录页跳转注册 + +1. 在 `/login` 页面点击注册链接 +2. 输入邮箱和密码 +3. **预期:** 注册成功后自动跳转 `/workspace` + +#### TC-UI-14: 重复邮箱注册 + +1. 用已注册的邮箱尝试注册 +2. **预期:** 显示 "Email already registered" 错误 + +### 4.6 密码重置(CLI) + +#### TC-UI-15: reset_admin 后重新登录 + +1. 执行 `cd backend && python -m app.gateway.auth.reset_admin` +2. 使用新密码登录 +3. **预期:** 跳转到 `/setup` 页面(`needs_setup` 被重置为 true) +4. 旧 session 已失效 + +--- + +## 五、升级测试 + +> 模拟从无 auth 版本(main 分支)升级到 auth 版本(feat/rfc-001-auth-module)。 + +### 5.1 准备旧版数据 + +```bash +# 1. 切到 main 分支,启动服务 +git stash && git checkout main +make dev + +# 2. 创建一些对话数据(无 auth,直接访问) +curl -s -X POST http://localhost:2026/api/langgraph/threads \ + -H "Content-Type: application/json" \ + -d '{"metadata":{"title":"old-thread-1"}}' | jq .thread_id + +curl -s -X POST http://localhost:2026/api/langgraph/threads \ + -H "Content-Type: application/json" \ + -d '{"metadata":{"title":"old-thread-2"}}' | jq .thread_id + +# 3. 记录 thread 数量 +curl -s http://localhost:2026/api/langgraph/threads | jq length +# 预期: 2+ + +# 4. 停止服务 +make stop +``` + +### 5.2 升级并启动 + +```bash +# 5. 切到 auth 分支 +git checkout feat/rfc-001-auth-module && git stash pop +make install +make dev +``` + +#### TC-UPG-01: 首次启动创建 admin + +**预期:** +- [ ] 控制台输出 admin 邮箱(`admin@deerflow.dev`)和随机密码 +- [ ] 无报错,正常启动 + +#### TC-UPG-02: 旧 Thread 迁移到 admin + +```bash +# 登录 admin +curl -s -X POST http://localhost:2026/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<控制台密码>" \ + -c cookies.txt + +# 查看 thread 列表 +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST http://localhost:2026/api/threads/search \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{}' | jq length +``` + +**预期:** +- [ ] 返回的 thread 数量 ≥ 旧版创建的数量 +- [ ] 控制台日志有 `Migrated N orphaned thread(s) to admin` +- [ ] 每个 thread 的 `metadata.owner_id` 都已被设为 admin 的 ID + +#### TC-UPG-03: 旧 Thread 内容完整 + +```bash +# 检查某个旧 thread 的内容 +curl -s http://localhost:2026/api/threads/ \ + -b cookies.txt | jq .metadata +``` + +**预期:** +- [ ] `metadata.title` 保留原值(如 `old-thread-1`) +- [ ] `metadata.owner_id` 已填充 + +#### TC-UPG-04: 新用户看不到旧 Thread + +```bash +# 注册新用户 +curl -s -X POST http://localhost:2026/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"newuser@example.com","password":"NewPass123!"}' \ + -c newuser.txt + +CSRF2=$(grep csrf_token newuser.txt | awk '{print $NF}') +curl -s -X POST http://localhost:2026/api/threads/search \ + -b newuser.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF2" \ + -d '{}' | jq length +``` + +**预期:** 返回 0(旧 thread 属于 admin,新用户不可见) + +### 5.3 数据库 Schema 兼容 + +#### TC-UPG-05: 无 users.db 时自动创建 + +```bash +ls -la backend/.deer-flow/users.db +``` + +**预期:** 文件存在,`sqlite3` 可查到 `users` 表含 `needs_setup`、`token_version` 列 + +#### TC-UPG-06: users.db WAL 模式 + +```bash +sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;" +``` + +**预期:** 返回 `wal` + +### 5.4 配置兼容 + +#### TC-UPG-07: 无 AUTH_JWT_SECRET 的旧 .env 文件 + +```bash +# 确认 .env 中没有 AUTH_JWT_SECRET +grep AUTH_JWT_SECRET backend/.env || echo "NOT SET" +``` + +**预期:** +- [ ] 启动时 warning:`AUTH_JWT_SECRET is not set — using auto-generated ephemeral secret` +- [ ] 服务正常可用 +- [ ] 重启后旧 session 失效(临时密钥变了) + +#### TC-UPG-08: 旧 config.yaml 无 auth 相关配置 + +```bash +# 检查 config.yaml 没有 auth 段 +grep -c "auth" config.yaml || echo "0" +``` + +**预期:** auth 模块不依赖 config.yaml(配置走环境变量),旧 config.yaml 不影响启动 + +### 5.5 前端兼容 + +#### TC-UPG-09: 旧前端缓存 + +1. 用旧版前端的浏览器缓存访问升级后的服务 +2. **预期:** 被 AuthMiddleware 拦截返回 401(旧前端无 cookie),页面自然刷新后加载新前端 + +#### TC-UPG-10: 书签 URL + +1. 用升级前保存的 workspace URL(如 `localhost:2026/workspace/chats/xxx`)直接访问 +2. **预期:** 跳转到 `/login`,登录后跳回原 URL(`?next=` 参数) + +### 5.6 降级回滚 + +#### TC-UPG-11: 回退到 main 分支 + +```bash +make stop +git checkout main +make dev +``` + +**预期:** +- [ ] 服务正常启动(忽略 `users.db`,无 auth 相关代码不报错) +- [ ] 旧对话数据仍然可访问 +- [ ] `users.db` 文件残留但不影响运行 + +#### TC-UPG-12: 再次升级到 auth 分支 + +```bash +make stop +git checkout feat/rfc-001-auth-module +make dev +``` + +**预期:** +- [ ] 识别已有 `users.db`,不重新创建 admin +- [ ] 旧的 admin 账号仍可登录(如果回退期间未删 `users.db`) + +### 5.7 休眠 Admin(初始密码未使用/未更改) + +> 首次启动生成 admin + 随机密码,但运维未登录、未改密码。 +> 密码只在首次启动的控制台闪过一次,后续启动不再显示。 + +#### TC-UPG-13: 重启后自动重置密码并打印 + +```bash +# 首次启动,记录密码 +rm -f backend/.deer-flow/users.db +make dev +# 控制台输出密码 P0,不登录 +make stop + +# 隔了几天,再次启动 +make dev +# 控制台输出新密码 P1 +``` + +**预期:** +- [ ] 控制台输出 `Admin account setup incomplete — password reset` +- [ ] 输出新密码 P1(P0 已失效) +- [ ] 用 P1 可以登录,P0 不可以 +- [ ] 登录后 `needs_setup=true`,跳转 `/setup` +- [ ] `token_version` 递增(旧 session 如有也失效) + +#### TC-UPG-14: 密码丢失 — 无需 CLI,重启即可 + +```bash +# 忘记了控制台密码 → 直接重启服务 +make stop && make dev +# 控制台自动输出新密码 +``` + +**预期:** +- [ ] 无需 `reset_admin`,重启服务即可拿到新密码 +- [ ] `reset_admin` CLI 仍然可用作手动备选方案 + +#### TC-UPG-15: 休眠 admin 期间普通用户注册 + +```bash +# admin 存在但从未登录,普通用户先注册 +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \ + -c early.txt -w "\nHTTP %{http_code}" +``` + +**预期:** +- [ ] 注册成功(201),角色为 `user` +- [ ] 无法提权为 admin +- [ ] 普通用户的数据与 admin 隔离 + +#### TC-UPG-16: 休眠 admin 不影响后续操作 + +```bash +# 普通用户正常创建 thread、发消息 +CSRF=$(grep csrf_token early.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads \ + -b early.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | jq .thread_id +``` + +**预期:** 正常创建,不受休眠 admin 影响 + +#### TC-UPG-17: 休眠 admin 最终完成 Setup + +```bash +# 运维终于登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=" \ + -c admin.txt | jq .needs_setup +# 预期: true + +# 完成 setup +CSRF=$(grep csrf_token admin.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b admin.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"<密码>","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \ + -c admin.txt + +# 验证 +curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}' +``` + +**预期:** +- [ ] `email` 变为 `admin@real.com` +- [ ] `needs_setup` 变为 `false` +- [ ] 后续重启控制台不再有 warning + +#### TC-UPG-18: 长期未用后 JWT 密钥轮换 + +```bash +# 场景:admin 未登录期间,运维更换了 AUTH_JWT_SECRET +# 1. 首次启动用自动生成的临时密钥 +# 2. 某天运维在 .env 设置了固定密钥 +echo "AUTH_JWT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" >> .env +make stop && make dev +``` + +**预期:** +- [ ] 服务正常启动 +- [ ] 旧密码仍可登录(密码存在 DB,与 JWT 密钥无关) +- [ ] 旧的 JWT token 失效(密钥变了签名不匹配)— 但因为从未登录过也没有旧 token + +--- + +## 六、可重入测试 + +> 验证 auth 模块在重复操作、并发、中断恢复等场景下行为正确,无竞态条件。 + +### 6.1 启动可重入 + +#### TC-REENT-01: 连续重启不重复创建 admin + +```bash +# 连续启动 3 次(daemon 模式,避免前台阻塞) +for i in 1 2 3; do + make dev-daemon && sleep 10 && make stop +done + +# 检查 admin 数量 +sqlite3 backend/.deer-flow/users.db \ + "SELECT COUNT(*) FROM users WHERE system_role='admin';" +``` + +**预期:** 始终为 1。不会因重启创建多个 admin。 + +#### TC-REENT-02: 多进程同时启动 + +```bash +# 模拟两个 gateway 进程同时启动(竞争 admin 创建) +cd backend +PYTHONPATH=. uv run python -c " +import asyncio +from app.gateway.app import create_app, _ensure_admin_user + +async def boot(): + app = create_app() + # 模拟两个并发 ensure_admin + await asyncio.gather( + _ensure_admin_user(app), + _ensure_admin_user(app), + ) + +asyncio.run(boot()) +" 2>&1 | grep -i "admin\|error\|duplicate" +``` + +**预期:** +- [ ] 不报错(SQLite UNIQUE 约束捕获竞争,第二个静默跳过) +- [ ] 最终只有 1 个 admin + +#### TC-REENT-03: Thread 迁移幂等 + +```bash +# 连续调用 _migrate_orphaned_threads 两次 +# 第二次应无 thread 需要迁移(已有 user_id) +``` + +**预期:** 第二次 `migrated = 0`,无副作用 + +### 6.2 登录可重入 + +#### TC-REENT-04: 重复登录获取新 cookie + +```bash +# 同一用户连续登录 3 次 +for i in 1 2 3; do + curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" \ + -c "cookies_$i.txt" -o /dev/null +done + +# 三个 cookie 都有效 +for i in 1 2 3; do + echo "Cookie $i: $(curl -s -w '%{http_code}' -o /dev/null $BASE/api/v1/auth/me -b cookies_$i.txt)" +done +``` + +**预期:** 三个 cookie 都返回 200(未改密码,token_version 相同,多 session 共存) + +#### TC-REENT-05: 登录-登出-登录 + +```bash +# 登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" \ + -c cookies.txt -o /dev/null + +# 登出 +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt -o /dev/null + +# 再次登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" \ + -c cookies.txt + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt +``` + +**预期:** 200。登出→再登录流程无状态残留。 + +### 6.3 改密码可重入 + +#### TC-REENT-06: 连续两次改密码 + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# 第一次改密码 +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"Pass1","new_password":"Pass2"}' \ + -c cookies.txt + +# 用新 cookie 的 CSRF 再改一次 +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"Pass2","new_password":"Pass3"}' \ + -c cookies.txt + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt +``` + +**预期:** +- [ ] 两次改密码都成功 +- [ ] 最终密码为 Pass3 +- [ ] `token_version` 递增两次(+2) +- [ ] 最新 cookie 有效 + +#### TC-REENT-07: 改密码后旧 cookie 全部失效 + +```bash +# 保存三个时间点的 cookie +# t1: 初始登录 → cookies_t1.txt +# t2: 第一次改密码后 → cookies_t2.txt +# t3: 第二次改密码后 → cookies_t3.txt + +# 用 t1 和 t2 的 cookie 访问 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t1.txt # 预期 401 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t2.txt # 预期 401 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t3.txt # 预期 200 +``` + +**预期:** 只有最新的 cookie 有效,历史 cookie 因 token_version 不匹配全部 401 + +### 6.4 注册可重入 + +#### TC-REENT-08: 同一邮箱并发注册 + +```bash +# 并发发送两个相同邮箱的注册请求 +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"race@example.com","password":"RacePass1!"}' & +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"race@example.com","password":"RacePass1!"}' & +wait + +# 检查用户数 +sqlite3 backend/.deer-flow/users.db \ + "SELECT COUNT(*) FROM users WHERE email='race@example.com';" +``` + +**预期:** +- [ ] 一个成功(201),一个失败(400 `email_already_exists`) +- [ ] 数据库中只有 1 条记录(UNIQUE 约束保护) + +### 6.5 Rate Limiter 可重入 + +#### TC-REENT-09: 限速过期后重新计数 + +```bash +# 触发锁定(5 次错误) +for i in $(seq 1 5); do + curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done + +# 确认被锁定 +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +# 预期: 429 + +# 等待锁定过期(5 分钟)或重启服务清除内存计数器 +make stop && make dev + +# 重新尝试 — 计数器应已重置 +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +# 预期: 401(不是 429) +``` + +**预期:** 锁定过期后恢复正常限速(从 0 开始计数),而非累积 + +#### TC-REENT-10: 成功登录重置计数后再次失败 + +```bash +# 3 次失败 +for i in $(seq 1 3); do + curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done + +# 1 次成功(重置计数) +curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" + +# 再 4 次失败(从 0 重新计数,未达阈值 5) +for i in $(seq 1 4); do + curl -s -w "attempt $i: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done +``` + +**预期:** 4 次全部返回 401(未锁定),因为成功登录已重置计数器 + +### 6.6 CSRF Token 可重入 + +#### TC-REENT-11: 登录后多次 POST 使用同一 CSRF token + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# 同一 CSRF token 多次使用 +for i in 1 2 3; do + echo "Request $i: $(curl -s -w '%{http_code}' -o /dev/null \ + -X POST $BASE/api/threads \ + -b cookies.txt \ + -H 'Content-Type: application/json' \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}')" +done +``` + +**预期:** 三次都成功(CSRF token 是 Double Submit Cookie,不是一次性 nonce) + +### 6.7 Thread 操作可重入 + +#### TC-REENT-12: 重复删除同一 Thread + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# 创建 thread +TID=$(curl -s -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | jq -r .thread_id) + +# 第一次删除 +curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \ + -b cookies.txt -H "X-CSRF-Token: $CSRF" +# 预期: 200 + +# 第二次删除(幂等) +curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \ + -b cookies.txt -H "X-CSRF-Token: $CSRF" +``` + +**预期:** 第二次返回 200 或 404,不报 500 + +### 6.8 reset_admin 可重入 + +#### TC-REENT-13: 连续两次 reset_admin + +```bash +cd backend +python -m app.gateway.auth.reset_admin +# 记录密码 P1 + +python -m app.gateway.auth.reset_admin +# 记录密码 P2 +``` + +**预期:** +- [ ] P1 ≠ P2(每次生成新随机密码) +- [ ] P1 不可用,只有 P2 有效 +- [ ] `token_version` 递增了 2 +- [ ] `needs_setup` 为 True + +### 6.9 Setup 流程可重入 + +#### TC-REENT-14: 完成 Setup 后再访问 /setup 页面 + +1. 完成 admin setup(改邮箱 + 改密码) +2. 直接访问 `/setup` +3. **预期:** 应跳转到 `/workspace`(`needs_setup` 已为 false,SSR guard 不会返回 `needs_setup` tag) + +#### TC-REENT-15: Setup 中途刷新页面 + +1. 在 `/setup` 页面填写一半 +2. 刷新页面 +3. **预期:** 仍在 `/setup`(`needs_setup` 仍为 true),表单清空但不报错 + +--- + +## 七、模式差异测试 + +> 以下用 `GW=http://localhost:8001` 表示直连 Gateway,`BASE=http://localhost:2026` 表示经 nginx。 +> Gateway 模式启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`)。 + +### 7.1 标准模式独有 + +> 启动命令:`make dev`(或 `./scripts/serve.sh --dev`) + +#### TC-MODE-01: LangGraph Server 独立运行,需 cookie + +```bash +# 无 cookie 访问 LangGraph +curl -s -w "%{http_code}" -o /dev/null $BASE/api/langgraph/threads/search +# 预期: 403(LangGraph auth handler 拒绝) +``` + +#### TC-MODE-02: LangGraph auth 的 token_version 检查 + +```bash +# 登录拿 cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -c cookies.txt + +# 改密码(bumps token_version) +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"正确密码","new_password":"NewPass1!"}' -c new_cookies.txt + +# 用旧 cookie 访问 LangGraph +curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt +# 预期: 403(token_version 不匹配) + +# 用新 cookie 访问 +CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}') +curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \ + -b new_cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' +# 预期: 200 +``` + +#### TC-MODE-03: LangGraph auth 的 owner filter 隔离 + +```bash +# user1 创建 thread +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user1@example.com&password=UserPass1!" -c u1.txt +CSRF1=$(grep csrf_token u1.txt | awk '{print $NF}') +TID=$(curl -s -X POST $BASE/api/langgraph/threads \ + -b u1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \ + -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])") + +# user2 搜索 — 应看不到 user1 的 thread +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user2@example.com&password=UserPass2!" -c u2.txt +CSRF2=$(grep csrf_token u2.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/langgraph/threads/search \ + -b u2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' | python3 -c " +import sys,json +threads = json.load(sys.stdin) +ids = [t['thread_id'] for t in threads] +assert '$TID' not in ids, 'LEAK: user2 can see user1 thread' +print('OK: user2 sees', len(threads), 'threads, none belong to user1') +" +``` + +### 7.2 Gateway 模式独有 + +> 启动命令:`make dev-pro`(或 `./scripts/serve.sh --dev --gateway`) +> 无 LangGraph Server 进程,agent runtime 嵌入 Gateway。 + +#### TC-MODE-04: 所有请求经 AuthMiddleware + +```bash +# 确认 LangGraph Server 未运行 +curl -s -w "%{http_code}" -o /dev/null http://localhost:2024/ok +# 预期: 000(连接被拒) + +# Gateway API 受保护 +curl -s -w "%{http_code}" -o /dev/null $BASE/api/models +# 预期: 401 + +# LangGraph 兼容路由(rewrite 到 Gateway)也受保护 +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/search \ + -H "Content-Type: application/json" -d '{}' +# 预期: 401 +``` + +#### TC-MODE-05: Gateway 模式下完整 auth 流程 + +```bash +# 登录 +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -c cookies.txt + +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# 创建 thread(走 Gateway 内嵌 runtime) +curl -s -X POST $BASE/api/langgraph/threads \ + -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])" +# 预期: 返回 thread_id + +# CSRF 保护(Gateway 模式下 CSRFMiddleware 直接覆盖所有路由) +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \ + -b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}' +# 预期: 403(CSRF token missing) +``` + +### 7.3 直连 Gateway(无 nginx) + +> 启动命令:`cd backend && make gateway`(端口 8001) +> 不经过 nginx,直接测试 Gateway 的 auth 层。 + +#### TC-GW-01: AuthMiddleware 保护所有非 public 路由 + +```bash +GW=http://localhost:8001 + +for path in /api/models /api/mcp/config /api/memory /api/skills \ + /api/v1/auth/me /api/v1/auth/change-password; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)" +done +# 预期: 全部 401 +``` + +#### TC-GW-02: Public 路由不需要 cookie + +```bash +GW=http://localhost:8001 + +for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)" +done +# 预期: 200 或 405/422(方法不对但不是 401) +``` + +#### TC-GW-03: 直连 Gateway 注册 + 登录 + CSRF 完整流程 + +```bash +GW=http://localhost:8001 + +# 注册 +curl -s -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"gwtest@example.com","password":"GwTest123!"}' \ + -c gw_cookies.txt -w "\nHTTP %{http_code}" +# 预期: 201 + +# 登录 +curl -s -X POST $GW/api/v1/auth/login/local \ + -d "username=gwtest@example.com&password=GwTest123!" \ + -c gw_cookies.txt -w "\nHTTP %{http_code}" +# 预期: 200 + +# GET(不需要 CSRF) +curl -s -w "%{http_code}" $GW/api/models -b gw_cookies.txt +# 预期: 200 + +# POST 无 CSRF +curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload -b gw_cookies.txt +# 预期: 403(CSRF token missing) + +# POST 有 CSRF +CSRF=$(grep csrf_token gw_cookies.txt | awk '{print $NF}') +curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload \ + -b gw_cookies.txt -H "X-CSRF-Token: $CSRF" +# 预期: 200 +``` + +#### TC-GW-04: 直连 Gateway 的 Rate Limiter + +```bash +GW=http://localhost:8001 + +# 直连时 request.client.host 是真实 IP(无 nginx 代理),不读 X-Real-IP +for i in $(seq 1 6); do + echo -n "attempt $i: " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done +# 预期: 前 5 次 401,第 6 次 429 +``` + +#### TC-GW-05: 直连 Gateway 不受 X-Real-IP 欺骗 + +```bash +GW=http://localhost:8001 + +# 直连时 client.host 不是 trusted proxy,X-Real-IP 被忽略 +for i in $(seq 1 6); do + echo -n "attempt $i (X-Real-IP spoofed): " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \ + -H "X-Real-IP: 10.0.0.$i" \ + -d "username=admin@example.com&password=wrong" +done +# 预期: 前 5 次 401,第 6 次 429(伪造的 X-Real-IP 无效,所有请求共享真实 IP 的桶) +``` + +### 7.4 Docker 部署 + +> 启动命令:`./scripts/deploy.sh`(标准)或 `./scripts/deploy.sh --gateway`(Gateway 模式) +> Docker Compose 文件:`docker/docker-compose.yaml` +> +> 前置条件: +> - `.env` 中设置 `AUTH_JWT_SECRET`(否则每次容器重启 session 全部失效) +> - `DEER_FLOW_HOME` 挂载到宿主机目录(持久化 `users.db`) + +#### TC-DOCKER-01: users.db 通过 volume 持久化 + +```bash +# 启动容器 +./scripts/deploy.sh + +# 等待启动完成 +sleep 15 +BASE=http://localhost:2026 + +# 注册用户 +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}" + +# 检查宿主机上的 users.db +ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db +sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db \ + "SELECT email FROM users WHERE email='docker-test@example.com';" +``` + +**预期:** users.db 在宿主机 `DEER_FLOW_HOME` 目录中,查询可见刚注册的用户。 + +#### TC-DOCKER-02: 重启容器后 session 保持 + +```bash +# 登录拿 cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=docker-test@example.com&password=DockerTest1!" \ + -c docker_cookies.txt -o /dev/null + +# 验证 cookie 有效 +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt +# 预期: 200 + +# 重启容器(不删 volume) +./scripts/deploy.sh down && ./scripts/deploy.sh +sleep 15 + +# 用旧 cookie 访问 +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt +``` + +**预期:** +- 有 `AUTH_JWT_SECRET` → 200(session 保持) +- 无 `AUTH_JWT_SECRET` → 401(每次启动生成新临时密钥,旧 JWT 签名失效) + +#### TC-DOCKER-03: 多 Worker 下 Rate Limiter 独立 + +```bash +# docker-compose.yaml 中 gateway 默认 4 workers +# 每个 worker 有独立的 _login_attempts dict +# 限速可能不精确(请求分散到不同 worker),但不会完全失效 + +for i in $(seq 1 20); do + echo -n "attempt $i: " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=docker-test@example.com&password=wrong" +done +``` + +**预期:** 在某个点开始返回 429(每个 worker 独立计数,阈值可能在 5~20 之间触发,取决于负载均衡分布)。 + +**已知限制:** In-process rate limiter 不跨 worker 共享。生产环境如需精确限速,需要 Redis 等外部存储。 + +#### TC-DOCKER-04: IM 渠道不经过 auth + +```bash +# IM 渠道(Feishu/Slack/Telegram)在 gateway 容器内部通过 LangGraph SDK 通信 +# 不走 nginx,不经过 AuthMiddleware + +# 验证方式:检查 gateway 日志中 channel manager 的请求不包含 auth 错误 +docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10 +``` + +**预期:** 无 auth 相关错误。渠道通过 `langgraph-sdk` 直连 LangGraph Server(`http://langgraph:2024`),不走 auth 层。 + +#### TC-DOCKER-05: admin 密码写入 0600 凭证文件(不再走日志) + +```bash +# 凭证文件写在挂载到宿主机的 DEER_FLOW_HOME 下 +ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt +# 预期文件权限: -rw------- (0600) + +cat ${DEER_FLOW_HOME:-backend/.deer-flow}/admin_initial_credentials.txt +# 预期内容: email + password 行 + +# 容器日志只输出文件路径,不输出密码本身 +docker logs deer-flow-gateway 2>&1 | grep -E "Credentials written to|Admin account" +# 预期看到: "Credentials written to: /...../admin_initial_credentials.txt (mode 0600)" + +# 反向验证: 日志里 NEVER 出现明文密码 +docker logs deer-flow-gateway 2>&1 | grep -iE "Password: .{15,}" && echo "FAIL: leaked" || echo "OK: not leaked" +``` + +**预期:** +- 凭证文件存在于 `DEER_FLOW_HOME` 下,权限 `0600` +- 容器日志输出**路径**(不是密码本身),符合 CodeQL `py/clear-text-logging-sensitive-data` 规则 +- `grep "Password:"` 在日志中**应当无匹配**(旧行为已废弃,simplify pass 移除了日志泄露路径) + +#### TC-DOCKER-06: Gateway 模式 Docker 部署 + +```bash +# Gateway 模式:无 langgraph 容器 +./scripts/deploy.sh --gateway +sleep 15 + +# 确认 langgraph 容器不存在 +docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l +# 预期: 0 + +# auth 流程正常 +curl -s -w "%{http_code}" -o /dev/null $BASE/api/models +# 预期: 401 + +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<日志密码>" \ + -c cookies.txt -w "\nHTTP %{http_code}" +# 预期: 200 +``` + +### 7.4 补充边界用例 + +#### TC-EDGE-01: 格式正确但随机 JWT + +```bash +RANDOM_JWT=$(python3 -c " +import jwt, time, uuid +print(jwt.encode({'sub':str(uuid.uuid4()),'ver':0,'exp':int(time.time())+3600}, 'wrong-secret-32chars-placeholder!!', algorithm='HS256')) +") +curl -s --cookie "access_token=$RANDOM_JWT" $BASE/api/v1/auth/me | jq .detail +``` + +**预期:** `{"code": "token_invalid", "message": "Token error: invalid_signature"}` + +#### TC-EDGE-02: 注册时传 system_role=admin + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"hacker@example.com","password":"HackPass1!","system_role":"admin"}' | jq .system_role +``` + +**预期:** `"user"`(`system_role` 字段被忽略) + +#### TC-EDGE-03: 并发改密码 + +```bash +# 注册用户,登录两个 session +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"edge03@example.com","password":"EdgePass3!"}' -o /dev/null +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=edge03@example.com&password=EdgePass3!" -c s1.txt -o /dev/null +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=edge03@example.com&password=EdgePass3!" -c s2.txt -o /dev/null + +CSRF1=$(grep csrf_token s1.txt | awk '{print $NF}') +CSRF2=$(grep csrf_token s2.txt | awk '{print $NF}') + +# 并发改密码 +curl -s -w "S1: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \ + -b s1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \ + -d '{"current_password":"EdgePass3!","new_password":"NewEdge3a!"}' & +curl -s -w "S2: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \ + -b s2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" \ + -d '{"current_password":"EdgePass3!","new_password":"NewEdge3b!"}' & +wait +``` + +**预期:** 一个 200、一个 400(current_password 已变导致验证失败)。极端并发下可能两个都 200(SQLite 串行写),但最终只有一个密码生效。 + +#### TC-EDGE-04: Cookie SameSite 验证 + +> 完整的 HTTP/HTTPS cookie 属性对比见 §3.3 TC-ATK-06/07/07a。 + +```bash +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" 2>/dev/null | grep -i set-cookie +``` + +**预期:** `access_token` → `SameSite=lax`,`csrf_token` → `SameSite=strict` + +#### TC-EDGE-05: HTTP 无 max_age / HTTPS 有 max_age + +```bash +# HTTP +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" 2>/dev/null \ + | grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)" + +# HTTPS +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -H "X-Forwarded-Proto: https" \ + -d "username=admin@example.com&password=正确密码" 2>/dev/null \ + | grep "access_token=" | grep -oi "max-age=[0-9]*" +``` + +**预期:** HTTP 无 `Max-Age`(session cookie,浏览器关闭即失效),HTTPS 有 `Max-Age=604800`(7 天) + +#### TC-EDGE-06: public 路径 trailing slash + +```bash +for path in /api/v1/auth/login/local/ /api/v1/auth/register/ \ + /api/v1/auth/logout/ /api/v1/auth/setup-status/; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**预期:** 全部 307(redirect 去掉 trailing slash)或 200/405,不是 401 + +### 7.5 红队对抗测试 + +> 模拟攻击者视角,验证防线没有可利用的缝隙。 + +#### 7.5.1 路径混淆绕过 + +```bash +# 通过编码/双斜杠/路径穿越尝试绕过 AuthMiddleware 公开路径判断 +for path in \ + "//api/v1/auth/me" \ + "/api/v1/auth/login/local/../me" \ + "/api/v1/auth/login/local%2f..%2fme" \ + "/api/v1/auth/login/local/..%2Fme" \ + "/API/V1/AUTH/ME"; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**预期:** 全部 401 或 404。不应有路径混淆导致跳过 auth 检查。 + +#### 7.5.2 CSRF 对抗矩阵 + +```bash +# 登录拿 cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -c cookies.txt + +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# Case 1: 有 cookie 无 header → 403 +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 2: 有 header 无 cookie → 403(删除 cookie 中的 csrf_token) +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "X-CSRF-Token: $CSRF" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 3: header 和 cookie 不匹配 → 403 +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "X-CSRF-Token: wrong-token" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 4: 旧 CSRF token(登出再登录后) → 旧 token 应失效 +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -c cookies.txt +# 用旧 CSRF 发请求 +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "X-CSRF-Token: $CSRF" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' +``` + +**预期:** Case 1-3 全部 403。Case 4 应 403(旧 CSRF 与新 cookie 不匹配)。 + +#### 7.5.3 Token Replay(登出后旧 token 重放) + +```bash +# 登录,保存 cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=正确密码" -c cookies.txt + +# 提取 access_token 值 +TOKEN=$(grep access_token cookies.txt | awk '{print $NF}') + +# 登出 +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt + +# 手工注入旧 token(模拟攻击者窃取了 token) +curl -s -w "%{http_code}" -o /dev/null \ + $BASE/api/v1/auth/me --cookie "access_token=$TOKEN" +``` + +**预期:** 200(已知限制:登出只清客户端 cookie,不 bump `token_version`。旧 token 在过期前仍有效)。 +**安全备注:** 如需严格防重放,需在登出时 `token_version += 1`。当前设计选择不做,因为成本是所有设备的 session 全部失效。 + +#### 7.5.4 跨站强制登出 + +```bash +# 攻击者从第三方站点 POST /logout(无需认证、无需 CSRF) +curl -s -X POST $BASE/api/v1/auth/logout -w "%{http_code}" +``` + +**预期:** 200(logout 是 public + CSRF 豁免)。 +**风险评估:** 低——只影响可用性(被强制登出),不泄露数据。浏览器 `SameSite=Lax` 限制了真实跨站场景下 cookie 不会被带上,所以实际上第三方站点的 POST 不会清除用户 cookie。 + +#### 7.5.5 Metadata 注入攻击(所有权伪造) + +```bash +# 尝试在创建 thread 时注入其他用户的 user_id +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{"owner_id":"victim-user-id"}}' | jq .metadata.owner_id +``` + +**预期:** 返回的 `metadata.owner_id` 应为当前登录用户的 ID,不是请求中注入的 `victim-user-id`。服务端应覆盖客户端提供的 `user_id`。 + +#### 7.5.6 HTTP Method 探测 + +```bash +# HEAD/OPTIONS 不应泄露受保护资源信息 +for method in HEAD OPTIONS TRACE; do + echo "$method /api/models: $(curl -s -w '%{http_code}' -o /dev/null -X $method $BASE/api/models)" +done +``` + +**预期:** HEAD/OPTIONS 返回 401 或 405。TRACE 应返回 405。 + +#### 7.5.7 Rate Limiter IP 维度缺陷验证 + +```bash +# 通过不同的 X-Forwarded-For 绕过限速(验证是否用 client.host 而非 header) +for i in $(seq 1 6); do + curl -s -w "attempt $i: %{http_code}\n" -o /dev/null \ + -X POST $BASE/api/v1/auth/login/local \ + -H "X-Forwarded-For: 10.0.0.$i" \ + -d "username=admin@example.com&password=wrong" +done +``` + +**预期:** 如果 rate limiter 基于 `request.client.host`(实际 TCP 连接 IP),所有请求来自同一 IP,第 6 个应返回 429。X-Forwarded-For 不应影响限速判断。 + +#### 7.5.8 Junk Cookie 穿透验证 + +```bash +# middleware 只检查 cookie 存在性,不验证 JWT +# 确认 junk cookie 能过 middleware 但被下游 @require_auth 拦截 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me \ + --cookie "access_token=not-a-jwt" +``` + +**预期:** 401(middleware 放行,`get_current_user_from_request` 解码失败返回 401)。 +**安全备注:** middleware 是 presence-only 检查,有意设计。完整验证交给 `@require_auth`。 + +#### 7.5.9 路由覆盖审计 + +```bash +# 列出所有注册的路由,检查哪些没有 @require_auth +cd backend && PYTHONPATH=. python3 -c " +from app.gateway.app import create_app +app = create_app() +public_prefixes = ['/health', '/docs', '/redoc', '/openapi.json', + '/api/v1/auth/login', '/api/v1/auth/register', + '/api/v1/auth/logout', '/api/v1/auth/setup-status'] +for route in app.routes: + path = getattr(route, 'path', '') + if not path or not path.startswith('/api'): + continue + is_public = any(path.startswith(p) for p in public_prefixes) + if not is_public: + print(f' {path}') +" 2>/dev/null +``` + +**预期:** 列出的所有路由都应由 AuthMiddleware(cookie 存在性)+ `@require_auth`/`@require_permission`(JWT 验证)双层保护。检查是否有遗漏。 + +--- + +## 八、回归清单 + +每次 auth 相关代码变更后必须通过: + +```bash +# 单元测试(168 个) +cd backend && PYTHONPATH=. uv run pytest \ + tests/test_auth.py \ + tests/test_auth_config.py \ + tests/test_auth_errors.py \ + tests/test_auth_type_system.py \ + tests/test_auth_middleware.py \ + tests/test_langgraph_auth.py \ + -v + +# 核心接口冒烟 +curl -s $BASE/health # 200 +curl -s $BASE/api/models # 401 (无 cookie) +curl -s -X POST $BASE/api/v1/auth/setup-status # 200 +curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (有 cookie) +``` diff --git a/backend/docs/AUTH_UPGRADE.md b/backend/docs/AUTH_UPGRADE.md new file mode 100644 index 000000000..344c488c4 --- /dev/null +++ b/backend/docs/AUTH_UPGRADE.md @@ -0,0 +1,129 @@ +# Authentication Upgrade Guide + +DeerFlow 内置了认证模块。本文档面向从无认证版本升级的用户。 + +## 核心概念 + +认证模块采用**始终强制**策略: + +- 首次启动时自动创建 admin 账号,随机密码打印到控制台日志 +- 认证从一开始就是强制的,无竞争窗口 +- 历史对话(升级前创建的 thread)自动迁移到 admin 名下 + +## 升级步骤 + +### 1. 更新代码 + +```bash +git pull origin main +cd backend && make install +``` + +### 2. 首次启动 + +```bash +make dev +``` + +控制台会输出: + +``` +============================================================ + Admin account created on first boot + Email: admin@deerflow.dev + Password: aB3xK9mN_pQ7rT2w + Change it after login: Settings → Account +============================================================ +``` + +如果未登录就重启了服务,不用担心——只要 setup 未完成,每次启动都会重置密码并重新打印到控制台。 + +### 3. 登录 + +访问 `http://localhost:2026/login`,使用控制台输出的邮箱和密码登录。 + +### 4. 修改密码 + +登录后进入 Settings → Account → Change Password。 + +### 5. 添加用户(可选) + +其他用户通过 `/login` 页面注册,自动获得 **user** 角色。每个用户只能看到自己的对话。 + +## 安全机制 + +| 机制 | 说明 | +|------|------| +| JWT HttpOnly Cookie | Token 不暴露给 JavaScript,防止 XSS 窃取 | +| CSRF Double Submit Cookie | 所有 POST/PUT/DELETE 请求需携带 `X-CSRF-Token` | +| bcrypt 密码哈希 | 密码不以明文存储 | +| 多租户隔离 | 用户只能访问自己的 thread | +| HTTPS 自适应 | 检测 `x-forwarded-proto`,自动设置 `Secure` cookie 标志 | + +## 常见操作 + +### 忘记密码 + +```bash +cd backend + +# 重置 admin 密码 +python -m app.gateway.auth.reset_admin + +# 重置指定用户密码 +python -m app.gateway.auth.reset_admin --email user@example.com +``` + +会输出新的随机密码。 + +### 完全重置 + +删除用户数据库,重启后自动创建新 admin: + +```bash +rm -f backend/.deer-flow/users.db +# 重启服务,控制台输出新密码 +``` + +## 数据存储 + +| 文件 | 内容 | +|------|------| +| `.deer-flow/users.db` | SQLite 用户数据库(密码哈希、角色) | +| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) | + +### 生产环境建议 + +```bash +# 生成持久化 JWT 密钥,避免重启后所有用户需重新登录 +python -c "import secrets; print(secrets.token_urlsafe(32))" +# 将输出添加到 .env: +# AUTH_JWT_SECRET=<生成的密钥> +``` + +## API 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/v1/auth/login/local` | POST | 邮箱密码登录(OAuth2 form) | +| `/api/v1/auth/register` | POST | 注册新用户(user 角色) | +| `/api/v1/auth/logout` | POST | 登出(清除 cookie) | +| `/api/v1/auth/me` | GET | 获取当前用户信息 | +| `/api/v1/auth/change-password` | POST | 修改密码 | +| `/api/v1/auth/setup-status` | GET | 检查 admin 是否存在 | + +## 兼容性 + +- **标准模式**(`make dev`):完全兼容,admin 自动创建 +- **Gateway 模式**(`make dev-pro`):完全兼容 +- **Docker 部署**:完全兼容,`.deer-flow/users.db` 需持久化卷挂载 +- **IM 渠道**(Feishu/Slack/Telegram):通过 LangGraph SDK 通信,不经过认证层 +- **DeerFlowClient**(嵌入式):不经过 HTTP,不受认证影响 + +## 故障排查 + +| 症状 | 原因 | 解决 | +|------|------|------| +| 启动后没看到密码 | admin 已存在(非首次启动) | 用 `reset_admin` 重置,或删 `users.db` | +| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 | +| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 | diff --git a/backend/langgraph.json b/backend/langgraph.json index 74f5c691d..28588c9f8 100644 --- a/backend/langgraph.json +++ b/backend/langgraph.json @@ -8,6 +8,9 @@ "graphs": { "lead_agent": "deerflow.agents:make_lead_agent" }, + "auth": { + "path": "./app/gateway/langgraph_auth.py:auth" + }, "checkpointer": { "path": "./packages/harness/deerflow/agents/checkpointer/async_provider.py:make_checkpointer" } diff --git a/backend/packages/harness/deerflow/persistence/engine.py b/backend/packages/harness/deerflow/persistence/engine.py index ddd9e510b..7e374788c 100644 --- a/backend/packages/harness/deerflow/persistence/engine.py +++ b/backend/packages/harness/deerflow/persistence/engine.py @@ -86,8 +86,27 @@ async def init_engine( if backend == "sqlite": import os + from sqlalchemy import event + os.makedirs(sqlite_dir or ".", exist_ok=True) _engine = create_async_engine(url, echo=echo, json_serializer=_json_serializer) + + # Enable WAL on every new connection. SQLite PRAGMA settings are + # per-connection, so we wire the listener instead of running PRAGMA + # once at startup. WAL gives concurrent reads + writers without + # blocking and is the standard recommendation for any production + # SQLite deployment (TC-UPG-06 in AUTH_TEST_PLAN.md). The companion + # ``synchronous=NORMAL`` is the safe-and-fast pairing — fsync only + # at WAL checkpoint boundaries instead of every commit. + @event.listens_for(_engine.sync_engine, "connect") + def _enable_sqlite_wal(dbapi_conn, _record): # noqa: ARG001 — SQLAlchemy contract + cursor = dbapi_conn.cursor() + try: + cursor.execute("PRAGMA journal_mode=WAL;") + cursor.execute("PRAGMA synchronous=NORMAL;") + cursor.execute("PRAGMA foreign_keys=ON;") + finally: + cursor.close() elif backend == "postgres": _engine = create_async_engine( url, diff --git a/backend/packages/harness/deerflow/persistence/feedback/sql.py b/backend/packages/harness/deerflow/persistence/feedback/sql.py index eae2f9997..903124953 100644 --- a/backend/packages/harness/deerflow/persistence/feedback/sql.py +++ b/backend/packages/harness/deerflow/persistence/feedback/sql.py @@ -12,6 +12,7 @@ from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.feedback.model import FeedbackRow +from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_owner_id class FeedbackRepository: @@ -32,18 +33,19 @@ class FeedbackRepository: run_id: str, thread_id: str, rating: int, - owner_id: str | None = None, + owner_id: str | None | _AutoSentinel = AUTO, message_id: str | None = None, comment: str | None = None, ) -> dict: """Create a feedback record. rating must be +1 or -1.""" if rating not in (1, -1): raise ValueError(f"rating must be +1 or -1, got {rating}") + resolved_owner_id = resolve_owner_id(owner_id, method_name="FeedbackRepository.create") row = FeedbackRow( feedback_id=str(uuid.uuid4()), run_id=run_id, thread_id=thread_id, - owner_id=owner_id, + owner_id=resolved_owner_id, message_id=message_id, rating=rating, comment=comment, @@ -55,27 +57,66 @@ class FeedbackRepository: await session.refresh(row) return self._row_to_dict(row) - async def get(self, feedback_id: str) -> dict | None: - async with self._sf() as session: - row = await session.get(FeedbackRow, feedback_id) - return self._row_to_dict(row) if row else None - - async def list_by_run(self, thread_id: str, run_id: str, *, limit: int = 100) -> list[dict]: - stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id, FeedbackRow.run_id == run_id).order_by(FeedbackRow.created_at.asc()).limit(limit) - async with self._sf() as session: - result = await session.execute(stmt) - return [self._row_to_dict(r) for r in result.scalars()] - - async def list_by_thread(self, thread_id: str, *, limit: int = 100) -> list[dict]: - stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id).order_by(FeedbackRow.created_at.asc()).limit(limit) - async with self._sf() as session: - result = await session.execute(stmt) - return [self._row_to_dict(r) for r in result.scalars()] - - async def delete(self, feedback_id: str) -> bool: + async def get( + self, + feedback_id: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> dict | None: + resolved_owner_id = resolve_owner_id(owner_id, method_name="FeedbackRepository.get") async with self._sf() as session: row = await session.get(FeedbackRow, feedback_id) if row is None: + return None + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return None + return self._row_to_dict(row) + + async def list_by_run( + self, + thread_id: str, + run_id: str, + *, + limit: int = 100, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> list[dict]: + resolved_owner_id = resolve_owner_id(owner_id, method_name="FeedbackRepository.list_by_run") + stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id, FeedbackRow.run_id == run_id) + if resolved_owner_id is not None: + stmt = stmt.where(FeedbackRow.owner_id == resolved_owner_id) + stmt = stmt.order_by(FeedbackRow.created_at.asc()).limit(limit) + async with self._sf() as session: + result = await session.execute(stmt) + return [self._row_to_dict(r) for r in result.scalars()] + + async def list_by_thread( + self, + thread_id: str, + *, + limit: int = 100, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> list[dict]: + resolved_owner_id = resolve_owner_id(owner_id, method_name="FeedbackRepository.list_by_thread") + stmt = select(FeedbackRow).where(FeedbackRow.thread_id == thread_id) + if resolved_owner_id is not None: + stmt = stmt.where(FeedbackRow.owner_id == resolved_owner_id) + stmt = stmt.order_by(FeedbackRow.created_at.asc()).limit(limit) + async with self._sf() as session: + result = await session.execute(stmt) + return [self._row_to_dict(r) for r in result.scalars()] + + async def delete( + self, + feedback_id: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> bool: + resolved_owner_id = resolve_owner_id(owner_id, method_name="FeedbackRepository.delete") + async with self._sf() as session: + row = await session.get(FeedbackRow, feedback_id) + if row is None: + return False + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: return False await session.delete(row) await session.commit() diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 659ac07f9..ab29a3536 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -7,6 +7,7 @@ The actual ORM classes have moved to entity-specific subpackages: - ``deerflow.persistence.thread_meta`` - ``deerflow.persistence.run`` - ``deerflow.persistence.feedback`` +- ``deerflow.persistence.user`` ``RunEventRow`` remains in ``deerflow.persistence.models.run_event`` because its storage implementation lives in ``deerflow.runtime.events.store.db`` and @@ -17,5 +18,6 @@ from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.run.model import RunRow from deerflow.persistence.thread_meta.model import ThreadMetaRow +from deerflow.persistence.user.model import UserRow -__all__ = ["FeedbackRow", "RunEventRow", "RunRow", "ThreadMetaRow"] +__all__ = ["FeedbackRow", "RunEventRow", "RunRow", "ThreadMetaRow", "UserRow"] diff --git a/backend/packages/harness/deerflow/persistence/models/run_event.py b/backend/packages/harness/deerflow/persistence/models/run_event.py index 8db50aea7..34f55ba03 100644 --- a/backend/packages/harness/deerflow/persistence/models/run_event.py +++ b/backend/packages/harness/deerflow/persistence/models/run_event.py @@ -16,6 +16,10 @@ class RunEventRow(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) thread_id: Mapped[str] = mapped_column(String(64), nullable=False) run_id: Mapped[str] = mapped_column(String(64), nullable=False) + # Owner of the conversation this event belongs to. Nullable for data + # created before auth was introduced; populated by auth middleware on + # new writes and by the boot-time orphan migration on existing rows. + owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) event_type: Mapped[str] = mapped_column(String(32), nullable=False) category: Mapped[str] = mapped_column(String(16), nullable=False) # "message" | "trace" | "lifecycle" diff --git a/backend/packages/harness/deerflow/persistence/run/sql.py b/backend/packages/harness/deerflow/persistence/run/sql.py index fac88d968..5d8656509 100644 --- a/backend/packages/harness/deerflow/persistence/run/sql.py +++ b/backend/packages/harness/deerflow/persistence/run/sql.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.run.model import RunRow from deerflow.runtime.runs.store.base import RunStore +from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_owner_id class RunRepository(RunStore): @@ -68,7 +69,7 @@ class RunRepository(RunStore): *, thread_id, assistant_id=None, - owner_id=None, + owner_id: str | None | _AutoSentinel = AUTO, status="pending", multitask_strategy="reject", metadata=None, @@ -77,12 +78,13 @@ class RunRepository(RunStore): created_at=None, follow_up_to_run_id=None, ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="RunRepository.put") now = datetime.now(UTC) row = RunRow( run_id=run_id, thread_id=thread_id, assistant_id=assistant_id, - owner_id=owner_id, + owner_id=resolved_owner_id, status=status, multitask_strategy=multitask_strategy, metadata_json=self._safe_json(metadata) or {}, @@ -96,15 +98,32 @@ class RunRepository(RunStore): session.add(row) await session.commit() - async def get(self, run_id): + async def get( + self, + run_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="RunRepository.get") async with self._sf() as session: row = await session.get(RunRow, run_id) - return self._row_to_dict(row) if row else None + if row is None: + return None + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return None + return self._row_to_dict(row) - async def list_by_thread(self, thread_id, *, owner_id=None, limit=100): + async def list_by_thread( + self, + thread_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + limit=100, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="RunRepository.list_by_thread") stmt = select(RunRow).where(RunRow.thread_id == thread_id) - if owner_id is not None: - stmt = stmt.where(RunRow.owner_id == owner_id) + if resolved_owner_id is not None: + stmt = stmt.where(RunRow.owner_id == resolved_owner_id) stmt = stmt.order_by(RunRow.created_at.desc()).limit(limit) async with self._sf() as session: result = await session.execute(stmt) @@ -118,12 +137,21 @@ class RunRepository(RunStore): await session.execute(update(RunRow).where(RunRow.run_id == run_id).values(**values)) await session.commit() - async def delete(self, run_id): + async def delete( + self, + run_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="RunRepository.delete") async with self._sf() as session: row = await session.get(RunRow, run_id) - if row is not None: - await session.delete(row) - await session.commit() + if row is None: + return + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return + await session.delete(row) + await session.commit() async def list_pending(self, *, before=None): if before is None: diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index 86c73030e..5a149e5d6 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.thread_meta.base import ThreadMetaStore from deerflow.persistence.thread_meta.model import ThreadMetaRow +from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_owner_id class ThreadMetaRepository(ThreadMetaStore): @@ -31,15 +32,18 @@ class ThreadMetaRepository(ThreadMetaStore): thread_id: str, *, assistant_id: str | None = None, - owner_id: str | None = None, + owner_id: str | None | _AutoSentinel = AUTO, display_name: str | None = None, metadata: dict | None = None, ) -> dict: + # Auto-resolve owner_id from contextvar when AUTO; explicit None + # creates an orphan row (used by migration scripts). + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.create") now = datetime.now(UTC) row = ThreadMetaRow( thread_id=thread_id, assistant_id=assistant_id, - owner_id=owner_id, + owner_id=resolved_owner_id, display_name=display_name, metadata_json=metadata or {}, created_at=now, @@ -51,10 +55,21 @@ class ThreadMetaRepository(ThreadMetaStore): await session.refresh(row) return self._row_to_dict(row) - async def get(self, thread_id: str) -> dict | None: + async def get( + self, + thread_id: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> dict | None: + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.get") async with self._sf() as session: row = await session.get(ThreadMetaRow, thread_id) - return self._row_to_dict(row) if row else None + if row is None: + return None + # Enforce owner filter unless explicitly bypassed (owner_id=None). + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return None + return self._row_to_dict(row) async def list_by_owner(self, owner_id: str, *, limit: int = 100, offset: int = 0) -> list[dict]: stmt = select(ThreadMetaRow).where(ThreadMetaRow.owner_id == owner_id).order_by(ThreadMetaRow.updated_at.desc()).limit(limit).offset(offset) @@ -62,16 +77,32 @@ class ThreadMetaRepository(ThreadMetaStore): result = await session.execute(stmt) return [self._row_to_dict(r) for r in result.scalars()] - async def check_access(self, thread_id: str, owner_id: str) -> bool: - """Check if owner_id has access to thread_id. + async def check_access(self, thread_id: str, owner_id: str, *, require_existing: bool = False) -> bool: + """Check if ``owner_id`` has access to ``thread_id``. - Returns True if: row doesn't exist (untracked thread), owner_id - is None on the row (shared thread), or owner_id matches. + Two modes — one row, two distinct semantics depending on what + the caller is about to do: + + - ``require_existing=False`` (default, permissive): + Returns True for: row missing (untracked legacy thread), + ``row.owner_id`` is None (shared / pre-auth data), + or ``row.owner_id == owner_id``. Use for **read-style** + decorators where treating an untracked thread as accessible + preserves backward-compat. + + - ``require_existing=True`` (strict): + Returns True **only** when the row exists AND + (``row.owner_id == owner_id`` OR ``row.owner_id is None``). + Use for **destructive / mutating** decorators (DELETE, PATCH, + state-update) so a thread that has *already been deleted* + cannot be re-targeted by any caller — closing the + delete-idempotence cross-user gap where the row vanishing + made every other user appear to "own" it. """ async with self._sf() as session: row = await session.get(ThreadMetaRow, thread_id) if row is None: - return True + return not require_existing if row.owner_id is None: return True return row.owner_id == owner_id @@ -83,9 +114,17 @@ class ThreadMetaRepository(ThreadMetaStore): status: str | None = None, limit: int = 100, offset: int = 0, + owner_id: str | None | _AutoSentinel = AUTO, ) -> list[dict]: - """Search threads with optional metadata and status filters.""" + """Search threads with optional metadata and status filters. + + Owner filter is enforced by default: caller must be in a user + context. Pass ``owner_id=None`` to bypass (migration/CLI). + """ + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.search") stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc()) + if resolved_owner_id is not None: + stmt = stmt.where(ThreadMetaRow.owner_id == resolved_owner_id) if status: stmt = stmt.where(ThreadMetaRow.status == status) @@ -105,36 +144,80 @@ class ThreadMetaRepository(ThreadMetaStore): result = await session.execute(stmt) return [self._row_to_dict(r) for r in result.scalars()] - async def update_display_name(self, thread_id: str, display_name: str) -> None: + async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_owner_id: str | None) -> bool: + """Return True if the row exists and is owned (or filter bypassed).""" + if resolved_owner_id is None: + return True # explicit bypass + row = await session.get(ThreadMetaRow, thread_id) + return row is not None and row.owner_id == resolved_owner_id + + async def update_display_name( + self, + thread_id: str, + display_name: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> None: """Update the display_name (title) for a thread.""" + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.update_display_name") async with self._sf() as session: + if not await self._check_ownership(session, thread_id, resolved_owner_id): + return await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(display_name=display_name, updated_at=datetime.now(UTC))) await session.commit() - async def update_status(self, thread_id: str, status: str) -> None: + async def update_status( + self, + thread_id: str, + status: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> None: + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.update_status") async with self._sf() as session: + if not await self._check_ownership(session, thread_id, resolved_owner_id): + return await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(status=status, updated_at=datetime.now(UTC))) await session.commit() - async def update_metadata(self, thread_id: str, metadata: dict) -> None: + async def update_metadata( + self, + thread_id: str, + metadata: dict, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> None: """Merge ``metadata`` into ``metadata_json``. Read-modify-write inside a single session/transaction so concurrent - callers see consistent state. No-op if the row does not exist. + callers see consistent state. No-op if the row does not exist or + the owner_id check fails. """ + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.update_metadata") async with self._sf() as session: row = await session.get(ThreadMetaRow, thread_id) if row is None: return + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return merged = dict(row.metadata_json or {}) merged.update(metadata) row.metadata_json = merged row.updated_at = datetime.now(UTC) await session.commit() - async def delete(self, thread_id: str) -> None: + async def delete( + self, + thread_id: str, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ) -> None: + resolved_owner_id = resolve_owner_id(owner_id, method_name="ThreadMetaRepository.delete") async with self._sf() as session: row = await session.get(ThreadMetaRow, thread_id) - if row is not None: - await session.delete(row) - await session.commit() + if row is None: + return + if resolved_owner_id is not None and row.owner_id != resolved_owner_id: + return + await session.delete(row) + await session.commit() diff --git a/backend/packages/harness/deerflow/persistence/user/__init__.py b/backend/packages/harness/deerflow/persistence/user/__init__.py new file mode 100644 index 000000000..a60eeef2c --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/user/__init__.py @@ -0,0 +1,12 @@ +"""User storage subpackage. + +Holds the ORM model for the ``users`` table. The concrete repository +implementation (``SQLiteUserRepository``) lives in the app layer +(``app.gateway.auth.repositories.sqlite``) because it converts +between the ORM row and the auth module's pydantic ``User`` class. +This keeps the harness package free of any dependency on app code. +""" + +from deerflow.persistence.user.model import UserRow + +__all__ = ["UserRow"] diff --git a/backend/packages/harness/deerflow/persistence/user/model.py b/backend/packages/harness/deerflow/persistence/user/model.py new file mode 100644 index 000000000..130d4bfcb --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/user/model.py @@ -0,0 +1,59 @@ +"""ORM model for the users table. + +Lives in the harness persistence package so it is picked up by +``Base.metadata.create_all()`` alongside ``threads_meta``, ``runs``, +``run_events``, and ``feedback``. Using the shared engine means: + +- One SQLite/Postgres database, one connection pool +- One schema initialisation codepath +- Consistent async sessions across auth and persistence reads +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, Index, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base + + +class UserRow(Base): + __tablename__ = "users" + + # UUIDs are stored as 36-char strings for cross-backend portability. + id: Mapped[str] = mapped_column(String(36), primary_key=True) + + email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) + password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + + # "admin" | "user" — kept as plain string to avoid ALTER TABLE pain + # when new roles are introduced. + system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) + + # OAuth linkage (optional). A partial unique index enforces one + # account per (provider, oauth_id) pair, leaving NULL/NULL rows + # unconstrained so plain password accounts can coexist. + oauth_provider: Mapped[str | None] = mapped_column(String(32), nullable=True) + oauth_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + + # Auth lifecycle flags + needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + token_version: Mapped[int] = mapped_column(nullable=False, default=0) + + __table_args__ = ( + Index( + "idx_users_oauth_identity", + "oauth_provider", + "oauth_id", + unique=True, + sqlite_where=text("oauth_provider IS NOT NULL AND oauth_id IS NOT NULL"), + ), + ) diff --git a/backend/packages/harness/deerflow/runtime/events/store/db.py b/backend/packages/harness/deerflow/runtime/events/store/db.py index 0502cd879..16252a26c 100644 --- a/backend/packages/harness/deerflow/runtime/events/store/db.py +++ b/backend/packages/harness/deerflow/runtime/events/store/db.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.models.run_event import RunEventRow from deerflow.runtime.events.store.base import RunEventStore +from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_owner_id logger = logging.getLogger(__name__) @@ -53,6 +54,18 @@ class DbRunEventStore(RunEventStore): metadata = {**(metadata or {}), "content_truncated": True, "original_byte_length": len(encoded)} return content, metadata or {} + @staticmethod + def _owner_from_context() -> str | None: + """Soft read of owner_id from contextvar for write paths. + + Returns ``None`` (no filter / no stamp) if contextvar is unset, + which is the expected case for background worker writes. HTTP + request writes will have the contextvar set by auth middleware + and get their user_id stamped automatically. + """ + user = get_current_user() + return user.id if user is not None else None + async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None): # noqa: D401 """Write a single event — low-frequency path only. @@ -68,6 +81,7 @@ class DbRunEventStore(RunEventStore): metadata = {**(metadata or {}), "content_is_dict": True} else: db_content = content + owner_id = self._owner_from_context() async with self._sf() as session: async with session.begin(): # Use FOR UPDATE to serialize seq assignment within a thread. @@ -78,6 +92,7 @@ class DbRunEventStore(RunEventStore): row = RunEventRow( thread_id=thread_id, run_id=run_id, + owner_id=owner_id, event_type=event_type, category=category, content=db_content, @@ -91,6 +106,7 @@ class DbRunEventStore(RunEventStore): async def put_batch(self, events): if not events: return [] + owner_id = self._owner_from_context() async with self._sf() as session: async with session.begin(): # Get max seq for the thread (assume all events in batch belong to same thread). @@ -114,6 +130,7 @@ class DbRunEventStore(RunEventStore): row = RunEventRow( thread_id=e["thread_id"], run_id=e["run_id"], + owner_id=e.get("owner_id", owner_id), event_type=e["event_type"], category=category, content=db_content, @@ -125,8 +142,19 @@ class DbRunEventStore(RunEventStore): rows.append(row) return [self._row_to_dict(r) for r in rows] - async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None): + async def list_messages( + self, + thread_id, + *, + limit=50, + before_seq=None, + after_seq=None, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.list_messages") stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message") + if resolved_owner_id is not None: + stmt = stmt.where(RunEventRow.owner_id == resolved_owner_id) if before_seq is not None: stmt = stmt.where(RunEventRow.seq < before_seq) if after_seq is not None: @@ -146,8 +174,19 @@ class DbRunEventStore(RunEventStore): rows = list(result.scalars()) return [self._row_to_dict(r) for r in reversed(rows)] - async def list_events(self, thread_id, run_id, *, event_types=None, limit=500): + async def list_events( + self, + thread_id, + run_id, + *, + event_types=None, + limit=500, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.list_events") stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id) + if resolved_owner_id is not None: + stmt = stmt.where(RunEventRow.owner_id == resolved_owner_id) if event_types: stmt = stmt.where(RunEventRow.event_type.in_(event_types)) stmt = stmt.order_by(RunEventRow.seq.asc()).limit(limit) @@ -155,31 +194,68 @@ class DbRunEventStore(RunEventStore): result = await session.execute(stmt) return [self._row_to_dict(r) for r in result.scalars()] - async def list_messages_by_run(self, thread_id, run_id): - stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id, RunEventRow.category == "message").order_by(RunEventRow.seq.asc()) + async def list_messages_by_run( + self, + thread_id, + run_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.list_messages_by_run") + stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id, RunEventRow.category == "message") + if resolved_owner_id is not None: + stmt = stmt.where(RunEventRow.owner_id == resolved_owner_id) + stmt = stmt.order_by(RunEventRow.seq.asc()) async with self._sf() as session: result = await session.execute(stmt) return [self._row_to_dict(r) for r in result.scalars()] - async def count_messages(self, thread_id): + async def count_messages( + self, + thread_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.count_messages") stmt = select(func.count()).select_from(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message") + if resolved_owner_id is not None: + stmt = stmt.where(RunEventRow.owner_id == resolved_owner_id) async with self._sf() as session: return await session.scalar(stmt) or 0 - async def delete_by_thread(self, thread_id): + async def delete_by_thread( + self, + thread_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.delete_by_thread") async with self._sf() as session: - count_stmt = select(func.count()).select_from(RunEventRow).where(RunEventRow.thread_id == thread_id) + count_conditions = [RunEventRow.thread_id == thread_id] + if resolved_owner_id is not None: + count_conditions.append(RunEventRow.owner_id == resolved_owner_id) + count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions) count = await session.scalar(count_stmt) or 0 if count > 0: - await session.execute(delete(RunEventRow).where(RunEventRow.thread_id == thread_id)) + await session.execute(delete(RunEventRow).where(*count_conditions)) await session.commit() return count - async def delete_by_run(self, thread_id, run_id): + async def delete_by_run( + self, + thread_id, + run_id, + *, + owner_id: str | None | _AutoSentinel = AUTO, + ): + resolved_owner_id = resolve_owner_id(owner_id, method_name="DbRunEventStore.delete_by_run") async with self._sf() as session: - count_stmt = select(func.count()).select_from(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id) + count_conditions = [RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id] + if resolved_owner_id is not None: + count_conditions.append(RunEventRow.owner_id == resolved_owner_id) + count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions) count = await session.scalar(count_stmt) or 0 if count > 0: - await session.execute(delete(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id)) + await session.execute(delete(RunEventRow).where(*count_conditions)) await session.commit() return count diff --git a/backend/packages/harness/deerflow/runtime/user_context.py b/backend/packages/harness/deerflow/runtime/user_context.py new file mode 100644 index 000000000..07ffbb744 --- /dev/null +++ b/backend/packages/harness/deerflow/runtime/user_context.py @@ -0,0 +1,148 @@ +"""Request-scoped user context for owner-based authorization. + +This module holds a :class:`~contextvars.ContextVar` that the gateway's +auth middleware sets after a successful authentication. Repository +methods read the contextvar via a sentinel default parameter, letting +routers stay free of ``owner_id`` boilerplate. + +Three-state semantics for the repository ``owner_id`` parameter (the +consumer side of this module lives in ``deerflow.persistence.*``): + +- ``_AUTO`` (module-private sentinel, default): read from contextvar; + raise :class:`RuntimeError` if unset. +- Explicit ``str``: use the provided value, overriding contextvar. +- Explicit ``None``: no WHERE clause — used only by migration scripts + and admin CLIs that intentionally bypass isolation. + +Dependency direction +-------------------- +``persistence`` (lower layer) reads from this module; ``gateway.auth`` +(higher layer) writes to it. ``CurrentUser`` is defined here as a +:class:`typing.Protocol` so that ``persistence`` never needs to import +the concrete ``User`` class from ``gateway.auth.models``. Any object +with an ``.id: str`` attribute structurally satisfies the protocol. + +Asyncio semantics +----------------- +``ContextVar`` is task-local under asyncio, not thread-local. Each +FastAPI request runs in its own task, so the context is naturally +isolated. ``asyncio.create_task`` and ``asyncio.to_thread`` inherit the +parent task's context, which is typically the intended behaviour; if +a background task must *not* see the foreground user, wrap it with +``contextvars.copy_context()`` to get a clean copy. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from typing import Final, Protocol, runtime_checkable + + +@runtime_checkable +class CurrentUser(Protocol): + """Structural type for the current authenticated user. + + Any object with an ``.id: str`` attribute satisfies this protocol. + Concrete implementations live in ``app.gateway.auth.models.User``. + """ + + id: str + + +_current_user: Final[ContextVar[CurrentUser | None]] = ContextVar("deerflow_current_user", default=None) + + +def set_current_user(user: CurrentUser) -> Token[CurrentUser | None]: + """Set the current user for this async task. + + Returns a reset token that should be passed to + :func:`reset_current_user` in a ``finally`` block to restore the + previous context. + """ + return _current_user.set(user) + + +def reset_current_user(token: Token[CurrentUser | None]) -> None: + """Restore the context to the state captured by ``token``.""" + _current_user.reset(token) + + +def get_current_user() -> CurrentUser | None: + """Return the current user, or ``None`` if unset. + + Safe to call in any context. Used by code paths that can proceed + without a user (e.g. migration scripts, public endpoints). + """ + return _current_user.get() + + +def require_current_user() -> CurrentUser: + """Return the current user, or raise :class:`RuntimeError`. + + Used by repository code that must not be called outside a + request-authenticated context. The error message is phrased so + that a caller debugging a stack trace can locate the offending + code path. + """ + user = _current_user.get() + if user is None: + raise RuntimeError("repository accessed without user context") + return user + + +# --------------------------------------------------------------------------- +# Sentinel-based owner_id resolution +# --------------------------------------------------------------------------- +# +# Repository methods accept an ``owner_id`` keyword-only argument that +# defaults to ``AUTO``. The three possible values drive distinct +# behaviours; see the docstring on :func:`resolve_owner_id`. + + +class _AutoSentinel: + """Singleton marker meaning 'resolve owner_id from contextvar'.""" + + _instance: _AutoSentinel | None = None + + def __new__(cls) -> _AutoSentinel: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "" + + +AUTO: Final[_AutoSentinel] = _AutoSentinel() + + +def resolve_owner_id( + value: str | None | _AutoSentinel, + *, + method_name: str = "repository method", +) -> str | None: + """Resolve the owner_id parameter passed to a repository method. + + Three-state semantics: + + - :data:`AUTO` (default): read from contextvar; raise + :class:`RuntimeError` if no user is in context. This is the + common case for request-scoped calls. + - Explicit ``str``: use the provided id verbatim, overriding any + contextvar value. Useful for tests and admin-override flows. + - Explicit ``None``: no filter — the repository should skip the + owner_id WHERE clause entirely. Reserved for migration scripts + and CLI tools that intentionally bypass isolation. + """ + if isinstance(value, _AutoSentinel): + user = _current_user.get() + if user is None: + raise RuntimeError(f"{method_name} called with owner_id=AUTO but no user context is set; pass an explicit owner_id, set the contextvar via auth middleware, or opt out with owner_id=None for migration/CLI paths.") + # Coerce to ``str`` at the boundary: ``User.id`` is typed as + # ``UUID`` for the API surface, but the persistence layer + # stores ``owner_id`` as ``String(64)`` and aiosqlite cannot + # bind a raw UUID object to a VARCHAR column ("type 'UUID' is + # not supported"). Honour the documented return type here + # rather than ripple a type change through every caller. + return str(user.id) + return value diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a13970ba0..a743d5e02 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "langgraph-sdk>=0.1.51", "markdown-to-mrkdwn>=0.3.1", "wecom-aibot-python-sdk>=0.1.6", + "bcrypt>=4.0.0", + "pyjwt>=2.9.0", + "email-validator>=2.0.0", ] [project.optional-dependencies] @@ -27,6 +30,11 @@ postgres = [ [dependency-groups] dev = ["pytest>=8.0.0", "ruff>=0.14.11"] +[tool.pytest.ini_options] +markers = [ + "no_auto_user: disable the conftest autouse contextvar fixture for this test", +] + [tool.uv.workspace] members = ["packages/harness"] diff --git a/backend/tests/_router_auth_helpers.py b/backend/tests/_router_auth_helpers.py new file mode 100644 index 000000000..e48d01146 --- /dev/null +++ b/backend/tests/_router_auth_helpers.py @@ -0,0 +1,134 @@ +"""Helpers for router-level tests that need a stubbed auth context. + +The production gateway runs ``AuthMiddleware`` (validates the JWT cookie) +ahead of every router, plus ``@require_permission(owner_check=True)`` +decorators that read ``request.state.auth`` and call +``thread_meta_repo.check_access``. Router-level unit tests construct +**bare** FastAPI apps that include only one router — they have neither +the auth middleware nor a real thread_meta_repo, so the decorators raise +401 (TestClient path) or ValueError (direct-call path). + +This module provides two surfaces: + +1. :func:`make_authed_test_app` — wraps ``FastAPI()`` with a tiny + ``BaseHTTPMiddleware`` that stamps a fake user / AuthContext on every + request, plus a permissive ``thread_meta_repo`` mock on + ``app.state``. Use from TestClient-based router tests. + +2. :func:`call_unwrapped` — invokes the underlying function bypassing + the ``@require_permission`` decorator chain by walking ``__wrapped__``. + Use from direct-call tests that previously imported the route + function and called it positionally. + +Both helpers are deliberately permissive: they never deny a request. +Tests that want to verify the *auth boundary itself* (e.g. +``test_auth_middleware``, ``test_auth_type_system``) build their own +apps with the real middleware — those should not use this module. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import ParamSpec, TypeVar +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from app.gateway.auth.models import User +from app.gateway.authz import AuthContext, Permissions + +# Default permission set granted to the stub user. Mirrors `_ALL_PERMISSIONS` +# in authz.py — kept inline so the tests don't import a private symbol. +_STUB_PERMISSIONS: list[str] = [ + Permissions.THREADS_READ, + Permissions.THREADS_WRITE, + Permissions.THREADS_DELETE, + Permissions.RUNS_CREATE, + Permissions.RUNS_READ, + Permissions.RUNS_CANCEL, +] + + +def _make_stub_user() -> User: + """A deterministic test user — same shape as production, fresh UUID.""" + return User( + email="router-test@example.com", + password_hash="x", + system_role="user", + id=uuid4(), + ) + + +class _StubAuthMiddleware(BaseHTTPMiddleware): + """Stamp a fake user / AuthContext onto every request. + + Mirrors what production ``AuthMiddleware`` does after the JWT decode + + DB lookup short-circuit, so ``@require_permission`` finds an + authenticated context and skips its own re-authentication path. + """ + + def __init__(self, app: ASGIApp, user_factory: Callable[[], User]) -> None: + super().__init__(app) + self._user_factory = user_factory + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + user = self._user_factory() + request.state.user = user + request.state.auth = AuthContext(user=user, permissions=list(_STUB_PERMISSIONS)) + return await call_next(request) + + +def make_authed_test_app( + *, + user_factory: Callable[[], User] | None = None, + owner_check_passes: bool = True, +) -> FastAPI: + """Build a FastAPI test app with stub auth + permissive thread_meta_repo. + + Args: + user_factory: Override the default test user. Must return a fully + populated :class:`User`. Useful for cross-user isolation tests + that need a stable id across requests. + owner_check_passes: When True (default), ``thread_meta_repo.check_access`` + returns True for every call so ``@require_permission(owner_check=True)`` + never blocks the route under test. Pass False to verify that + permission failures surface correctly. + + Returns: + A ``FastAPI`` app with the stub middleware installed and + ``app.state.thread_meta_repo`` set to a permissive mock. The + caller is still responsible for ``app.include_router(...)``. + """ + factory = user_factory or _make_stub_user + app = FastAPI() + app.add_middleware(_StubAuthMiddleware, user_factory=factory) + + repo = MagicMock() + repo.check_access = AsyncMock(return_value=owner_check_passes) + app.state.thread_meta_repo = repo + + return app + + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def call_unwrapped(decorated: Callable[_P, _R], /, *args: _P.args, **kwargs: _P.kwargs) -> _R: + """Invoke the underlying function of a ``@require_permission``-decorated route. + + ``functools.wraps`` sets ``__wrapped__`` on each layer; we walk all + the way down to the original handler, bypassing every authz + + require_auth wrapper. Use from tests that need to call route + functions directly (without TestClient) and don't want to construct + a fake ``Request`` just to satisfy the decorator. The ``ParamSpec`` + propagates the wrapped route's signature so call sites still get + parameter checking despite the unwrapping. + """ + fn: Callable = decorated + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ # type: ignore[attr-defined] + return fn(*args, **kwargs) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 491961c00..9b10430e5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,8 +6,11 @@ issues when unit-testing lightweight config/registry code in isolation. import sys from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + # Make 'app' and 'deerflow' importable from any working directory sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -31,3 +34,44 @@ _executor_mock.MAX_CONCURRENT_SUBAGENTS = 3 _executor_mock.get_background_task_result = MagicMock() sys.modules["deerflow.subagents.executor"] = _executor_mock + + +# --------------------------------------------------------------------------- +# Auto-set user context for every test unless marked no_auto_user +# --------------------------------------------------------------------------- +# +# Repository methods read ``owner_id`` from a contextvar by default +# (see ``deerflow.runtime.user_context``). Without this fixture, every +# pre-existing persistence test would raise RuntimeError because the +# contextvar is unset. The fixture sets a default test user on every +# test; tests that explicitly want to verify behaviour *without* a user +# context should mark themselves ``@pytest.mark.no_auto_user``. + + +@pytest.fixture(autouse=True) +def _auto_user_context(request): + """Inject a default ``test-user-autouse`` into the contextvar. + + Opt-out via ``@pytest.mark.no_auto_user``. Uses lazy import so that + tests which don't touch the persistence layer never pay the cost + of importing runtime.user_context. + """ + if request.node.get_closest_marker("no_auto_user"): + yield + return + + try: + from deerflow.runtime.user_context import ( + reset_current_user, + set_current_user, + ) + except ImportError: + yield + return + + user = SimpleNamespace(id="test-user-autouse", email="test@local") + token = set_current_user(user) + try: + yield + finally: + reset_current_user(token) diff --git a/backend/tests/test_artifacts_router.py b/backend/tests/test_artifacts_router.py index 9a30ff44e..df32e45dc 100644 --- a/backend/tests/test_artifacts_router.py +++ b/backend/tests/test_artifacts_router.py @@ -3,7 +3,7 @@ import zipfile from pathlib import Path import pytest -from fastapi import FastAPI +from _router_auth_helpers import call_unwrapped, make_authed_test_app from fastapi.testclient import TestClient from starlette.requests import Request from starlette.responses import FileResponse @@ -36,7 +36,7 @@ def test_get_artifact_reads_utf8_text_file_on_windows_locale(tmp_path, monkeypat monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path) request = _make_request() - response = asyncio.run(artifacts_router.get_artifact("thread-1", "mnt/user-data/outputs/note.txt", request)) + response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/note.txt", request)) assert bytes(response.body).decode("utf-8") == text assert response.media_type == "text/plain" @@ -49,7 +49,7 @@ def test_get_artifact_forces_download_for_active_content(tmp_path, monkeypatch, monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path) - response = asyncio.run(artifacts_router.get_artifact("thread-1", f"mnt/user-data/outputs/{filename}", _make_request())) + response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", f"mnt/user-data/outputs/{filename}", _make_request())) assert isinstance(response, FileResponse) assert response.headers.get("content-disposition", "").startswith("attachment;") @@ -63,7 +63,7 @@ def test_get_artifact_forces_download_for_active_content_in_skill_archive(tmp_pa monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: skill_path) - response = asyncio.run(artifacts_router.get_artifact("thread-1", f"mnt/user-data/outputs/sample.skill/{filename}", _make_request())) + response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", f"mnt/user-data/outputs/sample.skill/{filename}", _make_request())) assert response.headers.get("content-disposition", "").startswith("attachment;") assert bytes(response.body) == content.encode("utf-8") @@ -75,7 +75,7 @@ def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeyp monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path) - app = FastAPI() + app = make_authed_test_app() app.include_router(artifacts_router.router) with TestClient(app) as client: @@ -93,7 +93,7 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: skill_path) - app = FastAPI() + app = make_authed_test_app() app.include_router(artifacts_router.router) with TestClient(app) as client: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 000000000..ea4c5733a --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,654 @@ +"""Tests for authentication module: JWT, password hashing, AuthContext, and authz decorators.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.gateway.auth import create_access_token, decode_token, hash_password, verify_password +from app.gateway.auth.models import User +from app.gateway.authz import ( + AuthContext, + Permissions, + get_auth_context, + require_auth, + require_permission, +) + +# ── Password Hashing ──────────────────────────────────────────────────────── + + +def test_hash_password_and_verify(): + """Hashing and verification round-trip.""" + password = "s3cr3tP@ssw0rd!" + hashed = hash_password(password) + assert hashed != password + assert verify_password(password, hashed) is True + assert verify_password("wrongpassword", hashed) is False + + +def test_hash_password_different_each_time(): + """bcrypt generates unique salts, so same password has different hashes.""" + password = "testpassword" + h1 = hash_password(password) + h2 = hash_password(password) + assert h1 != h2 # Different salts + # But both verify correctly + assert verify_password(password, h1) is True + assert verify_password(password, h2) is True + + +def test_verify_password_rejects_empty(): + """Empty password should not verify.""" + hashed = hash_password("nonempty") + assert verify_password("", hashed) is False + + +# ── JWT ───────────────────────────────────────────────────────────────────── + + +def test_create_and_decode_token(): + """JWT creation and decoding round-trip.""" + user_id = str(uuid4()) + # Set a valid JWT secret for this test + import os + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(user_id) + assert isinstance(token, str) + + payload = decode_token(token) + assert payload is not None + assert payload.sub == user_id + + +def test_decode_token_expired(): + """Expired token returns TokenError.EXPIRED.""" + from app.gateway.auth.errors import TokenError + + user_id = str(uuid4()) + # Create token that expires immediately + token = create_access_token(user_id, expires_delta=timedelta(seconds=-1)) + payload = decode_token(token) + assert payload == TokenError.EXPIRED + + +def test_decode_token_invalid(): + """Invalid token returns TokenError.""" + from app.gateway.auth.errors import TokenError + + assert isinstance(decode_token("not.a.valid.token"), TokenError) + assert isinstance(decode_token(""), TokenError) + assert isinstance(decode_token("completely-wrong"), TokenError) + + +def test_create_token_custom_expiry(): + """Custom expiry is respected.""" + user_id = str(uuid4()) + token = create_access_token(user_id, expires_delta=timedelta(hours=1)) + payload = decode_token(token) + assert payload is not None + assert payload.sub == user_id + + +# ── AuthContext ──────────────────────────────────────────────────────────── + + +def test_auth_context_unauthenticated(): + """AuthContext with no user.""" + ctx = AuthContext(user=None, permissions=[]) + assert ctx.is_authenticated is False + assert ctx.has_permission("threads", "read") is False + + +def test_auth_context_authenticated_no_perms(): + """AuthContext with user but no permissions.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[]) + assert ctx.is_authenticated is True + assert ctx.has_permission("threads", "read") is False + + +def test_auth_context_has_permission(): + """AuthContext permission checking.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + perms = [Permissions.THREADS_READ, Permissions.THREADS_WRITE] + ctx = AuthContext(user=user, permissions=perms) + assert ctx.has_permission("threads", "read") is True + assert ctx.has_permission("threads", "write") is True + assert ctx.has_permission("threads", "delete") is False + assert ctx.has_permission("runs", "read") is False + + +def test_auth_context_require_user_raises(): + """require_user raises 401 when not authenticated.""" + ctx = AuthContext(user=None, permissions=[]) + with pytest.raises(HTTPException) as exc_info: + ctx.require_user() + assert exc_info.value.status_code == 401 + + +def test_auth_context_require_user_returns_user(): + """require_user returns user when authenticated.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[]) + returned = ctx.require_user() + assert returned == user + + +# ── get_auth_context helper ───────────────────────────────────────────────── + + +def test_get_auth_context_not_set(): + """get_auth_context returns None when auth not set on request.""" + mock_request = MagicMock() + # Make getattr return None (simulating attribute not set) + mock_request.state = MagicMock() + del mock_request.state.auth + assert get_auth_context(mock_request) is None + + +def test_get_auth_context_set(): + """get_auth_context returns the AuthContext from request.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[Permissions.THREADS_READ]) + + mock_request = MagicMock() + mock_request.state.auth = ctx + + assert get_auth_context(mock_request) == ctx + + +# ── require_auth decorator ────────────────────────────────────────────────── + + +def test_require_auth_sets_auth_context(): + """require_auth sets auth context on request from cookie.""" + from fastapi import Request + + app = FastAPI() + + @app.get("/test") + @require_auth + async def endpoint(request: Request): + ctx = get_auth_context(request) + return {"authenticated": ctx.is_authenticated} + + with TestClient(app) as client: + # No cookie → anonymous + response = client.get("/test") + assert response.status_code == 200 + assert response.json()["authenticated"] is False + + +def test_require_auth_requires_request_param(): + """require_auth raises ValueError if request parameter is missing.""" + import asyncio + + @require_auth + async def bad_endpoint(): # Missing `request` parameter + pass + + with pytest.raises(ValueError, match="require_auth decorator requires 'request' parameter"): + asyncio.run(bad_endpoint()) + + +# ── require_permission decorator ───────────────────────────────────────────── + + +def test_require_permission_requires_auth(): + """require_permission raises 401 when not authenticated.""" + from fastapi import Request + + app = FastAPI() + + @app.get("/test") + @require_permission("threads", "read") + async def endpoint(request: Request): + return {"ok": True} + + with TestClient(app) as client: + response = client.get("/test") + assert response.status_code == 401 + assert "Authentication required" in response.json()["detail"] + + +def test_require_permission_denies_wrong_permission(): + """User without required permission gets 403.""" + from fastapi import Request + + app = FastAPI() + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + + @app.get("/test") + @require_permission("threads", "delete") + async def endpoint(request: Request): + return {"ok": True} + + mock_auth = AuthContext(user=user, permissions=[Permissions.THREADS_READ]) + + with patch("app.gateway.authz._authenticate", return_value=mock_auth): + with TestClient(app) as client: + response = client.get("/test") + assert response.status_code == 403 + assert "Permission denied" in response.json()["detail"] + + +# ── Weak JWT secret warning ────────────────────────────────────────────────── + + +# ── User Model Fields ────────────────────────────────────────────────────── + + +def test_user_model_has_needs_setup_default_false(): + """New users default to needs_setup=False.""" + user = User(email="test@example.com", password_hash="hash") + assert user.needs_setup is False + + +def test_user_model_has_token_version_default_zero(): + """New users default to token_version=0.""" + user = User(email="test@example.com", password_hash="hash") + assert user.token_version == 0 + + +def test_user_model_needs_setup_true(): + """Auto-created admin has needs_setup=True.""" + user = User(email="admin@example.com", password_hash="hash", needs_setup=True) + assert user.needs_setup is True + + +def test_sqlite_round_trip_new_fields(): + """needs_setup and token_version survive create → read round-trip. + + Uses the shared persistence engine (same one threads_meta, runs, + run_events, and feedback use). The old separate .deer-flow/users.db + file is gone. + """ + import asyncio + import tempfile + + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + + async def _run() -> None: + from deerflow.persistence.engine import ( + close_engine, + get_session_factory, + init_engine, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + url = f"sqlite+aiosqlite:///{tmpdir}/scratch.db" + await init_engine("sqlite", url=url, sqlite_dir=tmpdir) + try: + repo = SQLiteUserRepository(get_session_factory()) + user = User( + email="setup@test.com", + password_hash="fakehash", + system_role="admin", + needs_setup=True, + token_version=3, + ) + created = await repo.create_user(user) + assert created.needs_setup is True + assert created.token_version == 3 + + fetched = await repo.get_user_by_email("setup@test.com") + assert fetched is not None + assert fetched.needs_setup is True + assert fetched.token_version == 3 + + fetched.needs_setup = False + fetched.token_version = 4 + await repo.update_user(fetched) + refetched = await repo.get_user_by_id(str(fetched.id)) + assert refetched is not None + assert refetched.needs_setup is False + assert refetched.token_version == 4 + finally: + await close_engine() + + asyncio.run(_run()) + + +def test_update_user_raises_when_row_concurrently_deleted(tmp_path): + """Concurrent-delete during update_user must hard-fail, not silently no-op. + + Earlier the SQLite repo returned the input unchanged when the row was + missing, making a phantom success path that admin password reset + callers (`reset_admin`, `_ensure_admin_user`) would happily log as + 'password reset'. The new contract: raise ``UserNotFoundError`` so + a vanished row never looks like a successful update. + """ + import asyncio + import tempfile + + from app.gateway.auth.repositories.base import UserNotFoundError + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + + async def _run() -> None: + from deerflow.persistence.engine import ( + close_engine, + get_session_factory, + init_engine, + ) + from deerflow.persistence.user.model import UserRow + + with tempfile.TemporaryDirectory() as d: + url = f"sqlite+aiosqlite:///{d}/scratch.db" + await init_engine("sqlite", url=url, sqlite_dir=d) + try: + sf = get_session_factory() + repo = SQLiteUserRepository(sf) + user = User( + email="ghost@test.com", + password_hash="fakehash", + system_role="user", + ) + created = await repo.create_user(user) + + # Simulate "row vanished underneath us" by deleting the row + # via the raw ORM session, then attempt to update. + async with sf() as session: + row = await session.get(UserRow, str(created.id)) + assert row is not None + await session.delete(row) + await session.commit() + + created.needs_setup = True + with pytest.raises(UserNotFoundError): + await repo.update_user(created) + finally: + await close_engine() + + asyncio.run(_run()) + + +# ── Token Versioning ─────────────────────────────────────────────────────── + + +def test_jwt_encodes_ver(): + """JWT payload includes ver field.""" + import os + + from app.gateway.auth.errors import TokenError + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4()), token_version=3) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 3 + + +def test_jwt_default_ver_zero(): + """JWT ver defaults to 0.""" + import os + + from app.gateway.auth.errors import TokenError + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4())) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 0 + + +def test_token_version_mismatch_rejects(): + """Token with stale ver is rejected by get_current_user_from_request.""" + import asyncio + import os + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + + user_id = str(uuid4()) + token = create_access_token(user_id, token_version=0) + + mock_user = User(id=user_id, email="test@example.com", password_hash="hash", token_version=1) + + mock_request = MagicMock() + mock_request.cookies = {"access_token": token} + + with patch("app.gateway.deps.get_local_provider") as mock_provider_fn: + mock_provider = MagicMock() + mock_provider.get_user = AsyncMock(return_value=mock_user) + mock_provider_fn.return_value = mock_provider + + from app.gateway.deps import get_current_user_from_request + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + assert "revoked" in str(exc_info.value.detail).lower() + + +# ── change-password extension ────────────────────────────────────────────── + + +def test_change_password_request_accepts_new_email(): + """ChangePasswordRequest model accepts optional new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + + req = ChangePasswordRequest( + current_password="old", + new_password="newpassword", + new_email="new@example.com", + ) + assert req.new_email == "new@example.com" + + +def test_change_password_request_new_email_optional(): + """ChangePasswordRequest model works without new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + + req = ChangePasswordRequest(current_password="old", new_password="newpassword") + assert req.new_email is None + + +def test_login_response_includes_needs_setup(): + """LoginResponse includes needs_setup field.""" + from app.gateway.routers.auth import LoginResponse + + resp = LoginResponse(expires_in=3600, needs_setup=True) + assert resp.needs_setup is True + resp2 = LoginResponse(expires_in=3600) + assert resp2.needs_setup is False + + +# ── Rate Limiting ────────────────────────────────────────────────────────── + + +def test_rate_limiter_allows_under_limit(): + """Requests under the limit are allowed.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts + + _login_attempts.clear() + _check_rate_limit("192.168.1.1") # Should not raise + + +def test_rate_limiter_blocks_after_max_failures(): + """IP is blocked after 5 consecutive failures.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure + + _login_attempts.clear() + ip = "10.0.0.1" + for _ in range(5): + _record_login_failure(ip) + with pytest.raises(HTTPException) as exc_info: + _check_rate_limit(ip) + assert exc_info.value.status_code == 429 + + +def test_rate_limiter_resets_on_success(): + """Successful login clears the failure counter.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure, _record_login_success + + _login_attempts.clear() + ip = "10.0.0.2" + for _ in range(4): + _record_login_failure(ip) + _record_login_success(ip) + _check_rate_limit(ip) # Should not raise + + +# ── Client IP extraction ───────────────────────────────────────────────── + + +def test_get_client_ip_direct_connection_no_proxy(monkeypatch): + """Direct mode (no AUTH_TRUSTED_PROXIES): use TCP peer regardless of X-Real-IP.""" + monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False) + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "203.0.113.42" + req.headers = {} + assert _get_client_ip(req) == "203.0.113.42" + + +def test_get_client_ip_x_real_ip_ignored_when_no_trusted_proxy(monkeypatch): + """X-Real-IP is silently ignored if AUTH_TRUSTED_PROXIES is unset. + + This closes the bypass where any client could rotate X-Real-IP per + request to dodge per-IP rate limits in dev / direct mode. + """ + monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False) + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "127.0.0.1" + req.headers = {"x-real-ip": "203.0.113.42"} + assert _get_client_ip(req) == "127.0.0.1" + + +def test_get_client_ip_x_real_ip_honored_from_trusted_proxy(monkeypatch): + """X-Real-IP is honored when the TCP peer matches AUTH_TRUSTED_PROXIES.""" + monkeypatch.setenv("AUTH_TRUSTED_PROXIES", "10.0.0.0/8") + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "10.5.6.7" # in trusted CIDR + req.headers = {"x-real-ip": "203.0.113.42"} + assert _get_client_ip(req) == "203.0.113.42" + + +def test_get_client_ip_x_real_ip_rejected_from_untrusted_peer(monkeypatch): + """X-Real-IP is rejected when the TCP peer is NOT in the trusted list.""" + monkeypatch.setenv("AUTH_TRUSTED_PROXIES", "10.0.0.0/8") + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "8.8.8.8" # NOT in trusted CIDR + req.headers = {"x-real-ip": "203.0.113.42"} # client trying to spoof + assert _get_client_ip(req) == "8.8.8.8" + + +def test_get_client_ip_xff_never_honored(monkeypatch): + """X-Forwarded-For is never used; only X-Real-IP from a trusted peer.""" + monkeypatch.setenv("AUTH_TRUSTED_PROXIES", "10.0.0.0/8") + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "10.0.0.1" + req.headers = {"x-forwarded-for": "198.51.100.5"} # no x-real-ip + assert _get_client_ip(req) == "10.0.0.1" + + +def test_get_client_ip_invalid_trusted_proxy_entry_skipped(monkeypatch, caplog): + """Garbage entries in AUTH_TRUSTED_PROXIES are warned and skipped.""" + monkeypatch.setenv("AUTH_TRUSTED_PROXIES", "not-an-ip,10.0.0.0/8") + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "10.5.6.7" + req.headers = {"x-real-ip": "203.0.113.42"} + assert _get_client_ip(req) == "203.0.113.42" # valid entry still works + + +def test_get_client_ip_no_client_returns_unknown(monkeypatch): + """No request.client → 'unknown' marker (no crash).""" + monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False) + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client = None + req.headers = {} + assert _get_client_ip(req) == "unknown" + + +# ── Common-password blocklist ──────────────────────────────────────────────── + + +def test_register_rejects_literal_password(): + """Pydantic validator rejects 'password' as a registration password.""" + from pydantic import ValidationError + + from app.gateway.routers.auth import RegisterRequest + + with pytest.raises(ValidationError) as exc: + RegisterRequest(email="x@example.com", password="password") + assert "too common" in str(exc.value) + + +def test_register_rejects_common_password_case_insensitive(): + """Case variants of common passwords are also rejected.""" + from pydantic import ValidationError + + from app.gateway.routers.auth import RegisterRequest + + for variant in ["PASSWORD", "Password1", "qwerty123", "letmein1"]: + with pytest.raises(ValidationError): + RegisterRequest(email="x@example.com", password=variant) + + +def test_register_accepts_strong_password(): + """A non-blocklisted password of length >=8 is accepted.""" + from app.gateway.routers.auth import RegisterRequest + + req = RegisterRequest(email="x@example.com", password="Tr0ub4dor&3-Horse") + assert req.password == "Tr0ub4dor&3-Horse" + + +def test_change_password_rejects_common_password(): + """The same blocklist applies to change-password.""" + from pydantic import ValidationError + + from app.gateway.routers.auth import ChangePasswordRequest + + with pytest.raises(ValidationError): + ChangePasswordRequest(current_password="anything", new_password="iloveyou") + + +def test_password_blocklist_keeps_short_passwords_for_length_check(): + """Short passwords still fail the min_length check (not the blocklist).""" + from pydantic import ValidationError + + from app.gateway.routers.auth import RegisterRequest + + with pytest.raises(ValidationError) as exc: + RegisterRequest(email="x@example.com", password="abc") + # the length check should fire, not the blocklist + assert "at least 8 characters" in str(exc.value) + + +# ── Weak JWT secret warning ────────────────────────────────────────────────── + + +def test_missing_jwt_secret_generates_ephemeral(monkeypatch, caplog): + """get_auth_config() auto-generates an ephemeral secret when AUTH_JWT_SECRET is unset.""" + import logging + + import app.gateway.auth.config as config_module + + config_module._auth_config = None + monkeypatch.delenv("AUTH_JWT_SECRET", raising=False) + + with caplog.at_level(logging.WARNING): + config = config_module.get_auth_config() + + assert config.jwt_secret # non-empty ephemeral secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + + # Cleanup + config_module._auth_config = None diff --git a/backend/tests/test_auth_config.py b/backend/tests/test_auth_config.py new file mode 100644 index 000000000..21b8bd81b --- /dev/null +++ b/backend/tests/test_auth_config.py @@ -0,0 +1,54 @@ +"""Tests for AuthConfig typed configuration.""" + +import os +from unittest.mock import patch + +import pytest + +from app.gateway.auth.config import AuthConfig + + +def test_auth_config_defaults(): + config = AuthConfig(jwt_secret="test-secret-key-123") + assert config.token_expiry_days == 7 + + +def test_auth_config_token_expiry_range(): + AuthConfig(jwt_secret="s", token_expiry_days=1) + AuthConfig(jwt_secret="s", token_expiry_days=30) + with pytest.raises(Exception): + AuthConfig(jwt_secret="s", token_expiry_days=0) + with pytest.raises(Exception): + AuthConfig(jwt_secret="s", token_expiry_days=31) + + +def test_auth_config_from_env(): + env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"} + with patch.dict(os.environ, env, clear=False): + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + config = cfg.get_auth_config() + assert config.jwt_secret == "test-jwt-secret-from-env" + finally: + cfg._auth_config = old + + +def test_auth_config_missing_secret_generates_ephemeral(caplog): + import logging + + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with caplog.at_level(logging.WARNING): + config = cfg.get_auth_config() + assert config.jwt_secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + finally: + cfg._auth_config = old diff --git a/backend/tests/test_auth_errors.py b/backend/tests/test_auth_errors.py new file mode 100644 index 000000000..b3b46c75f --- /dev/null +++ b/backend/tests/test_auth_errors.py @@ -0,0 +1,75 @@ +"""Tests for auth error types and typed decode_token.""" + +from datetime import UTC, datetime, timedelta + +import jwt as pyjwt + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import create_access_token, decode_token + + +def test_auth_error_code_values(): + assert AuthErrorCode.INVALID_CREDENTIALS == "invalid_credentials" + assert AuthErrorCode.TOKEN_EXPIRED == "token_expired" + assert AuthErrorCode.NOT_AUTHENTICATED == "not_authenticated" + + +def test_token_error_values(): + assert TokenError.EXPIRED == "expired" + assert TokenError.INVALID_SIGNATURE == "invalid_signature" + assert TokenError.MALFORMED == "malformed" + + +def test_auth_error_response_serialization(): + err = AuthErrorResponse( + code=AuthErrorCode.TOKEN_EXPIRED, + message="Token has expired", + ) + d = err.model_dump() + assert d == {"code": "token_expired", "message": "Token has expired"} + + +def test_auth_error_response_from_dict(): + d = {"code": "invalid_credentials", "message": "Wrong password"} + err = AuthErrorResponse(**d) + assert err.code == AuthErrorCode.INVALID_CREDENTIALS + + +# ── decode_token typed failure tests ────────────────────────────── + +_TEST_SECRET = "test-secret-for-jwt-decode-token-tests" + + +def _setup_config(): + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + + +def test_decode_token_returns_token_error_on_expired(): + _setup_config() + expired_payload = {"sub": "user-1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired_payload, _TEST_SECRET, algorithm="HS256") + result = decode_token(token) + assert result == TokenError.EXPIRED + + +def test_decode_token_returns_token_error_on_bad_signature(): + _setup_config() + payload = {"sub": "user-1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-secret", algorithm="HS256") + result = decode_token(token) + assert result == TokenError.INVALID_SIGNATURE + + +def test_decode_token_returns_token_error_on_malformed(): + _setup_config() + result = decode_token("not-a-jwt") + assert result == TokenError.MALFORMED + + +def test_decode_token_returns_payload_on_valid(): + _setup_config() + token = create_access_token("user-123") + result = decode_token(token) + assert not isinstance(result, TokenError) + assert result.sub == "user-123" diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py new file mode 100644 index 000000000..398f9cec6 --- /dev/null +++ b/backend/tests/test_auth_middleware.py @@ -0,0 +1,222 @@ +"""Tests for the global AuthMiddleware (fail-closed safety net).""" + +import pytest +from starlette.testclient import TestClient + +from app.gateway.auth_middleware import AuthMiddleware, _is_public + +# ── _is_public unit tests ───────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "path", + [ + "/health", + "/health/", + "/docs", + "/docs/", + "/redoc", + "/openapi.json", + "/api/v1/auth/login/local", + "/api/v1/auth/register", + "/api/v1/auth/logout", + "/api/v1/auth/setup-status", + ], +) +def test_public_paths(path: str): + assert _is_public(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/api/models", + "/api/mcp/config", + "/api/memory", + "/api/skills", + "/api/threads/123", + "/api/threads/123/uploads", + "/api/agents", + "/api/channels", + "/api/runs/stream", + "/api/threads/123/runs", + "/api/v1/auth/me", + "/api/v1/auth/change-password", + ], +) +def test_protected_paths(path: str): + assert _is_public(path) is False + + +# ── Trailing slash / normalization edge cases ───────────────────────────── + + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/auth/login/local/", + "/api/v1/auth/register/", + "/api/v1/auth/logout/", + "/api/v1/auth/setup-status/", + ], +) +def test_public_auth_paths_with_trailing_slash(path: str): + assert _is_public(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/api/models/", + "/api/v1/auth/me/", + "/api/v1/auth/change-password/", + ], +) +def test_protected_paths_with_trailing_slash(path: str): + assert _is_public(path) is False + + +def test_unknown_api_path_is_protected(): + """Fail-closed: any new /api/* path is protected by default.""" + assert _is_public("/api/new-feature") is False + assert _is_public("/api/v2/something") is False + assert _is_public("/api/v1/auth/new-endpoint") is False + + +# ── Middleware integration tests ────────────────────────────────────────── + + +def _make_app(): + """Create a minimal FastAPI app with AuthMiddleware for testing.""" + from fastapi import FastAPI + + app = FastAPI() + app.add_middleware(AuthMiddleware) + + @app.get("/health") + async def health(): + return {"status": "ok"} + + @app.get("/api/v1/auth/me") + async def auth_me(): + return {"id": "1", "email": "test@test.com"} + + @app.get("/api/v1/auth/setup-status") + async def setup_status(): + return {"needs_setup": False} + + @app.get("/api/models") + async def models_get(): + return {"models": []} + + @app.put("/api/mcp/config") + async def mcp_put(): + return {"ok": True} + + @app.delete("/api/threads/abc") + async def thread_delete(): + return {"ok": True} + + @app.patch("/api/threads/abc") + async def thread_patch(): + return {"ok": True} + + @app.post("/api/threads/abc/runs/stream") + async def stream(): + return {"ok": True} + + @app.get("/api/future-endpoint") + async def future(): + return {"ok": True} + + return app + + +@pytest.fixture +def client(): + return TestClient(_make_app()) + + +def test_public_path_no_cookie(client): + res = client.get("/health") + assert res.status_code == 200 + + +def test_public_auth_path_no_cookie(client): + """Public auth endpoints (login/register) pass without cookie.""" + res = client.get("/api/v1/auth/setup-status") + assert res.status_code == 200 + + +def test_protected_auth_path_no_cookie(client): + """/auth/me requires cookie even though it's under /api/v1/auth/.""" + res = client.get("/api/v1/auth/me") + assert res.status_code == 401 + + +def test_protected_path_no_cookie_returns_401(client): + res = client.get("/api/models") + assert res.status_code == 401 + body = res.json() + assert body["detail"]["code"] == "not_authenticated" + + +def test_protected_path_with_junk_cookie_rejected(client): + """Junk cookie → 401. Middleware strictly validates the JWT now + (AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad + tokens through to the route handler.""" + res = client.get("/api/models", cookies={"access_token": "some-token"}) + assert res.status_code == 401 + + +def test_protected_post_no_cookie_returns_401(client): + res = client.post("/api/threads/abc/runs/stream") + assert res.status_code == 401 + + +# ── Method matrix: PUT/DELETE/PATCH also protected ──────────────────────── + + +def test_protected_put_no_cookie(client): + res = client.put("/api/mcp/config") + assert res.status_code == 401 + + +def test_protected_delete_no_cookie(client): + res = client.delete("/api/threads/abc") + assert res.status_code == 401 + + +def test_protected_patch_no_cookie(client): + res = client.patch("/api/threads/abc") + assert res.status_code == 401 + + +def test_put_with_junk_cookie_rejected(client): + """Junk cookie on PUT → 401 (strict JWT validation in middleware).""" + client.cookies.set("access_token", "tok") + res = client.put("/api/mcp/config") + assert res.status_code == 401 + + +def test_delete_with_junk_cookie_rejected(client): + """Junk cookie on DELETE → 401 (strict JWT validation in middleware).""" + client.cookies.set("access_token", "tok") + res = client.delete("/api/threads/abc") + assert res.status_code == 401 + + +# ── Fail-closed: unknown future endpoints ───────────────────────────────── + + +def test_unknown_endpoint_no_cookie_returns_401(client): + """Any new /api/* endpoint is blocked by default without cookie.""" + res = client.get("/api/future-endpoint") + assert res.status_code == 401 + + +def test_unknown_endpoint_with_junk_cookie_rejected(client): + """New endpoints are also protected by strict JWT validation.""" + client.cookies.set("access_token", "tok") + res = client.get("/api/future-endpoint") + assert res.status_code == 401 diff --git a/backend/tests/test_auth_type_system.py b/backend/tests/test_auth_type_system.py new file mode 100644 index 000000000..226d3812c --- /dev/null +++ b/backend/tests/test_auth_type_system.py @@ -0,0 +1,701 @@ +"""Tests for auth type system hardening. + +Covers structured error responses, typed decode_token callers, +CSRF middleware path matching, config-driven cookie security, +and unhappy paths / edge cases for all auth boundaries. +""" + +import os +import secrets +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import jwt as pyjwt +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import decode_token +from app.gateway.csrf_middleware import ( + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CSRFMiddleware, + is_auth_endpoint, + should_check_csrf, +) + +# ── Setup ──────────────────────────────────────────────────────────── + +_TEST_SECRET = "test-secret-for-auth-type-system-tests-min32" + + +@pytest.fixture(autouse=True) +def _persistence_engine(tmp_path): + """Initialise a per-test SQLite engine + reset cached provider singletons. + + The auth tests call real HTTP handlers that go through + ``SQLiteUserRepository`` → ``get_session_factory``. Each test gets + a fresh DB plus a clean ``deps._cached_*`` so the cached provider + does not hold a dangling reference to the previous test's engine. + """ + import asyncio + + from app.gateway import deps + from deerflow.persistence.engine import close_engine, init_engine + + url = f"sqlite+aiosqlite:///{tmp_path}/auth_types.db" + asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))) + deps._cached_local_provider = None + deps._cached_repo = None + try: + yield + finally: + deps._cached_local_provider = None + deps._cached_repo = None + asyncio.run(close_engine()) + + +def _setup_config(): + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + + +# ── CSRF Middleware Path Matching ──────────────────────────────────── + + +class _FakeRequest: + """Minimal request mock for CSRF path matching tests.""" + + def __init__(self, path: str, method: str = "POST"): + self.method = method + + class _URL: + def __init__(self, p): + self.path = p + + self.url = _URL(path) + self.cookies = {} + self.headers = {} + + +def test_csrf_exempts_login_local(): + """login/local (actual route) should be exempt from CSRF.""" + req = _FakeRequest("/api/v1/auth/login/local") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_login_local_trailing_slash(): + """Trailing slash should also be exempt.""" + req = _FakeRequest("/api/v1/auth/login/local/") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_logout(): + req = _FakeRequest("/api/v1/auth/logout") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_register(): + req = _FakeRequest("/api/v1/auth/register") + assert is_auth_endpoint(req) is True + + +def test_csrf_does_not_exempt_old_login_path(): + """Old /api/v1/auth/login (without /local) should NOT be exempt.""" + req = _FakeRequest("/api/v1/auth/login") + assert is_auth_endpoint(req) is False + + +def test_csrf_does_not_exempt_me(): + req = _FakeRequest("/api/v1/auth/me") + assert is_auth_endpoint(req) is False + + +def test_csrf_skips_get_requests(): + req = _FakeRequest("/api/v1/auth/me", method="GET") + assert should_check_csrf(req) is False + + +def test_csrf_checks_post_to_protected(): + req = _FakeRequest("/api/v1/some/endpoint", method="POST") + assert should_check_csrf(req) is True + + +# ── Structured Error Response Format ──────────────────────────────── + + +def test_auth_error_response_has_code_and_message(): + """All auth errors should have structured {code, message} format.""" + err = AuthErrorResponse( + code=AuthErrorCode.INVALID_CREDENTIALS, + message="Wrong password", + ) + d = err.model_dump() + assert "code" in d + assert "message" in d + assert d["code"] == "invalid_credentials" + + +def test_auth_error_response_all_codes_serializable(): + """Every AuthErrorCode should be serializable in AuthErrorResponse.""" + for code in AuthErrorCode: + err = AuthErrorResponse(code=code, message=f"Test {code.value}") + d = err.model_dump() + assert d["code"] == code.value + + +# ── decode_token Caller Pattern ────────────────────────────────────── + + +def test_decode_token_expired_maps_to_token_expired_code(): + """TokenError.EXPIRED should map to AuthErrorCode.TOKEN_EXPIRED.""" + _setup_config() + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + result = decode_token(token) + assert result == TokenError.EXPIRED + + # Verify the mapping pattern used in route handlers + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_EXPIRED + + +def test_decode_token_invalid_sig_maps_to_token_invalid_code(): + """TokenError.INVALID_SIGNATURE should map to AuthErrorCode.TOKEN_INVALID.""" + _setup_config() + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-key", algorithm="HS256") + result = decode_token(token) + assert result == TokenError.INVALID_SIGNATURE + + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_INVALID + + +def test_decode_token_malformed_maps_to_token_invalid_code(): + """TokenError.MALFORMED should map to AuthErrorCode.TOKEN_INVALID.""" + _setup_config() + result = decode_token("garbage") + assert result == TokenError.MALFORMED + + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_INVALID + + +# ── Login Response Format ──────────────────────────────────────────── + + +def test_login_response_model_has_no_access_token(): + """LoginResponse should NOT contain access_token field (RFC-001).""" + from app.gateway.routers.auth import LoginResponse + + resp = LoginResponse(expires_in=604800) + d = resp.model_dump() + assert "access_token" not in d + assert "expires_in" in d + assert d["expires_in"] == 604800 + + +def test_login_response_model_fields(): + """LoginResponse has expires_in and needs_setup.""" + from app.gateway.routers.auth import LoginResponse + + fields = set(LoginResponse.model_fields.keys()) + assert fields == {"expires_in", "needs_setup"} + + +# ── AuthConfig in Route ────────────────────────────────────────────── + + +def test_auth_config_token_expiry_used_in_login_response(): + """LoginResponse.expires_in should come from config.token_expiry_days.""" + from app.gateway.routers.auth import LoginResponse + + expected_seconds = 14 * 24 * 3600 + resp = LoginResponse(expires_in=expected_seconds) + assert resp.expires_in == expected_seconds + + +# ── UserResponse Type Preservation ─────────────────────────────────── + + +def test_user_response_system_role_literal(): + """UserResponse.system_role should only accept 'admin' or 'user'.""" + from app.gateway.auth.models import UserResponse + + # Valid roles + resp = UserResponse(id="1", email="a@b.com", system_role="admin") + assert resp.system_role == "admin" + + resp = UserResponse(id="1", email="a@b.com", system_role="user") + assert resp.system_role == "user" + + +def test_user_response_rejects_invalid_role(): + """UserResponse should reject invalid system_role values.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com", system_role="superadmin") + + +# ══════════════════════════════════════════════════════════════════════ +# UNHAPPY PATHS / EDGE CASES +# ══════════════════════════════════════════════════════════════════════ + + +# ── get_current_user structured 401 responses ──────────────────────── + + +def test_get_current_user_no_cookie_returns_not_authenticated(): + """No cookie → 401 with code=not_authenticated.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + mock_request = type("MockRequest", (), {"cookies": {}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "not_authenticated" + + +def test_get_current_user_expired_token_returns_token_expired(): + """Expired token → 401 with code=token_expired.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + + mock_request = type("MockRequest", (), {"cookies": {"access_token": token}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_expired" + + +def test_get_current_user_invalid_token_returns_token_invalid(): + """Bad signature → 401 with code=token_invalid.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-secret", algorithm="HS256") + + mock_request = type("MockRequest", (), {"cookies": {"access_token": token}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_invalid" + + +def test_get_current_user_malformed_token_returns_token_invalid(): + """Garbage token → 401 with code=token_invalid.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + mock_request = type("MockRequest", (), {"cookies": {"access_token": "not-a-jwt"}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_invalid" + + +# ── decode_token edge cases ────────────────────────────────────────── + + +def test_decode_token_empty_string_returns_malformed(): + _setup_config() + result = decode_token("") + assert result == TokenError.MALFORMED + + +def test_decode_token_whitespace_returns_malformed(): + _setup_config() + result = decode_token(" ") + assert result == TokenError.MALFORMED + + +# ── AuthConfig validation edge cases ───────────────────────────────── + + +def test_auth_config_missing_jwt_secret_raises(): + """AuthConfig requires jwt_secret — no default allowed.""" + with pytest.raises(ValidationError): + AuthConfig() + + +def test_auth_config_token_expiry_zero_raises(): + """token_expiry_days must be >= 1.""" + with pytest.raises(ValidationError): + AuthConfig(jwt_secret="secret", token_expiry_days=0) + + +def test_auth_config_token_expiry_31_raises(): + """token_expiry_days must be <= 30.""" + with pytest.raises(ValidationError): + AuthConfig(jwt_secret="secret", token_expiry_days=31) + + +def test_auth_config_token_expiry_boundary_1_ok(): + config = AuthConfig(jwt_secret="secret", token_expiry_days=1) + assert config.token_expiry_days == 1 + + +def test_auth_config_token_expiry_boundary_30_ok(): + config = AuthConfig(jwt_secret="secret", token_expiry_days=30) + assert config.token_expiry_days == 30 + + +def test_get_auth_config_missing_env_var_generates_ephemeral(caplog): + """get_auth_config() auto-generates ephemeral secret when AUTH_JWT_SECRET is unset.""" + import logging + + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with caplog.at_level(logging.WARNING): + config = cfg.get_auth_config() + assert config.jwt_secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + finally: + cfg._auth_config = old + + +# ── CSRF middleware integration (unhappy paths) ────────────────────── + + +def _make_csrf_app(): + """Create a minimal FastAPI app with CSRFMiddleware for testing.""" + from fastapi import HTTPException as _HTTPException + from fastapi.responses import JSONResponse as _JSONResponse + + app = FastAPI() + + @app.exception_handler(_HTTPException) + async def _http_exc_handler(request, exc): + return _JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + app.add_middleware(CSRFMiddleware) + + @app.post("/api/v1/test/protected") + async def protected(): + return {"ok": True} + + @app.post("/api/v1/auth/login/local") + async def login(): + return {"ok": True} + + @app.get("/api/v1/test/read") + async def read_endpoint(): + return {"ok": True} + + return app + + +def test_csrf_middleware_blocks_post_without_token(): + """POST to protected endpoint without CSRF token → 403 with structured detail.""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/test/protected") + assert resp.status_code == 403 + assert "CSRF" in resp.json()["detail"] + assert "missing" in resp.json()["detail"].lower() + + +def test_csrf_middleware_blocks_post_with_mismatched_token(): + """POST with mismatched CSRF cookie/header → 403 with mismatch detail.""" + client = TestClient(_make_csrf_app()) + client.cookies.set(CSRF_COOKIE_NAME, "token-a") + resp = client.post( + "/api/v1/test/protected", + headers={CSRF_HEADER_NAME: "token-b"}, + ) + assert resp.status_code == 403 + assert "mismatch" in resp.json()["detail"].lower() + + +def test_csrf_middleware_allows_post_with_matching_token(): + """POST with matching CSRF cookie/header → 200.""" + client = TestClient(_make_csrf_app()) + token = secrets.token_urlsafe(64) + client.cookies.set(CSRF_COOKIE_NAME, token) + resp = client.post( + "/api/v1/test/protected", + headers={CSRF_HEADER_NAME: token}, + ) + assert resp.status_code == 200 + + +def test_csrf_middleware_allows_get_without_token(): + """GET requests bypass CSRF check.""" + client = TestClient(_make_csrf_app()) + resp = client.get("/api/v1/test/read") + assert resp.status_code == 200 + + +def test_csrf_middleware_exempts_login_local(): + """POST to login/local is exempt from CSRF (no token yet).""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/auth/login/local") + assert resp.status_code == 200 + + +def test_csrf_middleware_sets_cookie_on_auth_endpoint(): + """Auth endpoints should receive a CSRF cookie in response.""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/auth/login/local") + assert CSRF_COOKIE_NAME in resp.cookies + + +# ── UserResponse edge cases ────────────────────────────────────────── + + +def test_user_response_missing_required_fields(): + """UserResponse with missing fields → ValidationError.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1") # missing email, system_role + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com") # missing system_role + + +def test_user_response_empty_string_role_rejected(): + """Empty string is not a valid role.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com", system_role="") + + +# ══════════════════════════════════════════════════════════════════════ +# HTTP-LEVEL API CONTRACT TESTS +# ══════════════════════════════════════════════════════════════════════ + + +def _make_auth_app(): + """Create FastAPI app with auth routes for contract testing.""" + from app.gateway.app import create_app + + return create_app() + + +def _get_auth_client(): + """Get TestClient for auth API contract tests.""" + return TestClient(_make_auth_app()) + + +def test_api_auth_me_no_cookie_returns_structured_401(): + """/api/v1/auth/me without cookie → 401 with {code: 'not_authenticated'}.""" + _setup_config() + client = _get_auth_client() + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "not_authenticated" + assert "message" in body["detail"] + + +def test_api_auth_me_expired_token_returns_structured_401(): + """/api/v1/auth/me with expired token → 401 with {code: 'token_expired'}.""" + _setup_config() + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + + client = _get_auth_client() + client.cookies.set("access_token", token) + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "token_expired" + + +def test_api_auth_me_invalid_sig_returns_structured_401(): + """/api/v1/auth/me with bad signature → 401 with {code: 'token_invalid'}.""" + _setup_config() + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-key", algorithm="HS256") + + client = _get_auth_client() + client.cookies.set("access_token", token) + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "token_invalid" + + +def test_api_login_bad_credentials_returns_structured_401(): + """Login with wrong password → 401 with {code: 'invalid_credentials'}.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/login/local", + data={"username": "nonexistent@test.com", "password": "wrongpassword"}, + ) + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "invalid_credentials" + + +def test_api_login_success_no_token_in_body(): + """Successful login → response body has expires_in but NOT access_token.""" + _setup_config() + client = _get_auth_client() + # Register first + client.post( + "/api/v1/auth/register", + json={"email": "contract-test@test.com", "password": "securepassword123"}, + ) + # Login + resp = client.post( + "/api/v1/auth/login/local", + data={"username": "contract-test@test.com", "password": "securepassword123"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "expires_in" in body + assert "access_token" not in body + # Token should be in cookie, not body + assert "access_token" in resp.cookies + + +def test_api_register_duplicate_returns_structured_400(): + """Register with duplicate email → 400 with {code: 'email_already_exists'}.""" + _setup_config() + client = _get_auth_client() + email = "dup-contract-test@test.com" + # First register + client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"}) + # Duplicate + resp = client.post("/api/v1/auth/register", json={"email": email, "password": "AnotherStr0ngPwd!"}) + assert resp.status_code == 400 + body = resp.json() + assert body["detail"]["code"] == "email_already_exists" + + +# ── Cookie security: HTTP vs HTTPS ──────────────────────────────────── + + +def _unique_email(prefix: str) -> str: + return f"{prefix}-{secrets.token_hex(4)}@test.com" + + +def _get_set_cookie_headers(resp) -> list[str]: + """Extract all set-cookie header values from a TestClient response.""" + return [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"] + + +def test_register_http_cookie_httponly_true_secure_false(): + """HTTP register → access_token cookie is httponly=True, secure=False, no max_age.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("http-cookie"), "password": "Tr0ub4dor3a"}, + ) + assert resp.status_code == 201 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" not in cookie_header.lower().replace("samesite", "") + + +def test_register_https_cookie_httponly_true_secure_true(): + """HTTPS register (x-forwarded-proto) → access_token cookie is httponly=True, secure=True, has max_age.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("https-cookie"), "password": "Tr0ub4dor3a"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 201 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" in cookie_header.lower() + assert "max-age" in cookie_header.lower() + + +def test_login_https_sets_secure_cookie(): + """HTTPS login → access_token cookie has secure flag.""" + _setup_config() + client = _get_auth_client() + email = _unique_email("https-login") + client.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a"}) + resp = client.post( + "/api/v1/auth/login/local", + data={"username": email, "password": "Tr0ub4dor3a"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 200 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" in cookie_header.lower() + + +def test_csrf_cookie_secure_on_https(): + """HTTPS register → csrf_token cookie has secure flag but NOT httponly.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("csrf-https"), "password": "Tr0ub4dor3a"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 201 + csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h] + assert csrf_cookies, "csrf_token cookie not set on HTTPS register" + csrf_header = csrf_cookies[0] + assert "secure" in csrf_header.lower() + assert "httponly" not in csrf_header.lower() + + +def test_csrf_cookie_not_secure_on_http(): + """HTTP register → csrf_token cookie does NOT have secure flag.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("csrf-http"), "password": "Tr0ub4dor3a"}, + ) + assert resp.status_code == 201 + csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h] + assert csrf_cookies, "csrf_token cookie not set on HTTP register" + csrf_header = csrf_cookies[0] + assert "secure" not in csrf_header.lower().replace("samesite", "") diff --git a/backend/tests/test_ensure_admin.py b/backend/tests/test_ensure_admin.py new file mode 100644 index 000000000..1731455ef --- /dev/null +++ b/backend/tests/test_ensure_admin.py @@ -0,0 +1,319 @@ +"""Tests for _ensure_admin_user() in app.py. + +Covers: first-boot admin creation, auto-reset on needs_setup=True, +no-op on needs_setup=False, migration, and edge cases. +""" + +import asyncio +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-ensure-admin-testing-min-32") + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.models import User + +_JWT_SECRET = "test-secret-key-ensure-admin-testing-min-32" + + +@pytest.fixture(autouse=True) +def _setup_auth_config(): + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + yield + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + + +def _make_app_stub(store=None): + """Minimal app-like object with state.store.""" + app = SimpleNamespace() + app.state = SimpleNamespace() + app.state.store = store + return app + + +def _make_provider(user_count=0, admin_user=None): + p = AsyncMock() + p.count_users = AsyncMock(return_value=user_count) + p.create_user = AsyncMock( + side_effect=lambda **kw: User( + email=kw["email"], + password_hash="hashed", + system_role=kw.get("system_role", "user"), + needs_setup=kw.get("needs_setup", False), + ) + ) + p.get_user_by_email = AsyncMock(return_value=admin_user) + p.update_user = AsyncMock(side_effect=lambda u: u) + return p + + +# ── First boot: no users ───────────────────────────────────────────────── + + +def test_first_boot_creates_admin(): + """count_users==0 → create admin with needs_setup=True.""" + provider = _make_provider(user_count=0) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() + call_kwargs = provider.create_user.call_args[1] + assert call_kwargs["email"] == "admin@deerflow.dev" + assert call_kwargs["system_role"] == "admin" + assert call_kwargs["needs_setup"] is True + assert len(call_kwargs["password"]) > 10 # random password generated + + +def test_first_boot_triggers_migration_if_store_present(): + """First boot with store → _migrate_orphaned_threads called.""" + provider = _make_provider(user_count=0) + store = AsyncMock() + store.asearch = AsyncMock(return_value=[]) + app = _make_app_stub(store=store) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + store.asearch.assert_called_once() + + +def test_first_boot_no_store_skips_migration(): + """First boot without store → no crash, migration skipped.""" + provider = _make_provider(user_count=0) + app = _make_app_stub(store=None) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() + + +# ── Subsequent boot: needs_setup=True → auto-reset ─────────────────────── + + +def test_needs_setup_true_resets_password(): + """Existing admin with needs_setup=True → password reset + token_version bumped.""" + admin = User( + email="admin@deerflow.dev", + password_hash="old-hash", + system_role="admin", + needs_setup=True, + token_version=0, + created_at=datetime.now(UTC) - timedelta(seconds=30), + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + # Password was reset + provider.update_user.assert_called_once() + updated = provider.update_user.call_args[0][0] + assert updated.password_hash == "new-hash" + assert updated.token_version == 1 + + +def test_needs_setup_true_consecutive_resets_increment_version(): + """Two boots with needs_setup=True → token_version increments each time.""" + admin = User( + email="admin@deerflow.dev", + password_hash="hash", + system_role="admin", + needs_setup=True, + token_version=3, + created_at=datetime.now(UTC) - timedelta(seconds=30), + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + updated = provider.update_user.call_args[0][0] + assert updated.token_version == 4 + + +# ── Subsequent boot: needs_setup=False → no-op ────────────────────────── + + +def test_needs_setup_false_no_reset(): + """Admin with needs_setup=False → no password reset, no update.""" + admin = User( + email="admin@deerflow.dev", + password_hash="stable-hash", + system_role="admin", + needs_setup=False, + token_version=2, + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.update_user.assert_not_called() + assert admin.password_hash == "stable-hash" + assert admin.token_version == 2 + + +# ── Edge cases ─────────────────────────────────────────────────────────── + + +def test_no_admin_email_found_no_crash(): + """Users exist but no admin@deerflow.dev → no crash, no reset.""" + provider = _make_provider(user_count=3, admin_user=None) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.update_user.assert_not_called() + provider.create_user.assert_not_called() + + +def test_migration_failure_is_non_fatal(): + """_migrate_orphaned_threads exception is caught and logged.""" + provider = _make_provider(user_count=0) + store = AsyncMock() + store.asearch = AsyncMock(side_effect=RuntimeError("store crashed")) + app = _make_app_stub(store=store) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + # Should not raise + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() + + +# ── Section 5.1-5.6 upgrade path: orphan thread migration ──────────────── + + +def test_migrate_orphaned_threads_stamps_owner_id_on_unowned_rows(): + """First boot finds Store-only legacy threads → stamps admin's id. + + Validates the **TC-UPG-02 upgrade story**: an operator running main + (no auth) accumulates threads in the LangGraph Store namespace + ``("threads",)`` with no ``metadata.owner_id``. After upgrading to + feat/auth-on-2.0-rc, the first ``_ensure_admin_user`` boot should + rewrite each unowned item with the freshly created admin's id. + """ + from app.gateway.app import _migrate_orphaned_threads + + # Three orphan items + one already-owned item that should be left alone. + items = [ + SimpleNamespace(key="t1", value={"metadata": {"title": "old-thread-1"}}), + SimpleNamespace(key="t2", value={"metadata": {"title": "old-thread-2"}}), + SimpleNamespace(key="t3", value={"metadata": {}}), + SimpleNamespace(key="t4", value={"metadata": {"owner_id": "someone-else", "title": "preserved"}}), + ] + store = AsyncMock() + # asearch returns the entire batch on first call, then an empty page + # to terminate _iter_store_items. + store.asearch = AsyncMock(side_effect=[items, []]) + aput_calls: list[tuple[tuple, str, dict]] = [] + + async def _record_aput(namespace, key, value): + aput_calls.append((namespace, key, value)) + + store.aput = AsyncMock(side_effect=_record_aput) + + migrated = asyncio.run(_migrate_orphaned_threads(store, "admin-id-42")) + + # Three orphan rows migrated, one preserved. + assert migrated == 3 + assert len(aput_calls) == 3 + rewritten_keys = {call[1] for call in aput_calls} + assert rewritten_keys == {"t1", "t2", "t3"} + # Each rewrite carries the new owner_id; titles preserved where present. + by_key = {call[1]: call[2] for call in aput_calls} + assert by_key["t1"]["metadata"]["owner_id"] == "admin-id-42" + assert by_key["t1"]["metadata"]["title"] == "old-thread-1" + assert by_key["t3"]["metadata"]["owner_id"] == "admin-id-42" + # The pre-owned item must NOT have been rewritten. + assert "t4" not in rewritten_keys + + +def test_migrate_orphaned_threads_empty_store_is_noop(): + """A store with no threads → migrated == 0, no aput calls.""" + from app.gateway.app import _migrate_orphaned_threads + + store = AsyncMock() + store.asearch = AsyncMock(return_value=[]) + store.aput = AsyncMock() + + migrated = asyncio.run(_migrate_orphaned_threads(store, "admin-id-42")) + + assert migrated == 0 + store.aput.assert_not_called() + + +def test_iter_store_items_walks_multiple_pages(): + """Cursor-style iterator pulls every page until a short page terminates. + + Closes the regression where the old hardcoded ``limit=1000`` could + silently drop orphans on a large pre-upgrade dataset. The migration + code path uses the default ``page_size=500``; this test pins the + iterator with ``page_size=2`` so it stays fast. + """ + from app.gateway.app import _iter_store_items + + page_a = [SimpleNamespace(key=f"t{i}", value={"metadata": {}}) for i in range(2)] + page_b = [SimpleNamespace(key=f"t{i + 2}", value={"metadata": {}}) for i in range(2)] + page_c: list = [] # short page → loop terminates + + store = AsyncMock() + store.asearch = AsyncMock(side_effect=[page_a, page_b, page_c]) + + async def _collect(): + return [item.key async for item in _iter_store_items(store, ("threads",), page_size=2)] + + keys = asyncio.run(_collect()) + assert keys == ["t0", "t1", "t2", "t3"] + # Three asearch calls: full batch, full batch, empty terminator + assert store.asearch.await_count == 3 + + +def test_iter_store_items_terminates_on_short_page(): + """A short page (len < page_size) ends the loop without an extra call.""" + from app.gateway.app import _iter_store_items + + page = [SimpleNamespace(key=f"t{i}", value={}) for i in range(3)] + store = AsyncMock() + store.asearch = AsyncMock(return_value=page) + + async def _collect(): + return [item.key async for item in _iter_store_items(store, ("threads",), page_size=10)] + + keys = asyncio.run(_collect()) + assert keys == ["t0", "t1", "t2"] + # Only one call — no terminator probe needed because len(batch) < page_size + assert store.asearch.await_count == 1 diff --git a/backend/tests/test_langgraph_auth.py b/backend/tests/test_langgraph_auth.py new file mode 100644 index 000000000..41fbd0340 --- /dev/null +++ b/backend/tests/test_langgraph_auth.py @@ -0,0 +1,312 @@ +"""Tests for LangGraph Server auth handler (langgraph_auth.py). + +Validates that the LangGraph auth layer enforces the same rules as Gateway: + cookie → JWT decode → DB lookup → token_version check → owner filter +""" + +import asyncio +import os +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-for-langgraph-auth-testing-min-32") + +from langgraph_sdk import Auth + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.jwt import create_access_token, decode_token +from app.gateway.auth.models import User +from app.gateway.langgraph_auth import add_owner_filter, authenticate + +# ── Helpers ─────────────────────────────────────────────────────────────── + +_JWT_SECRET = "test-secret-key-for-langgraph-auth-testing-min-32" + + +@pytest.fixture(autouse=True) +def _setup_auth_config(): + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + yield + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + + +def _req(cookies=None, method="GET", headers=None): + return SimpleNamespace(cookies=cookies or {}, method=method, headers=headers or {}) + + +def _user(user_id=None, token_version=0): + return User(email="test@example.com", password_hash="fakehash", system_role="user", id=user_id or uuid4(), token_version=token_version) + + +def _mock_provider(user=None): + p = AsyncMock() + p.get_user = AsyncMock(return_value=user) + return p + + +# ── @auth.authenticate ─────────────────────────────────────────────────── + + +def test_no_cookie_raises_401(): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req())) + assert exc.value.status_code == 401 + assert "Not authenticated" in str(exc.value.detail) + + +def test_invalid_jwt_raises_401(): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": "garbage"}))) + assert exc.value.status_code == 401 + assert "Token error" in str(exc.value.detail) + + +def test_expired_jwt_raises_401(): + token = create_access_token("user-1", expires_delta=timedelta(seconds=-1)) + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + + +def test_user_not_found_raises_401(): + token = create_access_token("ghost") + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(None)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + assert "User not found" in str(exc.value.detail) + + +def test_token_version_mismatch_raises_401(): + user = _user(token_version=2) + token = create_access_token(str(user.id), token_version=1) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + assert "revoked" in str(exc.value.detail).lower() + + +def test_valid_token_returns_user_id(): + user = _user(token_version=0) + token = create_access_token(str(user.id), token_version=0) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": token}))) + assert result == str(user.id) + + +def test_valid_token_matching_version(): + user = _user(token_version=5) + token = create_access_token(str(user.id), token_version=5) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": token}))) + assert result == str(user.id) + + +# ── @auth.authenticate edge cases ──────────────────────────────────────── + + +def test_provider_exception_propagates(): + """Provider raises → should not be swallowed silently.""" + token = create_access_token("user-1") + p = AsyncMock() + p.get_user = AsyncMock(side_effect=RuntimeError("DB down")) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=p): + with pytest.raises(RuntimeError, match="DB down"): + asyncio.run(authenticate(_req({"access_token": token}))) + + +def test_jwt_missing_ver_defaults_to_zero(): + """JWT without 'ver' claim → decoded as ver=0, matches user with token_version=0.""" + import jwt as pyjwt + + uid = str(uuid4()) + raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + user = _user(user_id=uid, token_version=0) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": raw}))) + assert result == uid + + +def test_jwt_missing_ver_rejected_when_user_version_nonzero(): + """JWT without 'ver' (defaults 0) vs user with token_version=1 → 401.""" + import jwt as pyjwt + + uid = str(uuid4()) + raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + user = _user(user_id=uid, token_version=1) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": raw}))) + assert exc.value.status_code == 401 + + +def test_wrong_secret_raises_401(): + """Token signed with different secret → 401.""" + import jwt as pyjwt + + raw = pyjwt.encode({"sub": "user-1", "exp": 9999999999, "ver": 0}, "wrong-secret-that-is-long-enough-32chars!", algorithm="HS256") + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": raw}))) + assert exc.value.status_code == 401 + + +# ── @auth.on (owner filter) ────────────────────────────────────────────── + + +class _FakeUser: + """Minimal BaseUser-compatible object without langgraph_api.config dependency.""" + + def __init__(self, identity: str): + self.identity = identity + self.is_authenticated = True + self.display_name = identity + + +def _make_ctx(user_id): + return Auth.types.AuthContext(resource="threads", action="create", user=_FakeUser(user_id), permissions=[]) + + +def test_filter_injects_user_id(): + value = {} + asyncio.run(add_owner_filter(_make_ctx("user-a"), value)) + assert value["metadata"]["owner_id"] == "user-a" + + +def test_filter_preserves_existing_metadata(): + value = {"metadata": {"title": "hello"}} + asyncio.run(add_owner_filter(_make_ctx("user-a"), value)) + assert value["metadata"]["owner_id"] == "user-a" + assert value["metadata"]["title"] == "hello" + + +def test_filter_returns_user_id_dict(): + result = asyncio.run(add_owner_filter(_make_ctx("user-x"), {})) + assert result == {"owner_id": "user-x"} + + +def test_filter_read_write_consistency(): + value = {} + filter_dict = asyncio.run(add_owner_filter(_make_ctx("user-1"), value)) + assert value["metadata"]["owner_id"] == filter_dict["owner_id"] + + +def test_different_users_different_filters(): + f_a = asyncio.run(add_owner_filter(_make_ctx("a"), {})) + f_b = asyncio.run(add_owner_filter(_make_ctx("b"), {})) + assert f_a["owner_id"] != f_b["owner_id"] + + +def test_filter_overrides_conflicting_user_id(): + """If value already has a different user_id in metadata, it gets overwritten.""" + value = {"metadata": {"owner_id": "attacker"}} + asyncio.run(add_owner_filter(_make_ctx("real-owner"), value)) + assert value["metadata"]["owner_id"] == "real-owner" + + +def test_filter_with_empty_metadata(): + """Explicit empty metadata dict is fine.""" + value = {"metadata": {}} + result = asyncio.run(add_owner_filter(_make_ctx("user-z"), value)) + assert value["metadata"]["owner_id"] == "user-z" + assert result == {"owner_id": "user-z"} + + +# ── Gateway parity ─────────────────────────────────────────────────────── + + +def test_shared_jwt_secret(): + token = create_access_token("user-1", token_version=3) + payload = decode_token(token) + from app.gateway.auth.errors import TokenError + + assert not isinstance(payload, TokenError) + assert payload.sub == "user-1" + assert payload.ver == 3 + + +def test_langgraph_json_has_auth_path(): + import json + + config = json.loads((Path(__file__).parent.parent / "langgraph.json").read_text()) + assert "auth" in config + assert "langgraph_auth" in config["auth"]["path"] + + +def test_auth_handler_has_both_layers(): + from app.gateway.langgraph_auth import auth + + assert auth._authenticate_handler is not None + assert len(auth._global_handlers) == 1 + + +# ── CSRF in LangGraph auth ────────────────────────────────────────────── + + +def test_csrf_get_no_check(): + """GET requests skip CSRF — should proceed to JWT validation.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="GET"))) + # Rejected by missing cookie, NOT by CSRF + assert exc.value.status_code == 401 + assert "Not authenticated" in str(exc.value.detail) + + +def test_csrf_post_missing_token(): + """POST without CSRF token → 403.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="POST", cookies={"access_token": "some-jwt"}))) + assert exc.value.status_code == 403 + assert "CSRF token missing" in str(exc.value.detail) + + +def test_csrf_post_mismatched_token(): + """POST with mismatched CSRF tokens → 403.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run( + authenticate( + _req( + method="POST", + cookies={"access_token": "some-jwt", "csrf_token": "real-token"}, + headers={"x-csrf-token": "wrong-token"}, + ) + ) + ) + assert exc.value.status_code == 403 + assert "mismatch" in str(exc.value.detail) + + +def test_csrf_post_matching_token_proceeds_to_jwt(): + """POST with matching CSRF tokens passes CSRF check, then fails on JWT.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run( + authenticate( + _req( + method="POST", + cookies={"access_token": "garbage", "csrf_token": "same-token"}, + headers={"x-csrf-token": "same-token"}, + ) + ) + ) + # Past CSRF, rejected by JWT decode + assert exc.value.status_code == 401 + assert "Token error" in str(exc.value.detail) + + +def test_csrf_put_requires_token(): + """PUT also requires CSRF.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="PUT", cookies={"access_token": "jwt"}))) + assert exc.value.status_code == 403 + + +def test_csrf_delete_requires_token(): + """DELETE also requires CSRF.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="DELETE", cookies={"access_token": "jwt"}))) + assert exc.value.status_code == 403 diff --git a/backend/tests/test_owner_isolation.py b/backend/tests/test_owner_isolation.py new file mode 100644 index 000000000..4943936c7 --- /dev/null +++ b/backend/tests/test_owner_isolation.py @@ -0,0 +1,465 @@ +"""Cross-user isolation tests — non-negotiable safety gate. + +Mirrors TC-API-17..20 from backend/docs/AUTH_TEST_PLAN.md. A failure +here means users can see each other's data; PR must not merge. + +Architecture note +----------------- +These tests bypass the HTTP layer and exercise the storage-layer +owner filter directly by switching the ``user_context`` contextvar +between two users. The safety property under test is: + + After a repository write with owner_id=A, a subsequent read with + owner_id=B must not return the row, and vice versa. + +The HTTP layer is covered by test_auth_middleware.py, which proves +that a request cookie reaches the ``set_current_user`` call. Together +the two suites prove the full chain: + + cookie → middleware → contextvar → repository → isolation + +Every test in this file opts out of the autouse contextvar fixture +(``@pytest.mark.no_auto_user``) so it can set the contextvar to the +specific users it cares about. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from deerflow.runtime.user_context import ( + reset_current_user, + set_current_user, +) + +USER_A = SimpleNamespace(id="user-a", email="a@test.local") +USER_B = SimpleNamespace(id="user-b", email="b@test.local") + + +async def _make_engines(tmp_path): + """Initialize the shared engine against a per-test SQLite DB. + + Returns a cleanup coroutine the caller should await at the end. + """ + from deerflow.persistence.engine import close_engine, init_engine + + url = f"sqlite+aiosqlite:///{tmp_path / 'isolation.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + return close_engine + + +def _as_user(user): + """Context manager-like helper that set/reset the contextvar.""" + + class _Ctx: + def __enter__(self): + self._token = set_current_user(user) + return user + + def __exit__(self, *exc): + reset_current_user(self._token) + + return _Ctx() + + +# ── TC-API-17 — threads_meta isolation ──────────────────────────────────── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_thread_meta_cross_user_isolation(tmp_path): + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.thread_meta import ThreadMetaRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = ThreadMetaRepository(get_session_factory()) + + # User A creates a thread. + with _as_user(USER_A): + await repo.create("t-alpha", display_name="A's private thread") + + # User B creates a thread. + with _as_user(USER_B): + await repo.create("t-beta", display_name="B's private thread") + + # User A must see only A's thread. + with _as_user(USER_A): + a_view = await repo.get("t-alpha") + assert a_view is not None + assert a_view["display_name"] == "A's private thread" + + # CRITICAL: User A must NOT see B's thread. + leaked = await repo.get("t-beta") + assert leaked is None, f"User A leaked User B's thread: {leaked}" + + # Search should only return A's threads. + results = await repo.search() + assert [r["thread_id"] for r in results] == ["t-alpha"] + + # User B must see only B's thread. + with _as_user(USER_B): + b_view = await repo.get("t-beta") + assert b_view is not None + assert b_view["display_name"] == "B's private thread" + + leaked = await repo.get("t-alpha") + assert leaked is None, f"User B leaked User A's thread: {leaked}" + + results = await repo.search() + assert [r["thread_id"] for r in results] == ["t-beta"] + finally: + await cleanup() + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_thread_meta_cross_user_mutation_denied(tmp_path): + """User B cannot update or delete a thread owned by User A.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.thread_meta import ThreadMetaRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = ThreadMetaRepository(get_session_factory()) + + with _as_user(USER_A): + await repo.create("t-alpha", display_name="original") + + # User B tries to rename A's thread — must be a no-op. + with _as_user(USER_B): + await repo.update_display_name("t-alpha", "hacked") + + # Verify the row is unchanged from A's perspective. + with _as_user(USER_A): + row = await repo.get("t-alpha") + assert row is not None + assert row["display_name"] == "original" + + # User B tries to delete A's thread — must be a no-op. + with _as_user(USER_B): + await repo.delete("t-alpha") + + # A's thread still exists. + with _as_user(USER_A): + row = await repo.get("t-alpha") + assert row is not None + finally: + await cleanup() + + +# ── TC-API-18 — runs isolation ──────────────────────────────────────────── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_runs_cross_user_isolation(tmp_path): + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.run import RunRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = RunRepository(get_session_factory()) + + with _as_user(USER_A): + await repo.put("run-a1", thread_id="t-alpha") + await repo.put("run-a2", thread_id="t-alpha") + + with _as_user(USER_B): + await repo.put("run-b1", thread_id="t-beta") + + # User A must see only A's runs. + with _as_user(USER_A): + r = await repo.get("run-a1") + assert r is not None + assert r["run_id"] == "run-a1" + + leaked = await repo.get("run-b1") + assert leaked is None, "User A leaked User B's run" + + a_runs = await repo.list_by_thread("t-alpha") + assert {r["run_id"] for r in a_runs} == {"run-a1", "run-a2"} + + # Listing B's thread from A's perspective: empty + empty = await repo.list_by_thread("t-beta") + assert empty == [] + + # User B must see only B's runs. + with _as_user(USER_B): + leaked = await repo.get("run-a1") + assert leaked is None, "User B leaked User A's run" + + b_runs = await repo.list_by_thread("t-beta") + assert [r["run_id"] for r in b_runs] == ["run-b1"] + finally: + await cleanup() + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_runs_cross_user_delete_denied(tmp_path): + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.run import RunRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = RunRepository(get_session_factory()) + + with _as_user(USER_A): + await repo.put("run-a1", thread_id="t-alpha") + + # User B tries to delete A's run — no-op. + with _as_user(USER_B): + await repo.delete("run-a1") + + # A's run still exists. + with _as_user(USER_A): + row = await repo.get("run-a1") + assert row is not None + finally: + await cleanup() + + +# ── TC-API-19 — run_events isolation (CRITICAL: content leak) ───────────── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_run_events_cross_user_isolation(tmp_path): + """run_events holds raw conversation content — most sensitive leak vector.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.runtime.events.store.db import DbRunEventStore + + cleanup = await _make_engines(tmp_path) + try: + store = DbRunEventStore(get_session_factory()) + + with _as_user(USER_A): + await store.put( + thread_id="t-alpha", + run_id="run-a1", + event_type="human_message", + category="message", + content="User A private question", + ) + await store.put( + thread_id="t-alpha", + run_id="run-a1", + event_type="ai_message", + category="message", + content="User A private answer", + ) + + with _as_user(USER_B): + await store.put( + thread_id="t-beta", + run_id="run-b1", + event_type="human_message", + category="message", + content="User B private question", + ) + + # User A must see only A's events — CRITICAL. + with _as_user(USER_A): + msgs = await store.list_messages("t-alpha") + contents = [m["content"] for m in msgs] + assert "User A private question" in contents + assert "User A private answer" in contents + # CRITICAL: User B's content must not appear. + assert "User B private question" not in contents + + # Attempt to read B's thread by guessing thread_id. + leaked = await store.list_messages("t-beta") + assert leaked == [], f"User A leaked User B's messages: {leaked}" + + leaked_events = await store.list_events("t-beta", "run-b1") + assert leaked_events == [], "User A leaked User B's events" + + # count_messages must also be zero for B's thread from A's view. + count = await store.count_messages("t-beta") + assert count == 0 + + # User B must see only B's events. + with _as_user(USER_B): + msgs = await store.list_messages("t-beta") + contents = [m["content"] for m in msgs] + assert "User B private question" in contents + assert "User A private question" not in contents + assert "User A private answer" not in contents + + count = await store.count_messages("t-alpha") + assert count == 0 + finally: + await cleanup() + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_run_events_cross_user_delete_denied(tmp_path): + """User B cannot delete User A's event stream.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.runtime.events.store.db import DbRunEventStore + + cleanup = await _make_engines(tmp_path) + try: + store = DbRunEventStore(get_session_factory()) + + with _as_user(USER_A): + await store.put( + thread_id="t-alpha", + run_id="run-a1", + event_type="human_message", + category="message", + content="hello", + ) + + # User B tries to wipe A's thread events. + with _as_user(USER_B): + removed = await store.delete_by_thread("t-alpha") + assert removed == 0, f"User B deleted {removed} of User A's events" + + # A's events still exist. + with _as_user(USER_A): + count = await store.count_messages("t-alpha") + assert count == 1 + finally: + await cleanup() + + +# ── TC-API-20 — feedback isolation ──────────────────────────────────────── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_feedback_cross_user_isolation(tmp_path): + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.feedback import FeedbackRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = FeedbackRepository(get_session_factory()) + + # User A submits positive feedback. + with _as_user(USER_A): + a_feedback = await repo.create( + run_id="run-a1", + thread_id="t-alpha", + rating=1, + comment="A liked this", + ) + + # User B submits negative feedback. + with _as_user(USER_B): + b_feedback = await repo.create( + run_id="run-b1", + thread_id="t-beta", + rating=-1, + comment="B disliked this", + ) + + # User A must see only A's feedback. + with _as_user(USER_A): + retrieved = await repo.get(a_feedback["feedback_id"]) + assert retrieved is not None + assert retrieved["comment"] == "A liked this" + + # CRITICAL: cannot read B's feedback by id. + leaked = await repo.get(b_feedback["feedback_id"]) + assert leaked is None, "User A leaked User B's feedback" + + # list_by_run for B's run must be empty. + empty = await repo.list_by_run("t-beta", "run-b1") + assert empty == [] + + # User B must see only B's feedback. + with _as_user(USER_B): + leaked = await repo.get(a_feedback["feedback_id"]) + assert leaked is None, "User B leaked User A's feedback" + + b_list = await repo.list_by_run("t-beta", "run-b1") + assert len(b_list) == 1 + assert b_list[0]["comment"] == "B disliked this" + finally: + await cleanup() + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_feedback_cross_user_delete_denied(tmp_path): + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.feedback import FeedbackRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = FeedbackRepository(get_session_factory()) + + with _as_user(USER_A): + fb = await repo.create(run_id="run-a1", thread_id="t-alpha", rating=1) + + # User B tries to delete A's feedback — must return False (no-op). + with _as_user(USER_B): + deleted = await repo.delete(fb["feedback_id"]) + assert deleted is False, "User B deleted User A's feedback" + + # A's feedback still retrievable. + with _as_user(USER_A): + row = await repo.get(fb["feedback_id"]) + assert row is not None + finally: + await cleanup() + + +# ── Regression: AUTO sentinel without contextvar must raise ─────────────── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_repository_without_context_raises(tmp_path): + """Defense-in-depth: calling repo methods without a user context errors.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.thread_meta import ThreadMetaRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = ThreadMetaRepository(get_session_factory()) + # Contextvar is explicitly unset under @pytest.mark.no_auto_user. + with pytest.raises(RuntimeError, match="no user context is set"): + await repo.get("anything") + finally: + await cleanup() + + +# ── Escape hatch: explicit owner_id=None bypasses filter (for migration) ── + + +@pytest.mark.anyio +@pytest.mark.no_auto_user +async def test_explicit_none_bypasses_filter(tmp_path): + """Migration scripts pass owner_id=None to see all rows regardless of owner.""" + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.thread_meta import ThreadMetaRepository + + cleanup = await _make_engines(tmp_path) + try: + repo = ThreadMetaRepository(get_session_factory()) + + # Seed data as two different users. + with _as_user(USER_A): + await repo.create("t-alpha") + with _as_user(USER_B): + await repo.create("t-beta") + + # Migration-style read: no contextvar, explicit None bypass. + all_rows = await repo.search(owner_id=None) + thread_ids = {r["thread_id"] for r in all_rows} + assert thread_ids == {"t-alpha", "t-beta"} + + # Explicit get with None does not apply the filter either. + row_a = await repo.get("t-alpha", owner_id=None) + assert row_a is not None + row_b = await repo.get("t-beta", owner_id=None) + assert row_b is not None + finally: + await cleanup() diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index fee07dd44..ea9eb41df 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -46,7 +46,9 @@ def test_generate_suggestions_parses_and_limits(monkeypatch): fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='```json\n["Q1", "Q2", "Q3", "Q4"]\n```')) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) - result = asyncio.run(suggestions.generate_suggestions("t1", req)) + # Bypass the require_permission decorator (which needs request + + # thread_meta_repo) — these tests cover the parsing logic. + result = asyncio.run(suggestions.generate_suggestions.__wrapped__("t1", req, request=None)) assert result.suggestions == ["Q1", "Q2", "Q3"] @@ -64,7 +66,9 @@ def test_generate_suggestions_parses_list_block_content(monkeypatch): fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "text", "text": '```json\n["Q1", "Q2"]\n```'}])) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) - result = asyncio.run(suggestions.generate_suggestions("t1", req)) + # Bypass the require_permission decorator (which needs request + + # thread_meta_repo) — these tests cover the parsing logic. + result = asyncio.run(suggestions.generate_suggestions.__wrapped__("t1", req, request=None)) assert result.suggestions == ["Q1", "Q2"] @@ -82,7 +86,9 @@ def test_generate_suggestions_parses_output_text_block_content(monkeypatch): fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "output_text", "text": '```json\n["Q1", "Q2"]\n```'}])) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) - result = asyncio.run(suggestions.generate_suggestions("t1", req)) + # Bypass the require_permission decorator (which needs request + + # thread_meta_repo) — these tests cover the parsing logic. + result = asyncio.run(suggestions.generate_suggestions.__wrapped__("t1", req, request=None)) assert result.suggestions == ["Q1", "Q2"] @@ -97,6 +103,8 @@ def test_generate_suggestions_returns_empty_on_model_error(monkeypatch): fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("boom")) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) - result = asyncio.run(suggestions.generate_suggestions("t1", req)) + # Bypass the require_permission decorator (which needs request + + # thread_meta_repo) — these tests cover the parsing logic. + result = asyncio.run(suggestions.generate_suggestions.__wrapped__("t1", req, request=None)) assert result.suggestions == [] diff --git a/backend/tests/test_thread_meta_repo.py b/backend/tests/test_thread_meta_repo.py index dbb747a26..442cf388a 100644 --- a/backend/tests/test_thread_meta_repo.py +++ b/backend/tests/test_thread_meta_repo.py @@ -104,10 +104,51 @@ class TestThreadMetaRepository: @pytest.mark.anyio async def test_check_access_no_owner_allows_all(self, tmp_path): repo = await _make_repo(tmp_path) - await repo.create("t1") # owner_id=None + # Explicit owner_id=None to bypass the new AUTO default that + # would otherwise pick up the test user from the autouse fixture. + await repo.create("t1", owner_id=None) assert await repo.check_access("t1", "anyone") is True await _cleanup() + @pytest.mark.anyio + async def test_check_access_strict_missing_row_denied(self, tmp_path): + """require_existing=True flips the missing-row case to *denied*. + + Closes the delete-idempotence cross-user gap: after a thread is + deleted, the row is gone, and the permissive default would let any + caller "claim" it as untracked. The strict mode demands a row. + """ + repo = await _make_repo(tmp_path) + assert await repo.check_access("never-existed", "user1", require_existing=True) is False + await _cleanup() + + @pytest.mark.anyio + async def test_check_access_strict_owner_match_allowed(self, tmp_path): + repo = await _make_repo(tmp_path) + await repo.create("t1", owner_id="user1") + assert await repo.check_access("t1", "user1", require_existing=True) is True + await _cleanup() + + @pytest.mark.anyio + async def test_check_access_strict_owner_mismatch_denied(self, tmp_path): + repo = await _make_repo(tmp_path) + await repo.create("t1", owner_id="user1") + assert await repo.check_access("t1", "user2", require_existing=True) is False + await _cleanup() + + @pytest.mark.anyio + async def test_check_access_strict_null_owner_still_allowed(self, tmp_path): + """Even in strict mode, a row with NULL owner_id stays shared. + + The strict flag tightens the *missing row* case, not the *shared + row* case — legacy pre-auth rows that survived a clean migration + without an owner are still everyone's. + """ + repo = await _make_repo(tmp_path) + await repo.create("t1", owner_id=None) + assert await repo.check_access("t1", "anyone", require_existing=True) is True + await _cleanup() + @pytest.mark.anyio async def test_update_status(self, tmp_path): repo = await _make_repo(tmp_path) diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index ad3abe4e9..5864350a1 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -1,7 +1,8 @@ from unittest.mock import patch import pytest -from fastapi import FastAPI, HTTPException +from _router_auth_helpers import make_authed_test_app +from fastapi import HTTPException from fastapi.testclient import TestClient from app.gateway.routers import threads @@ -54,7 +55,7 @@ def test_delete_thread_route_cleans_thread_directory(tmp_path): paths.sandbox_work_dir("thread-route").mkdir(parents=True, exist_ok=True) (paths.sandbox_work_dir("thread-route") / "notes.txt").write_text("hello", encoding="utf-8") - app = FastAPI() + app = make_authed_test_app() app.include_router(threads.router) with patch("app.gateway.routers.threads.get_paths", return_value=paths): @@ -69,7 +70,7 @@ def test_delete_thread_route_cleans_thread_directory(tmp_path): def test_delete_thread_route_rejects_invalid_thread_id(tmp_path): paths = Paths(tmp_path) - app = FastAPI() + app = make_authed_test_app() app.include_router(threads.router) with patch("app.gateway.routers.threads.get_paths", return_value=paths): @@ -82,7 +83,7 @@ def test_delete_thread_route_rejects_invalid_thread_id(tmp_path): def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path): paths = Paths(tmp_path) - app = FastAPI() + app = make_authed_test_app() app.include_router(threads.router) with patch("app.gateway.routers.threads.get_paths", return_value=paths): @@ -107,3 +108,34 @@ def test_delete_thread_data_returns_generic_500_error(tmp_path): assert exc_info.value.detail == "Failed to delete local thread data." assert "/secret/path" not in exc_info.value.detail log_exception.assert_called_once_with("Failed to delete thread data for %s", "thread-cleanup") + + +# ── Server-reserved metadata key stripping ────────────────────────────────── + + +def test_strip_reserved_metadata_removes_owner_id(): + """Client-supplied owner_id is dropped to prevent reflection attacks.""" + out = threads._strip_reserved_metadata({"owner_id": "victim-id", "title": "ok"}) + assert out == {"title": "ok"} + + +def test_strip_reserved_metadata_removes_user_id(): + """user_id is also reserved (defense in depth for any future use).""" + out = threads._strip_reserved_metadata({"user_id": "victim-id", "title": "ok"}) + assert out == {"title": "ok"} + + +def test_strip_reserved_metadata_passes_through_safe_keys(): + """Non-reserved keys are preserved verbatim.""" + md = {"title": "ok", "tags": ["a", "b"], "custom": {"x": 1}} + assert threads._strip_reserved_metadata(md) == md + + +def test_strip_reserved_metadata_empty_input(): + """Empty / None metadata returns same object — no crash.""" + assert threads._strip_reserved_metadata({}) == {} + + +def test_strip_reserved_metadata_strips_both_simultaneously(): + out = threads._strip_reserved_metadata({"owner_id": "x", "user_id": "y", "keep": "me"}) + assert out == {"keep": "me"} diff --git a/backend/tests/test_uploads_router.py b/backend/tests/test_uploads_router.py index 68f0f4d22..490534ec2 100644 --- a/backend/tests/test_uploads_router.py +++ b/backend/tests/test_uploads_router.py @@ -4,6 +4,7 @@ from io import BytesIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch +from _router_auth_helpers import call_unwrapped from fastapi import UploadFile from app.gateway.routers import uploads @@ -24,7 +25,7 @@ def test_upload_files_writes_thread_storage_and_skips_local_sandbox_sync(tmp_pat patch.object(uploads, "get_sandbox_provider", return_value=provider), ): file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads")) - result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file])) assert result.success is True assert len(result.files) == 1 @@ -55,7 +56,7 @@ def test_upload_files_syncs_non_local_sandbox_and_marks_markdown_file(tmp_path): patch.object(uploads, "convert_file_to_markdown", AsyncMock(side_effect=fake_convert)), ): file = UploadFile(filename="report.pdf", file=BytesIO(b"pdf-bytes")) - result = asyncio.run(uploads.upload_files("thread-aio", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-aio", request=MagicMock(), files=[file])) assert result.success is True assert len(result.files) == 1 @@ -92,7 +93,7 @@ def test_upload_files_makes_non_local_files_sandbox_writable(tmp_path): patch.object(uploads, "_make_file_sandbox_writable") as make_writable, ): file = UploadFile(filename="report.pdf", file=BytesIO(b"pdf-bytes")) - result = asyncio.run(uploads.upload_files("thread-aio", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-aio", request=MagicMock(), files=[file])) assert result.success is True make_writable.assert_any_call(thread_uploads_dir / "report.pdf") @@ -115,7 +116,7 @@ def test_upload_files_does_not_adjust_permissions_for_local_sandbox(tmp_path): patch.object(uploads, "_make_file_sandbox_writable") as make_writable, ): file = UploadFile(filename="notes.txt", file=BytesIO(b"hello uploads")) - result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file])) assert result.success is True make_writable.assert_not_called() @@ -166,13 +167,13 @@ def test_upload_files_rejects_dotdot_and_dot_filenames(tmp_path): # These filenames must be rejected outright for bad_name in ["..", "."]: file = UploadFile(filename=bad_name, file=BytesIO(b"data")) - result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file])) assert result.success is True assert result.files == [], f"Expected no files for unsafe filename {bad_name!r}" # Path-traversal prefixes are stripped to the basename and accepted safely file = UploadFile(filename="../etc/passwd", file=BytesIO(b"data")) - result = asyncio.run(uploads.upload_files("thread-local", files=[file])) + result = asyncio.run(call_unwrapped(uploads.upload_files, "thread-local", request=MagicMock(), files=[file])) assert result.success is True assert len(result.files) == 1 assert result.files[0]["filename"] == "passwd" @@ -188,7 +189,7 @@ def test_delete_uploaded_file_removes_generated_markdown_companion(tmp_path): (thread_uploads_dir / "report.md").write_text("converted", encoding="utf-8") with patch.object(uploads, "get_uploads_dir", return_value=thread_uploads_dir): - result = asyncio.run(uploads.delete_uploaded_file("thread-aio", "report.pdf")) + result = asyncio.run(call_unwrapped(uploads.delete_uploaded_file, "thread-aio", "report.pdf", request=MagicMock())) assert result == {"success": True, "message": "Deleted report.pdf"} assert not (thread_uploads_dir / "report.pdf").exists() diff --git a/backend/tests/test_user_context.py b/backend/tests/test_user_context.py new file mode 100644 index 000000000..b7dd1efd0 --- /dev/null +++ b/backend/tests/test_user_context.py @@ -0,0 +1,69 @@ +"""Tests for runtime.user_context — contextvar three-state semantics. + +These tests opt out of the autouse contextvar fixture (added in +commit 6) because they explicitly test the cases where the contextvar +is set or unset. +""" + +from types import SimpleNamespace + +import pytest + +from deerflow.runtime.user_context import ( + CurrentUser, + get_current_user, + require_current_user, + reset_current_user, + set_current_user, +) + + +@pytest.mark.no_auto_user +def test_default_is_none(): + """Before any set, contextvar returns None.""" + assert get_current_user() is None + + +@pytest.mark.no_auto_user +def test_set_and_reset_roundtrip(): + """set_current_user returns a token that reset restores.""" + user = SimpleNamespace(id="user-1") + token = set_current_user(user) + try: + assert get_current_user() is user + finally: + reset_current_user(token) + assert get_current_user() is None + + +@pytest.mark.no_auto_user +def test_require_current_user_raises_when_unset(): + """require_current_user raises RuntimeError if contextvar is unset.""" + assert get_current_user() is None + with pytest.raises(RuntimeError, match="without user context"): + require_current_user() + + +@pytest.mark.no_auto_user +def test_require_current_user_returns_user_when_set(): + """require_current_user returns the user when contextvar is set.""" + user = SimpleNamespace(id="user-2") + token = set_current_user(user) + try: + assert require_current_user() is user + finally: + reset_current_user(token) + + +@pytest.mark.no_auto_user +def test_protocol_accepts_duck_typed(): + """CurrentUser is a runtime_checkable Protocol matching any .id-bearing object.""" + user = SimpleNamespace(id="user-3") + assert isinstance(user, CurrentUser) + + +@pytest.mark.no_auto_user +def test_protocol_rejects_no_id(): + """Objects without .id do not satisfy CurrentUser Protocol.""" + not_a_user = SimpleNamespace(email="no-id@example.com") + assert not isinstance(not_a_user, CurrentUser) diff --git a/backend/uv.lock b/backend/uv.lock index bc7e6b93f..17560d885 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -53,7 +53,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.4" +version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -64,76 +64,76 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, - { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, - { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, - { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, - { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, - { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, - { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, - { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, - { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, - { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, - { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, - { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, - { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, - { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, - { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, - { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, - { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, - { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, - { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, - { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, - { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, - { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, - { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, - { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, - { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, - { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, - { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, - { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, - { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] @@ -379,6 +379,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -724,12 +790,15 @@ name = "deer-flow" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "bcrypt" }, { name = "deerflow-harness" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "httpx" }, { name = "langgraph-sdk" }, { name = "lark-oapi" }, { name = "markdown-to-mrkdwn" }, + { name = "pyjwt" }, { name = "python-multipart" }, { name = "python-telegram-bot" }, { name = "slack-sdk" }, @@ -751,13 +820,16 @@ dev = [ [package.metadata] requires-dist = [ + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "deerflow-harness", editable = "packages/harness" }, { name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" }, + { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "langgraph-sdk", specifier = ">=0.1.51" }, { name = "lark-oapi", specifier = ">=1.4.0" }, { name = "markdown-to-mrkdwn", specifier = ">=0.3.1" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "slack-sdk", specifier = ">=3.33.0" }, @@ -881,6 +953,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -939,6 +1020,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -1739,11 +1833,12 @@ wheels = [ [[package]] name = "langfuse" -version = "4.0.5" +version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "httpx" }, + { name = "openai" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, @@ -1751,9 +1846,9 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/de/b319a127e231e6ac10fad7a75e040b0c961669d9aa1f372f131d48ee4835/langfuse-4.0.5.tar.gz", hash = "sha256:f07fc88526d0699b3696df6ff606bc3c509c86419b5f551dea3d95ed31b4b7f8", size = 273892, upload-time = "2026-04-01T11:05:48.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/92/b4699c9ce5f2e1ab04e7fc1c656cc14a522f10f2c7170d6e427013ce0d37/langfuse-4.0.5-py3-none-any.whl", hash = "sha256:48ef89fec839b40f0f0e68b26c160e7bc0178cf10c8e53932895f4aed428b4df", size = 472730, upload-time = "2026-04-01T11:05:46.948Z" }, + { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, ] [[package]] @@ -3580,7 +3675,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -3588,9 +3683,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -3739,11 +3834,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "80.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] [[package]] diff --git a/backend/uv.toml b/backend/uv.toml new file mode 100644 index 000000000..7884c96f1 --- /dev/null +++ b/backend/uv.toml @@ -0,0 +1 @@ +index-url = "https://pypi.org/simple" diff --git a/docker/nginx/nginx.local.conf b/docker/nginx/nginx.local.conf index e79508831..e5a2bef3d 100644 --- a/docker/nginx/nginx.local.conf +++ b/docker/nginx/nginx.local.conf @@ -218,6 +218,25 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Catch-all for any /api/* prefix not matched by a more specific block above. + # Covers the auth module (/api/v1/auth/login, /me, /change-password, ...), + # plus feedback / runs / token-usage routes that 2.0-rc added without + # updating this nginx config. Longest-prefix matching ensures the explicit + # blocks above (/api/models, /api/threads regex, /api/langgraph/, ...) still + # win for their paths — only truly unmatched /api/* requests land here. + location /api/ { + proxy_pass http://gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Auth endpoints set HttpOnly cookies — make sure nginx doesn't + # strip the Set-Cookie header from upstream responses. + proxy_pass_header Set-Cookie; + } + # All other requests go to frontend location / { proxy_pass http://frontend; @@ -232,6 +251,23 @@ http { proxy_set_header Connection 'upgrade'; proxy_cache_bypass $http_upgrade; + # Disable response buffering for the frontend. Without this, + # nginx tries to spool large upstream responses (e.g. Next.js + # static chunks) into ``proxy_temp_path``, which defaults to + # the system-owned ``/var/lib/nginx/proxy`` and fails with + # ``[crit] open() ... failed (13: Permission denied)`` when + # nginx is launched as a non-root user (every dev machine + # except production root containers). The symptom on the + # client side is ``ERR_INCOMPLETE_CHUNKED_ENCODING`` and + # ``ChunkLoadError`` partway through page hydration. + # + # Streaming the response straight through avoids the + # temp-file path entirely. The frontend already sets its + # own cache headers, so we don't lose anything from + # disabling nginx-side buffering. + proxy_buffering off; + proxy_request_buffering off; + # Timeouts proxy_connect_timeout 600s; proxy_send_timeout 600s; diff --git a/frontend/package.json b/frontend/package.json index 83f69b4e3..8d4ac8526 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,7 +52,6 @@ "@xyflow/react": "^12.10.0", "ai": "^6.0.33", "best-effort-json-parser": "^1.2.1", - "better-auth": "^1.3", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e317aaa64..3279f0665 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -113,9 +113,6 @@ importers: best-effort-json-parser: specifier: ^1.2.1 version: 1.2.1 - better-auth: - specifier: ^1.3 - version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.28(typescript@5.9.3)) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -317,27 +314,6 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.18': - resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} - peerDependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - better-call: 1.1.8 - jose: ^6.1.0 - kysely: ^0.28.5 - nanostores: ^1.0.1 - - '@better-auth/telemetry@1.4.18': - resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} - peerDependencies: - '@better-auth/core': 1.4.18 - - '@better-auth/utils@0.3.0': - resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} - - '@better-fetch/fetch@1.1.21': - resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} - '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -1116,14 +1092,6 @@ packages: cpu: [x64] os: [win32] - '@noble/ciphers@2.1.1': - resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} - engines: {node: '>= 20.19.0'} - - '@noble/hashes@2.0.1': - resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} - engines: {node: '>= 20.19.0'} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2696,76 +2664,6 @@ packages: best-effort-json-parser@1.2.1: resolution: {integrity: sha512-UICSLibQdzS1f+PBsi3u2YE3SsdXcWicHUg3IMvfuaePS2AYnZJdJeKhGv5OM8/mqJwPt79aDrEJ1oa84tELvw==} - better-auth@1.4.18: - resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} - peerDependencies: - '@lynx-js/react': '*' - '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 - '@sveltejs/kit': ^2.0.0 - '@tanstack/react-start': ^1.0.0 - '@tanstack/solid-start': ^1.0.0 - better-sqlite3: ^12.0.0 - drizzle-kit: '>=0.31.4' - drizzle-orm: '>=0.41.0' - mongodb: ^6.0.0 || ^7.0.0 - mysql2: ^3.0.0 - next: ^14.0.0 || ^15.0.0 || ^16.0.0 - pg: ^8.0.0 - prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - solid-js: ^1.0.0 - svelte: ^4.0.0 || ^5.0.0 - vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 - vue: ^3.0.0 - peerDependenciesMeta: - '@lynx-js/react': - optional: true - '@prisma/client': - optional: true - '@sveltejs/kit': - optional: true - '@tanstack/react-start': - optional: true - '@tanstack/solid-start': - optional: true - better-sqlite3: - optional: true - drizzle-kit: - optional: true - drizzle-orm: - optional: true - mongodb: - optional: true - mysql2: - optional: true - next: - optional: true - pg: - optional: true - prisma: - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vitest: - optional: true - vue: - optional: true - - better-call@1.1.8: - resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - better-react-mathjax@2.3.0: resolution: {integrity: sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w==} peerDependencies: @@ -3973,9 +3871,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -4026,10 +3921,6 @@ packages: knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} - kysely@0.28.11: - resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} - engines: {node: '>=20.0.0'} - langium@3.3.1: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} @@ -4458,10 +4349,6 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanostores@1.1.0: - resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} - engines: {node: ^20.0.0 || >=22.0.0} - napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5050,9 +4937,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rou3@0.7.12: - resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} - roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -5105,9 +4989,6 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5802,27 +5683,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - '@standard-schema/spec': 1.1.0 - better-call: 1.1.8(zod@4.3.6) - jose: 6.1.3 - kysely: 0.28.11 - nanostores: 1.1.0 - zod: 4.3.6 - - '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': - dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - - '@better-auth/utils@0.3.0': {} - - '@better-fetch/fetch@1.1.21': {} - '@braintree/sanitize-url@7.1.2': {} '@cfworker/json-schema@4.1.1': {} @@ -6671,10 +6531,6 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.7': optional: true - '@noble/ciphers@2.1.1': {} - - '@noble/hashes@2.0.1': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8242,35 +8098,6 @@ snapshots: best-effort-json-parser@1.2.1: {} - better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.28(typescript@5.9.3)): - dependencies: - '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - '@noble/ciphers': 2.1.1 - '@noble/hashes': 2.0.1 - better-call: 1.1.8(zod@4.3.6) - defu: 6.1.4 - jose: 6.1.3 - kysely: 0.28.11 - nanostores: 1.1.0 - zod: 4.3.6 - optionalDependencies: - next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - vue: 3.5.28(typescript@5.9.3) - - better-call@1.1.8(zod@4.3.6): - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - rou3: 0.7.12 - set-cookie-parser: 2.7.2 - optionalDependencies: - zod: 4.3.6 - better-react-mathjax@2.3.0(react@19.2.4): dependencies: mathjax-full: 3.2.2 @@ -9786,8 +9613,6 @@ snapshots: jiti@2.6.1: {} - jose@6.1.3: {} - js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -9833,8 +9658,6 @@ snapshots: knitwork@1.3.0: {} - kysely@0.28.11: {} - langium@3.3.1: dependencies: chevrotain: 11.0.3 @@ -10529,8 +10352,6 @@ snapshots: nanoid@5.1.6: {} - nanostores@1.1.0: {} - napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -11305,8 +11126,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.0 fsevents: 2.3.3 - rou3@0.7.12: {} - roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -11373,8 +11192,6 @@ snapshots: server-only@0.0.1: {} - set-cookie-parser@2.7.2: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx new file mode 100644 index 000000000..b916def52 --- /dev/null +++ b/frontend/src/app/(auth)/layout.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { type ReactNode } from "react"; + +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever } from "@/core/auth/types"; + +export const dynamic = "force-dynamic"; + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const result = await getServerSideUser(); + + switch (result.tag) { + case "authenticated": + redirect("/workspace"); + case "needs_setup": + // Allow access to setup page + return {children}; + case "unauthenticated": + return {children}; + case "gateway_unavailable": + return ( +
+

+ Service temporarily unavailable. +

+ + Retry + +
+ ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } +} diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 000000000..90ca15238 --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { parseAuthError } from "@/core/auth/types"; + +/** + * Validate next parameter + * Prevent open redirect attacks + * Per RFC-001: Only allow relative paths starting with / + */ +function validateNextParam(next: string | null): string | null { + if (!next) { + return null; + } + + // Need start with / (relative path) + if (!next.startsWith("/")) { + return null; + } + + // Disallow protocol-relative URLs + if ( + next.startsWith("//") || + next.startsWith("http://") || + next.startsWith("https://") + ) { + return null; + } + + // Disallow URLs with different protocols (e.g., javascript:, data:, etc) + if (next.includes(":") && !next.startsWith("/")) { + return null; + } + + // Valid relative path + return next; +} + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { isAuthenticated } = useAuth(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLogin, setIsLogin] = useState(true); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + // Get next parameter for validated redirect + const nextParam = searchParams.get("next"); + const redirectPath = validateNextParam(nextParam) ?? "/workspace"; + + // Redirect if already authenticated (client-side, post-login) + useEffect(() => { + if (isAuthenticated) { + router.push(redirectPath); + } + }, [isAuthenticated, redirectPath, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const endpoint = isLogin + ? "/api/v1/auth/login/local" + : "/api/v1/auth/register"; + const body = isLogin + ? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}` + : JSON.stringify({ email, password }); + + const headers: HeadersInit = isLogin + ? { "Content-Type": "application/x-www-form-urlencoded" } + : { "Content-Type": "application/json" }; + + const res = await fetch(endpoint, { + method: "POST", + headers, + body, + credentials: "include", // Important: include HttpOnly cookie + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + // Both login and register set a cookie — redirect to workspace + router.push(redirectPath); + } catch (_err) { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

DeerFlow

+

+ {isLogin ? "Sign in to your account" : "Create a new account"} +

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + className="mt-1 bg-white text-black" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="•••••••" + required + minLength={isLogin ? 6 : 8} + className="mt-1 bg-white text-black" + /> +
+ + {error &&

{error}

} + + +
+ +
+ +
+ +
+ + ← Back to home + +
+
+
+ ); +} diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx new file mode 100644 index 000000000..e70d1efc6 --- /dev/null +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCsrfHeaders } from "@/core/api/fetcher"; +import { parseAuthError } from "@/core/auth/types"; + +export default function SetupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSetup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + credentials: "include", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + new_email: email || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + router.push("/workspace"); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

DeerFlow

+

+ Complete admin account setup +

+

+ Set your real email and a new password. +

+
+
+ setEmail(e.target.value)} + required + /> + setCurrentPassword(e.target.value)} + required + /> + setNewPassword(e.target.value)} + required + minLength={8} + /> + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts deleted file mode 100644 index cde6018a8..000000000 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { toNextJsHandler } from "better-auth/next-js"; - -import { auth } from "@/server/better-auth"; - -export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 417c933d4..fa19025a0 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,47 +1,58 @@ -"use client"; +import Link from "next/link"; +import { redirect } from "next/navigation"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { Toaster } from "sonner"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever } from "@/core/auth/types"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { CommandPalette } from "@/components/workspace/command-palette"; -import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; -import { getLocalSettings, useLocalSettings } from "@/core/settings"; +import { WorkspaceContent } from "./workspace-content"; -const queryClient = new QueryClient(); +export const dynamic = "force-dynamic"; -export default function WorkspaceLayout({ +export default async function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const [settings, setSettings] = useLocalSettings(); - const [open, setOpen] = useState(false); // SSR default: open (matches server render) - useLayoutEffect(() => { - // Runs synchronously before first paint on the client — no visual flash - setOpen(!getLocalSettings().layout.sidebar_collapsed); - }, []); - useEffect(() => { - setOpen(!settings.layout.sidebar_collapsed); - }, [settings.layout.sidebar_collapsed]); - const handleOpenChange = useCallback( - (open: boolean) => { - setOpen(open); - setSettings("layout", { sidebar_collapsed: !open }); - }, - [setSettings], - ); - return ( - - - - {children} - - - - - ); + const result = await getServerSideUser(); + + switch (result.tag) { + case "authenticated": + return ( + + {children} + + ); + case "needs_setup": + redirect("/setup"); + case "unauthenticated": + redirect("/login"); + case "gateway_unavailable": + return ( +
+

+ Service temporarily unavailable. +

+

+ The backend may be restarting. Please wait a moment and try again. +

+
+ + Retry + + + Logout & Reset + +
+
+ ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } } diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx new file mode 100644 index 000000000..960ad28a2 --- /dev/null +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { Toaster } from "sonner"; + +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { CommandPalette } from "@/components/workspace/command-palette"; +import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; +import { getLocalSettings, useLocalSettings } from "@/core/settings"; + +export function WorkspaceContent({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const [queryClient] = useState(() => new QueryClient()); + const [settings, setSettings] = useLocalSettings(); + const [open, setOpen] = useState(false); // SSR default: open (matches server render) + + useLayoutEffect(() => { + // Runs synchronously before first paint on the client — no visual flash + setOpen(!getLocalSettings().layout.sidebar_collapsed); + }, []); + + useEffect(() => { + setOpen(!settings.layout.sidebar_collapsed); + }, [settings.layout.sidebar_collapsed]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setOpen(open); + setSettings("layout", { sidebar_collapsed: !open }); + }, + [setSettings], + ); + + return ( + + + + {children} + + + + + ); +} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index ddc682744..83bd75952 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -55,6 +55,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; @@ -395,16 +396,19 @@ export function InputBox({ setFollowupsLoading(true); setFollowups([]); - fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: recent, - n: 3, - model_name: context.model_name ?? undefined, - }), - signal: controller.signal, - }) + fetchWithAuth( + `${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: recent, + n: 3, + model_name: context.model_name ?? undefined, + }), + signal: controller.signal, + }, + ) .then(async (res) => { if (!res.ok) { return { suggestions: [] as string[] }; diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx new file mode 100644 index 000000000..6382b8859 --- /dev/null +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { LogOutIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { parseAuthError } from "@/core/auth/types"; + +import { SettingsSection } from "./settings-section"; + +export function AccountSettingsPage() { + const { user, logout } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setMessage(""); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetchWithAuth("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + setMessage("Password changed successfully"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+ Email + {user?.email ?? "—"} +
+
+ Role + + {user?.system_role ?? "—"} + +
+
+
+ + +
+ setCurrentPassword(e.target.value)} + required + /> + setNewPassword(e.target.value)} + required + minLength={8} + /> + setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error &&

{error}

} + {message &&

{message}

} + +
+
+ + + + +
+ ); +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index 3a111564b..5ad8b8966 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -6,6 +6,7 @@ import { BrainIcon, PaletteIcon, SparklesIcon, + UserIcon, WrenchIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -18,6 +19,7 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; +import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; @@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; type SettingsSection = + | "account" | "appearance" | "memory" | "tools" @@ -54,6 +57,11 @@ export function SettingsDialog(props: SettingsDialogProps) { const sections = useMemo( () => [ + { + id: "account", + label: t.settings.sections.account, + icon: UserIcon, + }, { id: "appearance", label: t.settings.sections.appearance, @@ -74,6 +82,7 @@ export function SettingsDialog(props: SettingsDialogProps) { { id: "about", label: t.settings.sections.about, icon: InfoIcon }, ], [ + t.settings.sections.account, t.settings.sections.appearance, t.settings.sections.memory, t.settings.sections.tools, @@ -124,6 +133,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
+ {activeSection === "account" && } {activeSection === "appearance" && } {activeSection === "memory" && } {activeSection === "tools" && } diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index 927b5f20b..984ee8832 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; @@ -28,7 +29,7 @@ export async function getAgent(name: string): Promise { } export async function createAgent(request: CreateAgentRequest): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -44,7 +45,7 @@ export async function updateAgent( name: string, request: UpdateAgentRequest, ): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), @@ -57,7 +58,7 @@ export async function updateAgent( } export async function deleteAgent(name: string): Promise { - const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { + const res = await fetchWithAuth(`${getBackendBaseURL()}/api/agents/${name}`, { method: "DELETE", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts index b86251513..5e71730e7 100644 --- a/frontend/src/core/api/api-client.ts +++ b/frontend/src/core/api/api-client.ts @@ -4,11 +4,37 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; import { getLangGraphBaseURL } from "../config"; +import { isStateChangingMethod, readCsrfCookie } from "./fetcher"; import { sanitizeRunStreamOptions } from "./stream-mode"; +/** + * SDK ``onRequest`` hook that mints the ``X-CSRF-Token`` header from the + * live ``csrf_token`` cookie just before each outbound fetch. + * + * Reading the cookie per-request (rather than baking it into the SDK's + * ``defaultHeaders`` at construction) handles login / logout / password + * change cookie rotation transparently. Both the ``/langgraph-compat/*`` + * SDK path and the direct REST endpoints in ``fetcher.ts:fetchWithAuth`` + * share :func:`readCsrfCookie` and :const:`STATE_CHANGING_METHODS` so + * the contract stays in lockstep. + */ +function injectCsrfHeader(_url: URL, init: RequestInit): RequestInit { + if (!isStateChangingMethod(init.method ?? "GET")) { + return init; + } + const token = readCsrfCookie(); + if (!token) return init; + const headers = new Headers(init.headers); + if (!headers.has("X-CSRF-Token")) { + headers.set("X-CSRF-Token", token); + } + return { ...init, headers }; +} + function createCompatibleClient(isMock?: boolean): LangGraphClient { const client = new LangGraphClient({ apiUrl: getLangGraphBaseURL(isMock), + onRequest: injectCsrfHeader, }); const originalRunStream = client.runs.stream.bind(client.runs); diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts new file mode 100644 index 000000000..d5f30404c --- /dev/null +++ b/frontend/src/core/api/fetcher.ts @@ -0,0 +1,104 @@ +import { buildLoginUrl } from "@/core/auth/types"; + +/** HTTP methods that the gateway's CSRFMiddleware checks. */ +export type StateChangingMethod = "POST" | "PUT" | "DELETE" | "PATCH"; + +export const STATE_CHANGING_METHODS: ReadonlySet = new Set( + ["POST", "PUT", "DELETE", "PATCH"], +); + +/** Mirror of the gateway's ``should_check_csrf`` decision. */ +export function isStateChangingMethod(method: string): boolean { + return (STATE_CHANGING_METHODS as ReadonlySet).has( + method.toUpperCase(), + ); +} + +const CSRF_COOKIE_PREFIX = "csrf_token="; + +/** + * Read the ``csrf_token`` cookie set by the gateway at login. + * + * SSR-safe: returns ``null`` when ``document`` is undefined so the same + * helper can be imported from server components without a guard. + * + * Uses `String.split` instead of a regex to side-step ESLint's + * `prefer-regexp-exec` rule and the cookie value's reliable `; ` + * separator (set by the gateway, not the browser, so format is stable). + */ +export function readCsrfCookie(): string | null { + if (typeof document === "undefined") return null; + for (const pair of document.cookie.split("; ")) { + if (pair.startsWith(CSRF_COOKIE_PREFIX)) { + return decodeURIComponent(pair.slice(CSRF_COOKIE_PREFIX.length)); + } + } + return null; +} + +/** + * Fetch with credentials and automatic CSRF protection. + * + * Two centralized contracts every API call needs: + * + * 1. ``credentials: "include"`` so the HttpOnly access_token cookie + * accompanies cross-origin SSR-routed requests. + * 2. ``X-CSRF-Token`` header on state-changing methods (POST/PUT/ + * DELETE/PATCH), echoed from the ``csrf_token`` cookie. The gateway's + * CSRFMiddleware enforces Double Submit Cookie comparison and returns + * 403 if the header is missing — silently breaking every call site + * that uses raw ``fetch()`` instead of this wrapper. + * + * Auto-redirects to ``/login`` on 401. Caller-supplied headers are + * preserved; the helper only ADDS the CSRF header when it isn't already + * present, so explicit overrides win. + */ +export async function fetchWithAuth( + input: RequestInfo | string, + init?: RequestInit, +): Promise { + const url = typeof input === "string" ? input : input.url; + + // Inject CSRF for state-changing methods. GET/HEAD/OPTIONS/TRACE skip + // it to mirror the gateway's ``should_check_csrf`` logic exactly. + let headers = init?.headers; + if (isStateChangingMethod(init?.method ?? "GET")) { + const token = readCsrfCookie(); + if (token) { + // Fresh Headers instance so we don't mutate caller-supplied objects. + const merged = new Headers(headers); + if (!merged.has("X-CSRF-Token")) { + merged.set("X-CSRF-Token", token); + } + headers = merged; + } + } + + const res = await fetch(url, { + ...init, + headers, + credentials: "include", + }); + + if (res.status === 401) { + window.location.href = buildLoginUrl(window.location.pathname); + throw new Error("Unauthorized"); + } + + return res; +} + +/** + * Build headers for CSRF-protected requests. + * + * **Prefer :func:`fetchWithAuth`** for new code — it injects the header + * automatically on state-changing methods. This helper exists for legacy + * call sites that need to compose headers manually (e.g. inside + * `next/server` route handlers that build their own ``Headers`` object). + * + * Per RFC-001: Double Submit Cookie pattern. + */ +export function getCsrfHeaders(): HeadersInit { + const token = readCsrfCookie(); + return token ? { "X-CSRF-Token": token } : {}; +} diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx new file mode 100644 index 000000000..652cc49b8 --- /dev/null +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + type ReactNode, +} from "react"; + +import { type User, buildLoginUrl } from "./types"; + +// Re-export for consumers +export type { User }; + +/** + * Authentication context provided to consuming components + */ +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + logout: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; + initialUser: User | null; +} + +/** + * AuthProvider - Unified authentication context for the application + * + * Per RFC-001: + * - Only holds display information (user), never JWT or tokens + * - initialUser comes from server-side guard, avoiding client flicker + * - Provides logout and refresh capabilities + */ +export function AuthProvider({ children, initialUser }: AuthProviderProps) { + const [user, setUser] = useState(initialUser); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + + const isAuthenticated = user !== null; + + /** + * Fetch current user from FastAPI + * Used when initialUser might be stale (e.g., after tab was inactive) + */ + const refreshUser = useCallback(async () => { + try { + setIsLoading(true); + const res = await fetch("/api/v1/auth/me", { + credentials: "include", + }); + + if (res.ok) { + const data = await res.json(); + setUser(data); + } else if (res.status === 401) { + // Session expired or invalid + setUser(null); + // Redirect to login if on a protected route + if (pathname?.startsWith("/workspace")) { + router.push(buildLoginUrl(pathname)); + } + } + } catch (err) { + console.error("Failed to refresh user:", err); + setUser(null); + } finally { + setIsLoading(false); + } + }, [pathname, router]); + + /** + * Logout - call FastAPI logout endpoint and clear local state + * Per RFC-001: Immediately clear local state, don't wait for server confirmation + */ + const logout = useCallback(async () => { + // Immediately clear local state to prevent UI flicker + setUser(null); + + try { + await fetch("/api/v1/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (err) { + console.error("Logout request failed:", err); + // Still redirect even if logout request fails + } + + // Redirect to home page + router.push("/"); + }, [router]); + + /** + * Handle visibility change - refresh user when tab becomes visible again. + * Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches. + */ + const lastCheckRef = React.useRef(0); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible" || user === null) return; + const now = Date.now(); + if (now - lastCheckRef.current < 60_000) return; + lastCheckRef.current = now; + void refreshUser(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [user, refreshUser]); + + const value: AuthContextType = { + user, + isAuthenticated, + isLoading, + logout, + refreshUser, + }; + + return {children}; +} + +/** + * Hook to access authentication context + * Throws if used outside AuthProvider - this is intentional for proper usage + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +/** + * Hook to require authentication - redirects to login if not authenticated + * Useful for client-side checks in addition to server-side guards + */ +export function useRequireAuth(): AuthContextType { + const auth = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // Only redirect if we're sure user is not authenticated (not just loading) + if (!auth.isLoading && !auth.isAuthenticated) { + router.push(buildLoginUrl(pathname || "/workspace")); + } + }, [auth.isAuthenticated, auth.isLoading, router, pathname]); + + return auth; +} diff --git a/frontend/src/core/auth/gateway-config.ts b/frontend/src/core/auth/gateway-config.ts new file mode 100644 index 000000000..61c6ae850 --- /dev/null +++ b/frontend/src/core/auth/gateway-config.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const gatewayConfigSchema = z.object({ + internalGatewayUrl: z.string().url(), + trustedOrigins: z.array(z.string()).min(1), +}); + +export type GatewayConfig = z.infer; + +let _cached: GatewayConfig | null = null; + +export function getGatewayConfig(): GatewayConfig { + if (_cached) return _cached; + + const isDev = process.env.NODE_ENV === "development"; + + const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim(); + const internalGatewayUrl = + rawUrl?.replace(/\/+$/, "") ?? + (isDev ? "http://localhost:8001" : undefined); + + const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim(); + const trustedOrigins = rawOrigins + ? rawOrigins + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : isDev + ? ["http://localhost:3000"] + : undefined; + + _cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins }); + return _cached; +} diff --git a/frontend/src/core/auth/proxy-policy.ts b/frontend/src/core/auth/proxy-policy.ts new file mode 100644 index 000000000..9e6f1f424 --- /dev/null +++ b/frontend/src/core/auth/proxy-policy.ts @@ -0,0 +1,55 @@ +export interface ProxyPolicy { + /** Allowed upstream path prefixes */ + readonly allowedPaths: readonly string[]; + /** Request headers to strip before forwarding */ + readonly strippedRequestHeaders: ReadonlySet; + /** Response headers to strip before returning */ + readonly strippedResponseHeaders: ReadonlySet; + /** Credential mode: which cookie to forward */ + readonly credential: { readonly type: "cookie"; readonly name: string }; + /** Timeout in ms */ + readonly timeoutMs: number; + /** CSRF: required for non-GET/HEAD */ + readonly csrf: boolean; +} + +export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = { + allowedPaths: [ + "threads", + "runs", + "assistants", + "store", + "models", + "mcp", + "skills", + "memory", + ], + strippedRequestHeaders: new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "authorization", + "x-api-key", + "origin", + "referer", + "proxy-authorization", + "proxy-authenticate", + ]), + strippedResponseHeaders: new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "content-length", + "set-cookie", + ]), + credential: { type: "cookie", name: "access_token" }, + timeoutMs: 120_000, + csrf: true, +}; diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts new file mode 100644 index 000000000..4229143aa --- /dev/null +++ b/frontend/src/core/auth/server.ts @@ -0,0 +1,57 @@ +import { cookies } from "next/headers"; + +import { getGatewayConfig } from "./gateway-config"; +import { type AuthResult, userSchema } from "./types"; + +const SSR_AUTH_TIMEOUT_MS = 5_000; + +/** + * Fetch the authenticated user from the gateway using the request's cookies. + * Returns a tagged AuthResult — callers use exhaustive switch, no try/catch. + */ +export async function getServerSideUser(): Promise { + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get("access_token"); + + let internalGatewayUrl: string; + try { + internalGatewayUrl = getGatewayConfig().internalGatewayUrl; + } catch (err) { + return { tag: "config_error", message: String(err) }; + } + + if (!sessionCookie) return { tag: "unauthenticated" }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS); + + try { + const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, { + headers: { Cookie: `access_token=${sessionCookie.value}` }, + cache: "no-store", + signal: controller.signal, + }); + clearTimeout(timeout); // Clear immediately — covers all response branches + + if (res.ok) { + const parsed = userSchema.safeParse(await res.json()); + if (!parsed.success) { + console.error("[SSR auth] Malformed /auth/me response:", parsed.error); + return { tag: "gateway_unavailable" }; + } + if (parsed.data.needs_setup) { + return { tag: "needs_setup", user: parsed.data }; + } + return { tag: "authenticated", user: parsed.data }; + } + if (res.status === 401 || res.status === 403) { + return { tag: "unauthenticated" }; + } + console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`); + return { tag: "gateway_unavailable" }; + } catch (err) { + clearTimeout(timeout); + console.error("[SSR auth] Failed to reach gateway:", err); + return { tag: "gateway_unavailable" }; + } +} diff --git a/frontend/src/core/auth/types.ts b/frontend/src/core/auth/types.ts new file mode 100644 index 000000000..4cf42583e --- /dev/null +++ b/frontend/src/core/auth/types.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +// ── User schema (single source of truth) ────────────────────────── + +export const userSchema = z.object({ + id: z.string(), + email: z.string().email(), + system_role: z.enum(["admin", "user"]), + needs_setup: z.boolean().optional().default(false), +}); + +export type User = z.infer; + +// ── SSR auth result (tagged union) ──────────────────────────────── + +export type AuthResult = + | { tag: "authenticated"; user: User } + | { tag: "needs_setup"; user: User } + | { tag: "unauthenticated" } + | { tag: "gateway_unavailable" } + | { tag: "config_error"; message: string }; + +export function assertNever(x: never): never { + throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`); +} + +export function buildLoginUrl(returnPath: string): string { + return `/login?next=${encodeURIComponent(returnPath)}`; +} + +// ── Backend error response parsing ──────────────────────────────── + +const AUTH_ERROR_CODES = [ + "invalid_credentials", + "token_expired", + "token_invalid", + "user_not_found", + "email_already_exists", + "provider_not_found", + "not_authenticated", +] as const; + +export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number]; + +export interface AuthErrorResponse { + code: AuthErrorCode; + message: string; +} + +const authErrorSchema = z.object({ + code: z.enum(AUTH_ERROR_CODES), + message: z.string(), +}); + +export function parseAuthError(data: unknown): AuthErrorResponse { + // Try top-level {code, message} first + const parsed = authErrorSchema.safeParse(data); + if (parsed.success) return parsed.data; + + // Unwrap FastAPI's {detail: {code, message}} envelope + if (typeof data === "object" && data !== null && "detail" in data) { + const detail = (data as Record).detail; + const nested = authErrorSchema.safeParse(detail); + if (nested.success) return nested.data; + // Legacy string-detail responses + if (typeof detail === "string") { + return { code: "invalid_credentials", message: detail }; + } + } + + return { code: "invalid_credentials", message: "Authentication failed" }; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index f031098fe..b357d25a4 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -236,6 +236,7 @@ export const enUS: Translations = { reportIssue: "Report a issue", contactUs: "Contact us", about: "About DeerFlow", + logout: "Log out", }, // Conversation @@ -320,6 +321,7 @@ export const enUS: Translations = { title: "Settings", description: "Adjust how DeerFlow looks and behaves for you.", sections: { + account: "Account", appearance: "Appearance", memory: "Memory", tools: "Tools", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 37a08c9f9..38f3c62a7 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -168,6 +168,7 @@ export interface Translations { reportIssue: string; contactUs: string; about: string; + logout: string; }; // Conversation @@ -250,6 +251,7 @@ export interface Translations { title: string; description: string; sections: { + account: string; appearance: string; memory: string; tools: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index a0db84381..7beab747d 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -224,6 +224,7 @@ export const zhCN: Translations = { reportIssue: "报告问题", contactUs: "联系我们", about: "关于 DeerFlow", + logout: "退出登录", }, // Conversation @@ -305,6 +306,7 @@ export const zhCN: Translations = { title: "设置", description: "根据你的偏好调整 DeerFlow 的界面和行为。", sections: { + account: "账号", appearance: "外观", memory: "记忆", tools: "工具", diff --git a/frontend/src/core/mcp/api.ts b/frontend/src/core/mcp/api.ts index 003303238..61e681d34 100644 --- a/frontend/src/core/mcp/api.ts +++ b/frontend/src/core/mcp/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { MCPConfig } from "./types"; @@ -8,12 +9,15 @@ export async function loadMCPConfig() { } export async function updateMCPConfig(config: MCPConfig) { - const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, { - method: "PUT", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/mcp/config`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(config), }, - body: JSON.stringify(config), - }); + ); return response.json(); } diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index 5fcf8e4c0..073328808 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import type { @@ -85,14 +86,14 @@ export async function loadMemory(): Promise { } export async function clearMemory(): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory`, { + const response = await fetchWithAuth(`${getBackendBaseURL()}/api/memory`, { method: "DELETE", }); return readMemoryResponse(response, "Failed to clear memory"); } export async function deleteMemoryFact(factId: string): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "DELETE", @@ -107,26 +108,32 @@ export async function exportMemory(): Promise { } export async function importMemory(memory: UserMemory): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/import`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(memory), }, - body: JSON.stringify(memory), - }); + ); return readMemoryResponse(response, "Failed to import memory"); } export async function createMemoryFact( input: MemoryFactInput, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/memory/facts`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }); + ); return readMemoryResponse(response, "Failed to create memory fact"); } @@ -134,7 +141,7 @@ export async function updateMemoryFact( factId: string, input: MemoryFactPatchInput, ): Promise { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "PATCH", diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index b6a358f03..03a713d92 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -1,3 +1,4 @@ +import { fetchWithAuth } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Skill } from "./type"; @@ -9,7 +10,7 @@ export async function loadSkills() { } export async function enableSkill(skillName: string, enabled: boolean) { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/skills/${skillName}`, { method: "PUT", @@ -38,13 +39,16 @@ export interface InstallSkillResponse { export async function installSkill( request: InstallSkillRequest, ): Promise { - const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await fetchWithAuth( + `${getBackendBaseURL()}/api/skills/install`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), }, - body: JSON.stringify(request), - }); + ); if (!response.ok) { // Handle HTTP error responses (4xx, 5xx) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 395f15604..5b48df1e2 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -8,6 +8,7 @@ import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { getAPIClient } from "../api"; +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; import type { FileInMessage } from "../messages/utils"; @@ -580,7 +581,7 @@ export function useDeleteThread() { mutationFn: async ({ threadId }: { threadId: string }) => { await apiClient.threads.delete(threadId); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 23d463c2d..a00a259cb 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -2,6 +2,7 @@ * API functions for file uploads */ +import { fetchWithAuth } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; export interface UploadedFileInfo { @@ -50,7 +51,7 @@ export async function uploadFiles( formData.append("files", file); }); - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads`, { method: "POST", @@ -91,7 +92,7 @@ export async function deleteUploadedFile( threadId: string, filename: string, ): Promise<{ success: boolean; message: string }> { - const response = await fetch( + const response = await fetchWithAuth( `${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`, { method: "DELETE", diff --git a/frontend/src/env.js b/frontend/src/env.js index f00fa7a6c..ea90cac5d 100644 --- a/frontend/src/env.js +++ b/frontend/src/env.js @@ -7,12 +7,6 @@ export const env = createEnv({ * isn't built with invalid env vars. */ server: { - BETTER_AUTH_SECRET: - process.env.NODE_ENV === "production" - ? z.string() - : z.string().optional(), - BETTER_AUTH_GITHUB_CLIENT_ID: z.string().optional(), - BETTER_AUTH_GITHUB_CLIENT_SECRET: z.string().optional(), GITHUB_OAUTH_TOKEN: z.string().optional(), NODE_ENV: z .enum(["development", "test", "production"]) @@ -35,10 +29,6 @@ export const env = createEnv({ * middlewares) or client-side so we need to destruct manually. */ runtimeEnv: { - BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, - BETTER_AUTH_GITHUB_CLIENT_ID: process.env.BETTER_AUTH_GITHUB_CLIENT_ID, - BETTER_AUTH_GITHUB_CLIENT_SECRET: - process.env.BETTER_AUTH_GITHUB_CLIENT_SECRET, NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_BACKEND_BASE_URL: process.env.NEXT_PUBLIC_BACKEND_BASE_URL, diff --git a/frontend/src/server/better-auth/client.ts b/frontend/src/server/better-auth/client.ts deleted file mode 100644 index 493f84993..000000000 --- a/frontend/src/server/better-auth/client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthClient } from "better-auth/react"; - -export const authClient = createAuthClient(); - -export type Session = typeof authClient.$Infer.Session; diff --git a/frontend/src/server/better-auth/config.ts b/frontend/src/server/better-auth/config.ts deleted file mode 100644 index abf50faca..000000000 --- a/frontend/src/server/better-auth/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { betterAuth } from "better-auth"; - -export const auth = betterAuth({ - emailAndPassword: { - enabled: true, - }, -}); - -export type Session = typeof auth.$Infer.Session; diff --git a/frontend/src/server/better-auth/index.ts b/frontend/src/server/better-auth/index.ts deleted file mode 100644 index d705e873e..000000000 --- a/frontend/src/server/better-auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth } from "./config"; diff --git a/frontend/src/server/better-auth/server.ts b/frontend/src/server/better-auth/server.ts deleted file mode 100644 index 064cd349c..000000000 --- a/frontend/src/server/better-auth/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { headers } from "next/headers"; -import { cache } from "react"; - -import { auth } from "."; - -export const getSession = cache(async () => - auth.api.getSession({ headers: await headers() }), -);