diff --git a/.env.example b/.env.example index 0264f21..6c8f3b8 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,21 @@ JWT_SECRET_KEY= # OPTIONAL — your public domain, e.g. https://notes.example.com # Accepts a comma-separated list for multiple origins. # Defaults to no allowed origins (cross-origin requests blocked) if not set. -# Only needed if you access the API from a different origin than the frontend. +# Only needed if you access the API from a different origin than the frontend, +# or from a sibling app in the suite (e.g. https://gam.example.com). CORS_ORIGIN= +# ─── Suite SSO (OPTIONAL) ────────────────────────────────────────────────────── +# Login also sets an HttpOnly session cookie so a sibling app in the suite +# (Gecko Asset Manager, Gecko Video Creator, …) on a different subdomain can +# share the session. +# +# Set to the parent domain so one login covers every subdomain. +# Leave blank for local development (yields a host-only cookie). +AUTH_COOKIE_DOMAIN= +# Only set false for local http development. +AUTH_COOKIE_SECURE=true + # OPTIONAL — note version history. # How often (minutes) the editor snapshots a note version while focused. Default 5. NOTE_VERSION_INTERVAL_MINUTES=5 diff --git a/backend/app/main.py b/backend/app/main.py index eca253d..587ad41 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -51,6 +51,9 @@ "/api/auth/resend-verification", "/api/auth/forgot-password", "/api/auth/reset-password", + # A JS-invisible HttpOnly cookie can't be cleared client-side, so logout must + # work even when the presented token is already expired. + "/api/auth/logout", } @@ -105,6 +108,23 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +def _origin_allowed_for_cookie(request: Request) -> bool: + """Cookie-authenticated writes must come from a known origin. + + Only applies to the cookie path: a Bearer header cannot be attached by a + cross-site form or image, so header-authenticated requests are exempt. Safe + methods are exempt too — they change nothing. + """ + if request.method in _SAFE_METHODS: + return True + origin = request.headers.get("Origin") or request.headers.get("Referer") + if not origin: + return False # fail closed: a same-origin browser write always sends one + return any(origin.startswith(o) for o in _cors_origins) + @app.middleware("http") async def add_cache_headers(request: Request, call_next): @@ -124,14 +144,24 @@ async def jwt_auth_middleware(request: Request, call_next): if not request.url.path.startswith("/api/") or _is_public(request.url.path): return await call_next(request) + token = None auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): + if auth_header.startswith("Bearer "): + token = auth_header[7:] + else: + token = request.cookies.get(auth_router.AUTH_COOKIE_NAME) + if token and not _origin_allowed_for_cookie(request): + return JSONResponse(status_code=403, content={"error": { + "code": "forbidden_origin", + "message": "Cookie authentication requires an allowed Origin", + }}) + + if not token: return JSONResponse( status_code=401, content={"error": {"code": "unauthorized", "message": "Missing or invalid Authorization header"}}, ) - token = auth_header[7:] try: payload = decode_token(token) request.state.user_id = payload.get("sub") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3c36139..4d5941d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,6 +1,7 @@ +import os import uuid from datetime import datetime, timedelta -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response from jose import JWTError from sqlmodel import Session, select, func @@ -15,7 +16,7 @@ TwoFactorDisableRequest, ) from app.auth import ( - hash_password, verify_password, create_access_token, + hash_password, verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_DAYS, create_challenge_token, decode_challenge_token, generate_url_token, generate_numeric_code, hash_token, encrypt_api_key, decrypt_api_key, @@ -35,6 +36,15 @@ EMAIL_2FA_CODE_EXPIRE_MINUTES = 10 EMAIL_2FA_MAX_ATTEMPTS = 5 +# ─── Suite SSO cookie ────────────────────────────────────────────────────────── +# In addition to the JSON token, login also sets an HttpOnly cookie so a sibling +# app on a different geckopico.com subdomain (GAM, later GVC) can share the +# session. The header stays the primary path for Gecko Notes' own frontend. +AUTH_COOKIE_NAME = "gecko_session" +# "" (dev) -> a host-only cookie. ".geckopico.com" -> sent to every subdomain. +AUTH_COOKIE_DOMAIN = os.getenv("AUTH_COOKIE_DOMAIN", "").strip() or None +AUTH_COOKIE_SECURE = os.getenv("AUTH_COOKIE_SECURE", "true").lower() != "false" + # ─── Small helpers ──────────────────────────────────────────────────────────── @@ -60,12 +70,22 @@ def _verification_required(session: Session) -> bool: return email_enabled() and get_bool(session, EMAIL_VERIFICATION_REQUIRED, True) -def _finalize_login(session: Session, user: User) -> Token: +def _finalize_login(session: Session, user: User, response: Response) -> Token: user.last_login = datetime.utcnow() session.add(user) session.commit() session.refresh(user) token = create_access_token({"sub": user.id, "username": user.username}) + response.set_cookie( + AUTH_COOKIE_NAME, + token, + max_age=ACCESS_TOKEN_EXPIRE_DAYS * 24 * 3600, + httponly=True, + secure=AUTH_COOKIE_SECURE, + samesite="lax", + domain=AUTH_COOKIE_DOMAIN, + path="/", + ) return Token(access_token=token, token_type="bearer", user=UserRead.model_validate(user)) @@ -217,7 +237,7 @@ def register(payload: UserCreate, background_tasks: BackgroundTasks, session: Se @router.post("/login") @limiter.limit("5/minute") def login(request: Request, payload: UserLogin, background_tasks: BackgroundTasks, - session: Session = Depends(get_session)): + response: Response, session: Session = Depends(get_session)): user = session.exec(select(User).where(User.username == payload.username)).first() if not user or not verify_password(payload.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") @@ -236,12 +256,12 @@ def login(request: Request, payload: UserLogin, background_tasks: BackgroundTask _issue_email_2fa_code(session, background_tasks, user) return TwoFactorRequired(method=user.two_factor_method, challenge_token=challenge) - return _finalize_login(session, user) + return _finalize_login(session, user, response) @router.post("/login/2fa", response_model=Token) @limiter.limit("10/minute") -def login_two_factor(request: Request, payload: LoginTwoFactorRequest, +def login_two_factor(request: Request, payload: LoginTwoFactorRequest, response: Response, session: Session = Depends(get_session)): try: claims = decode_challenge_token(payload.challenge_token) @@ -259,7 +279,17 @@ def login_two_factor(request: Request, payload: LoginTwoFactorRequest, if not ok: raise HTTPException(status_code=401, detail="Invalid verification code") - return _finalize_login(session, user) + return _finalize_login(session, user, response) + + +@router.post("/logout", status_code=204) +def logout(response: Response): + """Clear the suite session cookie. A JS-invisible HttpOnly cookie can't be + cleared from the client, so this needs a real endpoint — it's on + PUBLIC_PATHS so logging out still works with an expired token.""" + response.delete_cookie( + AUTH_COOKIE_NAME, domain=AUTH_COOKIE_DOMAIN, path="/", samesite="lax" + ) # ─── Email verification & password reset ────────────────────────────────────── @@ -326,6 +356,20 @@ def me(request: Request, session: Session = Depends(get_session)): return UserRead.model_validate(_require_auth(request, session)) +@router.get("/session", response_model=Token) +def current_session(request: Request, session: Session = Depends(get_session)): + """Exchange a valid session (cookie or header) for a token plus the user. + + This is what lets a sibling app in the suite start up signed in. It mints a + fresh token rather than echoing the presented one, so the sibling's copy has + its own full lifetime. + """ + user = _require_auth(request, session) + token = create_access_token({"sub": user.id, "username": user.username}) + return Token(access_token=token, token_type="bearer", + user=UserRead.model_validate(user)) + + @router.patch("/me", response_model=UserRead) def update_me(payload: UserUpdate, request: Request, background_tasks: BackgroundTasks, session: Session = Depends(get_session)): diff --git a/backend/tests/test_auth_cookie.py b/backend/tests/test_auth_cookie.py new file mode 100644 index 0000000..64553aa --- /dev/null +++ b/backend/tests/test_auth_cookie.py @@ -0,0 +1,188 @@ +"""Tests for the parent-domain SSO cookie (GN-1). + +Login sets an HttpOnly `gecko_session` cookie alongside the existing JSON token, the +auth middleware accepts it as a fallback behind the `Authorization: Bearer` header, +and logout clears it. Because a cookie is an ambient credential the browser attaches +on its own, a cookie-authenticated write is only accepted from a known Origin/Referer +(header-authenticated requests are exempt, since a cross-site page can't attach one). + +Runs the real app (`app.main.app`) — including the real middleware and the real +`auth` router — with `get_session` overridden onto an isolated in-memory database, so +this exercises actual login/logout/middleware behavior rather than a substitute. +""" + +from datetime import datetime, timezone + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine +from starlette.testclient import TestClient + +import app.main as main_module +from app.auth import ACCESS_TOKEN_EXPIRE_DAYS, hash_password +from app.database import get_session +from app.limiter import limiter +from app.main import app +from app.models import User +from app.routers import auth as auth_router + +USERNAME = "gecko" +PASSWORD = "correct-horse-battery" +ALLOWED_ORIGIN = "https://notes.geckopico.com" +FOREIGN_ORIGIN = "https://evil.example" + + +def _make_user(session: Session, *, user_id: str, username: str) -> None: + session.add(User( + id=user_id, + username=username, + email=f"{username}@example.com", + hashed_password=hash_password(PASSWORD), + email_verified=True, + created_at=datetime.now(timezone.utc), + )) + session.commit() + + +@pytest.fixture +def engine(): + # StaticPool: a bare "sqlite://" URL otherwise hands out a fresh, empty + # in-memory database to every new connection, which would lose state (e.g. the + # seeded user) between requests within the same test. + eng = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, + ) + SQLModel.metadata.create_all(eng) + return eng + + +@pytest.fixture +def client(engine, monkeypatch): + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + # Deterministic CSRF allowlist, independent of the ambient CORS_ORIGIN env var. + monkeypatch.setattr(main_module, "_cors_origins", [ALLOWED_ORIGIN]) + # Each test logs in at least once; the login limiter is process-global (keyed by + # client IP), so without a reset the 5/minute cap would bleed across tests. + limiter.reset() + + with Session(engine) as session: + _make_user(session, user_id="user-1", username=USERNAME) + + # Deliberately not used as a context manager: that would run app.main's real + # lifespan (init_db, background workers) against the real on-disk database, + # which get_session's override above is precisely trying to avoid touching. + # base_url is https:// because the cookie is Secure by default (AUTH_COOKIE_SECURE + # defaults to true) — a compliant cookie jar (httpx's included) won't resend a + # Secure cookie over plain http, which is exactly what a real browser does too. + test_client = TestClient(app, base_url="https://testserver") + yield test_client + app.dependency_overrides.clear() + + +def _login(client: TestClient, username: str = USERNAME): + return client.post("/api/auth/login", json={"username": username, "password": PASSWORD}) + + +# ─── Cookie set on login ──────────────────────────────────────────────────────── + + +def test_login_sets_the_session_cookie(client): + res = _login(client) + assert res.status_code == 200 + set_cookie = res.headers.get("set-cookie", "") + assert "gecko_session=" in set_cookie + assert "HttpOnly" in set_cookie + assert f"Max-Age={ACCESS_TOKEN_EXPIRE_DAYS * 24 * 3600}" in set_cookie + # The cookie carries the same token returned in the JSON body. + assert res.cookies.get("gecko_session") == res.json()["access_token"] + + +def test_no_cookie_domain_configured_means_no_domain_attribute(client, monkeypatch): + monkeypatch.setattr(auth_router, "AUTH_COOKIE_DOMAIN", None) + res = _login(client) + assert "Domain=" not in res.headers.get("set-cookie", "") + + +def test_a_configured_cookie_domain_is_sent(client, monkeypatch): + monkeypatch.setattr(auth_router, "AUTH_COOKIE_DOMAIN", ".geckopico.com") + res = _login(client) + assert "Domain=.geckopico.com" in res.headers.get("set-cookie", "") + + +# ─── Precedence: header over cookie ───────────────────────────────────────────── + + +def test_cookie_only_request_authenticates(client): + _login(client) # the client's cookie jar now holds gecko_session + res = client.get("/api/auth/me") # no Authorization header sent + assert res.status_code == 200 + assert res.json()["username"] == USERNAME + + +def test_header_wins_over_a_differing_cookie(client, engine): + token_1 = _login(client).json()["access_token"] + with Session(engine) as session: + _make_user(session, user_id="user-2", username="other") + # Logging in as "other" overwrites the jar's cookie with user-2's token. + _login(client, username="other") + + res = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token_1}"}) + assert res.status_code == 200 + assert res.json()["username"] == USERNAME # the header's user, not the cookie's + + +# ─── Logout ────────────────────────────────────────────────────────────────────── + + +def test_logout_clears_the_cookie(client): + _login(client) + assert client.cookies.get("gecko_session") + + logout_res = client.post("/api/auth/logout") + assert logout_res.status_code == 204 + assert client.cookies.get("gecko_session") is None + + res = client.get("/api/auth/me") + assert res.status_code == 401 + + +# ─── CSRF guard on the cookie path ────────────────────────────────────────────── + + +def test_cookie_write_from_a_foreign_origin_is_refused(client): + _login(client) + res = client.patch( + "/api/auth/me", json={"avatar_url": "https://x.test/a.png"}, + headers={"Origin": FOREIGN_ORIGIN}, + ) + assert res.status_code == 403 + assert res.json()["error"]["code"] == "forbidden_origin" + + +def test_cookie_write_with_no_origin_is_refused(client): + """Fail closed: a same-origin browser write always sends an Origin or Referer.""" + _login(client) + res = client.patch("/api/auth/me", json={"avatar_url": "https://x.test/a.png"}) + assert res.status_code == 403 + + +def test_cookie_read_from_a_foreign_origin_is_allowed(client): + """Safe methods change nothing, so the CSRF guard doesn't apply to them.""" + _login(client) + res = client.get("/api/auth/me", headers={"Origin": FOREIGN_ORIGIN}) + assert res.status_code == 200 + + +def test_header_write_from_a_foreign_origin_is_allowed(client): + """A cross-site page can't attach a Bearer header, so header auth is exempt.""" + token = _login(client).json()["access_token"] + client.cookies.clear() # only the header carries auth on this request + res = client.patch( + "/api/auth/me", json={"avatar_url": "https://x.test/a.png"}, + headers={"Authorization": f"Bearer {token}", "Origin": FOREIGN_ORIGIN}, + ) + assert res.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index 26532d0..f098e42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - MEDIA_DIR=/app/data/media - JWT_SECRET_KEY=${JWT_SECRET_KEY} - CORS_ORIGIN=${CORS_ORIGIN:-} + - AUTH_COOKIE_DOMAIN=${AUTH_COOKIE_DOMAIN:-} + - AUTH_COOKIE_SECURE=${AUTH_COOKIE_SECURE:-true} - NOTE_VERSION_INTERVAL_MINUTES=${NOTE_VERSION_INTERVAL_MINUTES:-5} - NOTE_VERSION_MAX_COUNT=${NOTE_VERSION_MAX_COUNT:-50} # Article-to-video rendering (see .env.example). diff --git a/frontend/nginx.conf b/frontend/nginx.conf index eebb3b2..f0ad300 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -97,7 +97,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: /media/; font-src 'self' data:; connect-src 'self'; media-src 'self' blob: /media/;" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: /media/ https://gam.geckopico.com; font-src 'self' data:; connect-src 'self' https://gam.geckopico.com; media-src 'self' blob: /media/ https://gam.geckopico.com;" always; } # Cache static assets diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 1f4e5e3..5dafb7a 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -58,6 +58,12 @@ export const authApi = { return res.data }, + // Clears the suite session cookie (see backend/app/routers/auth.py). The client + // is otherwise stateless (localStorage), so this only matters for the cookie. + async logout(): Promise { + await client.post('/auth/logout') + }, + async me(): Promise { const res = await client.get('/auth/me') return res.data diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e7b25c0..9d2e213 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -107,6 +107,9 @@ export const useAuthStore = create((set) => ({ }, logout() { + // Best-effort: clears the suite session cookie server-side. Ignore failure so + // a network hiccup never leaves the user stuck unable to log out locally. + authApi.logout().catch(() => {}) localStorage.removeItem('auth_token') localStorage.removeItem('auth_user') set({ token: null, user: null, isAuthenticated: false, error: null }) diff --git a/frontend/src/views/LoginView.tsx b/frontend/src/views/LoginView.tsx index 8a1e1a4..681a809 100644 --- a/frontend/src/views/LoginView.tsx +++ b/frontend/src/views/LoginView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, FormEvent } from 'react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useSearchParams } from 'react-router-dom' import { useAuthStore } from '@/stores/auth' import { authApi, isTwoFactorRequired } from '@/api/auth' import { configApi } from '@/api/config' @@ -11,8 +11,27 @@ const inputCls = 'w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm' const labelCls = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1' +// A sibling app in the suite (GAM, later GVC) sends the browser here with +// ?redirect= when it needs a signed-in session, so login can send it back. +// Only ever navigate to a *.geckopico.com origin — anything else is refused so +// this can't become an open redirect. +function validatedRedirect(raw: string | null): string | null { + if (!raw) return null + try { + const url = new URL(raw) + if (url.hostname === 'geckopico.com' || url.hostname.endsWith('.geckopico.com')) { + return url.toString() + } + } catch { + // not a valid absolute URL — ignore + } + return null +} + export default function LoginView() { const navigate = useNavigate() + const [searchParams] = useSearchParams() + const redirectTarget = validatedRedirect(searchParams.get('redirect')) const { login, completeTwoFactor, register, loading, error } = useAuthStore() const [mode, setMode] = useState('login') @@ -79,6 +98,10 @@ export default function LoginView() { setStep('twofa') return } + if (redirectTarget) { + window.location.href = redirectTarget + return + } navigate('/notes', { replace: true }) } catch (err: unknown) { const detail = (err as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail @@ -121,6 +144,10 @@ export default function LoginView() { async function handleTwoFactor() { try { await completeTwoFactor(challengeToken, code) + if (redirectTarget) { + window.location.href = redirectTarget + return + } navigate('/notes', { replace: true }) } catch { // store set the error