security: purge leaked deploy files, cookie auth, sanitize blog

This commit is contained in:
yves.gugger
2025-12-12 09:28:28 +01:00
parent ffcd47e61d
commit 5d7bd1a04f
15 changed files with 488 additions and 112 deletions

9
.gitignore vendored
View File

@ -28,6 +28,15 @@ dist/
.env.*.local
*.log
# Deployment env files (MUST NOT be committed)
DEPLOY_*.env
# Sensitive runtime artifacts
backend/data/cookies/*.json
# Local security backup artifacts (created during history rewrite)
.security-backup/
# IDEs
.vscode/
.idea/

View File

@ -0,0 +1,55 @@
# Deployment environment template (NO SECRETS)
#
# Copy to a *local-only* file and keep it OUT of git:
# cp DEPLOY_backend.env.example DEPLOY_backend.env
#
# Then fill values from your password manager / secret store.
# Never commit DEPLOY_backend.env.
#
# Core
DATABASE_URL=postgresql+asyncpg://pounce:<DB_PASSWORD>@db:5432/pounce
SECRET_KEY=<GENERATE_64_HEX_CHARS>
ACCESS_TOKEN_EXPIRE_MINUTES=1440
DEBUG=false
ENVIRONMENT=production
SITE_URL=https://your-domain.com
# CORS (comma-separated)
ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com
# Email (optional)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=pounce
SMTP_USE_TLS=true
SMTP_USE_SSL=false
CONTACT_EMAIL=
# Stripe (optional)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_PRICE_TRADER=
STRIPE_PRICE_TYCOON=
STRIPE_WEBHOOK_SECRET=
# OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://api.your-domain.com/api/v1/oauth/google/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=https://api.your-domain.com/api/v1/oauth/github/callback
# Optional integrations
DROPCATCH_CLIENT_ID=
DROPCATCH_CLIENT_SECRET=
DROPCATCH_API_BASE=https://api.dropcatch.com
SEDO_PARTNER_ID=
SEDO_SIGN_KEY=
SEDO_API_BASE=https://api.sedo.com/api/v1/
MOZ_ACCESS_ID=
MOZ_SECRET_KEY=

View File

@ -0,0 +1,7 @@
# Deployment environment template (NO SECRETS)
#
# Copy to a *local-only* file and keep it OUT of git:
# cp DEPLOY_frontend.env.example DEPLOY_frontend.env
#
NEXT_PUBLIC_API_URL=https://your-domain.com/api

View File

@ -17,7 +17,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request
from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Request, Response
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from slowapi import Limiter
@ -25,10 +25,11 @@ from slowapi.util import get_remote_address
from app.api.deps import Database, CurrentUser
from app.config import get_settings
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
from app.schemas.auth import UserCreate, UserLogin, UserResponse, LoginResponse
from app.services.auth import AuthService
from app.services.email_service import email_service
from app.models.user import User
from app.security import set_auth_cookie, clear_auth_cookie
logger = logging.getLogger(__name__)
@ -146,8 +147,8 @@ async def register(
return user
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: Database):
@router.post("/login", response_model=LoginResponse)
async def login(user_data: UserLogin, db: Database, response: Response):
"""
Authenticate user and return JWT token.
@ -210,13 +211,24 @@ async def login(user_data: UserLogin, db: Database):
data={"sub": str(user.id), "email": user.email},
expires_delta=access_token_expires,
)
return Token(
access_token=access_token,
token_type="bearer",
expires_in=settings.access_token_expire_minutes * 60,
# Set HttpOnly cookie (preferred for browser clients)
set_auth_cookie(
response=response,
token=access_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
)
# Do NOT return the token in the response body (prevents leaks via logs/JS storage)
return LoginResponse(expires_in=settings.access_token_expire_minutes * 60)
@router.post("/logout", response_model=MessageResponse)
async def logout(response: Response):
"""Clear auth cookie."""
clear_auth_cookie(response)
return MessageResponse(message="Logged out")
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser):

View File

@ -15,6 +15,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import Database, get_current_user, get_current_user_optional
from app.models.user import User
from app.models.blog import BlogPost
from app.services.html_sanitizer import sanitize_html
router = APIRouter()
@ -194,7 +195,9 @@ async def get_blog_post(
post.view_count += 1
await db.commit()
return post.to_dict(include_content=True)
data = post.to_dict(include_content=True)
data["content"] = sanitize_html(data.get("content") or "")
return data
# ============== Admin Endpoints ==============
@ -255,7 +258,7 @@ async def create_blog_post(
post = BlogPost(
title=data.title,
slug=slug,
content=data.content,
content=sanitize_html(data.content),
excerpt=data.excerpt,
cover_image=data.cover_image,
category=data.category,
@ -322,7 +325,7 @@ async def update_blog_post(
# Optionally update slug if title changes
# post.slug = generate_slug(data.title)
if data.content is not None:
post.content = data.content
post.content = sanitize_html(data.content)
if data.excerpt is not None:
post.excerpt = data.excerpt
if data.cover_image is not None:

View File

@ -1,21 +1,22 @@
"""API dependencies."""
from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.auth import AuthService
from app.models.user import User
from app.security import AUTH_COOKIE_NAME
# Security scheme
security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Get current authenticated user from JWT token."""
@ -25,7 +26,15 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
token: Optional[str] = None
if credentials is not None:
token = credentials.credentials
if not token:
token = request.cookies.get(AUTH_COOKIE_NAME)
if not token:
raise credentials_exception
payload = AuthService.decode_token(token)
if payload is None:
@ -67,6 +76,7 @@ async def get_current_active_user(
async def get_current_user_optional(
request: Request,
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> Optional[User]:
@ -75,10 +85,15 @@ async def get_current_user_optional(
This allows endpoints to work for both authenticated and anonymous users,
potentially showing different content based on auth status.
"""
if credentials is None:
token: Optional[str] = None
if credentials is not None:
token = credentials.credentials
if not token:
token = request.cookies.get(AUTH_COOKIE_NAME)
if not token:
return None
token = credentials.credentials
payload = AuthService.decode_token(token)
if payload is None:

View File

@ -5,15 +5,20 @@ Supports:
- Google OAuth 2.0
- GitHub OAuth
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import logging
import time
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, status, Query
from fastapi import APIRouter, HTTPException, status, Query, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
@ -23,6 +28,7 @@ from app.config import get_settings
from app.models.user import User
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
from app.services.auth import AuthService
from app.security import set_auth_cookie, should_use_secure_cookies
logger = logging.getLogger(__name__)
router = APIRouter()
@ -41,6 +47,123 @@ GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:8000/ap
FRONTEND_URL = os.getenv("SITE_URL", "http://localhost:3000")
OAUTH_STATE_TTL_SECONDS = 600 # 10 minutes
def _sanitize_redirect_path(redirect: Optional[str]) -> str:
"""
Only allow internal (relative) redirects.
Prevents open-redirect and token/referrer exfil paths.
"""
default = "/terminal/radar"
if not redirect:
return default
r = redirect.strip()
if not r.startswith("/"):
return default
if r.startswith("//"):
return default
if "://" in r:
return default
if "\\" in r:
return default
if len(r) > 2048:
return default
return r
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + pad)
def _oauth_nonce_cookie_name(provider: str) -> str:
return f"pounce_oauth_nonce_{provider}"
def _set_oauth_nonce_cookie(response: RedirectResponse, provider: str, nonce: str) -> None:
response.set_cookie(
key=_oauth_nonce_cookie_name(provider),
value=nonce,
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=OAUTH_STATE_TTL_SECONDS,
path="/api/v1/oauth",
)
def _clear_oauth_nonce_cookie(response: RedirectResponse, provider: str) -> None:
response.delete_cookie(
key=_oauth_nonce_cookie_name(provider),
path="/api/v1/oauth",
)
def _create_oauth_state(provider: str, nonce: str, redirect_path: str) -> str:
"""
Signed, short-lived state payload.
Also protects the redirect_path against tampering.
"""
if not settings.secret_key:
raise RuntimeError("SECRET_KEY is required for OAuth state signing")
payload = {
"p": provider,
"n": nonce,
"r": redirect_path,
"ts": int(time.time()),
}
payload_b64 = _b64url_encode(
json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
)
sig = hmac.new(
settings.secret_key.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
return f"{payload_b64}.{_b64url_encode(sig)}"
def _verify_oauth_state(state: str, provider: str) -> tuple[str, str]:
if not settings.secret_key:
raise ValueError("OAuth state verification not available (missing SECRET_KEY)")
if not state or "." not in state:
raise ValueError("Invalid state format")
payload_b64, sig_b64 = state.split(".", 1)
expected_sig = _b64url_encode(
hmac.new(
settings.secret_key.encode("utf-8"),
payload_b64.encode("utf-8"),
hashlib.sha256,
).digest()
)
if not hmac.compare_digest(expected_sig, sig_b64):
raise ValueError("Invalid state signature")
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
if payload.get("p") != provider:
raise ValueError("State provider mismatch")
ts = int(payload.get("ts") or 0)
if ts <= 0 or (int(time.time()) - ts) > OAUTH_STATE_TTL_SECONDS:
raise ValueError("State expired")
nonce = str(payload.get("n") or "")
redirect_path = _sanitize_redirect_path(payload.get("r"))
if not nonce:
raise ValueError("Missing nonce")
return nonce, redirect_path
# ============== Schemas ==============
@ -102,7 +225,8 @@ async def get_or_create_oauth_user(
# Create new user
user = User(
email=email.lower(),
hashed_password=secrets.token_urlsafe(32), # Random password (won't be used)
# Random password (won't be used), but keep it a valid bcrypt hash.
hashed_password=AuthService.hash_password(secrets.token_urlsafe(32)),
name=name,
oauth_provider=provider,
oauth_id=oauth_id,
@ -169,11 +293,10 @@ async def google_login(redirect: Optional[str] = Query(None)):
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
redirect_path = _sanitize_redirect_path(redirect)
nonce = secrets.token_urlsafe(16)
state = _create_oauth_state("google", nonce, redirect_path)
params = {
"client_id": GOOGLE_CLIENT_ID,
@ -186,11 +309,14 @@ async def google_login(redirect: Optional[str] = Query(None)):
}
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return RedirectResponse(url=url)
response = RedirectResponse(url=url)
_set_oauth_nonce_cookie(response, "google", nonce)
return response
@router.get("/google/callback")
async def google_callback(
request: Request,
code: str = Query(...),
state: str = Query(""),
db: Database = None,
@ -202,10 +328,16 @@ async def google_callback(
detail="Google OAuth not configured",
)
# Parse redirect from state
redirect_path = "/command/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
nonce, redirect_path = _verify_oauth_state(state, "google")
except Exception as e:
logger.warning(f"Invalid OAuth state (google): {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
cookie_nonce = request.cookies.get(_oauth_nonce_cookie_name("google"))
if not cookie_nonce or not hmac.compare_digest(cookie_nonce, nonce):
logger.warning("OAuth nonce mismatch (google)")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
try:
# Exchange code for tokens
@ -257,12 +389,20 @@ async def google_callback(
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
# Redirect to frontend WITHOUT token in URL; set auth cookie instead.
query = {"redirect": redirect_path}
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
query["new"] = "true"
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
response = RedirectResponse(url=redirect_url)
_clear_oauth_nonce_cookie(response, "google")
set_auth_cookie(
response=response,
token=jwt_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
)
return response
except Exception as e:
logger.exception(f"Google OAuth error: {e}")
@ -281,11 +421,10 @@ async def github_login(redirect: Optional[str] = Query(None)):
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
redirect_path = _sanitize_redirect_path(redirect)
nonce = secrets.token_urlsafe(16)
state = _create_oauth_state("github", nonce, redirect_path)
params = {
"client_id": GITHUB_CLIENT_ID,
@ -295,11 +434,14 @@ async def github_login(redirect: Optional[str] = Query(None)):
}
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return RedirectResponse(url=url)
response = RedirectResponse(url=url)
_set_oauth_nonce_cookie(response, "github", nonce)
return response
@router.get("/github/callback")
async def github_callback(
request: Request,
code: str = Query(...),
state: str = Query(""),
db: Database = None,
@ -311,10 +453,16 @@ async def github_callback(
detail="GitHub OAuth not configured",
)
# Parse redirect from state
redirect_path = "/command/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
nonce, redirect_path = _verify_oauth_state(state, "github")
except Exception as e:
logger.warning(f"Invalid OAuth state (github): {e}")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
cookie_nonce = request.cookies.get(_oauth_nonce_cookie_name("github"))
if not cookie_nonce or not hmac.compare_digest(cookie_nonce, nonce):
logger.warning("OAuth nonce mismatch (github)")
return RedirectResponse(url=f"{FRONTEND_URL}/login?error=oauth_state_invalid")
try:
async with httpx.AsyncClient() as client:
@ -399,12 +547,19 @@ async def github_callback(
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
query = {"redirect": redirect_path}
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
query["new"] = "true"
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
response = RedirectResponse(url=redirect_url)
_clear_oauth_nonce_cookie(response, "github")
set_auth_cookie(
response=response,
token=jwt_token,
max_age_seconds=settings.access_token_expire_minutes * 60,
)
return response
except Exception as e:
logger.exception(f"GitHub OAuth error: {e}")

View File

@ -39,6 +39,11 @@ class Token(BaseModel):
expires_in: int
class LoginResponse(BaseModel):
"""Login response when using HttpOnly cookie authentication."""
expires_in: int
class TokenData(BaseModel):
"""Schema for token payload data."""
user_id: Optional[int] = None

64
backend/app/security.py Normal file
View File

@ -0,0 +1,64 @@
"""
Security helpers (cookies, environment checks).
We use HttpOnly cookies for browser auth to avoid storing JWTs in localStorage/URLs.
"""
from __future__ import annotations
import os
from fastapi import Response
AUTH_COOKIE_NAME = "pounce_access_token"
def cookie_domain() -> str | None:
"""
Optional cookie domain override.
Use with care. Example (share across subdomains): COOKIE_DOMAIN=.pounce.ch
Leave empty in local development (localhost).
"""
value = os.getenv("COOKIE_DOMAIN", "").strip()
return value or None
def should_use_secure_cookies() -> bool:
"""
Determine whether cookies should be marked Secure.
Prefer explicit config via COOKIE_SECURE=true. Otherwise infer from SITE_URL / ENVIRONMENT.
"""
if os.getenv("COOKIE_SECURE", "").lower() == "true":
return True
site_url = os.getenv("SITE_URL", "")
if site_url.startswith("https://"):
return True
env = os.getenv("ENVIRONMENT", "").lower()
return env in {"prod", "production"}
def set_auth_cookie(response: Response, token: str, max_age_seconds: int) -> None:
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=max_age_seconds,
path="/",
domain=cookie_domain(),
)
def clear_auth_cookie(response: Response) -> None:
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
domain=cookie_domain(),
)

View File

@ -0,0 +1,51 @@
"""
HTML sanitization utilities.
Goal: prevent XSS when rendering stored HTML (e.g. blog posts) via dangerouslySetInnerHTML.
"""
from __future__ import annotations
import bleach
_ALLOWED_TAGS = [
"p",
"br",
"hr",
"blockquote",
"pre",
"code",
"strong",
"em",
"ul",
"ol",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
]
_ALLOWED_ATTRIBUTES = {
"a": ["href", "title", "rel", "target"],
}
_ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
def sanitize_html(html: str) -> str:
"""Sanitize potentially unsafe HTML input."""
if not html:
return ""
return bleach.clean(
html,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
protocols=_ALLOWED_PROTOCOLS,
strip=True,
)

View File

@ -36,6 +36,9 @@ apscheduler>=3.10.4
aiosmtplib>=3.0.2
jinja2>=3.1.2
# HTML sanitization (XSS protection)
bleach>=6.1.0
# Payments
stripe>=7.0.0

View File

@ -54,15 +54,27 @@ function LoginForm() {
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
const [verified, setVerified] = useState(false)
const sanitizeRedirect = (value: string | null | undefined): string => {
const fallback = '/terminal/radar'
if (!value) return fallback
const v = value.trim()
if (!v.startsWith('/')) return fallback
if (v.startsWith('//')) return fallback
if (v.includes('://')) return fallback
if (v.includes('\\')) return fallback
if (v.length > 2048) return fallback
return v
}
// Get redirect URL from query params or localStorage (set during registration)
const paramRedirect = searchParams.get('redirect')
const [redirectTo, setRedirectTo] = useState(paramRedirect || '/terminal/radar')
const [redirectTo, setRedirectTo] = useState(sanitizeRedirect(paramRedirect))
// Check localStorage for redirect (set during registration before email verification)
useEffect(() => {
const storedRedirect = localStorage.getItem('pounce_redirect_after_login')
if (storedRedirect && !paramRedirect) {
setRedirectTo(storedRedirect)
setRedirectTo(sanitizeRedirect(storedRedirect))
}
}, [paramRedirect])
@ -101,7 +113,7 @@ function LoginForm() {
localStorage.removeItem('pounce_redirect_after_login')
// Redirect to intended destination or dashboard
router.push(redirectTo)
router.push(sanitizeRedirect(redirectTo))
} catch (err: unknown) {
console.error('Login error:', err)
if (err instanceof Error) {

View File

@ -3,7 +3,7 @@
import { useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { Loader2, CheckCircle } from 'lucide-react'
import { Loader2 } from 'lucide-react'
function OAuthCallbackContent() {
const router = useRouter()
@ -11,32 +11,40 @@ function OAuthCallbackContent() {
const { checkAuth } = useStore()
useEffect(() => {
const token = searchParams.get('token')
const redirect = searchParams.get('redirect') || '/terminal/radar'
const sanitizeRedirect = (value: string | null): string => {
const fallback = '/terminal/radar'
if (!value) return fallback
const v = value.trim()
if (!v.startsWith('/')) return fallback
if (v.startsWith('//')) return fallback
if (v.includes('://')) return fallback
if (v.includes('\\')) return fallback
return v
}
const addWelcomeFlag = (path: string): string => {
try {
const u = new URL(path, window.location.origin)
u.searchParams.set('welcome', 'true')
return `${u.pathname}${u.search}${u.hash}`
} catch {
return path
}
}
const redirect = sanitizeRedirect(searchParams.get('redirect'))
const isNew = searchParams.get('new') === 'true'
const error = searchParams.get('error')
if (error) {
router.push(`/login?error=${error}`)
router.push(`/login?error=${encodeURIComponent(error)}`)
return
}
if (token) {
// Store the token (using 'token' key to match api.ts)
localStorage.setItem('token', token)
// Update auth state
checkAuth().then(() => {
// Redirect with welcome message for new users
if (isNew) {
router.push(`${redirect}?welcome=true`)
} else {
router.push(redirect)
}
})
} else {
router.push('/login?error=no_token')
}
// Auth cookie is set by backend during OAuth callback; just refresh auth state.
checkAuth().finally(() => {
router.push(isNew ? addWelcomeFlag(redirect) : redirect)
})
}, [searchParams, router, checkAuth])
return (

View File

@ -45,29 +45,10 @@ interface ApiError {
}
class ApiClient {
private token: string | null = null
get baseUrl(): string {
return getApiBaseUrl().replace('/api/v1', '')
}
setToken(token: string | null) {
this.token = token
if (token) {
localStorage.setItem('token', token)
} else {
localStorage.removeItem('token')
}
}
getToken(): string | null {
if (typeof window === 'undefined') return null
if (!this.token) {
this.token = localStorage.getItem('token')
}
return this.token
}
async request<T>(
endpoint: string,
options: RequestInit = {}
@ -78,14 +59,10 @@ class ApiClient {
...options.headers as Record<string, string>,
}
const token = this.getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(url, {
...options,
headers,
credentials: 'include', // send HttpOnly auth cookie
})
if (!response.ok) {
@ -112,19 +89,21 @@ class ApiClient {
}
async login(email: string, password: string) {
const response = await this.request<{ access_token: string; expires_in: number }>(
const response = await this.request<{ expires_in: number }>(
'/auth/login',
{
method: 'POST',
body: JSON.stringify({ email, password }),
}
)
this.setToken(response.access_token)
return response
}
async logout() {
this.setToken(null)
// Clears auth cookie on the backend
return this.request<{ message: string; success: boolean }>('/auth/logout', {
method: 'POST',
})
}
async getMe() {

View File

@ -124,18 +124,16 @@ export const useStore = create<AppState>((set, get) => ({
set({ isLoading: true })
try {
if (api.getToken()) {
const user = await api.getMe()
set({ user, isAuthenticated: true })
// Fetch in parallel for speed
await Promise.all([
get().fetchDomains(),
get().fetchSubscription()
])
}
// Cookie-based auth: if cookie is present and valid, /auth/me succeeds.
const user = await api.getMe()
set({ user, isAuthenticated: true })
// Fetch in parallel for speed
await Promise.all([
get().fetchDomains(),
get().fetchSubscription()
])
} catch {
api.logout()
set({ user: null, isAuthenticated: false })
} finally {
set({ isLoading: false })