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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user