Use encrypted cookie sessions
This commit is contained in:
parent
a7b0352d3c
commit
d84a885fdb
9 changed files with 131 additions and 27 deletions
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
14
app.py
|
|
@ -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
91
auth.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
11
settings.py
11
settings.py
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue