diff --git a/.env.example b/.env.example
index 84ad82c..b3d29cf 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,8 @@
# Never commit a real token. Export these variables in your shell.
RESUME_AGENT_USERNAME=resume-user
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_BASE_URL=https://service.nokod.ir/v1
RESUME_AGENT_MODEL=replace-with-a-model-id-from-resume-agent-models
diff --git a/README.md b/README.md
index f61c508..514cb71 100644
--- a/README.md
+++ b/README.md
@@ -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
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
-username/password prompt. Oh My CV offline caching is disabled so its app shell cannot
-bypass the server authentication gate.
+missing or still uses the placeholder value. The login page creates a signed, HttpOnly,
+SameSite session cookie that expires after 12 hours, and both frontends provide a sign-out
+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
-machine, place it behind an HTTPS reverse proxy and do not expose the plain HTTP port.
+Binding to localhost is suitable for personal use. If the app is reachable from another
+machine, place it behind an HTTPS reverse proxy, set
+`RESUME_AGENT_SECURE_COOKIES=true`, and do not expose the plain HTTP port.
For an OpenAI-compatible provider, configure the endpoint without putting credentials
in the repository:
diff --git a/src/resume_agent/static/app.js b/src/resume_agent/static/app.js
index 7268d89..f24bd92 100644
--- a/src/resume_agent/static/app.js
+++ b/src/resume_agent/static/app.js
@@ -25,6 +25,7 @@ document.addEventListener("DOMContentLoaded", async () => {
async function api(path, options = {}) {
const response = await fetch(path, options);
+ redirectToLoginIfExpired(response);
if (!response.ok) {
let message = "Something went wrong.";
try {
@@ -38,6 +39,13 @@ async function api(path, options = {}) {
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 = {}) {
const started = await api(`${path}/start`, options);
let consecutiveNetworkErrors = 0;
@@ -392,6 +400,7 @@ async function prepareOhMyCvImport(packageData) {
try {
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.");
const markdown = await response.text();
const role = packageData.job.role_title || "Tailored resume";
diff --git a/src/resume_agent/static/index.html b/src/resume_agent/static/index.html
index f7004a9..84d5db4 100644
--- a/src/resume_agent/static/index.html
+++ b/src/resume_agent/static/index.html
@@ -37,6 +37,9 @@
Checking provider…
+
diff --git a/src/resume_agent/static/login.html b/src/resume_agent/static/login.html
new file mode 100644
index 0000000..59a1998
--- /dev/null
+++ b/src/resume_agent/static/login.html
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+ Sign in — Signal Resume Agent
+
+
+
+
+ SSignal
+
+ Private career workspace
+ Welcome back.
+
+ Sign in to access your career profile, tailored resumes, and CV editor.
+
+
+
+ Your session stays in an HttpOnly browser cookie and expires automatically.
+
+
+
diff --git a/src/resume_agent/static/styles.css b/src/resume_agent/static/styles.css
index d435387..17c7adc 100644
--- a/src/resume_agent/static/styles.css
+++ b/src/resume_agent/static/styles.css
@@ -176,6 +176,27 @@ button {
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 {
width: 7px;
height: 7px;
diff --git a/src/resume_agent/webapp.py b/src/resume_agent/webapp.py
index bd1c4a9..a7ba659 100644
--- a/src/resume_agent/webapp.py
+++ b/src/resume_agent/webapp.py
@@ -2,19 +2,29 @@ from __future__ import annotations
import asyncio
import base64
-import binascii
+import hashlib
+import hmac
+import html
import logging
import os
import secrets
+import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Annotated, Any, Literal
-from urllib.parse import urlparse
+from urllib.parse import urlencode, urlparse
from uuid import uuid4
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
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 pydantic import BaseModel, ConfigDict, Field
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_PROFILE = Path(".resume-agent/profile.json")
DEFAULT_OUTPUT = Path("output")
+LOGIN_TEMPLATE = STATIC_DIR / "login.html"
+SESSION_COOKIE = "resume_agent_session"
DOWNLOADS = {
"resume.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")
-def _valid_basic_credentials(
- authorization: str | None,
+def _credentials_match(
+ username: str,
+ password: str,
expected_username: str,
expected_password: str,
) -> 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.encode("utf-8"), expected_username.encode("utf-8")
)
@@ -203,6 +204,71 @@ def _valid_basic_credentials(
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(
*,
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_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")
async def require_authentication(request: Request, call_next: Callable[..., Awaitable[Any]]):
@@ -239,22 +313,30 @@ def create_app(
status_code=503,
headers={"Cache-Control": "no-store"},
)
- if not _valid_basic_credentials(
- request.headers.get("Authorization"),
- resolved_auth_username,
- resolved_auth_password,
+ if request.url.path == "/login":
+ return await call_next(request)
+ if not _valid_session_token(
+ request.cookies.get(SESSION_COOKIE), resolved_auth_username, session_key
):
- return PlainTextResponse(
- "Authentication required.",
- status_code=401,
- headers={
- "Cache-Control": "no-store",
- "WWW-Authenticate": 'Basic realm="Resume Agent", charset="UTF-8"',
- },
+ if request.url.path.startswith("/api/"):
+ return JSONResponse(
+ {"detail": "Your session has expired. Sign in again."},
+ status_code=401,
+ headers={"Cache-Control": "no-store"},
+ )
+ 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)
- 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["X-Content-Type-Options"] = "nosniff"
return response
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("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:
TASK_LOGGER.info("task=%s event=start", task_id)
try:
@@ -342,9 +475,7 @@ def create_app(
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")
model = os.getenv("RESUME_AGENT_MODEL") or (None if base_url else "gpt-5.6-terra")
- configured = bool(
- api_key and model and not api_key.lower().startswith("replace-with-")
- )
+ configured = bool(api_key and model and not api_key.lower().startswith("replace-with-"))
return {
"configured": configured,
"provider": _provider_name(),
@@ -446,9 +577,7 @@ def create_app(
)
try:
app_settings = _load_app_settings(resolved_settings_path)
- evidence_mode = (
- request.evidence_mode if app_settings.evidence_guard else "profile"
- )
+ evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
profile = load_profile(profile_path)
if request.job_url:
job_text = await run_in_threadpool(fetch_job_page, request.job_url)
@@ -482,9 +611,7 @@ def create_app(
)
try:
app_settings = _load_app_settings(resolved_settings_path)
- evidence_mode = (
- request.evidence_mode if app_settings.evidence_guard else "profile"
- )
+ evidence_mode = request.evidence_mode if app_settings.evidence_guard else "profile"
llm = llm_factory()
profile = await run_in_threadpool(build_profile, llm, request.markdown, request.about)
save_json(profile, profile_path)
@@ -531,9 +658,7 @@ def create_app(
)
try:
profile = load_profile(profile_path)
- current = TailoringPackage.model_validate_json(
- package_path.read_text(encoding="utf-8")
- )
+ current = TailoringPackage.model_validate_json(package_path.read_text(encoding="utf-8"))
evidence_mode = _load_evidence_mode(output_dir)
if not _load_app_settings(resolved_settings_path).evidence_guard:
evidence_mode = "profile"
diff --git a/tests/test_webapp.py b/tests/test_webapp.py
index 6a229c9..f973974 100644
--- a/tests/test_webapp.py
+++ b/tests/test_webapp.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import base64
import time
from pathlib import Path
from typing import Any
@@ -15,16 +14,18 @@ from resume_agent.webapp import create_app
TEST_USERNAME = "resume-user"
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):
- def __init__(self, app: Any, **kwargs: Any) -> None:
- headers = {**TEST_AUTH_HEADERS, **kwargs.pop("headers", {})}
- super().__init__(app, headers=headers, **kwargs)
+ def __enter__(self) -> TestClient:
+ super().__enter__()
+ 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)
@@ -133,19 +134,47 @@ def test_authentication_protects_ui_api_static_and_editor(tmp_path: Path) -> Non
with RawTestClient(app) as client:
assert client.get("/healthz").status_code == 200
- for path in ("/", "/api/status", "/favicon.ico", "/cv/"):
- response = client.get(path)
- assert response.status_code == 401
- assert response.headers["www-authenticate"].startswith("Basic ")
- wrong = client.get(
- "/api/status",
- headers={"Authorization": "Basic " + base64.b64encode(b"wrong:wrong").decode()},
+ login_page = client.get("/login")
+ assert login_page.status_code == 200
+ assert "Welcome back" in login_page.text
+
+ assert client.get("/", follow_redirects=False).status_code == 303
+ api_response = client.get("/api/status")
+ 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 client.get("/", headers=TEST_AUTH_HEADERS).status_code == 200
- assert client.get("/api/status", headers=TEST_AUTH_HEADERS).status_code == 200
- assert client.get("/favicon.ico", headers=TEST_AUTH_HEADERS).status_code == 200
- assert client.get("/cv/", headers=TEST_AUTH_HEADERS).status_code == 200
+ assert "username or password is incorrect" in wrong.text
+
+ signed_in = client.post(
+ "/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(
@@ -416,9 +445,7 @@ def test_revision_chat_updates_tailored_outputs(tmp_path: Path) -> None:
download = client.get("/api/download/resume-ohmycv.md")
assert response.status_code == 200
- assert response.json()["package"]["changes_made"] == [
- "Made the summary more direct."
- ]
+ assert response.json()["package"]["changes_made"] == ["Made the summary more direct."]
assert "Updated and re-audited" in response.json()["reply"]
assert download.status_code == 200
assert "[F013]" not in download.text
diff --git a/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue b/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue
index 5319021..296f704 100644
--- a/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue
+++ b/vendor/oh-my-cv/site/src/components/editor/toolbar/file/AiTailor.vue
@@ -302,6 +302,7 @@ onMounted(async () => {
hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
try {
const response = await fetch(`${apiBase.value}/api/settings`);
+ redirectToLoginIfExpired(response);
if (!response.ok) return;
const settings = (await response.json()) as { evidence_guard: boolean };
globalEvidenceGuard.value = settings.evidence_guard;
@@ -324,6 +325,7 @@ const toggleGlobalEvidenceGuard = async () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ evidence_guard: next })
});
+ redirectToLoginIfExpired(response);
const settings = (await response.json()) as {
evidence_guard?: boolean;
detail?: string;
@@ -348,6 +350,7 @@ async function post(path: string, payload: Record): Promise<
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
+ redirectToLoginIfExpired(response);
const body = (await response.json()) as T & { detail?: string };
if (!response.ok) throw new Error(body.detail || "The Signal API returned an error.");
@@ -362,6 +365,7 @@ async function postTask(path: string, payload: Record): Prom
await new Promise((resolve) => window.setTimeout(resolve, 1200));
try {
const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`);
+ redirectToLoginIfExpired(response);
if (!response.ok) {
const body = (await response.json()) as { detail?: string };
throw new Error(body.detail || "Could not read the model task status.");
@@ -392,6 +396,13 @@ async function postTask(path: string, payload: Record): 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 = () => ({
job_url: mode.value === "url" ? jobURL.value.trim() : null,
job_text: mode.value === "text" ? jobText.value.trim() : null
diff --git a/vendor/oh-my-cv/site/src/components/shared/Header.vue b/vendor/oh-my-cv/site/src/components/shared/Header.vue
index d71128b..5c78adf 100644
--- a/vendor/oh-my-cv/site/src/components/shared/Header.vue
+++ b/vendor/oh-my-cv/site/src/components/shared/Header.vue
@@ -63,6 +63,19 @@
+
+