From 4db88255e87b24c6356cc01a896f62d0cbf7fa13 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:31:03 +0200 Subject: [PATCH] fix: stop a sixth sso account from overwriting the first account's sign-in `/api/auth/sso/complete` only accepted cookie slots 0-4 and quietly used slot 0 for anything higher. Since the account limit rose to `MAX_ACCOUNT_SLOTS` (50) on HTTP/2, adding a sixth account through server-side SSO wrote its refresh token over the first account's cookie, while the new account was registered on slot 5 with no cookie behind it. The route and the OAuth callback's pending-slot check now use the same bound as every other auth route. --- app/api/auth/sso/complete/route.ts | 7 +++++-- lib/__tests__/sso-complete-route.test.ts | 20 ++++++++++++++++++-- stores/auth-store.ts | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index 85cc6930d..de4a8f8de 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -13,6 +13,7 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { storeIdToken } from '@/lib/oauth/end-session'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes @@ -48,8 +49,10 @@ export async function POST(request: NextRequest) { // Per-account refresh-token cookie slot. Without this the route hardcoded // slot 0, so the "+ Add Account" flow overwrote the first account's // refresh-token cookie. Default to 0 for back-compat with any caller that - // omits slot. Mirrors the validation in /api/auth/token POST. - const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; + // omits slot. Mirrors the validation in /api/auth/token POST: slots run + // up to MAX_ACCOUNT_SLOTS, so a sixth account must not land on slot 0. + const slot = typeof bodySlot === 'number' && Number.isInteger(bodySlot) + && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0; // Read and decrypt the pending SSO cookie const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value; diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index db3d93d9f..c4e163448 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -48,11 +48,11 @@ function pending(extra: Record = {}) { }, 'sso-pending')); } -async function complete() { +async function complete(extra: Record = {}) { const res = await POST(new NextRequest('https://webmail.example/api/auth/sso/complete', { method: 'POST', headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, - body: JSON.stringify({ code: 'code-1', state: 'state-1' }), + body: JSON.stringify({ code: 'code-1', state: 'state-1', ...extra }), })); return { status: res.status, body: await res.json() }; } @@ -81,6 +81,22 @@ describe('/api/auth/sso/complete', () => { expect(exchangeCodeForTokens).toHaveBeenCalledOnce(); expect(jar.get('jmap_rt')).toBe('rt'); }); + + it('writes a sixth account to its own slot, not over the first account', async () => { + jar.set('jmap_rt', 'first-account-rt'); + pending(); + const res = await complete({ slot: 5 }); + expect(res.status).toBe(200); + expect(jar.get('jmap_rt_5')).toBe('rt'); + expect(jar.get('jmap_rt')).toBe('first-account-rt'); + }); + + it('falls back to slot 0 for a slot outside the account range', async () => { + pending(); + await complete({ slot: 50 }); + expect(jar.get('jmap_rt')).toBe('rt'); + expect(jar.has('jmap_rt_50')).toBe(false); + }); }); describe('/api/auth/sso/complete for the mobile app handoff', () => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f2aa30f9a..87391156a 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1539,7 +1539,7 @@ export const useAuthStore = create()( ? sessionStorage.getItem('oauth_cookie_slot') : null; const pendingSlot = rawSlot !== null ? parseInt(rawSlot, 10) : NaN; - const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot <= 4 + const slot = !isNaN(pendingSlot) && pendingSlot >= 0 && pendingSlot < MAX_ACCOUNT_SLOTS ? pendingSlot : accountStore.getNextCookieSlot();