Replace basic auth with session login

This commit is contained in:
2026-08-03 00:25:18 +03:30
parent f5f54bac66
commit 81d816ae54
10 changed files with 501 additions and 69 deletions
+2
View File
@@ -1,6 +1,8 @@
# Never commit a real token. Export these variables in your shell. # Never commit a real token. Export these variables in your shell.
RESUME_AGENT_USERNAME=resume-user RESUME_AGENT_USERNAME=resume-user
RESUME_AGENT_PASSWORD=replace-with-a-long-random-password RESUME_AGENT_PASSWORD=replace-with-a-long-random-password
# Set true when the browser reaches the app over HTTPS.
RESUME_AGENT_SECURE_COOKIES=false
RESUME_AGENT_API_KEY=replace-with-your-token RESUME_AGENT_API_KEY=replace-with-your-token
RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1 RESUME_AGENT_BASE_URL=https://service.nokod.ir/v1
RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models
+7 -6
View File
@@ -42,13 +42,14 @@ uv run python -c "import secrets; print(secrets.token_urlsafe(32))"
Authentication covers the Signal interface, APIs, downloads, static files, and the Authentication covers the Signal interface, APIs, downloads, static files, and the
bundled Oh My CV editor. The server fails closed with HTTP 503 when either credential is bundled Oh My CV editor. The server fails closed with HTTP 503 when either credential is
missing or still uses the placeholder value. Your browser will display its standard missing or still uses the placeholder value. The login page creates a signed, HttpOnly,
username/password prompt. Oh My CV offline caching is disabled so its app shell cannot SameSite session cookie that expires after 12 hours, and both frontends provide a sign-out
bypass the server authentication gate. button. Oh My CV offline caching is disabled so its app shell cannot bypass the server
authentication gate.
HTTP Basic authentication protects credentials only when the connection uses HTTPS. Binding to localhost is suitable for personal use. If the app is reachable from another
Binding to localhost is suitable for personal use; if the app is reachable from another machine, place it behind an HTTPS reverse proxy, set
machine, place it behind an HTTPS reverse proxy and do not expose the plain HTTP port. `RESUME_AGENT_SECURE_COOKIES=true`, and do not expose the plain HTTP port.
For an OpenAI-compatible provider, configure the endpoint without putting credentials For an OpenAI-compatible provider, configure the endpoint without putting credentials
in the repository: in the repository:
+9
View File
@@ -25,6 +25,7 @@ document.addEventListener("DOMContentLoaded", async () => {
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, options); const response = await fetch(path, options);
redirectToLoginIfExpired(response);
if (!response.ok) { if (!response.ok) {
let message = "Something went wrong."; let message = "Something went wrong.";
try { try {
@@ -38,6 +39,13 @@ async function api(path, options = {}) {
return response.json(); return response.json();
} }
function redirectToLoginIfExpired(response) {
if (response.status !== 401) return;
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error("Your session expired. Redirecting to sign in…");
}
async function apiTask(path, options = {}) { async function apiTask(path, options = {}) {
const started = await api(`${path}/start`, options); const started = await api(`${path}/start`, options);
let consecutiveNetworkErrors = 0; let consecutiveNetworkErrors = 0;
@@ -392,6 +400,7 @@ async function prepareOhMyCvImport(packageData) {
try { try {
const response = await fetch("/api/download/resume-ohmycv.md"); const response = await fetch("/api/download/resume-ohmycv.md");
redirectToLoginIfExpired(response);
if (!response.ok) throw new Error("Could not prepare the Oh My CV export."); if (!response.ok) throw new Error("Could not prepare the Oh My CV export.");
const markdown = await response.text(); const markdown = await response.text();
const role = packageData.job.role_title || "Tailored resume"; const role = packageData.job.role_title || "Tailored resume";
+3
View File
@@ -37,6 +37,9 @@
<span class="status-dot" id="statusDot"></span> <span class="status-dot" id="statusDot"></span>
<span id="providerText">Checking provider…</span> <span id="providerText">Checking provider…</span>
</div> </div>
<form action="/logout" method="post">
<button class="logout-button" type="submit">Sign out</button>
</form>
</div> </div>
</header> </header>
+220
View File
@@ -0,0 +1,220 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<title>Sign in — Signal Resume Agent</title>
<style>
:root {
color-scheme: light;
--ink: #172019;
--muted: #667169;
--green: #214d37;
--orange: #ec6f32;
--lime: #dcebad;
--paper: #f4f1e9;
--line: #d9d8ce;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
overflow: hidden;
color: var(--ink);
background:
radial-gradient(circle at 12% 8%, rgba(220, 235, 173, 0.7), transparent 34%),
radial-gradient(circle at 90% 92%, rgba(236, 111, 50, 0.16), transparent 30%),
var(--paper);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
body::before {
position: fixed;
inset: 0;
opacity: 0.26;
pointer-events: none;
content: "";
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E");
}
main {
width: min(92vw, 430px);
position: relative;
}
.brand {
margin-bottom: 24px;
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--green);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.brand-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
color: white;
background: var(--green);
font-family: Georgia, serif;
font-size: 19px;
font-style: italic;
}
.card {
padding: 38px;
border: 1px solid rgba(33, 77, 55, 0.18);
border-radius: 22px;
background: rgba(255, 255, 252, 0.9);
box-shadow: 0 24px 80px rgba(31, 48, 37, 0.14);
backdrop-filter: blur(12px);
}
.eyebrow {
margin: 0 0 12px;
color: var(--orange);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(32px, 8vw, 42px);
font-weight: 500;
line-height: 1.05;
}
.intro {
margin: 14px 0 28px;
color: var(--muted);
font-size: 13px;
line-height: 1.65;
}
label {
margin: 16px 0 7px;
display: block;
font-size: 11px;
font-weight: 750;
}
input {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: 10px;
outline: none;
color: var(--ink);
background: #fff;
font: inherit;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input:focus {
border-color: var(--green);
box-shadow: 0 0 0 3px rgba(33, 77, 55, 0.12);
}
button {
width: 100%;
margin-top: 24px;
padding: 14px 18px;
border: 0;
border-radius: 11px;
color: white;
background: var(--green);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.03em;
}
button:hover {
background: #153a29;
}
.error {
min-height: 18px;
margin: 18px 0 -4px;
color: #a33d25;
font-size: 11px;
font-weight: 700;
}
.privacy {
margin: 18px 4px 0;
color: var(--muted);
font-size: 10px;
line-height: 1.55;
text-align: center;
}
@media (max-width: 520px) {
main {
width: min(100% - 28px, 430px);
}
.card {
padding: 28px 24px;
border-radius: 18px;
}
}
</style>
</head>
<body>
<main>
<div class="brand"><span class="brand-mark">S</span><span>Signal</span></div>
<section class="card" aria-labelledby="login-title">
<p class="eyebrow">Private career workspace</p>
<h1 id="login-title">Welcome back.</h1>
<p class="intro">
Sign in to access your career profile, tailored resumes, and CV editor.
</p>
<form action="/login" method="post">
<input type="hidden" name="next" value="{{NEXT}}" />
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
autocomplete="username"
maxlength="256"
required
autofocus
/>
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
maxlength="4096"
required
/>
<p class="error" role="alert">{{ERROR}}</p>
<button type="submit">Enter workspace</button>
</form>
</section>
<p class="privacy">Your session stays in an HttpOnly browser cookie and expires automatically.</p>
</main>
</body>
</html>
+21
View File
@@ -176,6 +176,27 @@ button {
transform: translateY(-1px); transform: translateY(-1px);
} }
.topbar-actions form {
margin: 0;
}
.logout-button {
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: 10px;
font-weight: 750;
}
.logout-button:hover {
color: var(--ink);
border-color: #aeb1a9;
background: var(--surface-2);
}
.status-dot { .status-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;
+166 -41
View File
@@ -2,19 +2,29 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import binascii import hashlib
import hmac
import html
import logging import logging
import os import os
import secrets import secrets
import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from urllib.parse import urlparse from urllib.parse import urlencode, urlparse
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, PlainTextResponse from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
PlainTextResponse,
RedirectResponse,
Response,
)
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -39,6 +49,8 @@ STATIC_DIR = PACKAGE_DIR / "static"
DEFAULT_CV_DIST = PROJECT_DIR / "vendor/oh-my-cv/site/.output/public" DEFAULT_CV_DIST = PROJECT_DIR / "vendor/oh-my-cv/site/.output/public"
DEFAULT_PROFILE = Path(".resume-agent/profile.json") DEFAULT_PROFILE = Path(".resume-agent/profile.json")
DEFAULT_OUTPUT = Path("output") DEFAULT_OUTPUT = Path("output")
LOGIN_TEMPLATE = STATIC_DIR / "login.html"
SESSION_COOKIE = "resume_agent_session"
DOWNLOADS = { DOWNLOADS = {
"resume.md": "text/markdown", "resume.md": "text/markdown",
"resume-ohmycv.md": "text/markdown", "resume-ohmycv.md": "text/markdown",
@@ -177,23 +189,12 @@ def _save_app_settings(settings_path: Path, settings: AppSettings) -> None:
settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8") settings_path.write_text(settings.model_dump_json(indent=2), encoding="utf-8")
def _valid_basic_credentials( def _credentials_match(
authorization: str | None, username: str,
password: str,
expected_username: str, expected_username: str,
expected_password: str, expected_password: str,
) -> bool: ) -> bool:
if not authorization:
return False
scheme, separator, token = authorization.partition(" ")
if not separator or scheme.lower() != "basic":
return False
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return False
username, separator, password = decoded.partition(":")
if not separator:
return False
username_matches = secrets.compare_digest( username_matches = secrets.compare_digest(
username.encode("utf-8"), expected_username.encode("utf-8") username.encode("utf-8"), expected_username.encode("utf-8")
) )
@@ -203,6 +204,71 @@ def _valid_basic_credentials(
return username_matches and password_matches return username_matches and password_matches
def _session_key(username: str, password: str) -> bytes:
material = f"resume-agent-session\0{username}\0{password}".encode()
return hashlib.sha256(material).digest()
def _create_session_token(username: str, key: bytes, lifetime_seconds: int) -> str:
expires_at = int(time.time()) + lifetime_seconds
payload = f"{username}\0{expires_at}\0{secrets.token_urlsafe(18)}".encode()
encoded = base64.urlsafe_b64encode(payload).rstrip(b"=").decode()
signature = hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest()
return f"{encoded}.{signature}"
def _valid_session_token(token: str | None, expected_username: str, key: bytes) -> bool:
if not token:
return False
encoded, separator, signature = token.partition(".")
if not separator or not encoded or not signature:
return False
expected_signature = hmac.new(key, encoded.encode(), hashlib.sha256).hexdigest()
if not secrets.compare_digest(signature, expected_signature):
return False
try:
padding = "=" * (-len(encoded) % 4)
decoded = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
username, expires_at, _nonce = decoded.split("\0", 2)
expires = int(expires_at)
except (ValueError, UnicodeDecodeError):
return False
return secrets.compare_digest(username, expected_username) and expires >= int(time.time())
def _safe_next_path(value: str | None) -> str:
if not value or len(value) > 4_096 or not value.startswith("/") or value.startswith("//"):
return "/"
parsed = urlparse(value)
if parsed.scheme or parsed.netloc:
return "/"
return value
def _login_html(*, next_path: str, error: str = "") -> str:
template = LOGIN_TEMPLATE.read_text(encoding="utf-8")
return template.replace("{{NEXT}}", html.escape(next_path, quote=True)).replace(
"{{ERROR}}", html.escape(error)
)
def _login_response(*, next_path: str, error: str = "", status_code: int = 200) -> HTMLResponse:
return HTMLResponse(
_login_html(next_path=next_path, error=error),
status_code=status_code,
headers={
"Cache-Control": "no-store",
"Content-Security-Policy": (
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "
"base-uri 'none'; frame-ancestors 'none'"
),
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
},
)
def create_app( def create_app(
*, *,
profile_path: Path = DEFAULT_PROFILE, profile_path: Path = DEFAULT_PROFILE,
@@ -227,6 +293,14 @@ def create_app(
and not resolved_auth_username.lower().startswith("replace-with-") and not resolved_auth_username.lower().startswith("replace-with-")
and not resolved_auth_password.lower().startswith("replace-with-") and not resolved_auth_password.lower().startswith("replace-with-")
) )
session_key = _session_key(resolved_auth_username, resolved_auth_password)
session_lifetime_seconds = 12 * 60 * 60
secure_cookies = os.getenv("RESUME_AGENT_SECURE_COOKIES", "false").lower() in {
"1",
"true",
"yes",
"on",
}
@web_app.middleware("http") @web_app.middleware("http")
async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]): async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]):
@@ -239,22 +313,30 @@ def create_app(
status_code=503, status_code=503,
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
if not _valid_basic_credentials( if request.url.path == "/login":
request.headers.get("Authorization"), return await call_next(request)
resolved_auth_username, if not _valid_session_token(
resolved_auth_password, request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key
): ):
return PlainTextResponse( if request.url.path.startswith("/api/"):
"Authentication required.", return JSONResponse(
status_code=401, {"detail": "Your session has expired. Sign in again."},
headers={ status_code=401,
"Cache-Control": "no-store", headers={"Cache-Control": "no-store"},
"WWW-Authenticate": 'Basic realm="Resume Agent", charset="UTF-8"', )
}, requested_path = request.url.path
if request.url.query:
requested_path = f"{requested_path}?{request.url.query}"
return RedirectResponse(
f"/login?{urlencode({'next': requested_path})}",
status_code=303,
headers={"Cache-Control": "no-store"},
) )
response = await call_next(request) response = await call_next(request)
if request.url.path.startswith("/api/"): content_type = response.headers.get("content-type", "")
if request.url.path.startswith("/api/") or content_type.startswith("text/html"):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
return response return response
web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") web_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -271,6 +353,57 @@ def create_app(
return PlainTextResponse("authentication is not configured", status_code=503) return PlainTextResponse("authentication is not configured", status_code=503)
return PlainTextResponse("ok") return PlainTextResponse("ok")
@web_app.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_page(request: Request, next: str = "/") -> Response:
next_path = _safe_next_path(next)
if _valid_session_token(
request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key
):
return RedirectResponse(next_path, status_code=303)
return _login_response(next_path=next_path)
@web_app.post("/login", response_class=HTMLResponse, include_in_schema=False)
async def login(
username: Annotated[str, Form(max_length=256)],
password: Annotated[str, Form(max_length=4_096)],
next: Annotated[str, Form(max_length=4_096)] = "/",
) -> Response:
next_path = _safe_next_path(next)
if not _credentials_match(
username, password, resolved_auth_username, resolved_auth_password
):
await asyncio.sleep(0.35)
return _login_response(
next_path=next_path,
error="The username or password is incorrect.",
status_code=401,
)
response = RedirectResponse(next_path, status_code=303)
response.set_cookie(
SESSION_COOKIE,
_create_session_token(resolved_auth_username, session_key, session_lifetime_seconds),
max_age=session_lifetime_seconds,
httponly=True,
secure=secure_cookies,
samesite="strict",
path="/",
)
response.headers["Cache-Control"] = "no-store"
return response
@web_app.post("/logout", include_in_schema=False)
async def logout() -> RedirectResponse:
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(
SESSION_COOKIE,
httponly=True,
secure=secure_cookies,
samesite="strict",
path="/",
)
response.headers["Cache-Control"] = "no-store"
return response
async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None: async def run_task(task_id: str, operation: Awaitable[BaseModel]) -> None:
TASK_LOGGER.info("task=%s event=start", task_id) TASK_LOGGER.info("task=%s event=start", task_id)
try: try:
@@ -342,9 +475,7 @@ def create_app(
api_key = os.getenv("RESUME_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY") api_key = os.getenv("RESUME_AGENT_API_KEY") or os.getenv("OPENAI_API_KEY")
base_url = os.getenv("RESUME_AGENT_BASE_URL") or os.getenv("OPENAI_BASE_URL") base_url = os.getenv("RESUME_AGENT_BASE_URL") or os.getenv("OPENAI_BASE_URL")
model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra") model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra")
configured = bool( configured = bool(api_key and model and not api_key.lower().startswith("replace-with-"))
api_key and model and not api_key.lower().startswith("replace-with-")
)
return { return {
"configured": configured, "configured": configured,
"provider": _provider_name(), "provider": _provider_name(),
@@ -446,9 +577,7 @@ def create_app(
) )
try: try:
app_settings = _load_app_settings(resolved_settings_path) app_settings = _load_app_settings(resolved_settings_path)
evidence_mode = ( evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
request.evidence_mode if app_settings.evidence_guard else "profile"
)
profile = load_profile(profile_path) profile = load_profile(profile_path)
if request.job_url: if request.job_url:
job_text = await run_in_threadpool(fetch_job_page, request.job_url) job_text = await run_in_threadpool(fetch_job_page, request.job_url)
@@ -482,9 +611,7 @@ def create_app(
) )
try: try:
app_settings = _load_app_settings(resolved_settings_path) app_settings = _load_app_settings(resolved_settings_path)
evidence_mode = ( evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
request.evidence_mode if app_settings.evidence_guard else "profile"
)
llm = llm_factory() llm = llm_factory()
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about) profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
save_json(profile, profile_path) save_json(profile, profile_path)
@@ -531,9 +658,7 @@ def create_app(
) )
try: try:
profile = load_profile(profile_path) profile = load_profile(profile_path)
current = TailoringPackage.model_validate_json( current = TailoringPackage.model_validate_json(package_path.read_text(encoding="utf-8"))
package_path.read_text(encoding="utf-8")
)
evidence_mode = _load_evidence_mode(output_dir) evidence_mode = _load_evidence_mode(output_dir)
if not _load_app_settings(resolved_settings_path).evidence_guard: if not _load_app_settings(resolved_settings_path).evidence_guard:
evidence_mode = "profile" evidence_mode = "profile"
+49 -22
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import base64
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,16 +14,18 @@ from resume_agent.webapp import create_app
TEST_USERNAME = "resume-user" TEST_USERNAME = "resume-user"
TEST_PASSWORD = "test-password" TEST_PASSWORD = "test-password"
TEST_AUTHORIZATION = "Basic " + base64.b64encode(
f"{TEST_USERNAME}:{TEST_PASSWORD}".encode()
).decode()
TEST_AUTH_HEADERS = {"Authorization": TEST_AUTHORIZATION}
class TestClient(RawTestClient): class TestClient(RawTestClient):
def __init__(self, app: Any, **kwargs: Any) -> None: def __enter__(self) -> TestClient:
headers = {**TEST_AUTH_HEADERS, **kwargs.pop("headers", {})} super().__enter__()
super().__init__(app, headers=headers, **kwargs) response = self.post(
"/login",
data={"username": TEST_USERNAME, "password": TEST_PASSWORD, "next": "/"},
follow_redirects=False,
)
assert response.status_code == 303
return self
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -133,19 +134,47 @@ def test_authentication_protects_ui_api_static_and_editor(tmp_path: Path) -> Non
with RawTestClient(app) as client: with RawTestClient(app) as client:
assert client.get("/healthz").status_code == 200 assert client.get("/healthz").status_code == 200
for path in ("/", "/api/status", "/favicon.ico", "/cv/"): login_page = client.get("/login")
response = client.get(path) assert login_page.status_code == 200
assert response.status_code == 401 assert "Welcome back" in login_page.text
assert response.headers["www-authenticate"].startswith("Basic ")
wrong = client.get( assert client.get("/", follow_redirects=False).status_code == 303
"/api/status", api_response = client.get("/api/status")
headers={"Authorization": "Basic " + base64.b64encode(b"wrong:wrong").decode()}, assert api_response.status_code == 401
assert api_response.json()["detail"].startswith("Your session has expired")
assert client.get("/favicon.ico", follow_redirects=False).status_code == 303
assert client.get("/cv/", follow_redirects=False).status_code == 303
wrong = client.post(
"/login",
data={"username": "wrong", "password": "wrong", "next": "/"},
follow_redirects=False,
) )
assert wrong.status_code == 401 assert wrong.status_code == 401
assert client.get("/", headers=TEST_AUTH_HEADERS).status_code == 200 assert "username or password is incorrect" in wrong.text
assert client.get("/api/status", headers=TEST_AUTH_HEADERS).status_code == 200
assert client.get("/favicon.ico", headers=TEST_AUTH_HEADERS).status_code == 200 signed_in = client.post(
assert client.get("/cv/", headers=TEST_AUTH_HEADERS).status_code == 200 "/login",
data={
"username": TEST_USERNAME,
"password": TEST_PASSWORD,
"next": "https://attacker.example/steal",
},
follow_redirects=False,
)
assert signed_in.status_code == 303
assert signed_in.headers["location"] == "/"
assert "httponly" in signed_in.headers["set-cookie"].lower()
assert "samesite=strict" in signed_in.headers["set-cookie"].lower()
assert client.get("/").status_code == 200
assert client.get("/api/status").status_code == 200
assert client.get("/favicon.ico").status_code == 200
assert client.get("/cv/").status_code == 200
signed_out = client.post("/logout", follow_redirects=False)
assert signed_out.status_code == 303
assert signed_out.headers["location"] == "/login"
assert client.get("/api/status").status_code == 401
def test_missing_authentication_configuration_fails_closed( def test_missing_authentication_configuration_fails_closed(
@@ -416,9 +445,7 @@ def test_revision_chat_updates_tailored_outputs(tmp_path: Path) -> None:
download = client.get("/api/download/resume-ohmycv.md") download = client.get("/api/download/resume-ohmycv.md")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["package"]["changes_made"] == [ assert response.json()["package"]["changes_made"] == ["Made the summary more direct."]
"Made the summary more direct."
]
assert "Updated and re-audited" in response.json()["reply"] assert "Updated and re-audited" in response.json()["reply"]
assert download.status_code == 200 assert download.status_code == 200
assert "[F013]" not in download.text assert "[F013]" not in download.text
@@ -302,6 +302,7 @@ onMounted(async () => {
hasBackup.value = Boolean(localStorage.getItem(backupKey.value)); hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
try { try {
const response = await fetch(`${apiBase.value}/api/settings`); const response = await fetch(`${apiBase.value}/api/settings`);
redirectToLoginIfExpired(response);
if (!response.ok) return; if (!response.ok) return;
const settings = (await response.json()) as { evidence_guard: boolean }; const settings = (await response.json()) as { evidence_guard: boolean };
globalEvidenceGuard.value = settings.evidence_guard; globalEvidenceGuard.value = settings.evidence_guard;
@@ -324,6 +325,7 @@ const toggleGlobalEvidenceGuard = async () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ evidence_guard: next }) body: JSON.stringify({ evidence_guard: next })
}); });
redirectToLoginIfExpired(response);
const settings = (await response.json()) as { const settings = (await response.json()) as {
evidence_guard?: boolean; evidence_guard?: boolean;
detail?: string; detail?: string;
@@ -348,6 +350,7 @@ async function post<T>(path: string, payload: Record<string, unknown>): Promise<
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
redirectToLoginIfExpired(response);
const body = (await response.json()) as T & { detail?: string }; const body = (await response.json()) as T & { detail?: string };
if (!response.ok) throw new Error(body.detail || "The Signal API returned an error."); if (!response.ok) throw new Error(body.detail || "The Signal API returned an error.");
@@ -362,6 +365,7 @@ async function postTask<T>(path: string, payload: Record<string, unknown>): Prom
await new Promise((resolve) => window.setTimeout(resolve, 1200)); await new Promise((resolve) => window.setTimeout(resolve, 1200));
try { try {
const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`); const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`);
redirectToLoginIfExpired(response);
if (!response.ok) { if (!response.ok) {
const body = (await response.json()) as { detail?: string }; const body = (await response.json()) as { detail?: string };
throw new Error(body.detail || "Could not read the model task status."); throw new Error(body.detail || "Could not read the model task status.");
@@ -392,6 +396,13 @@ async function postTask<T>(path: string, payload: Record<string, unknown>): Prom
} }
} }
function redirectToLoginIfExpired(response: Response): void {
if (response.status !== 401) return;
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error("Your session expired. Redirecting to sign in…");
}
const jobPayload = () => ({ const jobPayload = () => ({
job_url: mode.value === "url" ? jobURL.value.trim() : null, job_url: mode.value === "url" ? jobURL.value.trim() : null,
job_text: mode.value === "text" ? jobText.value.trim() : null job_text: mode.value === "text" ? jobText.value.trim() : null
+13
View File
@@ -63,6 +63,19 @@
<SharedToggleDark /> <SharedToggleDark />
<form action="/logout" method="post">
<UiButton
type="submit"
variant="ghost-secondary"
size="xs"
class="h-8 gap-x-1"
aria-label="Sign out of Signal"
>
<span class="i-material-symbols:logout text-lg" />
<span class="hide-on-mobile text-base">Sign out</span>
</UiButton>
</form>
<UiButton <UiButton
as="a" as="a"
variant="ghost-secondary" variant="ghost-secondary"