From 22bc075c5813a760bb27c473be1ca6076bfaef63 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 21:01:51 +0200 Subject: [PATCH] Stamp 2.58.0 native JWT middleware and useAuthApi. Same skip paths, Bearer/query token, Clerk RS256 leftover, and local HS256 contract. routers/auth.py, user records, and AUTH_JWT_SECRET are untouched. --- periscope/src/backend/main.py | 2 +- periscope/src/backend/middleware/auth.py | 112 ++++++++++++++++++ periscope/src/frontend/content/changelog.md | 8 ++ periscope/src/frontend/package-lock.json | 4 +- periscope/src/frontend/package.json | 2 +- .../src/frontend/src/hooks/use-auth-api.ts | 18 +++ tests/test_periscope_auth_rewrite.py | 100 ++++++++++++++++ .../test_periscope_csp_middleware_rewrite.py | 3 +- ...test_periscope_frontend_content_overlay.py | 4 +- ...periscope_leftover_package_requirements.py | 2 +- 10 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 periscope/src/backend/middleware/auth.py create mode 100644 periscope/src/frontend/src/hooks/use-auth-api.ts create mode 100644 tests/test_periscope_auth_rewrite.py diff --git a/periscope/src/backend/main.py b/periscope/src/backend/main.py index b67dda7..d6f0886 100644 --- a/periscope/src/backend/main.py +++ b/periscope/src/backend/main.py @@ -110,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware): - """Attach ``request.state.user_id``. JWT verification stays in leftover middleware.""" + """Attach ``request.state.user_id`` from JWT verification.""" async def dispatch(self, request: Request, call_next): if request.method == "OPTIONS": diff --git a/periscope/src/backend/middleware/auth.py b/periscope/src/backend/middleware/auth.py new file mode 100644 index 0000000..6e275bd --- /dev/null +++ b/periscope/src/backend/middleware/auth.py @@ -0,0 +1,112 @@ +"""Request JWT checks for FastAPI (local HS256 or leftover Clerk JWKS).""" + +from __future__ import annotations + +from typing import Any + +import jwt +from fastapi import Request + +from backend.config import settings + +_jwks_client: jwt.PyJWKClient | None = None + +# Keep these unauthenticated. Changing this set would alter the login contract. +_SKIP_PATHS = { + "/docs", + "/openapi.json", + "/redoc", + "/health", + "/api/billing/webhook", + "/api/auth/mode", + "/api/auth/register", + "/api/auth/login", + "/api/contact", +} + + +def _get_jwks_client() -> jwt.PyJWKClient: + global _jwks_client + if _jwks_client is None: + jwks_url = settings.clerk_jwks_url + if not jwks_url: + raise RuntimeError( + "CLERK_JWKS_URL must be set for Clerk authentication. " + "Find it in your Clerk dashboard under API Keys." + ) + _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) + return _jwks_client + + +def _bearer_or_query_token(request: Request) -> str | None: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:] + return request.query_params.get("token") + + +async def verify_clerk_token(request: Request) -> str | None: + """RS256 Clerk JWT → user id, or None when the token is missing/invalid.""" + if request.url.path in _SKIP_PATHS: + return "anonymous" + + token = _bearer_or_query_token(request) + if not token: + return None + + try: + client = _get_jwks_client() + signing_key = client.get_signing_key_from_jwt(token) + payload: dict[str, Any] = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + options={ + "verify_exp": True, + "verify_aud": False, + "verify_iss": True, + }, + issuer=( + settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") + if settings.clerk_jwks_url + else None + ), + leeway=10, + ) + user_id = payload.get("sub") + if not user_id: + return None + return user_id + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + except Exception: + return None + + +async def verify_local_token(request: Request) -> str | None: + """HS256 Periscope JWT → user id, or None when the token is missing/invalid.""" + if request.url.path in _SKIP_PATHS: + return "anonymous" + + token = _bearer_or_query_token(request) + if not token: + return None + + from backend.services.local_jwt import decode_token + + payload = decode_token(token) + if not payload: + return None + user_id = payload.get("sub") + return str(user_id) if user_id else None + + +async def verify_request_user(request: Request) -> str | None: + """Clerk when configured, else local JWT when AUTH_JWT_SECRET is set.""" + if settings.use_clerk: + return await verify_clerk_token(request) + if settings.use_local_auth: + return await verify_local_token(request) + return None diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 7649608..78dcfd5 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Periscope. +## 2.58.0 — 2026-09-20 — Native JWT middleware and useAuthApi + +`backend/middleware/auth.py` and `frontend/src/hooks/use-auth-api.ts` are original Periscope code. Skip paths, Bearer/query token, Clerk RS256 leftover, and local HS256 (`local_jwt.decode_token`) stay the same HTTP contract. `routers/auth.py`, `data/auth/users`, and `AUTH_JWT_SECRET` are unchanged. Pad≠Via unchanged. + +- [New] Request JWT verification rewrite under `periscope/src`. +- [New] `useAuthApi` rewrite wires `setTokenGetter` from the optional-auth seam. +- [Changed] App stamp 2.58.0; login identifiers and password hashes are not rotated. + ## 2.57.0 — 2026-09-20 — Native package.json, requirements, and unused Docker copies Frontend `package.json` (`periscope-web`) and lockfile, `components.json`, and backend `requirements.txt` overlay from `periscope/src`. Docker installs those files from src. Unused PinScope public blobs (Faradworks logo, power-tree.gif, create-next-app SVGs) are dockerignored. EDA logos and KiCad tutorial examples live under src `public/`. Landing `report.png` / `datasheet.gif` / `derating.png` stay in `dependency/` until native UI captures exist. Auth/JWT still inherited. Pad≠Via unchanged. diff --git a/periscope/src/frontend/package-lock.json b/periscope/src/frontend/package-lock.json index 41cb63d..e17b2f4 100644 --- a/periscope/src/frontend/package-lock.json +++ b/periscope/src/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "periscope-web", - "version": "2.57.0", + "version": "2.58.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "periscope-web", - "version": "2.57.0", + "version": "2.58.0", "dependencies": { "@base-ui/react": "^1.3.0", "@types/dagre": "^0.7.54", diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index dde790c..38a4417 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.57.0", + "version": "2.58.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/hooks/use-auth-api.ts b/periscope/src/frontend/src/hooks/use-auth-api.ts new file mode 100644 index 0000000..7dabfe7 --- /dev/null +++ b/periscope/src/frontend/src/hooks/use-auth-api.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { setTokenGetter } from "@/lib/api"; +import { useOptionalAuth } from "@/hooks/use-optional-auth"; + +/** + * Wire the HTTP client to the current session token. + * Render once near the app shell (sidebar). Local/OSS mode yields a null + * token so requests omit Authorization and the API uses user_id "local". + */ +export function useAuthApi() { + const { getToken } = useOptionalAuth(); + + useEffect(() => { + setTokenGetter(getToken); + }, [getToken]); +} diff --git a/tests/test_periscope_auth_rewrite.py b/tests/test_periscope_auth_rewrite.py new file mode 100644 index 0000000..fd3b730 --- /dev/null +++ b/tests/test_periscope_auth_rewrite.py @@ -0,0 +1,100 @@ +"""JWT request verification lives under periscope/src (same HTTP contract).""" + +from __future__ import annotations + +import inspect +from pathlib import Path +from types import SimpleNamespace + +import pytest +from starlette.requests import Request + +from backend.middleware import auth as auth_mod +from backend.services.local_jwt import issue_token + + +ROOT = Path(__file__).resolve().parents[1] +SRC_AUTH = ROOT / "periscope" / "src" / "backend" / "middleware" / "auth.py" +SRC_HOOK = ROOT / "periscope" / "src" / "frontend" / "src" / "hooks" / "use-auth-api.ts" + + +def test_auth_modules_are_src(): + path = Path(auth_mod.__file__).resolve() + assert path == SRC_AUTH + assert "Native Periscope overlay" not in path.read_text(encoding="utf-8")[:400] + text = SRC_HOOK.read_text(encoding="utf-8") + assert "export function useAuthApi" in text + assert "setTokenGetter" in text + assert "Native Periscope overlay" not in text[:400] + + +def test_skip_paths_keep_login_contract(): + assert "/api/auth/login" in auth_mod._SKIP_PATHS + assert "/api/auth/register" in auth_mod._SKIP_PATHS + assert "/api/auth/mode" in auth_mod._SKIP_PATHS + assert "/api/contact" in auth_mod._SKIP_PATHS + assert "/health" in auth_mod._SKIP_PATHS + + +def _request(path: str, token: str | None = None, query_token: str | None = None) -> Request: + headers: list[tuple[bytes, bytes]] = [] + if token: + headers.append((b"authorization", f"Bearer {token}".encode())) + raw_qs = f"token={query_token}".encode() if query_token else b"" + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": raw_qs, + "headers": headers, + "client": ("127.0.0.1", 0), + "server": ("test", 80), + } + return Request(scope) + + +@pytest.fixture() +def local_auth_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from backend.config import settings + + monkeypatch.setattr(settings, "auth_jwt_secret", "test-secret-not-for-prod") + monkeypatch.setattr(settings, "clerk_secret_key", "") + monkeypatch.setattr(settings, "clerk_jwks_url", "") + monkeypatch.setattr(settings, "data_dir", tmp_path) + return settings + + +@pytest.mark.asyncio +async def test_verify_local_jwt_accepts_bearer(local_auth_settings): + token = issue_token("user-1", "a@example.com") + user = await auth_mod.verify_local_token(_request("/api/projects", token=token)) + assert user == "user-1" + dispatched = await auth_mod.verify_request_user(_request("/api/projects", token=token)) + assert dispatched == "user-1" + + +@pytest.mark.asyncio +async def test_verify_local_jwt_query_token_and_skips(local_auth_settings): + token = issue_token("user-2", "b@example.com") + user = await auth_mod.verify_local_token( + _request("/api/pipeline/x/events", query_token=token) + ) + assert user == "user-2" + skipped = await auth_mod.verify_local_token(_request("/api/auth/login")) + assert skipped == "anonymous" + missing = await auth_mod.verify_local_token(_request("/api/projects")) + assert missing is None + bad = await auth_mod.verify_local_token(_request("/api/projects", token="not-a-jwt")) + assert bad is None + + +def test_routers_auth_module_unchanged_contract(): + from backend.routers import auth as routers_auth + + src = inspect.getsource(routers_auth.login) + assert "local_users.authenticate" in src + assert "local_jwt.issue_token" in src diff --git a/tests/test_periscope_csp_middleware_rewrite.py b/tests/test_periscope_csp_middleware_rewrite.py index 103b246..4e07cf7 100644 --- a/tests/test_periscope_csp_middleware_rewrite.py +++ b/tests/test_periscope_csp_middleware_rewrite.py @@ -25,4 +25,5 @@ def test_middleware_package_is_src_and_does_not_export_auth(): assert "from backend.middleware.auth" not in text assert "verify_request_user" not in text auth = ROOT / "periscope" / "src" / "backend" / "middleware" / "auth.py" - assert not auth.exists() + assert auth.is_file() + assert "verify_request_user" in auth.read_text(encoding="utf-8") diff --git a/tests/test_periscope_frontend_content_overlay.py b/tests/test_periscope_frontend_content_overlay.py index cbf7d1a..ebea717 100644 --- a/tests/test_periscope_frontend_content_overlay.py +++ b/tests/test_periscope_frontend_content_overlay.py @@ -28,9 +28,9 @@ def test_legal_pages_are_operator_not_faradworks_controller(): assert "This Service is **not** operated by Faradworks, Inc." in terms -def test_changelog_stamp_is_2_57_0_and_src_wins(): +def test_changelog_stamp_is_2_58_0_and_src_wins(): text = (SRC / "changelog.md").read_text(encoding="utf-8") - assert "## 2.57.0 — 2026-09-20" in text + assert "## 2.58.0 — 2026-09-20" in text first = changelog_paths()[0] assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md" assert first == SRC / "changelog.md" diff --git a/tests/test_periscope_leftover_package_requirements.py b/tests/test_periscope_leftover_package_requirements.py index 1bdcb35..57e8b20 100644 --- a/tests/test_periscope_leftover_package_requirements.py +++ b/tests/test_periscope_leftover_package_requirements.py @@ -17,7 +17,7 @@ DOCKERIGNORE = ROOT / ".dockerignore" def test_package_json_is_periscope_web(): text = PKG.read_text(encoding="utf-8") assert '"name": "periscope-web"' in text - assert '"version": "2.57.0"' in text + assert '"version": "2.58.0"' in text assert "Native Periscope overlay" not in text[:400] assert LOCK.is_file() lock = LOCK.read_text(encoding="utf-8")