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.
This commit is contained in:
@@ -110,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
|
|
||||||
class AuthMiddleware(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):
|
async def dispatch(self, request: Request, call_next):
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.57.0",
|
"version": "2.58.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.57.0",
|
"version": "2.58.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.3.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
"@types/dagre": "^0.7.54",
|
"@types/dagre": "^0.7.54",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.57.0",
|
"version": "2.58.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -25,4 +25,5 @@ def test_middleware_package_is_src_and_does_not_export_auth():
|
|||||||
assert "from backend.middleware.auth" not in text
|
assert "from backend.middleware.auth" not in text
|
||||||
assert "verify_request_user" not in text
|
assert "verify_request_user" not in text
|
||||||
auth = ROOT / "periscope" / "src" / "backend" / "middleware" / "auth.py"
|
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")
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ def test_legal_pages_are_operator_not_faradworks_controller():
|
|||||||
assert "This Service is **not** operated by Faradworks, Inc." in terms
|
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")
|
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]
|
first = changelog_paths()[0]
|
||||||
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
|
assert first.parts[-3:] == ("src", "frontend", "content") or first.name == "changelog.md"
|
||||||
assert first == SRC / "changelog.md"
|
assert first == SRC / "changelog.md"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ DOCKERIGNORE = ROOT / ".dockerignore"
|
|||||||
def test_package_json_is_periscope_web():
|
def test_package_json_is_periscope_web():
|
||||||
text = PKG.read_text(encoding="utf-8")
|
text = PKG.read_text(encoding="utf-8")
|
||||||
assert '"name": "periscope-web"' in text
|
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 "Native Periscope overlay" not in text[:400]
|
||||||
assert LOCK.is_file()
|
assert LOCK.is_file()
|
||||||
lock = LOCK.read_text(encoding="utf-8")
|
lock = LOCK.read_text(encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user