Skip to content

Ask unverified accounts for the email code before Approve - #1566

Merged
helpcodeai merged 2 commits into
mainfrom
fix/consent-verify-email
Nov 6, 2026
Merged

helpcodeai merged 2 commits into
mainfrom
fix/consent-verify-email

Conversation

@helpcodeai

@helpcodeai helpcodeai commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Cloud, the MCP consent page (/auth/login) completed the authorization for a password account whose email address was never verified. The AI client then got a token that /mcp refuses on every call (EmailVerifiedGuard) and kept retrying it for weeks: 36 accounts since 28 Sep, 11 still retrying on 6 Nov (about 432 refused calls in seven hours). Details in #1565.

The consent page now asks such an account for its 6-digit code before Approve, then continues exactly as a verified sign-in would.

Changes

  • LoginController: on Cloud, a correct password for an unverified account renders a code step instead of setting login_user, so /callback cannot issue a code. The step has the code field (Enter submits), Send a new code, I opened the link in the email (continues once the link verified the address), and the enabled Continue with Google/Microsoft/GitHub buttons (they verify the address; for an unverified account they also drop the password, as social sign-in already does).
  • New form routes, all excluded from Swagger and throttled 10/min: POST /auth/verify-email (the code), POST /auth/verify-email/resend, POST /auth/verify-email/continue and POST /auth/verify-email/social/:provider. The buttons reach them through formaction, so no posted field picks what runs; each route goes through one helper that checks the CSRF token, the pending sign-in cookie and the account. The pending account id is in a signed, httpOnly pending_verify cookie (15 min, the code lifetime), with double-submit CSRF through verify_csrf. The account is re-read on every post; an account made SSO-only meanwhile starts over.
  • Codes: at most one new code per minute and at most 5 per hour, the same limits as the dashboard sign-in and resend-verification.
  • New EmailVerificationService with the logic moved out of AuthController (sendCode, verifyCode, markVerified with the Cloud trial and the ChatGPT Ads report, codesSentWithin). The dashboard endpoints and the consent page use the same code path. AuthController builds the service from its own dependencies, so its constructor and its specs stay as they are. The code is now drawn with crypto.randomInt instead of Math.random.
  • The "Continue with" start of the consent form moved into startSocialSignIn so the code step can reuse it.
  • Docs: docs/integrations/claude.md explains the code step; llms-full.txt regenerated.

Not changed: the dashboard session ("Allow access as …") already refuses unverified Cloud accounts (resolveDashboardSessionUser), and social sign-in only accepts provider-verified addresses. Accounts that already hold a token are still refused on /mcp, with the existing notice.

Known gap: the browser-side Google Ads sign_up_verified event fires only on the dashboard's code step. A verification on the consent page is server-rendered and does not push it (the server-side ChatGPT Ads report still runs).

Cloud and self-hosted

  • Cloud: unverified password accounts must enter the code (or use social sign-in) before Approve. Verified accounts see no change.
  • Self-hosted: unchanged. Verification is not enforced there; the password path completes as before, and POST /auth/verify-email answers 404.

Type

  • Bug fix
  • New feature
  • Documentation
  • Refactoring
  • Performance improvement
  • Other (describe below)

Testing

  • Added/updated unit tests
  • Tested manually (describe steps below)
  • No tests needed (explain why)

New specs: login.controller.verify-email.spec.ts (code step shown with no login_user/amcp_signin cookie, masked address, signed cookies; no second code within a minute; hourly cap; verified account goes to /callback; correct code completes; wrong, malformed code refused; CSRF and missing pending cookie refused; SSO-only meanwhile restarts; resend at most once a minute and within the hourly cap; continue after the link; social hand-over; every route refused without CSRF or the pending sign-in; self-hosted unchanged and 404 on every route) and email-verification.service.spec.ts. The existing cloud fixtures in login.controller.spec.ts and login.controller.file-search.spec.ts now say emailVerified: true, since they model verified accounts.

Commands, all in packages/backend, after npm ci at the root and npx prisma generate:

  • npm run lint: 0 errors, 11 warnings, all in files this PR does not touch. The eslint --fix run changed no files.
  • npx tsc --noEmit -p tsconfig.json: exit 0.
  • npx jest --maxWorkers=4 src/auth: 35 of 36 suites pass, 518 of 531 tests. The 13 failures are all in jwt-or-management-key.guard.spec.ts, which needs a live PostgreSQL (prisma.organization.create() in beforeAll). No database was running here; this is a baseline environment failure, not a regression.

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable)
  • All existing tests pass (npm test) — only src/auth was run; see above for the DB-backed suite
  • This PR has a descriptive title

Related Issues

Fixes #1565

On Cloud the MCP consent page accepted the password of an account whose
address was never verified and completed the authorization. The client
then held a token that /mcp refuses on every call, and retried it for
weeks. The page now asks for the 6-digit code first (with a resend and
the social sign-in buttons) and only then continues to Approve.

The code logic moves from AuthController to EmailVerificationService so
the dashboard and the consent page share it. Self-hosted is unchanged.
The step's buttons picked what ran through a posted action field, which
CodeQL flags as a user-controlled bypass of a sensitive call. Resend,
continue and social now post to their own routes via formaction, each
checking the CSRF token, the pending sign-in and the account through one
helper.
@helpcodeai
helpcodeai merged commit cfba910 into main Nov 6, 2026
16 checks passed
@helpcodeai
helpcodeai deleted the fix/consent-verify-email branch November 6, 2026 19:53
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloud: the MCP consent page lets an unverified account approve an AI client

2 participants