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.
This commit is contained in:
Linus Rath
2026-09-30 19:31:03 +02:00
parent 7f5728fea2
commit 4db88255e8
3 changed files with 24 additions and 5 deletions
+5 -2
View File
@@ -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;
+18 -2
View File
@@ -48,11 +48,11 @@ function pending(extra: Record<string, unknown> = {}) {
}, 'sso-pending'));
}
async function complete() {
async function complete(extra: Record<string, unknown> = {}) {
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', () => {
+1 -1
View File
@@ -1539,7 +1539,7 @@ export const useAuthStore = create<AuthState>()(
? 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();