Merge: fix dual-2FA login against real server (empty-string fields)

This commit is contained in:
2026-06-10 11:26:18 -07:00
2 changed files with 26 additions and 1 deletions

View File

@@ -76,8 +76,12 @@ const srpHandshake = async (
// twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the // twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the
// account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform // account has BOTH passkeys and TOTP. Prefer TOTP: a CLI cannot perform
// a WebAuthn ceremony, and the user has a TOTP secret enrolled. // 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 = const totpSessionID =
verifyResponse.twoFactorSessionID ?? verifyResponse.twoFactorSessionID ||
verifyResponse.twoFactorSessionIDV2; verifyResponse.twoFactorSessionIDV2;
if (totpSessionID) { if (totpSessionID) {
return { kind: "totp", sessionID: totpSessionID }; return { kind: "totp", sessionID: totpSessionID };

View File

@@ -200,11 +200,25 @@ const buildMockFetch = (
srpServer.checkM1(Buffer.from(body.srpM1, "base64")); srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
const M2 = srpServer.computeM2(); 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) { if (opts?.requireTOTP) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
id: 42,
srpM2: M2.toString("base64"), srpM2: M2.toString("base64"),
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "totp-session-999", twoFactorSessionID: "totp-session-999",
twoFactorSessionIDV2: "",
}), }),
{ {
status: 200, status: 200,
@@ -218,11 +232,14 @@ const buildMockFetch = (
// server sets passkeySessionID + twoFactorSessionIDV2 (not // server sets passkeySessionID + twoFactorSessionIDV2 (not
// twoFactorSessionID -- that's deliberate, so old clients // twoFactorSessionID -- that's deliberate, so old clients
// that only know the V1 field keep using the passkey flow). // that only know the V1 field keep using the passkey flow).
// The V1 field is still present on the wire as "".
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
id: 42,
srpM2: M2.toString("base64"), srpM2: M2.toString("base64"),
passkeySessionID: "passkey-session-123", passkeySessionID: "passkey-session-123",
accountsUrl: "https://accounts.ente.io", accountsUrl: "https://accounts.ente.io",
twoFactorSessionID: "",
twoFactorSessionIDV2: "totp-session-v2-456", twoFactorSessionIDV2: "totp-session-v2-456",
}), }),
{ {
@@ -238,6 +255,10 @@ const buildMockFetch = (
id: 42, id: 42,
keyAttributes: fixture.keyAttributes, keyAttributes: fixture.keyAttributes,
encryptedToken: fixture.encryptedToken, encryptedToken: fixture.encryptedToken,
passkeySessionID: "",
accountsUrl: "",
twoFactorSessionID: "",
twoFactorSessionIDV2: "",
}), }),
{ {
status: 200, status: 200,