Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down Expand Up @@ -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):
Expand All @@ -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")
Expand Down
58 changes: 51 additions & 7 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand All @@ -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 ────────────────────────────────────────────────────────────

Expand All @@ -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))


Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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 ──────────────────────────────────────
Expand Down Expand Up @@ -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)):
Expand Down
188 changes: 188 additions & 0 deletions backend/tests/test_auth_cookie.py
Original file line number Diff line number Diff line change
@@ -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
Loading