diff --git a/src/auth/login.ts b/src/auth/login.ts index 5a53f42..4123b1b 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -73,8 +73,14 @@ const srpHandshake = async ( srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64")); - if (verifyResponse.twoFactorSessionID) { - return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID }; + // 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. + const totpSessionID = + verifyResponse.twoFactorSessionID ?? + verifyResponse.twoFactorSessionIDV2; + if (totpSessionID) { + return { kind: "totp", sessionID: totpSessionID }; } if (verifyResponse.passkeySessionID) { return { diff --git a/src/auth/types.ts b/src/auth/types.ts index e6a23f4..5fd71e7 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -34,13 +34,18 @@ export interface SRPAttributes { // The body of a successful login response. Exactly one of the following // situations applies, and the caller dispatches on the populated fields: // - both keyAttributes and encryptedToken present: login is complete -// - twoFactorSessionID present: caller must submit a TOTP code -// - passkeySessionID present: caller must complete a passkey ceremony +// - twoFactorSessionID present: caller must submit a TOTP code (TOTP-only +// account) +// - passkeySessionID + twoFactorSessionIDV2 present: account has both +// passkeys and TOTP; the V2 field is set instead of twoFactorSessionID +// so that older clients keep using the passkey flow +// - only passkeySessionID present: caller must complete a passkey ceremony export interface AuthorizationResponse { id: number; keyAttributes?: KeyAttributes; encryptedToken?: Base64; twoFactorSessionID?: string; + twoFactorSessionIDV2?: string; passkeySessionID?: string; } diff --git a/test/auth/login.test.ts b/test/auth/login.test.ts index 0519a22..e1bf4a6 100644 --- a/test/auth/login.test.ts +++ b/test/auth/login.test.ts @@ -145,7 +145,7 @@ const buildServerFixture = async (password: string) => { */ const buildMockFetch = ( fixture: Awaited>, - opts?: { requireTOTP?: boolean }, + opts?: { requireTOTP?: boolean; requirePasskeyAndTOTP?: boolean }, ) => { let srpServer: SrpServer; const sessionID = "test-session-id"; @@ -213,6 +213,25 @@ const buildMockFetch = ( ); } + if (opts?.requirePasskeyAndTOTP) { + // When the account has BOTH passkeys and TOTP enabled, the + // server sets passkeySessionID + twoFactorSessionIDV2 (not + // twoFactorSessionID -- that's deliberate, so old clients + // that only know the V1 field keep using the passkey flow). + return new Response( + JSON.stringify({ + srpM2: M2.toString("base64"), + passkeySessionID: "passkey-session-123", + accountsUrl: "https://accounts.ente.io", + twoFactorSessionIDV2: "totp-session-v2-456", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + } + return new Response( JSON.stringify({ srpM2: M2.toString("base64"), @@ -305,6 +324,26 @@ describe("beginLogin via SRP", () => { } }); + it("prefers TOTP over passkey when the account has both", async () => { + // Accounts with both passkeys and TOTP get passkeySessionID + + // twoFactorSessionIDV2 in the verify-session response. A CLI + // cannot perform a WebAuthn ceremony, so quak must take the TOTP + // path using the V2 session ID. Returning { kind: "passkey" } here + // would make such accounts unusable from the CLI even though they + // have a perfectly good TOTP secret enrolled. + const fixture = await buildServerFixture(TEST_PASSWORD); + const api = new ApiClient({ + fetch: buildMockFetch(fixture, { requirePasskeyAndTOTP: true }), + }); + + const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD); + + expect(challenge.kind).toBe("totp"); + if (challenge.kind === "totp") { + expect(challenge.sessionID).toBe("totp-session-v2-456"); + } + }); + it("rejects a wrong password (SRP M1 verification fails)", async () => { const fixture = await buildServerFixture(TEST_PASSWORD); const api = new ApiClient({ fetch: buildMockFetch(fixture) });