Use encrypted cookie sessions

This commit is contained in:
kacper 2026-04-12 22:02:47 -04:00
parent a7b0352d3c
commit d84a885fdb
9 changed files with 131 additions and 27 deletions

View file

@ -1,4 +1,6 @@
APP_BASE_URL=http://kacper-dev-pod:8800 APP_BASE_URL=http://kacper-dev-pod:8800
AUTH_SECRET_KEY=replace-with-a-random-32-byte-or-longer-secret
AUTH_COOKIE_SECURE=false
FORGEJO_BASE_URL=https://aksal.cloud FORGEJO_BASE_URL=https://aksal.cloud
FORGEJO_TOKEN= FORGEJO_TOKEN=
FORGEJO_OAUTH_CLIENT_ID= FORGEJO_OAUTH_CLIENT_ID=

View file

@ -63,6 +63,8 @@ Useful variables:
- `FORGEJO_BASE_URL=https://aksal.cloud` - `FORGEJO_BASE_URL=https://aksal.cloud`
- `APP_BASE_URL=http://kacper-dev-pod:8800` - `APP_BASE_URL=http://kacper-dev-pod:8800`
- `AUTH_SECRET_KEY=...`
- `AUTH_COOKIE_SECURE=false`
- `FORGEJO_OAUTH_CLIENT_ID=...` - `FORGEJO_OAUTH_CLIENT_ID=...`
- `FORGEJO_OAUTH_CLIENT_SECRET=...` - `FORGEJO_OAUTH_CLIENT_SECRET=...`
- `FORGEJO_OAUTH_SCOPES=openid profile` - `FORGEJO_OAUTH_SCOPES=openid profile`
@ -74,7 +76,7 @@ Useful variables:
Notes: Notes:
- Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL. - Browser sign-in uses Forgejo OAuth/OIDC. `APP_BASE_URL` must match the URL opened in the browser, and the Forgejo OAuth app must include `/api/auth/forgejo/callback` under that base URL.
- Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token server-side and may use it only after enforcing public-repository checks. - Browser OAuth requests only identity scopes. The backend stores the resulting Forgejo token in an encrypted `HttpOnly` cookie and may use it only after enforcing public-repository checks.
- `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public. - `FORGEJO_TOKEN` is optional and should be treated as a read-only local fallback. Browser sessions and API token calls may write comments only after verifying the target repo is public.
- API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`. - API clients can query with `Authorization: token ...` or `Authorization: Bearer ...`.
- `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds. - `CALENDAR_FEED_URLS` is optional and accepts comma-separated `webcal://` or `https://` ICS feeds.

View file

@ -39,6 +39,7 @@ Optional live Forgejo configuration:
```bash ```bash
export APP_BASE_URL="http://kacper-dev-pod:8800" export APP_BASE_URL="http://kacper-dev-pod:8800"
export AUTH_SECRET_KEY="$(openssl rand -hex 32)"
export FORGEJO_BASE_URL="https://aksal.cloud" export FORGEJO_BASE_URL="https://aksal.cloud"
export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id" export FORGEJO_OAUTH_CLIENT_ID="your-forgejo-oauth-client-id"
export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret" export FORGEJO_OAUTH_CLIENT_SECRET="your-forgejo-oauth-client-secret"
@ -52,7 +53,9 @@ export CALENDAR_FEED_URLS="webcal://example.com/calendar.ics,https://example.com
http://kacper-dev-pod:8800/api/auth/forgejo/callback http://kacper-dev-pod:8800/api/auth/forgejo/callback
``` ```
`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments. `AUTH_SECRET_KEY` is required for Forgejo OAuth sign-in. It encrypts the `HttpOnly` browser session cookie that carries the signed-in user's Forgejo token and identity. Set `AUTH_COOKIE_SECURE=true` when serving over HTTPS.
`FORGEJO_TOKEN` is optional. When set, it is a read fallback for local development. Browser OAuth requests only identity scopes, then the backend uses the signed-in user's Forgejo identity from the encrypted session cookie for public repo reads and public issue replies. The backend must verify repositories are public before reading discussion data or writing comments.
Or put those values in `.env`: Or put those values in `.env`:
@ -60,7 +63,7 @@ Or put those values in `.env`:
cp .env.example .env cp .env.example .env
``` ```
Sign in through `/signin` using Forgejo OAuth, or query the API directly with: Use the site `Sign in` button for Forgejo OAuth, or query the API directly with:
```bash ```bash
curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype curl -H "Authorization: token your-forgejo-api-token" http://127.0.0.1:8800/api/prototype

14
app.py
View file

@ -43,7 +43,7 @@ def create_app() -> FastAPI:
@app.get("/api/prototype") @app.get("/api/prototype")
async def prototype(request: Request) -> JSONResponse: async def prototype(request: Request) -> JSONResponse:
settings = get_settings() settings = get_settings()
session_user = current_session_user(request) session_user = current_session_user(request, settings)
forgejo_token, auth_source = resolve_forgejo_token(request, settings) forgejo_token, auth_source = resolve_forgejo_token(request, settings)
return JSONResponse( return JSONResponse(
await build_live_prototype_payload( await build_live_prototype_payload(
@ -56,11 +56,11 @@ def create_app() -> FastAPI:
@app.get("/api/auth/session") @app.get("/api/auth/session")
async def auth_session(request: Request) -> JSONResponse: async def auth_session(request: Request) -> JSONResponse:
session_user = current_session_user(request) settings = get_settings()
session_user = current_session_user(request, settings)
if session_user: if session_user:
return JSONResponse(_auth_payload(session_user, "session")) return JSONResponse(_auth_payload(session_user, "session"))
settings = get_settings()
forgejo_token, auth_source = resolve_forgejo_token(request, settings) forgejo_token, auth_source = resolve_forgejo_token(request, settings)
if not forgejo_token or auth_source == "server": if not forgejo_token or auth_source == "server":
return JSONResponse(_auth_payload(None, "none")) return JSONResponse(_auth_payload(None, "none"))
@ -131,7 +131,7 @@ def create_app() -> FastAPI:
return _signin_error_redirect(str(exchange_error)) return _signin_error_redirect(str(exchange_error))
response = RedirectResponse(oauth_state.return_to, status_code=303) response = RedirectResponse(oauth_state.return_to, status_code=303)
create_login_session(response, access_token, user) create_login_session(response, settings, access_token, user)
return response return response
@app.delete("/api/auth/session") @app.delete("/api/auth/session")
@ -255,7 +255,11 @@ def _auth_payload(user: dict[str, Any] | None, source: str) -> dict[str, object]
def _oauth_configured(settings: Settings) -> bool: def _oauth_configured(settings: Settings) -> bool:
return bool(settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret) return bool(
settings.auth_secret_key
and settings.forgejo_oauth_client_id
and settings.forgejo_oauth_client_secret
)
def _oauth_redirect_uri(request: Request, settings: Settings) -> str: def _oauth_redirect_uri(request: Request, settings: Settings) -> str:

91
auth.py
View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import secrets import secrets
import time import time
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
@ -7,6 +8,7 @@ from dataclasses import dataclass
from hashlib import sha256 from hashlib import sha256
from typing import Any from typing import Any
from cryptography.fernet import Fernet, InvalidToken
from fastapi import Request, Response from fastapi import Request, Response
from settings import Settings from settings import Settings
@ -21,6 +23,7 @@ class SessionRecord:
forgejo_token: str | None forgejo_token: str | None
user: dict[str, Any] user: dict[str, Any]
created_at: float created_at: float
expires_at: float
@dataclass @dataclass
@ -31,7 +34,6 @@ class OAuthStateRecord:
created_at: float created_at: float
_SESSIONS: dict[str, SessionRecord] = {}
_OAUTH_STATES: dict[str, OAuthStateRecord] = {} _OAUTH_STATES: dict[str, OAuthStateRecord] = {}
@ -40,7 +42,7 @@ def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | N
if header_token: if header_token:
return header_token, "authorization" return header_token, "authorization"
session = _session_from_request(request) session = _session_from_request(request, settings)
if session and session.forgejo_token: if session and session.forgejo_token:
return session.forgejo_token, "session" return session.forgejo_token, "session"
@ -50,26 +52,30 @@ def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | N
return None, "none" return None, "none"
def current_session_user(request: Request) -> dict[str, Any] | None: def current_session_user(request: Request, settings: Settings) -> dict[str, Any] | None:
session = _session_from_request(request) session = _session_from_request(request, settings)
return session.user if session else None return session.user if session else None
def create_login_session( def create_login_session(
response: Response, response: Response,
settings: Settings,
forgejo_token: str | None, forgejo_token: str | None,
user: dict[str, Any], user: dict[str, Any],
) -> None: ) -> None:
session_id = secrets.token_urlsafe(32) created_at = time.time()
_SESSIONS[session_id] = SessionRecord( session = SessionRecord(
forgejo_token=forgejo_token, forgejo_token=forgejo_token,
user=user, user=user,
created_at=time.time(), created_at=created_at,
expires_at=created_at + SESSION_MAX_AGE_SECONDS,
) )
encrypted_session = _encrypt_session(session, settings)
response.set_cookie( response.set_cookie(
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
session_id, encrypted_session,
httponly=True, httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax", samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS, max_age=SESSION_MAX_AGE_SECONDS,
path="/", path="/",
@ -77,9 +83,6 @@ def create_login_session(
def clear_login_session(request: Request, response: Response) -> None: def clear_login_session(request: Request, response: Response) -> None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if session_id:
_SESSIONS.pop(session_id, None)
response.delete_cookie(SESSION_COOKIE_NAME, path="/") response.delete_cookie(SESSION_COOKIE_NAME, path="/")
@ -126,17 +129,69 @@ def _safe_return_path(value: str) -> str:
return value return value
def _session_from_request(request: Request) -> SessionRecord | None: def _session_from_request(request: Request, settings: Settings) -> SessionRecord | None:
session_id = request.cookies.get(SESSION_COOKIE_NAME) encrypted_session = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id: if not encrypted_session or not settings.auth_secret_key:
return None return None
session = _SESSIONS.get(session_id) try:
if not session: payload = _session_cipher(settings).decrypt(
encrypted_session.encode("utf-8"),
ttl=SESSION_MAX_AGE_SECONDS,
)
raw_session = json.loads(payload.decode("utf-8"))
except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError, ValueError):
return None return None
if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS: session = _session_from_payload(raw_session)
_SESSIONS.pop(session_id, None) if session is None or session.expires_at <= time.time():
return None return None
return session return session
def _encrypt_session(session: SessionRecord, settings: Settings) -> str:
payload = {
"forgejo_token": session.forgejo_token,
"user": session.user,
"created_at": session.created_at,
"expires_at": session.expires_at,
}
return (
_session_cipher(settings)
.encrypt(
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
)
.decode("utf-8")
)
def _session_from_payload(payload: object) -> SessionRecord | None:
if not isinstance(payload, dict):
return None
forgejo_token = payload.get("forgejo_token")
user = payload.get("user")
created_at = payload.get("created_at")
expires_at = payload.get("expires_at")
if forgejo_token is not None and not isinstance(forgejo_token, str):
return None
if not isinstance(user, dict):
return None
if not isinstance(created_at, (int, float)) or not isinstance(expires_at, (int, float)):
return None
return SessionRecord(
forgejo_token=forgejo_token,
user=user,
created_at=float(created_at),
expires_at=float(expires_at),
)
def _session_cipher(settings: Settings) -> Fernet:
if not settings.auth_secret_key:
raise ValueError("AUTH_SECRET_KEY is required for encrypted login sessions.")
key = urlsafe_b64encode(sha256(settings.auth_secret_key.encode("utf-8")).digest())
return Fernet(key)

View file

@ -481,7 +481,9 @@ def _auth_payload(
settings: Settings, settings: Settings,
) -> dict[str, object]: ) -> dict[str, object]:
oauth_configured = bool( oauth_configured = bool(
settings.forgejo_oauth_client_id and settings.forgejo_oauth_client_secret settings.auth_secret_key
and settings.forgejo_oauth_client_id
and settings.forgejo_oauth_client_secret
) )
if not user: if not user:
return { return {

View file

@ -1,3 +1,4 @@
fastapi>=0.116.0,<1.0.0 fastapi>=0.116.0,<1.0.0
uvicorn[standard]>=0.35.0,<1.0.0 uvicorn[standard]>=0.35.0,<1.0.0
httpx>=0.28.0,<1.0.0 httpx>=0.28.0,<1.0.0
cryptography>=42.0.0,<47.0.0

View file

@ -8,6 +8,8 @@ from functools import lru_cache
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
app_base_url: str | None app_base_url: str | None
auth_secret_key: str | None
auth_cookie_secure: bool
forgejo_base_url: str forgejo_base_url: str
forgejo_token: str | None forgejo_token: str | None
forgejo_oauth_client_id: str | None forgejo_oauth_client_id: str | None
@ -44,12 +46,21 @@ def _parse_scopes(raw_value: str | None) -> tuple[str, ...]:
return tuple(scope for scope in value.replace(",", " ").split() if scope) return tuple(scope for scope in value.replace(",", " ").split() if scope)
def _parse_bool(raw_value: str | None, *, default: bool = False) -> bool:
value = (raw_value or "").strip().lower()
if not value:
return default
return value in {"1", "true", "yes", "on"}
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings( return Settings(
app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL")) app_base_url=_normalize_base_url(os.getenv("APP_BASE_URL"))
if os.getenv("APP_BASE_URL") if os.getenv("APP_BASE_URL")
else None, else None,
auth_secret_key=os.getenv("AUTH_SECRET_KEY") or None,
auth_cookie_secure=_parse_bool(os.getenv("AUTH_COOKIE_SECURE")),
forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")), forgejo_base_url=_normalize_base_url(os.getenv("FORGEJO_BASE_URL")),
forgejo_token=os.getenv("FORGEJO_TOKEN") or None, forgejo_token=os.getenv("FORGEJO_TOKEN") or None,
forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None, forgejo_oauth_client_id=os.getenv("FORGEJO_OAUTH_CLIENT_ID") or None,

View file

@ -17,6 +17,7 @@ class AppTestCase(unittest.TestCase):
os.environ, os.environ,
{ {
"APP_BASE_URL": "http://testserver", "APP_BASE_URL": "http://testserver",
"AUTH_SECRET_KEY": "test-auth-secret-key-that-is-long-enough",
"FORGEJO_TOKEN": "", "FORGEJO_TOKEN": "",
"FORGEJO_OAUTH_CLIENT_ID": "client-id", "FORGEJO_OAUTH_CLIENT_ID": "client-id",
"FORGEJO_OAUTH_CLIENT_SECRET": "client-secret", "FORGEJO_OAUTH_CLIENT_SECRET": "client-secret",
@ -172,6 +173,7 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(callback_response.status_code, 303) self.assertEqual(callback_response.status_code, 303)
self.assertEqual(callback_response.headers["location"], "/discussions/7") self.assertEqual(callback_response.headers["location"], "/discussions/7")
self.assertIn("robot_u_session", callback_response.cookies) self.assertIn("robot_u_session", callback_response.cookies)
self.assertNotIn("oauth-token", callback_response.cookies["robot_u_session"])
self.assertEqual(fake_client.exchanged_code, "auth-code") self.assertEqual(fake_client.exchanged_code, "auth-code")
session_response = self.client.get("/api/auth/session") session_response = self.client.get("/api/auth/session")
@ -219,6 +221,28 @@ class AppTestCase(unittest.TestCase):
self.assertEqual(builder.call_args.kwargs["auth_source"], "session") self.assertEqual(builder.call_args.kwargs["auth_source"], "session")
self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper") self.assertEqual(builder.call_args.kwargs["session_user"]["login"], "kacper")
def test_encrypted_session_cookie_survives_new_app_instance(self) -> None:
fake_client = _FakeForgejoClient(user={"login": "kacper"}, access_token="oauth-token")
with patch("app.ForgejoClient", return_value=fake_client):
start_response = self.client.get(
"/api/auth/forgejo/start",
follow_redirects=False,
)
state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0]
callback_response = self.client.get(
f"/api/auth/forgejo/callback?code=auth-code&state={state}",
follow_redirects=False,
)
fresh_client = TestClient(create_app())
fresh_client.cookies.set("robot_u_session", callback_response.cookies["robot_u_session"])
session_response = fresh_client.get("/api/auth/session")
self.assertEqual(session_response.status_code, 200)
self.assertEqual(session_response.json()["authenticated"], True)
self.assertEqual(session_response.json()["login"], "kacper")
self.assertEqual(session_response.json()["source"], "session")
def test_create_discussion_reply(self) -> None: def test_create_discussion_reply(self) -> None:
fake_client = _FakeForgejoClient( fake_client = _FakeForgejoClient(
comment={ comment={