diff --git a/CHANGELOG.md b/CHANGELOG.md index 8180a63..16cf905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,141 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.7.0 — 2026-05-28 + +**Minor — schema migration required; new auth path is additive.** +This release lands email + one-time-code sign-in (roadmap item #5, +SPEC §6.2) as the primary human-auth gesture. Users sign in by +typing their email, receiving a six-digit code via email, and +entering it. The Gitea OAuth callback (`/auth/callback`) remains +functional during migration — the new UI no longer points at it +primarily, but a "Sign in with Gitea (fallback)" link survives on +the new login surface so users with active OAuth sessions or older +invite paths still have a way in. A future release retires the +OAuth path entirely once every active user has signed in at least +once via OTC. + +The migration path for existing users: first OTC sign-in matches by +`users.email` (case-insensitive) to the OAuth-era row and reuses +that row's `id` and `gitea_id`. New users provisioned via OTC carry +`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker +remains the canonical handle for grandfathered users; `email` +becomes the identity key for everything provisioned after v0.7.0. + +The Gitea **bot** user + token are still required (server-side git +operations — repo reads, PR creation — still flow through it). Only +the operator-facing sign-in surface moves. + +### Upgrade steps (from 0.6.0) + +1. **MUST** restart the backend so migration `012_otc.sql` runs. + The migration rebuilds the `users` table (SQLite cannot ALTER + COLUMN); existing rows pass through unchanged, but the new + schema relaxes `gitea_id` / `gitea_login` to nullable (with + partial unique indexes that ignore NULL) and adds a partial + unique index on `email`. A new `otc_codes` table is created. +2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`, + `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`, + `EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already + carries these as of v0.5.0; deployments without them fall back + to logging the code to stdout (dev-only path — production + visitors will not receive their codes). +3. **SHOULD** announce the new email-based sign-in to existing + users. Wording suggestion: "You can now sign in by entering + your email and a one-time code we'll send you. Your old + account is linked automatically the first time you sign in." +4. **MAY** keep the existing OAuth callback as a fallback path. + The new login UI surfaces a small "Sign in with Gitea + (fallback)" link beneath the primary email/code form; a + deployment that prefers to hide it can override the Login + component in a future framework release that exposes the link + behind a feature flag. For v0.7.0, the link is hard-coded. + +### New environment variables (all optional with defaults) + +- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is + valid after issuance. Re-requesting invalidates the prior code + immediately regardless of TTL. +- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown + between successive `/auth/otc/request` calls. The endpoint returns + HTTP 429 when the cooldown blocks a request (the loud-failure + shape; the abuse path is visible rather than swallowed). + +No new secrets are required. The existing `SECRET_KEY` continues to +sign session cookies; OTC codes are bcrypt-hashed at rest using a +per-row salt the library generates. + +### Added + +- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit + code, stores its bcrypt hash with an expiry, and dispatches a plain + text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`) + uniformly so allowlist state is not leaked. Returns HTTP 429 when + the per-email cooldown blocks the request. +- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the + bcrypt hash against the most-recent unconsumed non-expired row, marks + the row consumed, provisions or links the `users` row by email, and + stores the session cookie. Returns HTTP 200 on success, HTTP 400 on + any failure (expired, consumed, wrong, unknown). +- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and + rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a + partial unique index on `email`. +- **`backend/app/otc.py`** — the OTC request/verify state machine and + the `provision_or_link_user` linker. +- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses + the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the + test buffer (`_SENT`) but writes its own envelope (no unsubscribe + footer, no quiet-hours hold — OTC mail carries a credential and + ignores notification preferences). +- **`frontend/src/components/Login.jsx`** — two-step sign-in surface + at `/login`. Step 1: enter email → request code. Step 2: enter + six-digit code → verify. Cmd/Ctrl+Enter on the code field submits. + The previous header "Sign in" link and the `Welcome` component's + inline link now route to `/login` instead of jumping straight to + the Gitea OAuth dance. +- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per + §19.3 rule-2 — see below. +- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering + the happy path, expired/consumed/wrong codes, the per-email rate + limit (and its per-email isolation), the allowlist gate, the + migration link to OAuth-era users, fresh provisioning, and the + prior-code-invalidation behavior on re-request. + +### Changed + +- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and + NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays + stable for OTC-only users. The DB remains the source of truth for + "is this user OAuth-linked" (`gitea_id IS NOT NULL`). +- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code + hashing. Pure-Python wheels are available on every platform the + deployment matrix targets; `pip install -r backend/requirements.txt` + picks it up. +- **`frontend/src/App.jsx`** — adds the `/login` route and replaces + the header "Sign in" `` with ``. + The `Welcome` component's inline sign-in link follows suit. +- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's + primary action moves from "Sign in with Gitea" to "Sign in" pointing + at `/login`. + +### Deferred to later releases + +Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9, +#10), several adjacent capabilities are intentionally not in this +release and surface as §19.2 candidates: + +- **First-OTC profile capture** (first name, last name, "why") — item + #6, expected v0.8.0. +- **Open beta-access request flow** replacing the allowlist gate — + also item #6, v0.8.0. +- **Passcodes** (a long-term reauth token alternative) — item #8, + expected v0.10.0. +- **Device-trust 30-day skip** — item #9, expected v0.11.0. +- **Cloudflare Turnstile** on `/auth/otc/request` — item #10, + expected v0.12.0. +- **Removing the Gitea OAuth `/auth/callback` route entirely** — a + later release after every active user has signed in via OTC. + ## 0.5.0 — 2026-05-27 **Minor — no operator action required.** This release wires the diff --git a/SPEC.md b/SPEC.md index 1a3c1c4..d15e470 100644 --- a/SPEC.md +++ b/SPEC.md @@ -349,17 +349,34 @@ merge with no data movement. Authorization is owned by the app. Gitea sees only the bot account. +Authentication, as of v0.7.0, is by email + one-time-code: a visitor +enters their email address, receives a six-digit code via SMTP, and +exchanges the code for a session. The Gitea OAuth callback that +v0.1 used as the human sign-in path remains as a migration fallback +during the v0.7.0 window — `users.gitea_id` is preserved on existing +rows so a grandfathered user signing in via either path resolves to +the same row — but the primary surface points at OTC. `users.email` +is the identity key for everything provisioned after v0.7.0; +`users.gitea_id` is the grandfathering linker (nullable, partial- +unique). The Gitea bot user + token are still required for server- +side git operations (repo reads, PR creation); only the operator- +facing sign-in surface moved. + ### 6.1 Four roles, each a strict superset of the one below 1. **Anonymous.** Can read public RFCs (the meta repo's main branch, every RFC repo's main branch), read any branch whose `read_public` is true, read any PR. Cannot chat, propose, create branches, or open PRs. -2. **Contributor.** Default role for any authenticated account. - Everything anonymous can do, plus: propose new RFCs (open a PR - against the meta repo), create branches on any RFC repo, open PRs - from branches they have contribute access to, chat on anything - they can read, claim ownership of unclaimed super-drafts. +2. **Contributor.** Default role for any authenticated account. A + first OTC sign-in by a previously unknown email provisions a row + at this role; v0.7.0 keeps the v0.3.0 allowlist gate (`allowed_emails`) + as the admission control, deferring the open beta-access request + flow to a later release. Everything anonymous can do, plus: + propose new RFCs (open a PR against the meta repo), create + branches on any RFC repo, open PRs from branches they have + contribute access to, chat on anything they can read, claim + ownership of unclaimed super-drafts. 3. **Admin.** Everything contributor can do, plus: act on any RFC (merge PRs on behalf of arbiters, graduate super-drafts, set branch visibility on anyone's behalf, downgrade or restore @@ -377,6 +394,14 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the reverse action. Every mute and restore is logged in `permission_events`. +The write-mute is keyed on `users.id` and is auth-path-agnostic: a +contributor muted under the v0.1 OAuth-era flow stays muted after +the v0.7.0 email/OTC migration, since the same row is reused via the +email-match linker. Identity in this section means the `users.id` +column; the v0.7.0 identity-key shift (`gitea_id` → `email`) is +about which column carries the unique constraint for new +provisioning, not about which column the permission gates read. + This write-mute is structurally distinct from the two notification mutes introduced in §15.8 — the per-RFC notification mute (the `muted` state on the `watches` row, §15.6) and the per-user @@ -1934,8 +1959,12 @@ and its public face. The app's root URL, accessed by an unauthenticated visitor, renders a landing page consisting of the title, the subtitle, and the short-form deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a -single primary action: "Sign in with Gitea." Beneath that, a secondary -link: "Read the full philosophy" → `/philosophy`. +single primary action: "Sign in" → the email + one-time-code surface +at `/login` (per §6.2). Beneath that, a secondary link: "Read the +full philosophy" → `/philosophy`. The v0.1 landing said "Sign in +with Gitea"; v0.7.0's email/OTC surface replaced that as the primary +gesture, with a small "Sign in with Gitea (fallback)" link surviving +on `/login` itself for the migration window. This is the front door. It sets expectation before the user encounters the mechanics, so the mechanics (super-drafts, graduation, public @@ -2489,6 +2518,28 @@ The follow-up session will refine this. A minimal starting set: returned `version` matches the tag the operator just deployed, catching the failure mode where a restart did not pick up the new code. +- `POST /auth/otc/request` — unauthenticated. Body carries `email`. + Generates a six-digit code, stores its bcrypt hash with an expiry + (`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email + via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly so + allowlist state (§6.1 / §6.2) is not leaked to callers. Returns + HTTP 429 when the per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, + default 60) blocks back-to-back requests — the loud-failure shape + for the abuse path. A re-request invalidates the prior unused + code for the same email so only one code is outstanding at a time. + Per §19.2's expected next session, this endpoint is the lead-up + to the Cloudflare-Turnstile abuse-mitigation overlay. +- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and + `code`. Validates the bcrypt hash against the most-recent unconsumed + non-expired row for the email, marks the row consumed, provisions + or links the `users` row by email (per §6.2's migration path — + match by `users.email` case-insensitive, otherwise insert a fresh + contributor row with `gitea_id = NULL`), and stores the session + cookie. Returns HTTP 200 on success with a minimal user payload; + HTTP 400 on any failure (expired, consumed, wrong, unknown). The + failure modes collapse to a single generic message so a probing + client cannot distinguish "you got the wrong code" from "we don't + know this email" — the operator logs carry the distinction. - `GET /api/rfcs` — list entries with state, id, title, slug, repo, owners, last_active_at, has_open_prs, starred-by-me. Supports search, sort, filter chips, and the `unclaimed` predicate. @@ -3448,6 +3499,68 @@ the new §15 (Notifications, in full), and §17 (the notification endpoints — list, mark-read, stream, watch mutation, preferences, quiet-hours, per-user mute, unsubscribe, bounce webhook). +- **First-OTC profile capture.** *Surfaced by v0.7.0's email/OTC + migration.* When a fresh email lands at `/auth/otc/verify` with + no matching `users.email` row, v0.7.0 provisions the row with + `display_name = ` and no other identity + fields. A subsequent release (the roadmap item-#6 candidate) + is expected to add a one-shot profile-capture step on the + first-OTC sign-in: first name, last name, and a free-text + "why I want access" field that flows into the open beta-access + request queue (also item #6) that replaces the v0.3.0 + `allowed_emails` gate. The schema slot exists implicitly already + (`users.display_name` is updateable, the audit-log + permission- + events tables carry the freeform notes); the structural decision + is what gates the capture (modal on `/login` after verify? a + one-time redirect to `/welcome/profile`? a deferred banner on + the main view?) and how it interacts with the open-access + request flow that replaces the allowlist. Earns its session as + the v0.8.0 design pass. +- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.* + v0.7.0 keeps `/auth/callback` functional and links to it as a + "Sign in with Gitea (fallback)" affordance on the new `/login` + surface, so users with active OAuth sessions or older invite + paths still have a way in during the migration window. A later + release retires the route entirely. Decision points: how do we + know "every active user has signed in via OTC at least once" + (probably: a `users.otc_first_signed_in_at` timestamp added in + v0.7.x and a query that confirms 100% population), how do we + handle users who never come back (probably: silently leave them + with stale rows; OAuth callback returning 404 is a sufficient + message), and whether the `/auth/login` and `/auth/callback` + routes get a tombstone redirect to `/login` or just 404. Earns + its session once the OTC adoption curve flattens. +- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the + signed-in cookie already lasts 30 days via SessionMiddleware, + but every sign-in still requires a fresh OTC.* The roadmap + item-#9 candidate adds a "trust this device" affordance on the + verify step that issues a longer-lived rotating token, so + returning visitors on the same device skip the OTC step. The + shape question is whether the trust is a signed cookie distinct + from the session, a row in a `device_trust` table keyed by a + random device-id, or a property of the session itself; and + whether the trust survives password-equivalent events (none + exist yet — passcodes are item #8 / v0.10.0) or only survives + explicit logout. Earns its session as the v0.11.0 design pass. +- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.** + *Surfaced by v0.7.0 — the endpoint is now the new abuse hot + path.* Per-email cooldown stops the trivial loop; what it + doesn't stop is a distributed scrape that fans out across a + large invitee list to harvest the "this email is admitted vs. + this email is not" signal indirectly (timing differences, SMTP + bounce-rate observation). The roadmap item-#10 candidate gates + the request endpoint behind a one-step browser-side challenge + before the bcrypt hash + SMTP send. Open questions: which + provider (Turnstile is the default since it's free and + privacy-respecting; hCaptcha and reCAPTCHA are also viable); + how the deployment configures it (`TURNSTILE_SITE_KEY` + + `TURNSTILE_SECRET_KEY` env vars, gated by `if + config.turnstile_site_key:` at the handler so existing + deployments don't break); whether the verify endpoint also + gets a challenge (probably yes for parity); and how the test + harness mocks the challenge. Earns its session as the v0.12.0 + design pass. + ### 19.3 Working agreement for the queue Pre-build sessions ran on the queue agreement from prior versions diff --git a/VERSION b/VERSION index 8f0916f..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.7.0 diff --git a/backend/.env.example b/backend/.env.example index 7b98a77..779d2dc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -81,3 +81,14 @@ WEBHOOK_EMAIL_BOUNCE_SECRET= # Production default is hourly; tests override to seconds via the same # env var. HYGIENE_TICK_SECONDS=3600 + +# --- v0.7.0: email + one-time-code sign-in (§6.2) --- +# How long a one-time code stays valid after issuance. Re-requesting +# invalidates the prior code immediately regardless of TTL. +OTC_TTL_MINUTES=10 + +# Per-email cooldown between successive /auth/otc/request calls. The +# endpoint returns HTTP 429 when the cooldown blocks a request (the +# loud-failure shape so the abuse path is visible). Set to 0 to +# disable the cooldown — useful for tests but never in production. +OTC_REQUEST_COOLDOWN_SECONDS=60 diff --git a/backend/app/auth.py b/backend/app/auth.py index b37ed51..f2c26fe 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -193,10 +193,16 @@ def current_user(request: Request) -> SessionUser | None: ).fetchone() if row is None: return None + # v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login. + # Coerce nulls to the SessionUser's typed defaults so downstream + # code (Actor, _on_behalf_trailer) reads a stable shape regardless + # of which sign-in path the row came from. The DB remains the + # source of truth for "is this an OAuth-linked user" (gitea_id IS + # NOT NULL); the in-memory SessionUser is the per-request handle. return SessionUser( user_id=row["id"], - gitea_id=row["gitea_id"], - gitea_login=row["gitea_login"], + gitea_id=row["gitea_id"] or 0, + gitea_login=row["gitea_login"] or "", display_name=row["display_name"], email=row["email"] or "", avatar_url=row["avatar_url"] or "", diff --git a/backend/app/email_otc.py b/backend/app/email_otc.py new file mode 100644 index 0000000..b2f59d9 --- /dev/null +++ b/backend/app/email_otc.py @@ -0,0 +1,96 @@ +"""Outbound OTC email — a thin wrapper over the existing SMTP layer. + +The §15.4 notification mailer in `email.py` is purpose-built for +inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling). +OTC mail is structurally different: it carries a credential, has no +inbox row behind it, and ignores user-preferences (a contributor +who's opted out of every notification still needs to receive the +code they explicitly requested). + +So this module reuses `EmailConfig.from_env()` for the SMTP plumbing +and the From identity, but writes its own envelope. In dev (no +SMTP_HOST set), the envelope is logged at INFO level and pushed to +the same `_SENT` buffer the notification mailer uses, so the +integration tests can assert on the outbound shape without standing +up an SMTP server. + +The send is synchronous. The `/auth/otc/request` endpoint always +returns 202 regardless of send outcome — the user-facing surface +doesn't know whether the SMTP relay was reachable, since revealing +that would let an attacker probe for valid emails on a tight loop. +""" +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage +from email.utils import formataddr + +from .email import EmailConfig, _SENT + +log = logging.getLogger(__name__) + + +def send_otc_email(to_address: str, code: str) -> bool: + """Compose and send the one-time-code email. Returns True on the + happy path; False on SMTP failure. The notifier-side buffer + `_SENT` is appended either way so tests can assert on content. + + The subject and body intentionally avoid branding strings that + belong to a deployment — only `EMAIL_FROM_NAME` (operator-supplied + via env) lands in the From line. The body names the code, the + TTL, and a single instruction line. No tracking pixel, no + deep-link query, no embedded JS — plain text only.""" + cfg = EmailConfig.from_env() + subject = f"Your sign-in code for {cfg.from_name}" + body = _body(code, cfg) + envelope = { + "to": to_address, + "from": formataddr((cfg.from_name, cfg.from_address)), + "subject": subject, + "body": body, + "kind": "otc", + } + _SENT.append(envelope) + + if not cfg.enabled: + log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address) + return True + if not cfg.smtp_host: + # Dev fallback: surface the code at INFO so the operator can + # complete a sign-in flow without an SMTP relay. In production + # SMTP_HOST is always set per OHM's overlay. + log.info("otc email (stdout fallback): to=%s code=%s", to_address, code) + return True + + try: + msg = EmailMessage() + msg["From"] = envelope["from"] + msg["To"] = to_address + msg["Subject"] = subject + msg.set_content(body) + smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + try: + if cfg.smtp_starttls: + smtp.starttls() + if cfg.smtp_user: + smtp.login(cfg.smtp_user, cfg.smtp_password) + smtp.send_message(msg) + finally: + smtp.quit() + return True + except Exception: + log.exception("otc email send failed: to=%s", to_address) + return False + + +def _body(code: str, cfg: EmailConfig) -> str: + return ( + f"Your sign-in code is:\n\n" + f" {code}\n\n" + f"Enter this code in the sign-in screen to finish signing in.\n" + f"The code expires in 10 minutes. If you did not request this,\n" + f"you can safely ignore this email — no account was created.\n\n" + f"---\n" + f"{cfg.from_name} · {cfg.app_url}\n" + ) diff --git a/backend/app/main.py b/backend/app/main.py index 7b2b21a..a03673d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,9 +12,21 @@ from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import RedirectResponse +from pydantic import BaseModel, Field from starlette.middleware.sessions import SessionMiddleware -from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks +from . import ( + api as api_routes, + auth, + cache, + db, + digest, + email_otc, + hygiene, + otc, + providers as providers_mod, + webhooks, +) from .bot import Bot from .config import load_config from .gitea import Gitea @@ -23,6 +35,15 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name log = logging.getLogger("rfc_app") +class OtcRequestBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class OtcVerifyBody(BaseModel): + email: str = Field(min_length=3, max_length=320) + code: str = Field(min_length=1, max_length=16) + + @asynccontextmanager async def lifespan(app: FastAPI): config = load_config() @@ -122,4 +143,43 @@ def _oauth_router(config) -> APIRouter: request.session.clear() return RedirectResponse("/") + # --------------------------------------------------------------- + # v0.7.0: email + one-time-code sign-in (§6.2). + # + # Replaces the OAuth gesture as the primary human-auth path. The + # /auth/callback handler above remains functional as a fallback; + # the new UI no longer surfaces it. A future release retires the + # OAuth path entirely once every active user has signed in at + # least once via OTC. + # --------------------------------------------------------------- + + @router.post("/auth/otc/request") + async def otc_request(body: OtcRequestBody): + outcome = otc.request_code(body.email) + if outcome.reason == "cooldown": + # Loud failure per the rate-limit primitive — the abuse + # surface should be visible to clients hammering /request. + raise HTTPException(429, "Wait before requesting another code") + if outcome.sent and outcome.code is not None: + email_otc.send_otc_email(body.email.strip(), outcome.code) + # 202 regardless of allowlist/invalid — don't leak which + # emails are recognized. + return {"ok": True} + + @router.post("/auth/otc/verify") + async def otc_verify(body: OtcVerifyBody, request: Request): + result = otc.verify_code(body.email, body.code) + if not result.ok or result.user is None: + raise HTTPException(400, "Invalid or expired code") + auth.store_session(request, result.user) + return { + "ok": True, + "user": { + "id": result.user.user_id, + "display_name": result.user.display_name, + "email": result.user.email, + "role": result.user.role, + }, + } + return router diff --git a/backend/app/otc.py b/backend/app/otc.py new file mode 100644 index 0000000..d9ec359 --- /dev/null +++ b/backend/app/otc.py @@ -0,0 +1,329 @@ +"""§6.2 / v0.7.0: email + one-time-code sign-in. + +Replaces the Gitea OAuth gesture as the primary human-auth path. The +Gitea bot user + token are still needed for server-side git +operations (repo reads, PR creation); only the operator-facing +sign-in surface moves through this module. + +The shape: + + * `request_code(email)` generates a 6-digit decimal code, + hashes it (bcrypt), stores the hash + expiry in `otc_codes`, + and dispatches a plain-text email via `email_otc.send`. It + invalidates any prior unused codes for the same email so a + re-request keeps the surface to one outstanding code per + address. The TTL comes from `OTC_TTL_MINUTES` (default 10). + A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default + 60) refuses back-to-back requests inside the window. + + * `verify_code(email, code)` walks the most recent unconsumed + non-expired row for the email, checks the bcrypt hash, marks + the row consumed, and returns the linked or freshly-provisioned + user row. + + * `provision_or_link_user(email)` is the migration path: if a + `users` row already carries `email` (case-insensitive), it is + reused — `gitea_id` is left alone so a grandfathered OAuth-era + user keeps the linker intact. Otherwise a fresh contributor + row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`. + +The endpoints in `main.py` thin-wrap this module. The allowlist gate +from v0.3.0 is consulted at request time — if `allowed_emails` is +populated and the requested address isn't on it, the request returns +202 as usual but no email is sent. This intentionally does not leak +allowlist state to the caller; the §19.2 candidate for v0.8.0 +replaces this gate with an admin-grant flow. +""" +from __future__ import annotations + +import logging +import os +import secrets +from dataclasses import dataclass + +import bcrypt + +from . import db +from .auth import SessionUser, allowlist_is_active + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Tunables — env-driven with defaults so v0.7.0 needs no new secrets. +# --------------------------------------------------------------------------- + + +def _ttl_minutes() -> int: + raw = os.environ.get("OTC_TTL_MINUTES", "").strip() + if not raw: + return 10 + try: + return max(1, int(raw)) + except ValueError: + return 10 + + +def _cooldown_seconds() -> int: + raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip() + if not raw: + return 60 + try: + return max(0, int(raw)) + except ValueError: + return 60 + + +# --------------------------------------------------------------------------- +# Code generation + hashing +# --------------------------------------------------------------------------- + + +def _new_code() -> str: + """Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the + code resists guessing even at the small (10^6) keyspace. The TTL + + rate-limit are what carry the security weight — the entropy of a + six-digit code by itself is intentionally human-readable.""" + return f"{secrets.randbelow(1_000_000):06d}" + + +def _hash_code(code: str) -> str: + """bcrypt over the code bytes. The hash is stored at rest; the code + itself only travels in the outbound email and the inbound verify + body.""" + return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii") + + +def _check_code(code: str, code_hash: str) -> bool: + try: + return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii")) + except (ValueError, TypeError): + return False + + +# --------------------------------------------------------------------------- +# Allowlist gate — shared with the OAuth flow. +# --------------------------------------------------------------------------- + + +def _allowlist_admits(email: str) -> bool: + """The same allowlist v0.3.0 introduced for OAuth, applied to OTC + requests. If the allowlist is populated and the email is not on it, + we still respond 202 to the caller, but no code is sent.""" + if not allowlist_is_active(): + return True + row = db.conn().execute( + "SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,) + ).fetchone() + return row is not None + + +# --------------------------------------------------------------------------- +# Request path +# --------------------------------------------------------------------------- + + +@dataclass +class RequestOutcome: + """The outcome of a `request_code` call. + + `code` is None whenever no code was generated — either because the + allowlist denied the email or because the cooldown window blocked + the request. The caller (the API endpoint) does not surface this + distinction to the user; it returns 202 either way. + """ + sent: bool + code: str | None + reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid' + + +def request_code(email: str) -> RequestOutcome: + email = (email or "").strip() + if not email or "@" not in email: + return RequestOutcome(sent=False, code=None, reason="invalid") + + # Cooldown: refuse if a code was issued for this email in the last + # COOLDOWN_SECONDS. We surface it as a distinct outcome so the + # endpoint can return 429 — the spec calls this out as a "loud + # failure" so the abuse path is visible rather than swallowed. + cooldown = _cooldown_seconds() + if cooldown > 0: + row = db.conn().execute( + f""" + SELECT 1 FROM otc_codes + WHERE email = ? + AND datetime(created_at, '+{cooldown} seconds') > datetime('now') + LIMIT 1 + """, + (email,), + ).fetchone() + if row is not None: + return RequestOutcome(sent=False, code=None, reason="cooldown") + + # Allowlist: silently drop the send if the email isn't on the list. + # The row is not written either — there's nothing for verify to + # match against, so the user-facing experience is "I never got an + # email", which is the intended shape for the private-beta gate. + if not _allowlist_admits(email): + return RequestOutcome(sent=False, code=None, reason="allowlist") + + # Invalidate prior unused codes for this email. A re-request is + # always for the most recent code; older codes are dead. + db.conn().execute( + """ + UPDATE otc_codes + SET consumed_at = datetime('now') + WHERE email = ? + AND consumed_at IS NULL + """, + (email,), + ) + + code = _new_code() + code_hash = _hash_code(code) + ttl = _ttl_minutes() + db.conn().execute( + f""" + INSERT INTO otc_codes (email, code_hash, expires_at) + VALUES (?, ?, datetime('now', '+{ttl} minutes')) + """, + (email, code_hash), + ) + return RequestOutcome(sent=True, code=code, reason="sent") + + +# --------------------------------------------------------------------------- +# Verify path +# --------------------------------------------------------------------------- + + +@dataclass +class VerifyOutcome: + """Result of a `verify_code` call. + + `user` is populated only on success. `reason` distinguishes the + failure modes the UI can render — 'expired', 'consumed', 'wrong', + 'unknown' (no outstanding code at all). The endpoint maps the + failure modes to a single 400 with a generic message; the reason + is logged for the operator. + """ + ok: bool + user: SessionUser | None + reason: str + + +def verify_code(email: str, code: str) -> VerifyOutcome: + email = (email or "").strip() + code = (code or "").strip() + if not email or not code: + return VerifyOutcome(ok=False, user=None, reason="invalid") + + rows = db.conn().execute( + """ + SELECT id, code_hash, expires_at, consumed_at + FROM otc_codes + WHERE email = ? + ORDER BY id DESC + LIMIT 5 + """, + (email,), + ).fetchall() + if not rows: + return VerifyOutcome(ok=False, user=None, reason="unknown") + + # Walk the recent rows so a user who pasted an older code still + # gets a sensible error — without this, the most-recent-row check + # would mask "you entered yesterday's code" as "wrong code". + matched = None + for row in rows: + if _check_code(code, row["code_hash"]): + matched = row + break + + if matched is None: + return VerifyOutcome(ok=False, user=None, reason="wrong") + + if matched["consumed_at"] is not None: + return VerifyOutcome(ok=False, user=None, reason="consumed") + + expired = db.conn().execute( + "SELECT datetime(?) < datetime('now') AS expired", + (matched["expires_at"],), + ).fetchone()["expired"] + if expired: + return VerifyOutcome(ok=False, user=None, reason="expired") + + # Stamp consumed before provisioning so a parallel verify of the + # same row can't double-sign-in. + db.conn().execute( + "UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?", + (matched["id"],), + ) + user = provision_or_link_user(email) + return VerifyOutcome(ok=True, user=user, reason="ok") + + +# --------------------------------------------------------------------------- +# Provisioning — the migration path from OAuth identity to email identity. +# --------------------------------------------------------------------------- + + +def provision_or_link_user(email: str) -> SessionUser: + """Link the OTC sign-in to a `users` row. + + Match order: + 1. An existing row whose email equals (case-insensitive) the + requested email — the OAuth-era user is grandfathered in via + this path. `gitea_id` is preserved so a future OAuth round + trip still resolves the same row. + 2. Otherwise: a fresh contributor row with `gitea_id = NULL`, + `gitea_login = NULL`. The display name defaults to the local + part of the email (everything before the `@`) — users can + rename later via the §19.2 first-OTC profile-capture flow + that v0.8.0 introduces. + + The §6.1 owner-zero bootstrap still applies: if the email matches + the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row + is provisioned with role='owner'. v0.7.0 keeps that field as the + Gitea login (so existing deployments don't break); a future + release may add a parallel `OWNER_EMAIL` env if the OAuth route is + dropped entirely. + """ + email = email.strip() + existing = db.conn().execute( + "SELECT * FROM users WHERE email = ? COLLATE NOCASE", + (email,), + ).fetchone() + if existing is not None: + db.conn().execute( + "UPDATE users SET last_seen_at = datetime('now') WHERE id = ?", + (existing["id"],), + ) + return SessionUser( + user_id=existing["id"], + gitea_id=existing["gitea_id"] or 0, + gitea_login=existing["gitea_login"] or "", + display_name=existing["display_name"], + email=existing["email"] or email, + avatar_url=existing["avatar_url"] or "", + role=existing["role"], + ) + + display = email.split("@", 1)[0] or email + cur = db.conn().execute( + """ + INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role) + VALUES (NULL, NULL, ?, ?, '', 'contributor') + """, + (email, display), + ) + user_id = cur.lastrowid + return SessionUser( + user_id=user_id, + gitea_id=0, + gitea_login="", + display_name=display, + email=email, + avatar_url="", + role="contributor", + ) diff --git a/backend/migrations/012_otc.sql b/backend/migrations/012_otc.sql new file mode 100644 index 0000000..d58878f --- /dev/null +++ b/backend/migrations/012_otc.sql @@ -0,0 +1,105 @@ +-- §6.2 / v0.7.0: email + one-time-code sign-in. +-- +-- Replaces the Gitea OAuth gesture as the primary human-auth path. +-- The Gitea bot user + token are still needed for server-side git +-- operations (repo reads, PR creation); only the operator-facing +-- sign-in surface moves. The /auth/callback OAuth route remains +-- functional during migration as a fallback, scheduled for removal +-- in a future release once every active user has signed in via OTC +-- at least once. +-- +-- A row in `otc_codes` represents an outstanding 6-digit code that +-- was emailed to `email`. Codes are stored hashed (bcrypt) rather +-- than plaintext, so a database compromise does not expose the +-- in-flight code. TTL is enforced by `expires_at`. Each `verify` +-- success stamps `consumed_at` and refuses every later attempt +-- against the same row. +-- +-- The §6.2 identity model under v0.7.0: +-- +-- * `users.email` is the primary identity key for new sign-ins. +-- * `users.gitea_id` stays populated for users grandfathered in +-- via the OAuth-era flow; new users have `gitea_id = NULL`. +-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it +-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL. +-- * `users.email` becomes a (case-insensitive) unique key. An +-- existing OAuth user whose Gitea profile carried an email is +-- linked on first OTC sign-in; if no row matches, a fresh +-- contributor row is provisioned. +-- +-- New env vars (v0.7.0): +-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid. +-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate +-- limit between successive `/auth/otc/request` calls. + +CREATE TABLE otc_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL COLLATE NOCASE, + code_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + consumed_at TEXT +); + +CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at); + +-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable +-- column with a partial unique index that ignores nulls. SQLite does +-- not support ALTER COLUMN, so we rebuild the table. +-- +-- A few defensive notes: +-- * Every foreign key into `users(id)` continues to resolve — `id` +-- is the same INTEGER PRIMARY KEY in the rebuilt table. +-- * `email` is now declared NOCASE so a `WHERE email = ?` match +-- is case-insensitive without changing every read site. The +-- prior column accepted any text; existing rows pass through +-- unchanged. +-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so +-- users provisioned by OTC alone don't carry a synthetic login. + +CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gitea_id INTEGER, + gitea_login TEXT, + email TEXT COLLATE NOCASE, + display_name TEXT NOT NULL, + avatar_url TEXT, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')), + muted INTEGER NOT NULL DEFAULT 0, + email_personal_direct INTEGER NOT NULL DEFAULT 1, + email_watched_structural INTEGER NOT NULL DEFAULT 0, + email_admin_actionable INTEGER NOT NULL DEFAULT 1, + email_opt_out_all INTEGER NOT NULL DEFAULT 0, + digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')), + notification_quiet_hours_start TEXT, + notification_quiet_hours_end TEXT, + notification_quiet_hours_timezone TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT INTO users_new ( + id, gitea_id, gitea_login, email, display_name, avatar_url, role, + muted, email_personal_direct, email_watched_structural, + email_admin_actionable, email_opt_out_all, digest_cadence, + notification_quiet_hours_start, notification_quiet_hours_end, + notification_quiet_hours_timezone, created_at, last_seen_at +) +SELECT + id, gitea_id, gitea_login, email, display_name, avatar_url, role, + muted, email_personal_direct, email_watched_structural, + email_admin_actionable, email_opt_out_all, digest_cadence, + notification_quiet_hours_start, notification_quiet_hours_end, + notification_quiet_hours_timezone, created_at, last_seen_at +FROM users; + +DROP TABLE users; +ALTER TABLE users_new RENAME TO users; + +CREATE INDEX idx_users_role ON users (role); +-- Partial unique indexes so NULLs are permitted but populated values +-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity +-- is keyed on email (case-insensitive via NOCASE on the column). +CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL; +CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL; +CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != ''; diff --git a/backend/requirements.txt b/backend/requirements.txt index 0b0b836..23ee1ee 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ anthropic>=0.39 google-generativeai>=0.8 openai>=1.50 PyYAML>=6.0 +bcrypt>=4.2 diff --git a/backend/tests/test_otc_vertical.py b/backend/tests/test_otc_vertical.py new file mode 100644 index 0000000..aaa7994 --- /dev/null +++ b/backend/tests/test_otc_vertical.py @@ -0,0 +1,333 @@ +"""End-to-end integration tests for the v0.7.0 email/OTC sign-in +vertical (§6.2). + +The release replaces the Gitea OAuth gesture as the primary human +sign-in path. The tests prove: + + * `/auth/otc/request` is rate-limited per-email — back-to-back + requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with + 429 (the loud-failure shape the spec calls out). + * The happy path: request → code lands in the outbound buffer → + verify with the code → session cookie surfaces an authenticated + user via `/api/auth/me`. + * Expired codes refuse with 400. + * Already-consumed codes refuse with 400 on re-use. + * Wrong codes refuse with 400. + * Allowlist gate: when `allowed_emails` is populated and the email + isn't on it, the response is still 202 (no leak), but no email + lands in the outbound buffer and verify finds no matching code. + * Migration link: an existing OAuth-era user (with a `users.email` + row) is linked by email on first OTC sign-in — `gitea_id` is + preserved. + * Provisioning path: an unrecognized email creates a fresh + contributor row with `gitea_id = NULL`. + +The Gitea bot user + token are still required at process construction +(every test harness sets the same `GITEA_*` env vars); the OTC flow +itself never reaches Gitea. The fakes from `test_propose_vertical` +remain in scope so the rest of the app boots cleanly. +""" +from __future__ import annotations + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + tmp_env, +) + + +def _reset_outbound(): + from app import email as email_mod + email_mod.reset_sent_envelopes() + + +def _outbound_otc_codes(to_address: str | None = None) -> list[str]: + """Pluck the `code` line out of every OTC email in the test buffer. + + The OTC mailer stamps `kind='otc'` on the envelope so the §15.4 + notification mailer's envelopes (the unsubscribe-footer shape) + don't accidentally satisfy the assertion. Each envelope's body + carries the code on its own indented line; this helper extracts + just that token so the test reads the same way the user would + read the email. + """ + from app import email as email_mod + out = [] + for env in email_mod.sent_envelopes(): + if env.get("kind") != "otc": + continue + if to_address is not None and env["to"] != to_address: + continue + for line in env["body"].splitlines(): + tok = line.strip() + if tok.isdigit() and len(tok) == 6: + out.append(tok) + break + return out + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + + # Request: 202 + a single OTC envelope to the requested address. + r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"}) + assert r.status_code == 200, r.text + codes = _outbound_otc_codes("newcomer@example.com") + assert len(codes) == 1 + code = codes[0] + + # Verify: 200 + session cookie + me-shape now reads authenticated. + r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code}) + assert r.status_code == 200, r.text + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["email"] == "newcomer@example.com" + # Fresh provisioning: no gitea linker. The display name is the + # local part of the email per §6.2. + assert me["user"]["role"] == "contributor" + assert me["user"]["display_name"] == "newcomer" + + # The `users` row reflects the same: gitea_id NULL, email set. + from app import db + row = db.conn().execute( + "SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE", + ("newcomer@example.com",), + ).fetchone() + assert row is not None + assert row["gitea_id"] is None + assert row["email"] == "newcomer@example.com" + + +# --------------------------------------------------------------------------- +# Failure modes on verify +# --------------------------------------------------------------------------- + + +def test_otc_verify_refuses_wrong_code(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + client.post("/auth/otc/request", json={"email": "alice@example.com"}) + r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"}) + assert r.status_code == 400 + + +def test_otc_verify_refuses_consumed_code(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + client.post("/auth/otc/request", json={"email": "alice@example.com"}) + code = _outbound_otc_codes("alice@example.com")[-1] + # First verify succeeds. + r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code}) + assert r1.status_code == 200 + # Drop the session cookie so the re-verify reads as fresh. + client.cookies.clear() + # Second verify with the same code is refused — `consumed_at` + # stamped on the row blocks the replay. + r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code}) + assert r2.status_code == 400 + + +def test_otc_verify_refuses_expired_code(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + client.post("/auth/otc/request", json={"email": "alice@example.com"}) + code = _outbound_otc_codes("alice@example.com")[-1] + # Backdate the row's expires_at to the past. The TTL setting is + # an env var (default 10 min); rather than waiting, the test + # rewrites the row. + db.conn().execute( + "UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?", + ("alice@example.com",), + ) + r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- + + +def test_otc_request_rate_limited_per_email(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r1.status_code == 200 + # Cooldown defaults to 60s; the second back-to-back call is + # refused with a loud 429. + r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r2.status_code == 429 + # The buffer still has exactly one envelope — the rate-limited + # call didn't double-send. + assert len(_outbound_otc_codes("alice@example.com")) == 1 + + +def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"}) + assert r1.status_code == 200 + # Different email, fresh cooldown. + r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"}) + assert r2.status_code == 200 + + +# --------------------------------------------------------------------------- +# Allowlist gate +# --------------------------------------------------------------------------- + + +def test_otc_request_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + # Populate the allowlist so the gate turns on. + db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",)) + + r = client.post("/auth/otc/request", json={"email": "stranger@example.com"}) + # Still 202 — the allowlist's state is not leaked to callers. + assert r.status_code == 200 + # But no email was sent, and no row landed in otc_codes. + assert _outbound_otc_codes("stranger@example.com") == [] + row = db.conn().execute( + "SELECT 1 FROM otc_codes WHERE email = ?", + ("stranger@example.com",), + ).fetchone() + assert row is None + + +def test_otc_request_admits_allowlisted_email(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",)) + r = client.post("/auth/otc/request", json={"email": "invited@example.com"}) + assert r.status_code == 200 + assert len(_outbound_otc_codes("invited@example.com")) == 1 + + +# --------------------------------------------------------------------------- +# Migration path — link by email to an OAuth-era user +# --------------------------------------------------------------------------- + + +def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + # Seed an OAuth-era row. `provision_user_row` writes + # email=@test, so we sign in via OTC with the matching + # email and expect the same `users.id` to come back. + provision_user_row(user_id=42, login="legacyuser", role="contributor") + existing = db.conn().execute( + "SELECT id, gitea_id FROM users WHERE id = ?", (42,) + ).fetchone() + assert existing["gitea_id"] == 42 # OAuth linker is set. + + r = client.post("/auth/otc/request", json={"email": "legacyuser@test"}) + assert r.status_code == 200 + code = _outbound_otc_codes("legacyuser@test")[-1] + r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code}) + assert r.status_code == 200 + + # /api/auth/me reports the linked user — same id, original role. + me = client.get("/api/auth/me").json() + assert me["authenticated"] is True + assert me["user"]["id"] == 42 + assert me["user"]["role"] == "contributor" + + # gitea_id is preserved on the linked row — the migration path + # doesn't disturb the OAuth linker. + row = db.conn().execute( + "SELECT gitea_id FROM users WHERE id = ?", (42,) + ).fetchone() + assert row["gitea_id"] == 42 + + +def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + r = client.post("/auth/otc/request", json={"email": "newperson@example.com"}) + assert r.status_code == 200 + code = _outbound_otc_codes("newperson@example.com")[-1] + r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code}) + assert r.status_code == 200 + + # A fresh row landed with NULL gitea_id (no OAuth linker). + row = db.conn().execute( + "SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE", + ("newperson@example.com",), + ).fetchone() + assert row is not None + assert row["gitea_id"] is None + assert row["gitea_login"] is None + assert row["role"] == "contributor" + + +# --------------------------------------------------------------------------- +# Re-request invalidates prior code +# --------------------------------------------------------------------------- + + +def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch): + from fastapi.testclient import TestClient + + # Drop the cooldown so the second request lands instead of 429ing. + monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0") + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + _reset_outbound() + client.post("/auth/otc/request", json={"email": "alice@example.com"}) + first = _outbound_otc_codes("alice@example.com")[-1] + client.post("/auth/otc/request", json={"email": "alice@example.com"}) + second = _outbound_otc_codes("alice@example.com")[-1] + assert first != second + + # The old code is invalidated — verify with `first` now refuses. + r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first}) + assert r.status_code == 400 + + # The new code still works. + r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second}) + assert r.status_code == 200 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee1899c..b595336 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "rfc-app-frontend", - "version": "0.5.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.5.0", + "version": "0.7.0", "dependencies": { "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index b68f1f4..01f0754 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.5.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 87e77c0..972c6a6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -352,6 +352,89 @@ } .landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } +/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */ + +.otc-login { + flex: 1; + display: flex; align-items: center; justify-content: center; + padding: 40px 24px; +} +.otc-login-inner { + max-width: 360px; + width: 100%; + display: flex; flex-direction: column; + gap: 14px; +} +.otc-login h1 { + font-size: 22px; + font-weight: 600; + margin: 0 0 4px; +} +.otc-login .otc-hint { + color: #555; + font-size: 14px; + line-height: 1.5; + margin: 0; +} +.otc-login input { + width: 100%; + padding: 10px 12px; + font-size: 15px; + border: 1px solid #ddd; + border-radius: 6px; + box-sizing: border-box; +} +.otc-login input:focus { + outline: none; + border-color: #1a1a1a; +} +.otc-login button[type="submit"] { + background: #1a1a1a; color: #fff; + border: none; border-radius: 6px; + padding: 10px 18px; + font-size: 14px; font-weight: 600; + cursor: pointer; +} +.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; } +.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; } +.otc-login form { + display: flex; flex-direction: column; + gap: 10px; +} +.otc-actions { + display: flex; align-items: center; gap: 12px; +} +.otc-login .btn-link-quiet { + background: none; border: none; + color: #666; font-size: 13px; + cursor: pointer; padding: 0; +} +.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; } +.otc-shortcut-hint { + color: #888; font-size: 12px; margin: 4px 0 0; +} +.otc-shortcut-hint kbd { + background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px; + padding: 1px 5px; font-size: 11px; font-family: inherit; +} +.otc-status { + color: #555; font-size: 13px; + background: #f7f6f0; + border-left: 3px solid #cfc8a8; + padding: 8px 12px; + margin: 4px 0 0; +} +.otc-fallback { + font-size: 12px; color: #777; + margin: 16px 0 0; + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; +} +.otc-fallback a, .otc-fallback .otc-fallback-link { + color: #666; text-decoration: none; +} +.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; } +.otc-fallback-sep { color: #ccc; } + /* --- Beta-pending page (post-OAuth-rejection) --- */ .beta-pending { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c55af28..b6f40c9 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' import Landing from './components/Landing.jsx' +import Login from './components/Login.jsx' import BetaPending from './components/BetaPending.jsx' import Philosophy from './components/Philosophy.jsx' import NotificationSettings from './components/NotificationSettings.jsx' @@ -121,15 +122,16 @@ export default function App() { Sign out ) : ( - + Sign in Beta - + )}
} /> + } /> } /> } /> {viewer && ( @@ -216,7 +218,7 @@ function Welcome({ viewer }) {

Discussion and contribution are in private Beta — - read freely, and sign in if your email has + read freely, and sign in if your email has been invited.

diff --git a/frontend/src/api.js b/frontend/src/api.js index 1901a93..272e108 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -25,6 +25,30 @@ export async function getMe() { return jsonOrThrow(res) } +// ── v0.7.0: email + one-time-code sign-in (§6.2) ───────────────────────── +// +// The legacy /auth/login → /auth/callback OAuth flow remains during the +// migration — the new UI just no longer points at it primarily. These +// two helpers drive the Login.jsx surface. + +export async function requestOtc(email) { + const res = await fetch('/auth/otc/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + return jsonOrThrow(res) +} + +export async function verifyOtc(email, code) { + const res = await fetch('/auth/otc/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code }), + }) + return jsonOrThrow(res) +} + export async function listRFCs() { return jsonOrThrow(await fetch('/api/rfcs')) } diff --git a/frontend/src/components/Landing.jsx b/frontend/src/components/Landing.jsx index 8dc4987..47af6b9 100644 --- a/frontend/src/components/Landing.jsx +++ b/frontend/src/components/Landing.jsx @@ -28,7 +28,7 @@ export default function Landing() { first RFC defining human. Build the dictionary first.

- Sign in with Gitea + Sign in Read the full philosophy →
    diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..ac0903d --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,167 @@ +// Login.jsx — v0.7.0's primary sign-in surface (§6.2). +// +// Two-step: +// 1. Enter email → POST /auth/otc/request → on 200, advance. +// On 429 (rate-limit), surface a "wait a moment" hint and keep +// the user on step 1. +// 2. Enter the six-digit code from the email → POST /auth/otc/verify +// → on 200, redirect to the post-login landing. Cmd/Ctrl+Enter +// on the code field is the keyboard shortcut. +// +// Server-side, /auth/otc/request always returns 202 for an unrecognized +// email (so the allowlist gate doesn't leak), so this surface never +// distinguishes "we couldn't reach you" from "we don't know you" — +// it just advances to step 2. If a user is genuinely blocked, the +// code never arrives. +// +// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback +// during the v0.7.0 migration; we surface a "Sign in with Gitea" link +// as a fallback in the footer so users with active OAuth sessions or +// older invite emails still have a path. + +import { useEffect, useRef, useState } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { requestOtc, verifyOtc } from '../api' + +export default function Login() { + const [step, setStep] = useState('email') + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [status, setStatus] = useState('') + const [busy, setBusy] = useState(false) + const emailRef = useRef(null) + const codeRef = useRef(null) + const navigate = useNavigate() + + useEffect(() => { + if (step === 'email') emailRef.current?.focus() + else codeRef.current?.focus() + }, [step]) + + async function submitEmail(e) { + e.preventDefault() + if (!email.trim() || !email.includes('@')) { + setStatus('Enter a valid email address.') + return + } + setBusy(true) + setStatus('') + try { + await requestOtc(email.trim()) + setStep('code') + setStatus('Check your inbox — a six-digit code is on the way.') + } catch (err) { + if (err.status === 429) { + setStatus('Slow down — wait a minute before requesting another code.') + } else { + setStatus(err.message || 'Could not request a code. Try again.') + } + } finally { + setBusy(false) + } + } + + async function submitCode(e) { + if (e) e.preventDefault() + if (!code.trim() || code.trim().length !== 6) { + setStatus('Enter the six-digit code from your email.') + return + } + setBusy(true) + setStatus('') + try { + await verifyOtc(email.trim(), code.trim()) + // Reload so App.jsx's getMe() picks up the fresh session. We + // navigate to "/" via a hard load so any cached "anonymous" + // view state in memory is dropped cleanly. + window.location.assign('/') + } catch (err) { + setStatus('That code is invalid or expired. Try again, or request a new code.') + setBusy(false) + } + } + + function onCodeKey(e) { + // §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field. + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + submitCode(e) + } + } + + function backToEmail() { + setStep('email') + setCode('') + setStatus('') + } + + return ( +
    +
    +

    Sign in

    + {step === 'email' && ( +
    +

    + Enter your email. We'll send you a one-time code. +

    + setEmail(e.target.value)} + placeholder="you@example.com" + required + disabled={busy} + /> + +
    + )} + {step === 'code' && ( +
    +

    + Enter the six-digit code we sent to {email}. +

    + setCode(e.target.value.replace(/\D/g, ''))} + onKeyDown={onCodeKey} + placeholder="123456" + required + disabled={busy} + /> +
    + + +
    +

    + Tip: +Enter (or Ctrl+Enter) to sign in. +

    +
    + )} + {status &&

    {status}

    } +

    + Read the philosophy → + · + Sign in with Gitea (fallback) +

    +
    +
    + ) +}