Verified-email recovery flow for OAuth sign-in (email_in_use lockout) #72

Open
opened 2026-06-17 01:27:40 +00:00 by james · 0 comments
Owner

Follow-up to #12 / ADR-0014. The OAuth integration deliberately refuses to auto-link an OAuth identity to an existing User by email — the published account-takeover attack against several Sign-In-With-X integrations is exactly that auto-link. Today the refusal looks like a lockout:

A user registered locally with email X, forgot the password, and now tries "Sign in with GitHub" with email X. They're redirected to /login?error=email_in_use with the documented copy "An account with this email exists. Sign in to it, then connect GitHub in Account settings." — but they can't sign in to the existing account because they forgot the password.

This ticket adds the recovery path.

Scope

  • New flow triggered when the OAuth callback hits the email_in_use branch and the provider has returned a verified email matching an existing User:
    • Send a one-time email to the address (containing a signed token that encodes user_id + provider + provider_user_id + expiry).
    • When the user clicks the link, the callback verifies the token and attaches the new oauth_identity to the existing User. The user is signed in.
    • The token is single-use and expires in 15 minutes.
  • A simple email-sending abstraction (e.g. lib/email/send.ts) with a console-printer transport as the dev default and SMTP env-driven for production.
  • UI:
    • The /login?error=email_in_use copy gains a "Send verification email" affordance (button or auto-trigger) that puts a "Check your inbox" message on screen.
    • A landing page at /auth/oauth/verify-link/[token] (or similar) handles the click. Render success / token expired / token invalid states.
  • ADR addendum or new ADR covers: token signing key management, dev-mode-only console transport, the 15-minute expiry, single-use semantics, what happens if the OAuth provider returned an email but the user no longer controls the inbox (limitation: this PR cannot defend against compromised email).

Acceptance criteria

  • Triggering OAuth sign-in for an OAuth identity whose email matches an existing local user no longer dead-ends. The user gets a "check your inbox" page.
  • Following the emailed link signs the user into the matching existing User AND links the new oauth_identity to that user. The lockout case from ADR-0014 is closed.
  • Tokens are single-use; a second click is a friendly "this link was already used" page.
  • Tokens expire in 15 minutes; expired tokens render a friendly "ask for a new one" page.
  • Dev transport prints the email + link to the console (no SMTP config required for local dev). Production transport is configurable via env (SMTP_* or a single EMAIL_TRANSPORT URL).
  • Tests: token signing/verification round-trip; consume-once semantics; expiry rejection; the full callback → email → click → linked path; the "different OAuth identity tries to consume the same token" rejection.

Out of scope

  • Password reset by email. The ADR-0014 deferral comment names password reset as a separate concern; this ticket addresses the OAuth-specific recovery only.
  • Other email-driven flows (welcome email, account-changed notifications). Scoped to the lockout fix.
  • HTML email templating beyond a single plain-text-ish body. Future tickets can layer on a templating system.

Part of epic #1. References ADR-0014.

Follow-up to #12 / ADR-0014. The OAuth integration deliberately refuses to auto-link an OAuth identity to an existing User by email — the published account-takeover attack against several Sign-In-With-X integrations is exactly that auto-link. Today the refusal looks like a lockout: A user registered locally with email X, forgot the password, and now tries "Sign in with GitHub" with email X. They're redirected to `/login?error=email_in_use` with the documented copy "An account with this email exists. Sign in to it, then connect GitHub in Account settings." — but they can't sign in to the existing account because they forgot the password. This ticket adds the recovery path. ## Scope - New flow triggered when the OAuth callback hits the `email_in_use` branch and the provider has returned a **verified** email matching an existing User: - Send a one-time email to the address (containing a signed token that encodes user_id + provider + provider_user_id + expiry). - When the user clicks the link, the callback verifies the token and attaches the new oauth_identity to the existing User. The user is signed in. - The token is single-use and expires in 15 minutes. - A simple email-sending abstraction (e.g. `lib/email/send.ts`) with a console-printer transport as the dev default and SMTP env-driven for production. - UI: - The `/login?error=email_in_use` copy gains a "Send verification email" affordance (button or auto-trigger) that puts a "Check your inbox" message on screen. - A landing page at `/auth/oauth/verify-link/[token]` (or similar) handles the click. Render success / token expired / token invalid states. - ADR addendum or new ADR covers: token signing key management, dev-mode-only console transport, the 15-minute expiry, single-use semantics, what happens if the OAuth provider returned an email but the user no longer controls the inbox (limitation: this PR cannot defend against compromised email). ## Acceptance criteria - [ ] Triggering OAuth sign-in for an OAuth identity whose email matches an existing local user no longer dead-ends. The user gets a "check your inbox" page. - [ ] Following the emailed link signs the user into the matching existing User AND links the new oauth_identity to that user. The lockout case from ADR-0014 is closed. - [ ] Tokens are single-use; a second click is a friendly "this link was already used" page. - [ ] Tokens expire in 15 minutes; expired tokens render a friendly "ask for a new one" page. - [ ] Dev transport prints the email + link to the console (no SMTP config required for local dev). Production transport is configurable via env (`SMTP_*` or a single `EMAIL_TRANSPORT` URL). - [ ] Tests: token signing/verification round-trip; consume-once semantics; expiry rejection; the full callback → email → click → linked path; the "different OAuth identity tries to consume the same token" rejection. ## Out of scope - Password reset by email. The ADR-0014 deferral comment names password reset as a separate concern; this ticket addresses the **OAuth-specific** recovery only. - Other email-driven flows (welcome email, account-changed notifications). Scoped to the lockout fix. - HTML email templating beyond a single plain-text-ish body. Future tickets can layer on a templating system. Part of epic #1. References ADR-0014.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#72
No description provided.