From 2c510742944ff706ae769001c83aea77b695a875 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:25:25 -0700 Subject: [PATCH 1/2] Red: mock server must serialize empty 2FA fields like Go does The museum server's EmailAuthorizationResponse declares passkeySessionID, accountsUrl, twoFactorSessionID, and twoFactorSessionIDV2 without `omitempty`, so Go always sends them, as "" when unset. The previous mock omitted the unset fields entirely, which let the ?? -based dispatch pass in tests while the real server's "" defeated it and dual-2FA logins fell through to the unsupported passkey branch. --- test/auth/login.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/auth/login.test.ts b/test/auth/login.test.ts index e1bf4a6..177b5ed 100644 --- a/test/auth/login.test.ts +++ b/test/auth/login.test.ts @@ -200,11 +200,25 @@ const buildMockFetch = ( srpServer.checkM1(Buffer.from(body.srpM1, "base64")); const M2 = srpServer.computeM2(); + // IMPORTANT: the museum server's EmailAuthorizationResponse + // (server/ente/user.go) declares passkeySessionID, accountsUrl, + // twoFactorSessionID, and twoFactorSessionIDV2 WITHOUT the + // `omitempty` JSON tag. Go therefore always serializes them, + // sending "" (empty string, NOT null/absent) for any that do + // not apply. These mocks must reproduce that faithfully: a + // client that distinguishes fields with `??` instead of `||` + // passes against an omitting mock but breaks against the real + // server. + if (opts?.requireTOTP) { return new Response( JSON.stringify({ + id: 42, srpM2: M2.toString("base64"), + passkeySessionID: "", + accountsUrl: "", twoFactorSessionID: "totp-session-999", + twoFactorSessionIDV2: "", }), { status: 200, @@ -218,11 +232,14 @@ const buildMockFetch = ( // server sets passkeySessionID + twoFactorSessionIDV2 (not // twoFactorSessionID -- that's deliberate, so old clients // that only know the V1 field keep using the passkey flow). + // The V1 field is still present on the wire as "". return new Response( JSON.stringify({ + id: 42, srpM2: M2.toString("base64"), passkeySessionID: "passkey-session-123", accountsUrl: "https://accounts.ente.io", + twoFactorSessionID: "", twoFactorSessionIDV2: "totp-session-v2-456", }), { @@ -238,6 +255,10 @@ const buildMockFetch = ( id: 42, keyAttributes: fixture.keyAttributes, encryptedToken: fixture.encryptedToken, + passkeySessionID: "", + accountsUrl: "", + twoFactorSessionID: "", + twoFactorSessionIDV2: "", }), { status: 200, From 68d8cfb7fe560bf019f4efa0b764872a49483351 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:26:15 -0700 Subject: [PATCH 2/2] Green: treat empty-string 2FA session fields as absent Use || instead of ?? when picking the TOTP session ID. The server sends "" for unset 2FA fields (no omitempty), and "" ?? v2 short-circuits to "", which made dual-2FA accounts fall through to the unsupported passkey branch. --- src/auth/login.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index 4123b1b..fac00eb 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -76,8 +76,12 @@ const srpHandshake = async ( // twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the // account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform // a WebAuthn ceremony, and the user has a TOTP secret enrolled. + // + // The server marshals these fields without `omitempty`, so unset fields + // arrive as "" rather than being absent. Use || (not ??) so empty + // strings are treated as not-set. const totpSessionID = - verifyResponse.twoFactorSessionID ?? + verifyResponse.twoFactorSessionID || verifyResponse.twoFactorSessionIDV2; if (totpSessionID) { return { kind: "totp", sessionID: totpSessionID };