Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,42 @@ FALKORDB_URL=redis://localhost:6379/0 # REQUIRED - change to your FalkorDB URL
# OAUTH_BASE_URL=http://localhost:5000

# -----------------------------
# Email Configuration (optional - for sending invitation emails)
# -----------------------------
# Email Configuration
# -----------------------------
# Signup with email/password sends a six-digit confirmation code, and the
# account is only created when that code is typed back into the signup form.
# With APP_ENV=development and no MAIL_SERVER the message is written to the
# application log instead of being sent, which is enough for local development
# -- copy the code out of the log. Anywhere else an unconfigured process
# refuses the send rather than logging the code, so signup fails loudly instead
# of leaving users waiting for mail nobody sent.
#
# Any provider works: Mailgun, SendGrid, Resend, SES and Postmark all expose an
# SMTP endpoint.
# MAIL_SERVER=smtp.mailgun.org
# MAIL_PORT=587
# MAIL_USE_TLS=True
# MAIL_USERNAME=your_mail_username
# MAIL_PORT=587 # 465 selects implicit TLS (SMTPS)
# MAIL_USE_TLS=True # STARTTLS on non-465 ports
# MAIL_USERNAME=your_mail_username # omit to send unauthenticated
# MAIL_PASSWORD=your_mail_password
# MAIL_DEFAULT_SENDER=noreply@yourdomain.com
# MAIL_TIMEOUT_SECONDS=10

# Write messages to this directory as .eml files instead of sending them. Used
# by the Playwright suite to read the verification code back. Takes precedence
# over MAIL_SERVER, so a machine with a real relay configured can still run the
# tests without mailing anyone.
# MAIL_OUTBOX_DIR=e2e/.mail

# Email/password auth is on by default when no OAuth provider is configured.
# EMAIL_AUTH_ENABLED=false

# Confirmation code lifetime, wrong guesses allowed per code, and per-address
# send limits.
# EMAIL_VERIFICATION_TTL_MINUTES=15
# EMAIL_VERIFICATION_MAX_ATTEMPTS=5
# EMAIL_VERIFICATION_RESEND_SECONDS=60
# EMAIL_VERIFICATION_MAX_SENDS=5

# -----------------------------
# Frontend / analytics (optional)
# -----------------------------
Expand Down
5 changes: 5 additions & 0 deletions .github/wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,8 @@ SDK
Dependabot
PyPI
pypi
signup
SMTP
outbox
PendingSignup
unconfigured
4 changes: 4 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ jobs:
FASTAPI_DEBUG=False
FALKORDB_URL=redis://localhost:6379
DISABLE_MCP=true
MAIL_OUTBOX_DIR=e2e/.mail
EOF

# Start the FastAPI application
Expand Down Expand Up @@ -169,6 +170,9 @@ jobs:
FASTAPI_DEBUG: False
FALKORDB_URL: redis://localhost:6379
DISABLE_MCP: true
# Signup mails a confirmation code and the account is only created when
# it is typed back, so the suite has to be able to read the message.
MAIL_OUTBOX_DIR: e2e/.mail
# Azure OpenAI API keys - required for database schema analysis
AZURE_API_KEY: ${{ secrets.AZURE_API_KEY }}
AZURE_API_BASE: ${{ secrets.AZURE_API_BASE }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ demo_tokens.py
/playwright/.cache/
/playwright/.auth/
e2e/.auth/
e2e/.mail/
# Build artifacts
clients/python/queryweaver_client.egg-info/
clients/ts/dist/
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ Optional overrides: `COMPLETION_MODEL`, `EMBEDDING_MODEL` (must match provider),

Authentication is deliberately split three ways — a signed session cookie for the browser login (no FalkorDB dependency, see `api/auth/browser_session.py`), FalkorDB-backed API tokens for programmatic clients, and per-request data-source credentials. `validate_user` in `api/auth/user_management.py` owns the precedence between them.

Email/password signup is verified before the account exists: `POST /signup/email` parks the details on a `PendingSignup` node (`api/auth/email_verification.py`) and mails a six-digit code, and `POST /signup/email/verify` is what creates the `User` and establishes the session. A code rather than a link, so that confirmation has to come back through the session that submitted the form — an emailed link can be opened by a victim who never signed up, which would create an account under a password the sender chose. Holding the code is not on its own enough: every submission also mints a ticket that stays in the submitting browser's session (`remember_signup_ticket` in `api/auth/browser_session.py`) and is matched, hashed, inside the same query that spends the code, so a code read out of another person's inbox cannot be redeemed. Wrong guesses are charged against a per-record attempt budget that destroys the pending signup once it runs out; that budget, not the six digits, is what makes a short code safe. The send budget is likewise per record and resets when the record expires, so exhausting it cannot lock an address out permanently. There is therefore no `email_verified` flag anywhere — an unconfirmed address is simply absent from the account graph. Every branch of `POST /signup/email` answers the same 202, so nothing about a pending signup is observable from outside. Mail goes through `api/mail.py`, which picks a transport from the environment: a file outbox (`MAIL_OUTBOX_DIR`, used by the Playwright suite to read the code back), SMTP (`MAIL_SERVER`), or — only when `APP_ENV=development` — the console; an unconfigured process anywhere else fails the send rather than logging the code.

See `.env.example` for the full list.

## CI/CD
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ clean: ## Clean up test artifacts
rm -rf test-results/
rm -rf playwright-report/
rm -rf e2e/.auth/
rm -rf e2e/.mail/
rm -rf __pycache__/
rm -rf dist/
rm -rf *.egg-info/
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,46 @@ the browser, so there is none to revoke — tokens are created explicitly from t
tokens API and revoked there. (A legacy `api_token` cookie left over from an
older release is cleared and revoked on logout too.)

#### Email signup is verified before the account exists

Signing up with an email address and password does not create an account. The
submitted details are parked, a six-digit confirmation code is mailed to the
address, and the account — and the session — come into being only when that code
is typed back into the signup form. So an address the registrant does not
control never becomes an account at all, and there is no half-real user for the
rest of the system to reason about.

A code rather than an emailed link, because the code has to come back to the
session that submitted the form. A link can be opened by anyone who receives it:
a stranger could submit your address with a password of their choosing, and your
single click would create an account they knew the password to. Nobody can be
signed up by someone else here, because the person who fills in the form is the
only one who ever holds both halves.

The code is single-use, expires after 15 minutes and tolerates only a handful of
wrong guesses before the pending signup is discarded — a short code is only safe
while the number of attempts is small. It is also only redeemable in the browser
that submitted the form: each submission mints a ticket that stays in that
browser's session, and a code presented without its ticket is refused. Entering
it signs the browser in directly: the password was chosen minutes earlier, and
asking for it again would prove nothing. A code can be re-sent from the same
screen, subject to a per-address rate limit; the send budget is per pending
signup, so it starts over once the pending signup expires and an address can
always be signed up again later. Typing a code that has expired is not one of
the wrong guesses and does not discard anything — the pending signup is left
where it is so the same screen can send a fresh code.

In development, a message with no mail server configured is written to the
application log instead of being sent, so the flow can be completed by copying
the code out of the log. This needs `APP_ENV=development` — anywhere else an
unconfigured process refuses the send rather than logging the code and
reporting success. Set `MAIL_SERVER` (plus `MAIL_PORT`, `MAIL_USERNAME`,
`MAIL_PASSWORD`, `MAIL_DEFAULT_SENDER`) to send for real; any provider with an
SMTP endpoint works. `EMAIL_VERIFICATION_TTL_MINUTES`,
`EMAIL_VERIFICATION_MAX_ATTEMPTS`, `EMAIL_VERIFICATION_RESEND_SECONDS` and
`EMAIL_VERIFICATION_MAX_SENDS` tune the lifetime and the limits. See
`.env.example` for the full list.

The trade-off of a signed session cookie is that it cannot be revoked from
the server before it expires: the TTL bounds the damage, and rotating
`FASTAPI_SECRET_KEY` invalidates every browser login at once. API tokens keep
Expand Down
42 changes: 42 additions & 0 deletions api/auth/browser_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
# Key under which the login payload lives inside the Starlette session dict.
SESSION_KEY = "browser_login"

# Key under which a signup awaiting its mailed code parks its ticket. Separate
# from the login: it is held before any account exists, and clearing one must
# not clear the other.
SIGNUP_TICKET_KEY = "signup_ticket"

# Bumped whenever the payload shape changes, so old cookies are ignored rather
# than misread.
SESSION_VERSION = 1
Expand Down Expand Up @@ -176,3 +181,40 @@ def mark_provisioned(request: Request) -> None:
payload["provisioned"] = True
# Reassign so Starlette re-serialises the mutated payload.
store[SESSION_KEY] = payload


def remember_signup_ticket(request: Request, *, email: str, ticket: str) -> None:
"""Hold the ticket for a signup this browser just started.

Not a login -- there is no account yet. It is the browser's half of the
pending signup, and it lives here rather than in the graph because that is
exactly what it has to prove: that the caller redeeming the mailed code is
the same browser that submitted the password being redeemed. One at a time
is enough; a second signup in the same browser replaces the first.

Storing it in a signed-but-readable cookie is fine. It is a capability of
the browser it was handed to, so its owner reading it learns nothing they
did not already have, and it is worthless without the code that was mailed.
"""
store = _session_store(request)
if store is None:
logging.error("Cannot hold a signup ticket: SessionMiddleware is not installed")
return
store[SIGNUP_TICKET_KEY] = {"email": email, "ticket": ticket}


def read_signup_ticket(request: Request, *, email: str) -> Optional[str]:
"""Return this browser's ticket for ``email``, or ``None``."""
store = _session_store(request)
payload = store.get(SIGNUP_TICKET_KEY) if store else None
if not isinstance(payload, dict) or payload.get("email") != email:
return None
ticket = payload.get("ticket")
return ticket if isinstance(ticket, str) and ticket else None


def forget_signup_ticket(request: Request) -> None:
"""Drop any held signup ticket. The code it belonged to is spent."""
store = _session_store(request)
if store is not None:
store.pop(SIGNUP_TICKET_KEY, None)
Loading