Add local Pinscope multi-user auth for shared projects.

Self-host email/password accounts enable the existing collaborator
invite flow without Clerk; first admin inherits users/local projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 14:31:37 +02:00
co-authored by Cursor
parent c5b260b404
commit 5a69b380da
22 changed files with 1045 additions and 116 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Pinscope local JWT helpers (HS256)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from backend.config import settings
ALGORITHM = "HS256"
TOKEN_TTL_DAYS = 30
def issue_token(user_id: str, email: str) -> str:
secret = settings.auth_jwt_secret
if not secret:
raise RuntimeError("AUTH_JWT_SECRET is not configured")
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"email": email,
"iss": "pinscope-local",
"iat": now,
"exp": now + timedelta(days=TOKEN_TTL_DAYS),
}
return jwt.encode(payload, secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any] | None:
secret = settings.auth_jwt_secret
if not secret:
return None
try:
return jwt.decode(
token,
secret,
algorithms=[ALGORITHM],
issuer="pinscope-local",
options={"verify_aud": False},
leeway=10,
)
except jwt.PyJWTError:
return None
+204
View File
@@ -0,0 +1,204 @@
"""Local Pinscope user directory (self-host auth, no Clerk).
Users live under ``data/auth/users/{user_id}.json`` with an email index.
Passwords use stdlib ``hashlib.scrypt``.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import secrets
import shutil
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from backend.config import settings
logger = logging.getLogger(__name__)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass
class LocalUser:
user_id: str
email: str
name: str | None
password_hash: str
is_admin: bool = False
created_at: str = ""
def public(self) -> dict:
return {
"user_id": self.user_id,
"email": self.email,
"name": self.name,
"is_admin": self.is_admin,
}
def _auth_root() -> Path:
root = Path(settings.data_dir) / "auth"
(root / "users").mkdir(parents=True, exist_ok=True)
(root / "by_email").mkdir(parents=True, exist_ok=True)
return root
def _email_key(email: str) -> str:
return email.strip().lower()
def _email_path(email: str) -> Path:
# Filesystem-safe key from normalized email
key = _email_key(email).replace("/", "_")
return _auth_root() / "by_email" / f"{key}.json"
def _user_path(user_id: str) -> Path:
return _auth_root() / "users" / f"{user_id}.json"
def hash_password(password: str, *, salt: bytes | None = None) -> str:
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return f"scrypt${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
algo, salt_hex, digest_hex = encoded.split("$", 2)
except ValueError:
return False
if algo != "scrypt":
return False
salt = bytes.fromhex(salt_hex)
check = hash_password(password, salt=salt)
return secrets.compare_digest(check, encoded)
def validate_email(email: str) -> str:
e = email.strip().lower()
if not _EMAIL_RE.match(e):
raise ValueError("Invalid email address")
return e
def validate_password(password: str) -> None:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
def get_user(user_id: str) -> LocalUser | None:
path = _user_path(user_id)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return LocalUser(**data)
def find_by_email(email: str) -> LocalUser | None:
path = _email_path(email)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
uid = data.get("user_id")
if not uid:
return None
return get_user(uid)
def list_users() -> list[LocalUser]:
users_dir = _auth_root() / "users"
out: list[LocalUser] = []
for path in sorted(users_dir.glob("*.json")):
try:
out.append(LocalUser(**json.loads(path.read_text(encoding="utf-8"))))
except Exception:
logger.exception("Skipping corrupt user file %s", path)
return out
def _save_user(user: LocalUser) -> None:
_user_path(user.user_id).write_text(
json.dumps(asdict(user), indent=2) + "\n", encoding="utf-8"
)
_email_path(user.email).write_text(
json.dumps({"user_id": user.user_id}) + "\n", encoding="utf-8"
)
def user_count() -> int:
return len(list((_auth_root() / "users").glob("*.json")))
def _migrate_local_projects(new_owner_id: str) -> int:
"""Move ``users/local/projects/*`` under the first admin, if present."""
local_projects = Path(settings.data_dir) / "users" / "local" / "projects"
if not local_projects.is_dir():
return 0
dest_root = Path(settings.data_dir) / "users" / new_owner_id / "projects"
dest_root.mkdir(parents=True, exist_ok=True)
moved = 0
for child in local_projects.iterdir():
if not child.is_dir():
continue
target = dest_root / child.name
if target.exists():
continue
shutil.move(str(child), str(target))
moved += 1
logger.info("Migrated project %s → user %s", child.name, new_owner_id)
return moved
def create_user(email: str, password: str, name: str | None = None) -> LocalUser:
email = validate_email(email)
validate_password(password)
if find_by_email(email):
raise ValueError("An account with that email already exists")
from datetime import datetime, timezone
first = user_count() == 0
admin_emails = {
e.strip().lower()
for e in (settings.auth_admin_emails or "").split(",")
if e.strip()
}
is_admin = first or email in admin_emails
user = LocalUser(
user_id="usr_" + uuid.uuid4().hex,
email=email,
name=(name or "").strip() or None,
password_hash=hash_password(password),
is_admin=is_admin,
created_at=datetime.now(timezone.utc).isoformat(),
)
_save_user(user)
if first:
try:
n = _migrate_local_projects(user.user_id)
if n:
logger.info("First admin inherited %s local project(s)", n)
except Exception:
logger.exception("Failed to migrate users/local projects")
return user
def authenticate(email: str, password: str) -> LocalUser | None:
user = find_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
+81
View File
@@ -0,0 +1,81 @@
"""User profile lookup for collaborators / admin — Clerk or local auth."""
from __future__ import annotations
import logging
from backend.config import settings
logger = logging.getLogger(__name__)
async def find_user_id_by_email(email: str) -> str | None:
email = email.strip().lower()
if not email:
return None
if settings.use_clerk:
import httpx
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.clerk.com/v1/users",
params={"email_address": [email]},
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code != 200:
logger.warning("Clerk email lookup failed: %s", resp.status_code)
return None
users = resp.json()
if not users:
return None
return users[0].get("id")
if settings.use_local_auth:
from backend.services import local_users
user = local_users.find_by_email(email)
return user.user_id if user else None
return None
async def get_user_profile(user_id: str) -> dict:
"""Return {user_id, name, email, image_url, is_admin?}."""
entry = {
"user_id": user_id,
"name": None,
"email": None,
"image_url": None,
"is_admin": False,
}
if settings.use_clerk:
import httpx
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.clerk.com/v1/users/{user_id}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
entry["name"] = f"{first} {last}".strip() or None
emails = clerk.get("email_addresses", [])
if emails:
entry["email"] = emails[0].get("email_address")
entry["image_url"] = clerk.get("image_url")
role = (clerk.get("public_metadata") or {}).get("role")
entry["is_admin"] = role == "admin"
except Exception:
logger.exception("Clerk profile fetch failed for %s", user_id)
return entry
if settings.use_local_auth:
from backend.services import local_users
user = local_users.get_user(user_id)
if user:
entry["name"] = user.name
entry["email"] = user.email
entry["is_admin"] = user.is_admin
return entry
return entry