> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 07 05 azure oauth email verification design

# Verified Azure OAuth on Better-Auth — Design

Date: 2026-07-05
Author: Ali (with Claude)
Status: Draft — pending review

## 1. Problem — nOAuth account takeover

Azure (Microsoft Entra) OAuth was hidden from both login pages because we trust
the `email` claim Microsoft sends **without verifying it is actually owned by the
signer**. A user in any Microsoft tenant can set their account's email to
`victim@company.com`. On login we match/create an OpenCX user by that email, so
the attacker lands in the victim's account. This is the documented **nOAuth**
pattern. Our app is registered as **multi-tenant + personal accounts**
("All Microsoft account users"), which is exactly the exposed configuration.

Hiding the buttons (commit `8c51c1f661`, 2026-06-29) was cosmetic — the providers
are still live server-side and the sign-in endpoints are directly reachable.

## 2. Scope

* **Fix Better-Auth only.** Implement verified Azure login on the Better-Auth
  stack (`backend/src/platform-auth/`) and re-enable the Microsoft button on the
  Better-Auth login page (`/auth-2`).
* **Do NOT touch NextAuth.** NextAuth's `/marhaba` path is the truly vulnerable
  one (pure email find-or-create, zero verification), but NextAuth is being
  retired. A **separate future session** will delete everything NextAuth-related,
  which removes that path entirely. Until then its Azure button stays hidden and
  we do not re-enable it.
* Non-goal: hardening `/backend/user/marhaba` or the NextAuth `signIn` callback.

## 3. Azure prerequisites (app registration)

Code alone cannot make Azure emit the verification signal — these are set on the
Entra app registration and are a hard prerequisite. The code fails **closed**
without them (no verification claim ⇒ login rejected), so the ordering is safe.

**Dev app — `open.cx`, client id `d08b3b44-d4e1-4319-b95c-3f4afae48599`, object id
`d5310670-24a6-4125-b51a-545b46c1b7ac`, tenant `2c87cf71-...` — DONE (2026-07-05):**

1. ✅ Optional claim `email` added to the **ID** token.
2. ✅ Optional claim `xms_edov` added to the **ID** token
   ("email domain owner verified").
3. ✅ `authenticationBehaviors.removeUnverifiedEmailClaim = true` set via
   `PATCH https://graph.microsoft.com/beta/applications/{objectId}` with body
   `{"authenticationBehaviors": {"removeUnverifiedEmailClaim": true}}`. This
   strips the email claim entirely from tokens whose domain owner is unverified.
   Must be set explicitly because the safe default exempts multi-tenant apps with
   prior unverified sign-in activity.

**Prod app — TODO before prod ship.** Prod uses a *different*
`AZURE_AD_CLIENT_ID` (from deploy secrets, not the repo). Run the identical three
steps against the prod registration. Verify with
`GET .../applications/{objectId}?$select=authenticationBehaviors`.

## 4. Security model

Trust the Azure `email` claim **only** when the token proves the email is
verified. Defense in depth, four independent layers — any one of which blocks the
takeover:

1. **Source (Entra):** `removeUnverifiedEmailClaim: true` ⇒ unverified-domain
   logins arrive with **no email** ⇒ rejected at `callback.mjs:129`
   (`email_not_found`) before any DB lookup.
2. **Claims gate (our code):** `mapProfileToUser` computes a strict `emailVerified`
   from the claims and, when not verified, returns `email: null` +
   `emailVerified: false` — so even if Entra config regressed, an unverified login
   still has no usable email and is not trusted.
3. **Linking gate (Better-Auth, existing):** `microsoft` is **never** a trusted
   provider, so linking a provider login to an existing user requires
   `userInfo.emailVerified === true` (`link-account.mjs:22`). An unverified login
   cannot adopt an existing account.
4. **Creation gate (our code):** the `user.create.before` hook rejects any user
   creation with `emailVerified !== true`, closing new-account email squatting.

Plus `disableIdTokenSignIn: true` removes the client-submitted-ID-token sign-in
surface entirely (we only use the redirect code flow).

**Why not throw from `mapProfileToUser`:** the `getUserInfo` call at
`callback.mjs:80` is **not** wrapped in try/catch, so a thrown error surfaces as a
generic unhandled failure, not a clean coded redirect. Security therefore rests on
the data-flow gates above (proven by reading better-auth 1.6.14 source), not on
exceptions. `mapProfileToUser` returns values; it does not throw.

**Why claim-decoding without signature verification is safe here:** in the
authorization-code flow, `getUserInfo` decodes the ID token that better-auth
fetched directly from Microsoft's token endpoint over TLS. Provenance is the token
endpoint, not the browser, so the claims are authentic. (Signature verification —
approach "B" — would be redundant for this flow and only adds upgrade drift. The
one place a client supplies a raw token, ID-token sign-in, is disabled outright.)

## 5. Changes

### 5.1 New — `backend/src/platform-auth/microsoft-claims.utils.ts`

`MicrosoftClaimsUtils` (static-method class, per repo convention). Pure, fully
typed against `MicrosoftEntraIDProfile` from `@better-auth/core` — no `any`, no
`as`.

* `isEmailVerified(claims): boolean` — strict:
  `claims.xms_edov === true || claims.email_verified === true ||
  (email != null && (verified_primary_email or verified_secondary_email contains
  email, case-insensitive))`. Only boolean `true` counts — a string `"true"`
  must NOT verify (Zod-typed boolean; defensive test below).
* `mapProfileToUser(profile) => { email: string | null; emailVerified: boolean }`
  — verified ⇒ `{ email: profile.email ?? null, emailVerified: true }`;
  not verified ⇒ `{ email: null, emailVerified: false }`. Also logs a structured
  `console.error('[better-auth] microsoft email not verified', { tid, hasEdov,
  hasEmailVerified })` on rejection for HyperDX (no PII beyond tenant id; never use
  `error` as an attribute key — use `_e`).

Signature matches better-auth's `mapProfileToUser?: (profile) => { email?: string | null; emailVerified?: boolean; ... }`, so the return overrides the provider's
default `emailVerified` computation (spread last in `getUserInfo`).

### 5.2 `backend/src/platform-auth/better-auth.service.ts`

* `socialProviders.microsoft` gains:
  * `mapProfileToUser: MicrosoftClaimsUtils.mapProfileToUser`
  * `disableIdTokenSignIn: true`
* Add `account: { accountLinking: { requireLocalEmailVerified: false } }`.
  * Rationale: legacy users migrated from NextAuth have `users.email_verified =
    false`. With the default (`true`), Better-Auth blocks linking **any**
    provider (including Google) to those accounts. Setting `false` lets a
    provider-verified login link to a legacy account; Better-Auth then
    auto-sets local `email_verified = true` on link (`link-account.mjs:48/64`),
    so the column self-heals. Safe because takeover is still blocked by the
    provider-side `emailVerified` requirement (microsoft not trusted).
  * `microsoft` is **not** added to `trustedProviders` — deliberately.
* `databaseHooks.user.create.before` — add an `emailVerified !== true` rejection
  (returns `false`) alongside the existing personal-domain check. Password signup
  is disabled and Google is always verified, so this only bites unverified OAuth.

### 5.3 `dashboard/apps/dashboard/app/auth-2/BetterAuthLoginPanel.tsx`

* Restore the "Continue with Microsoft" button (the `startSocial('microsoft', …)`
  handler already supports it; only the button JSX was removed).
* On callback redirect back with an `?error=` param, show a friendly message.
  Better-auth can surface `email_not_found` / `account_not_linked` on the reject
  paths — copy: *"We couldn't verify your Microsoft email. Ask your organization's
  admin, or sign in with Google / your work email."* Keyed off presence of the
  error param (not a custom code, per §4).
* NextAuth `/auth` page untouched.

## 6. Rejection matrix (data flow)

| Actor                                 | Claims                                                   | Outcome                                                                                                        | Gate                               |
| ------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| Attacker, own tenant, email=victim\@x | domain unverified ⇒ no email (Entra)                     | rejected, no DB write                                                                                          | §4.1 `email_not_found`             |
| Attacker (if Entra regressed)         | email present, `xms_edov=false`, `email_verified` absent | `email:null`, not trusted ⇒ rejected                                                                           | §4.2 + §4.3/§4.4                   |
| Legit existing user                   | verified (`xms_edov=true`)                               | links `auth_accounts` once, thereafter matches by `(sub, provider)`; local `email_verified` self-heals to true | §4.3                               |
| Legit new business user               | verified + business domain                               | account created                                                                                                | passes §4.4 + existing domain gate |
| New personal MSA                      | no `xms_edov` (Entra-only claim)                         | rejected                                                                                                       | §4.2 + business-domain gate        |
| Claim `xms_edov: "true"` (string)     | not boolean true                                         | not verified ⇒ rejected                                                                                        | §4.2 (defensive)                   |

## 7. Testing (extensive + defensive — mandatory closing step)

Real integrations, one scenario per file where it maps to a distinct path.

**Unit — `MicrosoftClaimsUtils` (`microsoft-claims.utils.spec.ts`):** every branch —
`xms_edov` true / false / absent; `email_verified` true / false / absent; email in
primary list; email in secondary list; case-insensitive email match; email absent;
empty verified lists; junk/extra claims pass through; **string `"true"` does NOT
verify** (defensive); verified path returns the email, unverified returns
`email: null`.

**Integration — the provider seam (`microsoft-claims.provider-integration.spec.ts`):**
construct the real `microsoft(...)` provider from `@better-auth/core` with our
options, feed crafted ID tokens (a signed/opaque JWT with controlled claims), and
assert our `mapProfileToUser` return **overrides** the provider's default
`emailVerified`, and that an unverified token yields `email:null` /
`emailVerified:false`. Pins the spread-order assumption so a better-auth bump that
changes it fails loudly.

**Config regression (`better-auth-config.security.spec.ts`):** assert `microsoft`
is not in resolved `trustedProviders`; `requireLocalEmailVerified === false` is the
only linking relaxation; `disableIdTokenSignIn === true`. A future config edit that
reopens the hole fails here.

**Create-gate (`better-auth-user-create-gate.spec.ts`):** user creation with
`emailVerified !== true` rejected; `true` + business domain passes; existing
personal-domain rejections still hold (no regression).

**Automated e2e — the composed takeover-blocked outcome
(`microsoft-oauth-callback.e2e.spec.ts`):** drives the REAL prod config
(`BetterAuthService`) through the actual sign-in/social → callback handshake,
offline (test creds injected via env before the module builds; the Microsoft
token endpoint + Graph photo stubbed at the `fetch` boundary — everything inside
better-auth runs for real). Asserts: (1) an unverified email for an EXISTING
victim ⇒ 302 error, no session cookie, victim's `auth_accounts`/`auth_sessions`
untouched; (2) string `"true"` xms\_edov ⇒ same rejection; (3) a verified login for
a legacy (`email_verified=false`) user ⇒ links + session + self-heals
`email_verified`; (4) a verified new business email ⇒ account created + linked +
session. This is the proof the fix blocks the takeover, not just that our helper
returns the right value.

**Manual e2e (with Ali, after Azure verified):** real Microsoft login on dev to
confirm live claims arrive — happy path + attack simulation from a real test
tenant. Documented because it needs a live Microsoft tenant we can't automate.

## 8. Rollout order

1. Land + deploy backend code — safe immediately; fails closed even before Entra
   is configured on prod.
2. Configure the **prod** app registration (§3 TODO). Verify via Graph GET.
3. e2e verify on dev/staging (happy path + attack sim).
4. Ship the Microsoft button on `/auth-2`.
5. (Future session, tracked separately) delete NextAuth entirely — until then its
   Azure path stays hidden.

## 9. Open questions / follow-ups

* Confirm prod `AZURE_AD_CLIENT_ID` and run §3 against it before step 4.
* `requireLocalEmailVerified: false` is global to Better-Auth account linking (not
  microsoft-scoped — better-auth has no per-provider setting). Google linking to
  legacy accounts benefits identically; confirm that is acceptable (it is — Google
  is verified-only too).
