Skip to main content

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 [email protected]. 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)

ActorClaimsOutcomeGate
Attacker, own tenant, email=victim@xdomain unverified ⇒ no email (Entra)rejected, no DB write§4.1 email_not_found
Attacker (if Entra regressed)email present, xms_edov=false, email_verified absentemail:null, not trusted ⇒ rejected§4.2 + §4.3/§4.4
Legit existing userverified (xms_edov=true)links auth_accounts once, thereafter matches by (sub, provider); local email_verified self-heals to true§4.3
Legit new business userverified + business domainaccount createdpasses §4.4 + existing domain gate
New personal MSAno xms_edov (Entra-only claim)rejected§4.2 + business-domain gate
Claim xms_edov: "true" (string)not boolean truenot 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).