# Auth: Email Normalization and OAuth Identity Recovery

This document covers two tightly coupled concerns that together determine how
a returning OAuth user is matched to an existing account:

1. **Email normalization** - how every email is canonicalized before any
   lookup or write.
2. **OAuth identity recovery** - how a user is still resolved when their
   `user.email` no longer matches what the OAuth provider returns (typically
   because an admin edited the email after the OAuth account was linked).

Read this before touching `lib/auth-config.ts`, `lib/email-utils.ts`, or any
code path that calls `prisma.user.update({ data: { email } })`.

> This doc supersedes the older, narrower `docs/EMAIL_NORMALIZATION.md`, which
> only covered the `+tag`/case rule. That file is retained as a pointer.

---

## 1. Email normalization rule

All emails are lowercased and have their `+tag` suffix stripped before any
database lookup or user/account creation.

**Implementation:** `lib/email-utils.ts:21-34` - `normalizeEmail(email)`.

| Input                       | Normalized             |
| --------------------------- | ---------------------- |
| `timothy+test@aimclear.com` | `timothy@aimclear.com` |
| `User+Signup@Gmail.com`     | `user@gmail.com`       |
| `JOHN@Example.COM`          | `john@example.com`     |

**Rationale.** OAuth providers return the canonical mailbox (no `+tag`). If a
Stripe checkout or dev signup captured `user+tag@...` and we stored that
verbatim, the next OAuth sign-in would miss by email lookup and create a
duplicate `User` row.

**Where normalization is applied.** Every call site that takes an email from
the network must normalize before passing it to Prisma:

- `lib/auth-config.ts:44` - OAuth `signIn` callback normalizes `user.email`
  before the first `findUnique`.
- `lib/auth-config.ts:197` - `jwt` callback normalizes before the
  session-refresh lookup.
- `app/api/admin/dealers/[id]/email/route.ts:52` - admin email edit
  normalizes before uniqueness check and update.
- `lib/stripe-webhook-handlers.ts` - webhook-driven user resolution.

**Invariant.** The `User.email` column (`prisma/schema.prisma:12`) always
stores a normalized value. Never write a raw email into that column. Every
write path currently goes through `normalizeEmail()` - do not add a new one
without doing the same.

---

## 2. OAuth account linking

NextAuth identifies an OAuth identity by the tuple
`(provider, providerAccountId)`, enforced by the unique constraint on the
`Account` model (`prisma/schema.prisma:78`):

```prisma
@@unique([provider, providerAccountId])
```

`providerAccountId` is the stable identifier issued by the IdP (Google `sub`
claim, Apple `sub` claim). It does **not** change when the user changes their
email at the provider, and it does not change when an admin edits
`user.email` in our database.

`Account` rows hang off `User` via `userId` with `onDelete: Cascade`
(`prisma/schema.prisma:76`). A single `User` can have multiple `Account` rows
(one per linked provider).

---

## 3. The "admin-edited email" scenario

This is the subtle case the `signIn` callback is written to survive.

**Scenario.** A dealer originally signed up with
`old@example.com` via Google. We created:

- `User { id: u1, email: "old@example.com" }`
- `Account { userId: u1, provider: "google", providerAccountId: "google-sub-123" }`

An admin later changes the user's login email to `new@example.com` via
`PATCH /api/admin/dealers/[id]/email`
(`app/api/admin/dealers/[id]/email/route.ts:92`). After the write:

- `User.email = "new@example.com"`
- `Account` row is untouched - still has `providerAccountId = "google-sub-123"`.

Now the user signs in with Google again. Google still returns
`email: "old@example.com"` (Google doesn't know about our admin edit).

**What the code does** (`lib/auth-config.ts:44-84`):

1. Normalize the Google-returned email → `"old@example.com"`.
2. `prisma.user.findUnique({ where: { email: "old@example.com" } })` → `null`.
3. **Fallback:** look up the `Account` by
   `provider_providerAccountId` (`lib/auth-config.ts:60-72`).
4. Hit. Resolve to the correct `User` (id `u1`, current email
   `"new@example.com"`). Log `'[SignIn] Found existing user by OAuth
identity (email mismatch - likely admin-changed)'`.
5. Skip user creation, skip account linking (the provider is already linked
   by definition - `lib/auth-config.ts:146-154`), return `true`.

The `jwt` callback applies the same fallback on session refresh
(`lib/auth-config.ts:205-227`).

**Why match by `providerAccountId`, not email.**

- `providerAccountId` is stable at the IdP.
- `user.email` in our DB is mutable - admins can edit it, and may do so for
  legitimate support reasons.
- The OAuth token returned by Google/Apple carries the IdP's current view of
  the user's email, which has no obligation to match ours.

**What would break if a future change matched by email instead.**

If the fallback in `lib/auth-config.ts:60-72` were removed, or replaced with
"create a new user when email doesn't match":

- The admin-edited user's next OAuth sign-in would fail to find their
  account, and instead create a **duplicate** `User` row with the
  provider-returned email.
- The `prisma.account.create` that follows would then hit the
  `@@unique([provider, providerAccountId])` constraint, because the existing
  `Account` row still points to the original user. Sign-in would error out
  with `LinkAccountError` (`lib/auth-config.ts:178`).
- In short: the user would be locked out of their own account until the
  admin reverted the email edit or the duplicate was manually cleaned up.

Do not "simplify" sign-in by dropping the `providerAccountId` fallback.

---

## 4. Implications for new auth features

When adding new auth-adjacent code (new provider, magic link, re-auth
challenge, session repair), hold these assumptions:

- **`user.email` is not the OAuth identity.** It is a mutable, human-editable
  contact address. Do not use it as a join key against the identity provider.
- **Always normalize before lookup.** `normalizeEmail()` is cheap and
  idempotent. Call it at the boundary, once, then pass the normalized value
  inward.
- **Always normalize before write.** If you persist an email (new model, new
  column, pending registration, password reset), normalize first. See
  `PendingRegistration` (`prisma/schema.prisma:31`) and `PasswordResetToken`
  (`prisma/schema.prisma:48`) for existing examples.
- **Prefer `(provider, providerAccountId)` for OAuth lookups.** If you need
  to find an existing OAuth identity, go through `Account`, not `User.email`.
- **Log the mismatch.** When the OAuth-identity fallback fires, log it with
  both emails (see `lib/auth-config.ts:76-83`). This is our primary signal
  that admin-edited accounts are being used.

---

## 5. Pitfalls

**Case-sensitivity mismatches.** `User.email` is `@unique` but the database
collation does not guarantee case-insensitive equality. The `normalizeEmail`
step (lowercasing) is what prevents `John@X.com` and `john@x.com` from
producing two rows. If you ever bypass normalization on either the write side
or the read side, you will miss existing users and create duplicates.

**Duplicate account creation.** The sign-in callback's two-step lookup
(email, then `providerAccountId`) is ordered deliberately. If you flip the
order or remove the second step, you will create a new `User` for an
admin-edited dealer on their next sign-in, and then hit a unique-constraint
violation when linking the account. See section 3.

**Admin email edits during impersonation are blocked.** The admin email
route rejects edits while an admin impersonation session is active
(`app/api/admin/dealers/[id]/email/route.ts:26-35`). If you add new
admin-mutation routes that change identity fields, copy that guard.

**Uniqueness race on email edit.** The admin route checks
`findUnique({ email: normalized })` before updating
(`app/api/admin/dealers/[id]/email/route.ts:80-89`). Two concurrent admins
picking the same new email for different dealers will both pass the check;
one will then fail on the `@unique` constraint at write time. This is fine -
the DB is the source of truth. Don't paper over it by skipping the pre-check;
the pre-check gives a clean 409 to the common case.

**`PendingRegistration` / `PasswordResetToken` do NOT participate in OAuth
recovery.** Those tables are keyed by email only. An admin email edit
orphans any in-flight password reset or pending registration for the old
address. This is intentional: those flows have short TTLs
(`prisma/schema.prisma:36,52`) and the user will re-request on the new
address. If you build a new token-based flow, decide explicitly whether it
should key on `userId` instead of `email`.

---

## Cross-references

- `docs/EMAIL_NORMALIZATION.md` - legacy summary of the `+tag`/case rule.
- `docs/APPLE_OAUTH_MAINTENANCE.md` - Apple `sub` claim details and JWT key
  rotation (the Apple `providerAccountId` source).
- `docs/REGISTRATION_FLOW_MAP.md` - full registration flow including pending
  registrations and Stripe checkout.
- `docs/ADMIN_STATUS_MANAGEMENT.md` - other admin-editable dealer fields and
  their audit logging conventions.
