diff --git a/app/api/routes_auth.py b/app/api/routes_auth.py
index 17a9bf4..b009d16 100644
--- a/app/api/routes_auth.py
+++ b/app/api/routes_auth.py
@@ -28,6 +28,7 @@
from app.core.audit import AuditEvent, get_audit_logger
from app.core import auth_lockout
from app.core.auth import hash_password, mint_jwt, verify_password
+from app.core import password_reset
from app.core import refresh_tokens
from app.core.token_revocation import revoke as revoke_jti
from app.db.tenants_repository import get_tenants_repository
@@ -102,6 +103,24 @@ class RefreshRequest(BaseModel):
refresh_token: str = Field(min_length=1)
+class ForgotPasswordRequest(BaseModel):
+ email: str
+
+ @field_validator("email")
+ @classmethod
+ def _email(cls, v: str) -> str:
+ return _validate_email(v)
+
+
+class ResetPasswordRequest(BaseModel):
+ token: str = Field(min_length=1)
+ password: str = Field(min_length=1)
+
+
+class MessageResponse(BaseModel):
+ message: str
+
+
class LogoutRequest(BaseModel):
# Optional: when present, the refresh token is revoked too so the session
# cannot be continued after logout.
@@ -401,6 +420,117 @@ async def refresh(req: RefreshRequest) -> AuthResponse:
)
+@router.post("/forgot-password", response_model=MessageResponse)
+async def forgot_password(req: ForgotPasswordRequest) -> MessageResponse:
+ """Start a password reset for the given email.
+
+ Deliberately enumeration-safe: the response is identical whether or not the
+ email maps to an account, so an attacker can't probe which addresses are
+ registered. When the email does belong to an active user we mint a
+ short-lived single-use reset token and email a link; the work happens behind
+ the same neutral 200.
+ """
+ settings = get_settings()
+ # Same neutral message in every branch - never reveals account existence.
+ neutral = MessageResponse(
+ message="If that email belongs to an account, a reset link is on its way.",
+ )
+
+ users = get_users_repository()
+ record = users.find_by_email_any_tenant(req.email)
+ if record is None:
+ return neutral
+
+ try:
+ ttl_minutes = int(getattr(settings, "password_reset_ttl_minutes", 30))
+ raw_token = password_reset.issue(
+ user_id=record["id"],
+ tenant_id=record["tenant_id"],
+ email=record["email"],
+ ttl_seconds=ttl_minutes * 60,
+ )
+ from app.core.email import send_password_reset_email
+
+ delivery = send_password_reset_email(
+ to_email=record["email"], raw_token=raw_token, ttl_minutes=ttl_minutes
+ )
+ audit_logger.write(AuditEvent(
+ event_type="auth_password_reset_requested",
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ role=record["role"],
+ route="/auth/forgot-password",
+ request={"email": record["email"]},
+ response={"email_sent": bool(delivery.get("email_sent"))},
+ metadata={},
+ ))
+ except Exception: # noqa: BLE001
+ # Never surface an internal failure here - it would turn into an oracle
+ # (error vs neutral 200 => email exists). The user can retry.
+ pass
+ return neutral
+
+
+@router.post("/reset-password", response_model=AuthResponse)
+async def reset_password(req: ResetPasswordRequest) -> AuthResponse:
+ """Complete a password reset: consume the emailed token, set the new
+ password, and sign the user in (returning a fresh access + refresh pair).
+
+ The reset token is single-use and short-lived; an unknown/expired/used token
+ yields an opaque 400 so nothing is leaked about why it failed.
+ """
+ _validate_password(req.password)
+
+ invalid = HTTPException(
+ status_code=400, detail="this reset link is invalid or has expired"
+ )
+ ref = password_reset.consume(req.token)
+ if ref is None:
+ raise invalid
+
+ users = get_users_repository()
+ record = users.get(tenant_id=ref.tenant_id, user_id=ref.user_id)
+ if record is None or record.get("status") != "active":
+ # The token was already consumed above, so a deactivated/missing account
+ # simply cannot complete the reset.
+ raise invalid
+
+ try:
+ record = users.set_password(
+ tenant_id=ref.tenant_id,
+ user_id=ref.user_id,
+ password_hash=hash_password(req.password),
+ )
+ except KeyError as exc:
+ raise invalid from exc
+
+ # A successful reset clears any active lockout so the user isn't locked out of
+ # the account they just regained control of.
+ try:
+ auth_lockout.record_success(record["tenant_id"], record["email"])
+ except Exception: # noqa: BLE001
+ pass
+
+ token = _mint_for(record)
+ refresh_token = _issue_refresh(record)
+ audit_logger.write(AuditEvent(
+ event_type="auth_password_reset_completed",
+ tenant_id=record["tenant_id"],
+ user_id=record["id"],
+ role=record["role"],
+ route="/auth/reset-password",
+ request={"email": record["email"]},
+ response={"user_id": record["id"]},
+ metadata={},
+ ))
+ return AuthResponse(
+ token=token,
+ refresh_token=refresh_token,
+ principal=_to_principal_payload(record),
+ onboarding_required=False,
+ )
+
+
@router.post("/logout", status_code=204)
async def logout(
authorization: str = Header(default=""),
diff --git a/app/config.py b/app/config.py
index 47e2319..5bd3a98 100644
--- a/app/config.py
+++ b/app/config.py
@@ -223,6 +223,9 @@ class Settings(BaseSettings):
# lockout. We have lockout (below) but not yet HIBP; raising to 12 reduces
# the brute-forceable space while we wire that up.
password_min_length: int = 12
+ # "Forgot password" reset links are short-lived and single-use. Kept tight
+ # because the link is a bearer credential delivered over email.
+ password_reset_ttl_minutes: int = 30
# Per-account brute force lockout. After this many consecutive failed
# /auth/signin attempts within auth_lockout_window_minutes, further
# attempts for the same email are rejected for auth_lockout_minutes.
diff --git a/app/core/email.py b/app/core/email.py
index 61e3aad..dc5e5d2 100644
--- a/app/core/email.py
+++ b/app/core/email.py
@@ -44,6 +44,11 @@ def _invite_url(raw_token: str) -> str:
return f"{base}/invitations/{raw_token}"
+def _reset_url(raw_token: str) -> str:
+ base = get_settings().app_public_base_url.rstrip("/")
+ return f"{base}/reset-password?token={raw_token}"
+
+
def _render_invite_html(
*, company_name: str, role_label: str, invite_url: str, expires: str, message: str | None
) -> str:
@@ -78,6 +83,80 @@ def _render_invite_html(
"""
+def _render_reset_html(*, reset_url: str, ttl_minutes: int) -> str:
+ expiry = (
+ f"about {ttl_minutes} minutes"
+ if ttl_minutes < 60
+ else f"about {ttl_minutes // 60} hour(s)"
+ )
+ return f"""\
+
+
+
PetroBrain password reset
+
Reset your password
+
+ We received a request to reset the password on your PetroBrain account. Click the button below to choose a new one. If you did not request this, you can safely ignore this email - your password will not change.
+
+ {sent ? 'Check your email' : 'Reset your password'}
+
+
+ {sent
+ ? confirmation
+ : 'Enter your account email and we will send you a link to set a new password.'}
+
+
+
+ {sent ? (
+
+
+ The link expires soon and can be used once. If it does not arrive, check your
+ spam folder or try again.
+
+
+ Back to sign in
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/apps/web/lib/auth/ResetPasswordForm.tsx b/frontend/apps/web/lib/auth/ResetPasswordForm.tsx
new file mode 100644
index 0000000..fe9f896
--- /dev/null
+++ b/frontend/apps/web/lib/auth/ResetPasswordForm.tsx
@@ -0,0 +1,186 @@
+'use client';
+
+import Link from 'next/link';
+import type { Route } from 'next';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useEffect, useRef, useState, type FormEvent } from 'react';
+
+import { Logo } from '@petrobrain/ui';
+
+import { useChatStore } from '@/lib/chat/store';
+
+import { AuthError, resetPassword } from './api';
+
+const MIN_PASSWORD_LENGTH = 8;
+
+/**
+ * Completes a password reset. The token comes from the emailed link
+ * (/reset-password?token=...). On success the backend signs the user straight
+ * in, so we store the session and drop them into the app just like signin.
+ */
+export function ResetPasswordForm() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const apiBaseUrl = useChatStore((s) => s.apiBaseUrl);
+ const setSession = useChatStore((s) => s.setSession);
+ const clearSessionExpired = useChatStore((s) => s.clearSessionExpired);
+
+ const token = searchParams.get('token') ?? '';
+
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const abortRef = useRef(null);
+ useEffect(() => () => abortRef.current?.abort(), []);
+
+ async function submit(e: FormEvent) {
+ e.preventDefault();
+ if (busy) return;
+ if (password.length < MIN_PASSWORD_LENGTH) {
+ setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
+ return;
+ }
+ if (password !== confirm) {
+ setError('Passwords do not match.');
+ return;
+ }
+ setError(null);
+ setBusy(true);
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const abortTimer = setTimeout(() => controller.abort(), 60_000);
+ try {
+ const res = await resetPassword(apiBaseUrl, { token, password }, controller.signal);
+ setSession(res.token, res.refresh_token, res.principal);
+ clearSessionExpired();
+ router.push('/chat' as Route);
+ } catch (err) {
+ if (err instanceof AuthError) {
+ setError(err.message);
+ } else if ((err as { name?: string }).name === 'AbortError') {
+ setError('That took too long. Please wait a moment and try again.');
+ } else {
+ setError('Could not reset your password. Check your connection and try again.');
+ }
+ setBusy(false);
+ } finally {
+ clearTimeout(abortTimer);
+ }
+ }
+
+ const missingToken = !token;
+
+ return (
+
+
+
+
+
+
+
+
+ PetroBrain
+
+
+ Choose a new password
+
+
+ {missingToken
+ ? 'This reset link is missing its token. Request a new one to continue.'
+ : 'Enter a new password for your account. You will be signed in once it is saved.'}
+
+
+
+ {missingToken ? (
+
+
+ Request a new link
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/apps/web/lib/auth/api.ts b/frontend/apps/web/lib/auth/api.ts
index d3dadae..4c4b7af 100644
--- a/frontend/apps/web/lib/auth/api.ts
+++ b/frontend/apps/web/lib/auth/api.ts
@@ -80,6 +80,72 @@ export function signin(
return postAuth(baseUrl, '/auth/signin', body, signal);
}
+/**
+ * Kick off a password reset for an email. The backend is enumeration-safe and
+ * always answers 200 with a neutral message, so this resolves with that message
+ * regardless of whether the email maps to an account. Only network/5xx faults
+ * throw, so the UI can show one neutral confirmation in the success path.
+ */
+export async function requestPasswordReset(
+ baseUrl: string,
+ email: string,
+ signal?: AbortSignal,
+): Promise {
+ const res = await fetch(`${baseUrl}/auth/forgot-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email }),
+ ...(signal ? { signal } : {}),
+ });
+ if (res.ok) {
+ try {
+ const data = (await res.json()) as { message?: string };
+ if (data && typeof data.message === 'string') return data.message;
+ } catch {
+ // Non-JSON 200; fall through to the generic confirmation.
+ }
+ return 'If that email belongs to an account, a reset link is on its way.';
+ }
+ let detail = `${res.status} ${res.statusText}`;
+ try {
+ const data = (await res.json()) as AuthErrorBody;
+ if (data && typeof data.detail === 'string') detail = data.detail;
+ } catch {
+ // non-JSON body; keep the status-line fallback
+ }
+ throw new AuthError(detail, res.status);
+}
+
+/**
+ * Complete a password reset with the emailed token and a new password. On
+ * success the backend signs the user straight in, returning the same
+ * `{token, refresh_token, principal}` shape as signin. Throws AuthError on an
+ * invalid/expired token (400) or a too-weak password (422).
+ */
+export async function resetPassword(
+ baseUrl: string,
+ body: { token: string; password: string },
+ signal?: AbortSignal,
+): Promise {
+ const res = await fetch(`${baseUrl}/auth/reset-password`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ ...(signal ? { signal } : {}),
+ });
+ if (res.ok) {
+ return (await res.json()) as AuthResponse;
+ }
+ let detail = `${res.status} ${res.statusText}`;
+ try {
+ const data = (await res.json()) as AuthErrorBody;
+ if (data && typeof data.detail === 'string') detail = data.detail;
+ } catch {
+ // non-JSON body; keep the status-line fallback
+ }
+ throw new AuthError(detail, res.status);
+}
+
/**
* Exchange a refresh token for a fresh access + refresh pair. Throws AuthError
* on a 401 (token used/expired/revoked) so the caller can drop the session and
diff --git a/tests/test_auth_password_reset.py b/tests/test_auth_password_reset.py
new file mode 100644
index 0000000..2b946e1
--- /dev/null
+++ b/tests/test_auth_password_reset.py
@@ -0,0 +1,166 @@
+"""Forgot-password / reset-password flow tests for /auth."""
+import os
+import sys
+from types import SimpleNamespace
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.api import deps, routes_auth
+from app.core import password_reset
+from app.db.tenants_repository import LocalJsonTenantsRepository
+from app.db.users_repository import LocalJsonUsersRepository
+from app.main import app
+from tests.auth_helpers import JWT_AUDIENCE, JWT_ISSUER, JWT_SECRET
+
+
+client = TestClient(app)
+
+
+def _auth_settings(**overrides):
+ base = {
+ "jwt_secret": JWT_SECRET,
+ "jwt_public_key": "",
+ "jwt_issuer": JWT_ISSUER,
+ "jwt_audience": JWT_AUDIENCE,
+ "jwt_ttl_hours": 1,
+ "refresh_token_ttl_days": 14,
+ "refresh_token_backend": "memory",
+ "enable_self_signup": True,
+ "default_signup_tenant_id": "demo",
+ "default_signup_tenant_name": "Demo",
+ "default_signup_role": "engineer",
+ "password_min_length": 8,
+ "password_reset_ttl_minutes": 30,
+ "environment": "dev",
+ "resend_api_key": "",
+ }
+ base.update(overrides)
+ return SimpleNamespace(**base)
+
+
+@pytest.fixture
+def tenants_repo(tmp_path):
+ return LocalJsonTenantsRepository(tmp_path / "tenants.jsonl")
+
+
+@pytest.fixture
+def users_repo(tmp_path):
+ return LocalJsonUsersRepository(tmp_path / "users.jsonl")
+
+
+@pytest.fixture
+def captured_emails(monkeypatch):
+ """Capture password-reset emails instead of sending them, exposing the raw
+ token so a test can drive the second leg of the flow."""
+ sent: list[dict] = []
+
+ def _fake_send(*, to_email, raw_token, ttl_minutes):
+ sent.append({"to_email": to_email, "raw_token": raw_token})
+ return {"email_sent": True, "message": "sent"}
+
+ monkeypatch.setattr("app.core.email.send_password_reset_email", _fake_send)
+ return sent
+
+
+@pytest.fixture(autouse=True)
+def wire(monkeypatch, tenants_repo, users_repo):
+ settings = _auth_settings()
+ monkeypatch.setattr(deps, "get_settings", lambda: settings)
+ monkeypatch.setattr(routes_auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(routes_auth, "get_users_repository", lambda: users_repo)
+ monkeypatch.setattr(routes_auth, "get_tenants_repository", lambda: tenants_repo)
+ from app.core import auth_lockout
+ auth_lockout.reset_for_tests()
+ password_reset.reset_for_tests()
+ from app.core.http_hardening import clear_rate_limits
+ clear_rate_limits()
+ yield
+ password_reset.reset_for_tests()
+
+
+def _signup(email: str, password: str = "hunter2hunter2") -> dict:
+ r = client.post("/auth/signup", json={"email": email, "password": password})
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+def test_forgot_password_unknown_email_is_neutral_200(captured_emails):
+ r = client.post("/auth/forgot-password", json={"email": "nobody@example.com"})
+ assert r.status_code == 200, r.text
+ assert "reset link" in r.json()["message"].lower()
+ # No email issued for an unknown address.
+ assert captured_emails == []
+
+
+def test_forgot_password_known_email_issues_reset_token(captured_emails):
+ _signup("known@example.com")
+ r = client.post("/auth/forgot-password", json={"email": "known@example.com"})
+ assert r.status_code == 200, r.text
+ assert len(captured_emails) == 1
+ assert captured_emails[0]["to_email"] == "known@example.com"
+ assert captured_emails[0]["raw_token"]
+
+
+def test_forgot_password_response_identical_for_known_and_unknown(captured_emails):
+ _signup("real@example.com")
+ known = client.post("/auth/forgot-password", json={"email": "real@example.com"})
+ unknown = client.post("/auth/forgot-password", json={"email": "ghost@example.com"})
+ # Enumeration-safety: same status and same body either way.
+ assert known.status_code == unknown.status_code == 200
+ assert known.json() == unknown.json()
+
+
+def test_reset_password_completes_and_signs_in(captured_emails):
+ _signup("reset@example.com", password="oldpassword1")
+ client.post("/auth/forgot-password", json={"email": "reset@example.com"})
+ token = captured_emails[0]["raw_token"]
+
+ r = client.post(
+ "/auth/reset-password", json={"token": token, "password": "brandnewpass1"}
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["token"] and body["refresh_token"]
+ assert body["principal"]["email"] == "reset@example.com"
+
+ # New password works; old one no longer does.
+ assert client.post(
+ "/auth/signin", json={"email": "reset@example.com", "password": "brandnewpass1"}
+ ).status_code == 200
+ assert client.post(
+ "/auth/signin", json={"email": "reset@example.com", "password": "oldpassword1"}
+ ).status_code == 401
+
+
+def test_reset_token_is_single_use(captured_emails):
+ _signup("once@example.com")
+ client.post("/auth/forgot-password", json={"email": "once@example.com"})
+ token = captured_emails[0]["raw_token"]
+
+ first = client.post(
+ "/auth/reset-password", json={"token": token, "password": "firstchange1"}
+ )
+ assert first.status_code == 200
+ second = client.post(
+ "/auth/reset-password", json={"token": token, "password": "secondchange1"}
+ )
+ assert second.status_code == 400
+
+
+def test_reset_password_rejects_unknown_token():
+ r = client.post(
+ "/auth/reset-password", json={"token": "not-a-real-token", "password": "whatever12"}
+ )
+ assert r.status_code == 400
+ assert "invalid" in r.json()["detail"].lower() or "expired" in r.json()["detail"].lower()
+
+
+def test_reset_password_rejects_short_password(captured_emails):
+ _signup("shorty@example.com")
+ client.post("/auth/forgot-password", json={"email": "shorty@example.com"})
+ token = captured_emails[0]["raw_token"]
+ r = client.post("/auth/reset-password", json={"token": token, "password": "abc"})
+ assert r.status_code == 422