diff --git a/PACKAGES.md b/PACKAGES.md index d245745..7352f52 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -119,3 +119,18 @@ permission disappears. Provider-only accounts stay provider-only even if the provider later supplies an email; adopting an address or merging existing accounts requires a separate account-recovery flow. Without a common email or an existing binding, different providers cannot be matched automatically. + +`rememberLoginMethod: true` enables Better Auth's last-login-method plugin with +no database field. A successful login sets a readable 30-day cookie containing +only `email` or a provider ID. Failed attempts and explicit links do not update +it; logout retains it. The name is `__Host-pgstencil.last_login_method` on HTTPS +and `pgstencil.last_login_method` locally. This is an untrusted display hint, +never proof of identity or a replacement for a session. New browsers/private +windows and cleared cookies have no hint. + +The Better Auth integration also supports `microsoft` with `MICROSOFT_CLIENT_ID` +and `MICROSOFT_CLIENT_SECRET`. Its callback is `/api/auth/callback/microsoft`. +Personal and work/school accounts are supported; verified email can participate +in same-email linking. Unverified email does not establish an account match. +[Microsoft configuration and identity checks](examples/better-auth/README.md#microsoft) +include the optional ID-token claims needed for email matching. diff --git a/examples/better-auth/README.md b/examples/better-auth/README.md index ba20f05..9a85c38 100644 --- a/examples/better-auth/README.md +++ b/examples/better-auth/README.md @@ -1,6 +1,6 @@ # Better Auth with pgstencil -Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook and GitHub +Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook, GitHub and Microsoft OAuth. pgstencil owns SQL migrations, Docker/IntegreSQL clones, email capture, security policy and deterministic tests. The reusable exports live in `@pgstencil/auth`; see [package consumption](../../PACKAGES.md#better-auth-integration). This example is isolated from the old auth implementation and from TTR production. @@ -53,7 +53,7 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. replay claim. Apple form_post relays to a GET that receives the Lax cookies. Callback destinations are fixed to the application origin. Direct provider-token sign-in and unused upstream auth endpoints are unavailable. -- The pinned version's Google/Apple redirect profile readers only decode ID tokens. +- The pinned version's Google/Apple/Microsoft redirect profile readers only decode ID tokens. `verifiedOidc` explicitly enables Better Auth's signature/issuer/audience/expiry/ nonce verification through its plugin API. Negative tests cover each check. GitHub requires a verified primary email; Facebook uses the authenticated email @@ -71,7 +71,7 @@ and outbound fetch facades use AsyncLocalStorage to select each app's clock, random stream and local provider server. They do not replace process globals, cryptographic hashing/signing or timers. Normal builds have no injection or test clock routes. Repeatable email/OAuth cookies, sessions and timestamps are tested -across parallel apps; all four providers also run through real workerd. +across parallel apps; all five providers also run through real workerd. This adapter covers these APIs in the bundled dependency graph. Dependency upgrades must rerun security and snapshot tests. The same request order is @@ -106,3 +106,24 @@ Enter secrets directly into deployment tooling, not chat, source files or logs. Do not delete Supabase/Pages until email and each required provider pass in a real browser. The TTR test must also confirm that a second device's login signs out the first, and that its chosen linking policy preserves account IDs across login methods. + +## Microsoft + +The Better Auth adapter supports personal and work/school Microsoft accounts at +`https://login.microsoftonline.com/common`. Configure `MICROSOFT_CLIENT_ID` and +`MICROSOFT_CLIENT_SECRET`, with a Web redirect URI of +`https:///api/auth/callback/microsoft`. It requests only OpenID, profile, +and email scopes; there is no Graph photo request or offline access. + +Signatures, audience, expiration, nonce and the tenant-specific issuer are checked +before reading claims. Stable identities include both `tid` and `oid`. +An email can join an existing account only with `email_verified: true`, +`xms_edov: true`, or membership in the verified email claims. Ordinary `email` +and `preferred_username` are insufficient. Request `email` and `xms_edov` as +optional ID-token claims in the app registration. With `allowMissingEmail`, +missing/unverified email becomes a provider-only account without a code prompt. +`trustedEmailProviders: ['microsoft']` bypasses the additional local mailbox proof +only after these Microsoft-specific verification checks pass. + +See [Microsoft's claim reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference) +and [Better Auth's provider setup](https://better-auth.com/docs/authentication/microsoft). diff --git a/examples/better-auth/src/auth.ts b/examples/better-auth/src/auth.ts index 7b64b11..d43333e 100644 --- a/examples/better-auth/src/auth.ts +++ b/examples/better-auth/src/auth.ts @@ -23,7 +23,7 @@ const loginScript = ` const send = document.querySelector('#send'), verify = document.querySelector('#verify'); const status = document.querySelector('#status'), logout = document.querySelector('#logout'); let csrf, signedIn = false; -const providerNames = {google:'Google', apple:'Apple', facebook:'Facebook', github:'GitHub'}; +const providerNames = {google:'Google', apple:'Apple', facebook:'Facebook', github:'GitHub', microsoft:'Microsoft'}; const enabledProviders = await (await fetch('/api/providers')).json(); const showProviders = async () => { const container = document.querySelector('#providers'); container.replaceChildren(); diff --git a/packages/auth/src/better-auth-email.ts b/packages/auth/src/better-auth-email.ts index a8aa909..82526ed 100644 --- a/packages/auth/src/better-auth-email.ts +++ b/packages/auth/src/better-auth-email.ts @@ -24,3 +24,21 @@ export function identityEmail( .digest('hex') + identityDomain ); } + +/** Microsoft object IDs are tenant-scoped. Never substitute mutable email/UPN. */ +export function providerSubject( + provider: string, + profile: Record | undefined, +) { + if (provider !== 'microsoft') return profile?.sub ?? profile?.id; + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if ( + typeof profile?.tid !== 'string' || + !uuid.test(profile.tid) || + typeof profile.oid !== 'string' || + !uuid.test(profile.oid) + ) + throw new Error('Invalid Microsoft identity'); + return `${profile.tid.toLowerCase()}:${profile.oid.toLowerCase()}`; +} diff --git a/packages/auth/src/better-auth-oauth.ts b/packages/auth/src/better-auth-oauth.ts index a1c9664..66695eb 100644 --- a/packages/auth/src/better-auth-oauth.ts +++ b/packages/auth/src/better-auth-oauth.ts @@ -4,37 +4,65 @@ import type { GithubProfile } from 'better-auth/social-providers'; import { makeSignature } from 'better-auth/crypto'; import { sql } from 'kysely'; import { equal, keyed } from './better-auth-security.ts'; -import { identityEmail, isIdentityEmail } from './better-auth-email.ts'; +import { + identityEmail, + isIdentityEmail, + providerSubject, +} from './better-auth-email.ts'; import type { connectDatabase } from 'pgstencil/postgres'; -export const providers = ['google', 'apple', 'facebook', 'github'] as const; +export const providers = [ + 'google', + 'apple', + 'facebook', + 'github', + 'microsoft', +] as const; export type Provider = (typeof providers)[number]; export type OAuthSettings = Partial< Record >; /** Pin the redirect flow's OIDC checks explicitly on Better Auth 1.7.3. - * Its Google/Apple provider metadata supports verification, but their default + * Its Google/Apple/Microsoft provider metadata supports verification, but their default * redirect getUserInfo only decodes claims. Use the library's actual verifier. */ export const verifiedOidc: BetterAuthPlugin = { id: 'pgstencil-verified-oidc', init(context) { for (const provider of context.socialProviders) { - if (provider.id !== 'google' && provider.id !== 'apple') continue; + if (!['google', 'apple', 'microsoft'].includes(provider.id)) continue; provider.requiresIdTokenNonce = true; - provider.issuer = - provider.id === 'google' - ? 'https://accounts.google.com' - : 'https://appleid.apple.com'; + if (provider.id !== 'microsoft') + provider.issuer = + provider.id === 'google' + ? 'https://accounts.google.com' + : 'https://appleid.apple.com'; if (provider.idToken && 'jwks' in provider.idToken) provider.idToken.algorithms = ['RS256']; + if (provider.id === 'microsoft') { + provider.accountSubject = ({ profile }) => + String( + providerSubject('microsoft', profile as Record), + ); + if (provider.idToken && 'jwks' in provider.idToken) { + const verifyClaims = provider.idToken.verifyClaims; + provider.idToken.verifyClaims = (claims) => { + try { + providerSubject('microsoft', claims); + } catch { + return false; + } + return verifyClaims?.(claims) === true; + }; + } + } const authorization = provider.createAuthorizationURL.bind(provider); provider.createAuthorizationURL = async (data) => { if (!data.idTokenNonce) throw new Error('Missing OIDC nonce'); const url = await authorization(data); url.searchParams.set('nonce', data.idTokenNonce); - if (provider.id === 'google') + if (provider.id === 'google' || provider.id === 'microsoft') url.searchParams.set('prompt', 'select_account'); return url; }; @@ -78,7 +106,13 @@ export function socialProviders( ): BetterAuthOptions['socialProviders'] { const missingEmail = ( provider: Provider, - profile: { email?: string | null; sub?: string; id?: string | number }, + profile: { + email?: string | null; + sub?: string; + id?: string | number; + tid?: string; + oid?: string; + }, ) => { if (profile.email) { if (isIdentityEmail(profile.email)) @@ -90,7 +124,7 @@ export function socialProviders( email: identityEmail( provider, settings[provider]!.clientId, - profile.sub ?? profile.id, + providerSubject(provider, profile), ), emailVerified: false, } @@ -115,6 +149,49 @@ export function socialProviders( }, } : {}), + ...(settings.microsoft + ? { + microsoft: { + ...settings.microsoft, + tenantId: 'common', + disableProfilePhoto: true, + disableDefaultScope: true, + scope: ['openid', 'profile', 'email'], + mapProfileToUser: async (profile) => { + const email = profile.email; + const verified = + profile.email_verified === true || + profile.xms_edov === true || + [ + profile.verified_primary_email, + profile.verified_secondary_email, + ].some( + (values) => + Array.isArray(values) && + values.some( + (value) => + typeof value === 'string' && + value.toLowerCase() === email?.toLowerCase(), + ), + ); + // A tenant admin can edit ordinary email/UPN. Only verified claims may + // join an existing email account; otherwise allow provider-only signup. + return { + name: + typeof profile.name === 'string' + ? profile.name + : 'Microsoft user', + email: verified ? (email ?? null) : null, + emailVerified: !!email && verified, + ...missingEmail('microsoft', { + ...profile, + email: verified ? (email ?? null) : null, + }), + }; + }, + }, + } + : {}), ...(settings.facebook ? { facebook: { diff --git a/packages/auth/src/better-auth-security.ts b/packages/auth/src/better-auth-security.ts index 4936311..00e0887 100644 --- a/packages/auth/src/better-auth-security.ts +++ b/packages/auth/src/better-auth-security.ts @@ -95,7 +95,8 @@ export function protectAuth( const path = c.req.path.slice('/api/auth'.length); if (path === '/link-social' && options.accountLinking === 'same-email') return c.json({ message: 'Not found' }, 404); - const callback = /^\/callback\/(google|github|apple|facebook)$/.test(path); + const callback = + /^\/callback\/(google|github|apple|facebook|microsoft)$/.test(path); const reads = ['/get-session', '/list-accounts']; const writes = [ '/email-otp/send-verification-otp', diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts index 3989a28..42ef474 100644 --- a/packages/auth/src/better-auth-workers.ts +++ b/packages/auth/src/better-auth-workers.ts @@ -26,6 +26,7 @@ export function createBetterAuthWorker( | 'accountLinking' | 'trustedEmailProviders' | 'allowMissingEmail' + | 'rememberLoginMethod' | 'appName' | 'successPath' | 'errorPath' diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts index f0cbb1f..f9ca40d 100644 --- a/packages/auth/src/better-auth.ts +++ b/packages/auth/src/better-auth.ts @@ -1,5 +1,6 @@ import { betterAuth, type BetterAuthOptions } from 'better-auth'; import { getSessionFromCtx } from 'better-auth/api'; +import { lastLoginMethod } from 'better-auth/plugins'; import { emailOTP } from 'better-auth/plugins/email-otp'; import { Hono } from 'hono'; import { sql } from 'kysely'; @@ -18,7 +19,11 @@ import { type Provider, } from './better-auth-oauth.ts'; -import { identityEmail, isIdentityEmail } from './better-auth-email.ts'; +import { + identityEmail, + isIdentityEmail, + providerSubject, +} from './better-auth-email.ts'; export interface AuthOptions { database: ReturnType; @@ -32,6 +37,8 @@ export interface AuthOptions { trustedEmailProviders?: Provider[]; /** Permit provider-only accounts; their public session email is null. */ allowMissingEmail?: boolean; + /** Remember the last successful method in a readable, non-authenticating 30-day cookie. */ + rememberLoginMethod?: boolean; oauth?: OAuthSettings; appName?: string; successPath?: string; @@ -80,7 +87,7 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { identityEmail( provider, options.oauth[provider].clientId, - profile?.sub ?? profile?.id, + providerSubject(provider, profile), ) ) return; @@ -216,6 +223,20 @@ export function authOptions(options: AuthOptions): BetterAuthOptions { }, plugins: [ verifiedOidc, + ...(options.rememberLoginMethod + ? [ + lastLoginMethod({ + cookieName: secure + ? '__Host-pgstencil.last_login_method' + : 'pgstencil.last_login_method', + storeInDatabase: false, + customResolveMethod: (context) => + context.path === '/sign-in/email-otp' ? 'email' : null, + // Do not record failed callbacks, cookie clearing, or explicit linking. + beforeStoreCookie: (context) => !!context.context.newSession, + }), + ] + : []), emailOTP({ otpLength: 8, expiresIn: 600, diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts index 7ee8582..05e4d9a 100644 --- a/tests/integration/better-auth-oauth.test.ts +++ b/tests/integration/better-auth-oauth.test.ts @@ -9,7 +9,7 @@ import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; import { mockOAuthServer, endpointPaths, - allOAuthCredentials, + betterAuthCredentials as allOAuthCredentials, type GrantOptions, } from '../support/oauth-server.ts'; import type { AuthOptions } from '../../packages/auth/src/better-auth.ts'; @@ -46,7 +46,7 @@ async function fixture( accountLinking: 'explicit' | 'same-email' = 'explicit', emailPolicy: Pick< AuthOptions, - 'trustedEmailProviders' | 'allowMissingEmail' + 'trustedEmailProviders' | 'allowMissingEmail' | 'rememberLoginMethod' > = {}, ) { const context = await createTestContext({ @@ -180,11 +180,19 @@ for (const provider of providers) expect(response.status, await response.clone().text()).toBe(302); expect(response.headers.get('location')).toBe(origin + '/'); expect((await session(browser))?.user.email).toBe('oauth@example.test'); - if (provider === 'google' || provider === 'github') + if ( + provider === 'google' || + provider === 'github' || + provider === 'microsoft' + ) expect(authorization.searchParams.get('code_challenge_method')).toBe( 'S256', ); - if (provider === 'google' || provider === 'apple') + if ( + provider === 'google' || + provider === 'apple' || + provider === 'microsoft' + ) expect(authorization.searchParams.get('nonce')).toBeTruthy(); expect((await browser.follow(callback)).headers.get('location')).toContain( 'error=oauth_failed', @@ -204,7 +212,7 @@ for (const provider of providers) expect(f.destinations.length).toBeGreaterThan(0); }); -for (const provider of ['google', 'apple'] as const) +for (const provider of ['google', 'apple', 'microsoft'] as const) test(`Better Auth OAuth: ${provider} rejects invalid signed claims`, async ({ onTestFinished, }) => { @@ -579,7 +587,10 @@ for (const provider of providers) expect(await session(first)).toBeNull(); const other = await f.browser(); await login(f, other, provider, { - subject: '987654321', + subject: + provider === 'microsoft' + ? '22222222-2222-4222-8222-222222222222' + : '987654321', email: '', githubEmails: [], }); @@ -797,3 +808,137 @@ test('Same-email linking: wrong-email, OAuth-only, expired and revoked sessions await queryDatabase(f.database.url, 'SELECT "providerId" FROM account'), ).toEqual([{ providerId: 'apple' }]); }); + +test('Last login method remembers only successful sign-ins and survives logout', async ({ + onTestFinished, +}) => { + const f = await fixture('multiple', 'same-email', { + rememberLoginMethod: true, + }); + onTestFinished(() => f.close()); + const browser = await f.browser(); + const cookieName = '__Host-pgstencil.last_login_method'; + expect(browser.jar.get(cookieName)).toBeUndefined(); + await start(browser, 'apple'); + expect(browser.jar.get(cookieName)).toBeUndefined(); + await login(f, browser, 'apple', { badSignature: true }); + expect(browser.jar.get(cookieName)).toBeUndefined(); + const result = await login(f, browser, 'apple'); + expect(browser.jar.get(cookieName)).toBe('apple'); + const hint = result.response.headers + .getSetCookie() + .find((value) => value.startsWith(cookieName + '='))!; + expect(hint).toContain('Max-Age=2592000'); + expect(hint).toContain('Secure'); + expect(hint).toContain('SameSite=Lax'); + expect(hint).not.toContain('HttpOnly'); + await browser.post('sign-out', {}); + expect(await session(browser)).toBeNull(); + expect(browser.jar.get(cookieName)).toBe('apple'); + await browser.post('sign-in/email-otp', { + email: 'hint@example.test', + otp: '00000000', + }); + expect(browser.jar.get(cookieName)).toBe('apple'); + await emailLogin(f, browser, 'hint@example.test'); + expect(browser.jar.get(cookieName)).toBe('email'); + f.time.advanceMilliseconds(11_000); + await login(f, browser, 'google', { badSignature: true }); + expect(browser.jar.get(cookieName)).toBe('email'); + // Remembering another browser's method never authenticates this browser. + const other = await f.browser(); + other.jar.set(cookieName, 'apple'); + expect(await session(other)).toBeNull(); +}); + +for (const oauthFirst of [false, true]) + test(`Microsoft verified email: ${oauthFirst ? 'OAuth first' : 'email first'} shares an account`, async ({ + onTestFinished, + }) => { + const f = await fixture('single', 'same-email', { + trustedEmailProviders: ['microsoft'], + rememberLoginMethod: true, + }); + onTestFinished(() => f.close()); + const microsoft = await f.browser(), + emailBrowser = await f.browser(); + const email = 'player@example.test'; + let emailId: string | undefined; + if (!oauthFirst) emailId = await emailLogin(f, emailBrowser, email); + const result = await login(f, microsoft, 'microsoft', { email }); + expect(result.response.headers.get('location')).toBe(origin + '/'); + expect(result.authorization.searchParams.get('prompt')).toBe( + 'select_account', + ); + expect(result.authorization.searchParams.get('scope')).toBe( + 'openid profile email', + ); + expect(microsoft.jar.get('__Host-pgstencil.last_login_method')).toBe( + 'microsoft', + ); + const id = (await session(microsoft))!.user.id; + if (oauthFirst) { + emailId = await emailLogin(f, emailBrowser, email); + expect(await session(microsoft)).toBeNull(); + } else expect(await session(emailBrowser)).toBeNull(); + expect(emailId).toBe(id); + expect( + await queryDatabase(f.database.url, 'SELECT id FROM "user"'), + ).toEqual([{ id }]); + }); + +test('Microsoft tenant identity and unverified email cannot capture another account', async ({ + onTestFinished, +}) => { + const f = await fixture('single', 'same-email', { + trustedEmailProviders: ['microsoft'], + allowMissingEmail: true, + }); + onTestFinished(() => f.close()); + const owner = await f.browser(); + const email = 'owner@example.test'; + const ownerId = await emailLogin(f, owner, email); + const microsoft = await f.browser(); + await login(f, microsoft, 'microsoft', { + email, + verified: false, + claims: { preferred_username: email }, + }); + const user = (await session(microsoft))!.user; + expect(user.email).toBeNull(); + expect(user.id).not.toBe(ownerId); + expect((await session(owner))!.user.id).toBe(ownerId); + const workTenant = '33333333-3333-4333-8333-333333333333'; + const work = await f.browser(); + await login(f, work, 'microsoft', { + email, + verified: false, + claims: { + tid: workTenant, + iss: `https://login.microsoftonline.com/${workTenant}/v2.0`, + }, + }); + expect((await session(work))!.user.id).not.toBe(user.id); + // Stable tenant/object identity, not the token's mutable display/email data. + const returning = await f.browser(); + await login(f, returning, 'microsoft', { + email: '', + claims: { sub: 'another-pairwise-value' }, + }); + expect((await session(returning))!.user).toMatchObject(user); + expect(await session(microsoft)).toBeNull(); + for (const claims of [ + { tid: null }, + { tid: workTenant }, + { oid: null }, + { oid: 'invalid' }, + ]) { + const browser = await f.browser(); + expect( + (await login(f, browser, 'microsoft', { claims })).response.headers.get( + 'location', + ), + ).toContain('error=oauth_failed'); + expect(await session(browser)).toBeNull(); + } +}); diff --git a/tests/integration/better-auth-workers.test.ts b/tests/integration/better-auth-workers.test.ts index e9ce50b..580fed3 100644 --- a/tests/integration/better-auth-workers.test.ts +++ b/tests/integration/better-auth-workers.test.ts @@ -13,7 +13,7 @@ import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; import { mockOAuthServer, endpointPaths, - allOAuthCredentials, + betterAuthCredentials as allOAuthCredentials, } from '../support/oauth-server.ts'; import { providers } from '../../examples/better-auth/src/oauth.ts'; diff --git a/tests/support/oauth-server.ts b/tests/support/oauth-server.ts index 6bd1a82..28a8be4 100644 --- a/tests/support/oauth-server.ts +++ b/tests/support/oauth-server.ts @@ -1,11 +1,11 @@ import { createServer } from 'node:http'; import { once } from 'node:events'; import { createHash, createHmac, generateKeyPairSync, sign } from 'node:crypto'; +import type { OAuthFetch } from '../../examples/login/src/oauth-providers.ts'; import type { - OAuthFetch, Provider, OAuthSettings, -} from '../../examples/login/src/oauth-providers.ts'; +} from '../../packages/auth/src/better-auth-oauth.ts'; export const allOAuthCredentials = { google: { @@ -22,6 +22,14 @@ export const allOAuthCredentials = { clientSecret: 'test-github-secret', }, } satisfies OAuthSettings; +export const microsoftTenant = '9188040d-6c67-4c5b-b112-36a304b66dad'; +export const betterAuthCredentials = { + ...allOAuthCredentials, + microsoft: { + clientId: 'test-microsoft-client', + clientSecret: 'test-microsoft-secret', + }, +} satisfies OAuthSettings; export const oauthCredentials = { google: allOAuthCredentials.google, github: allOAuthCredentials.github, @@ -38,6 +46,9 @@ const jwk = { alg: 'RS256', }; export const endpointPaths: Record = { + 'https://login.microsoftonline.com/common/oauth2/v2.0/token': + '/microsoft/token', + 'https://login.microsoftonline.com/common/discovery/v2.0/keys': '/keys', 'https://appleid.apple.com/.well-known/openid-configuration': '/apple/discovery', 'https://appleid.apple.com/auth/token': '/apple/token', @@ -123,7 +134,7 @@ export async function mockOAuthServer( const grant = grants.get(code); grants.delete(code); if (!grant) return json({ error: 'invalid_grant' }, 400); - const credentials = allOAuthCredentials[grant.provider]; + const credentials = betterAuthCredentials[grant.provider]; const challenge = createHash('sha256') .update(form.get('code_verifier') ?? '') .digest('base64url'); @@ -163,7 +174,7 @@ export async function mockOAuthServer( : 'read:user,user:email', }; if ( - (grant.provider === 'google' || grant.provider === 'apple') && + ['google', 'apple', 'microsoft'].includes(grant.provider) && !grant.options.missingIdToken ) { const now = Math.floor( @@ -181,6 +192,19 @@ export async function mockOAuthServer( nonce: grant.authorization.searchParams.get('nonce'), iat: now, exp: now + 3600, + ...(grant.provider === 'microsoft' + ? { + iss: `https://login.microsoftonline.com/${microsoftTenant}/v2.0`, + tid: microsoftTenant, + oid: + grant.options.subject ?? + '11111111-1111-4111-8111-111111111111', + name: 'Mock Microsoft', + // Real Microsoft tokens typically use optional xms_edov, not email_verified. + email_verified: undefined, + xms_edov: grant.options.verified ?? true, + } + : {}), ...grant.options.claims, }; const unsigned = [