diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a3bbdb..23e5bf45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,17 @@ when a version is cut. **Fixed** +- **Microsoft sign-in now needs one more claim before it will trust an address.** Naming your tenant + says which directory vouched for a sign-in; it never said the person owns the address they + presented. Inside your own tenant a member or an invited guest could present a colleague's — an + administrator's — and have their Microsoft account attached to it. ProjectSend now also requires + the `xms_edov` claim, which is Microsoft's own answer to that question. + + *Who this affected:* only installations with Microsoft sign-in enabled. **There is something to do + — see the upgrade notes.** Nobody is locked out: accounts already linked to Microsoft keep working + untouched, because they are matched on the account itself rather than on the address. + + Reported by Dickson Massawe. - **Changing your own email address now asks for your password.** It did not, and that address is where a password reset is sent — so anybody who got hold of a signed-in session could point the account at their own inbox, request a reset, and keep the account for good. Deleting your account @@ -48,6 +59,15 @@ when a version is cut. or `1` — including `no`, `off`, and a misspelling — used to switch the CAPTCHA off on the login and registration forms. Only an explicit `true` or `1` does now; everything else leaves it on. +### Upgrade notes + +- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In + the Entra portal: your app registration → Token configuration → Add optional claim → ID → + `xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it + will no longer attach itself to an account that already exists — the person is told to sign in + with their password and connect Microsoft from their settings instead. People already signed in + with Microsoft are not affected. + ## 2.4.0 — 8 September 2026 diff --git a/app/Modules/Identity/Social/SocialIdentity.php b/app/Modules/Identity/Social/SocialIdentity.php index 631765bc..80b99e16 100644 --- a/app/Modules/Identity/Social/SocialIdentity.php +++ b/app/Modules/Identity/Social/SocialIdentity.php @@ -39,8 +39,40 @@ final readonly class SocialIdentity * | LinkedIn | `email_verified` claim | * | OpenID Connect | `email_verified` claim, from the ID token | * | GitHub | An address at all — Socialite's GithubProvider replaces `email` with the result of `getEmailByToken()`, which only ever returns one that is **primary and verified** | - * | Microsoft | The token's `tid` matching the configured tenant. Entra does not emit a usable `email_verified`, and its `email` claim is user-mutable — pinning the tenant is what makes it mean anything (this is the *nOAuth* class of bug) | + * | Microsoft | The token's `tid` matching the configured tenant **and** `xms_edov` — see below | * | Facebook | Nothing. The Graph API has no equivalent claim, so an address from Facebook is never treated as verified | + * + * ### Microsoft takes two claims, not one + * + * Entra emits no usable `email_verified`, and its `email` claim is + * user-mutable — populated from `otherMails`/proxyAddresses for a B2B + * guest, among other places. Pinning the tenant was the first answer + * and it is half of one: it defeats the classic cross-tenant *nOAuth*, + * where a stranger's own tenant asserts your address, because a + * foreign tenant carries a different `tid`. + * + * It does nothing about the same attack from *inside* the pinned + * tenant. A colleague, or a guest somebody invited, could shape their + * `email` claim to an administrator's address and have their subject + * bound to that account (GHSA-2rfh-v3j2-2jg7). Tenant-pinning answers + * "which directory said this", never "does this person own that + * address". + * + * `xms_edov` is Microsoft's own answer to the second question — the + * optional claim meaning the tenant has verified it owns the email's + * domain — and their guidance says to require it wherever `email` + * identifies an account. Absent is treated as unverified, which is the + * only safe reading: it is absent by default, so anything else would + * be no check at all. + * + * **What an installation has to do.** The claim must be added to the + * app registration (Token configuration → optional claims → `xms_edov` + * on the ID token). Until it is, Microsoft sign-in still works and + * still creates new accounts — it simply stops silently attaching + * itself to accounts that already exist, and says so, pointing the + * person at signing in with a password and connecting the provider + * from their settings. Accounts already linked are unaffected: they + * resolve by subject, before this is consulted at all. */ public static function fromSocialite( SocialProvider $provider, @@ -72,7 +104,8 @@ final readonly class SocialIdentity || ($raw['email_verified'] ?? null) === 'true', SocialProvider::Github => true, SocialProvider::Microsoft => is_string($settings->tenant_id) - && ($raw['tid'] ?? null) === $settings->tenant_id, + && ($raw['tid'] ?? null) === $settings->tenant_id + && (($raw['xms_edov'] ?? null) === true || ($raw['xms_edov'] ?? null) === 'true'), SocialProvider::Facebook => false, }, name: is_string($user->getName()) && trim($user->getName()) !== '' diff --git a/resources/js/pages/system/settings/social-login.tsx b/resources/js/pages/system/settings/social-login.tsx index 79c42bf0..ec9b183f 100644 --- a/resources/js/pages/system/settings/social-login.tsx +++ b/resources/js/pages/system/settings/social-login.tsx @@ -197,6 +197,11 @@ function ProviderCard({ provider, open, onToggle }: { provider: ProviderSettings 'Your own tenant, not "common". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.', )}
++ {t( + 'Also add the "xms_edov" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague\'s address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.', + )} +