Merge: TOTP login for accounts that also have passkeys
The server signals dual-2FA accounts with passkeySessionID + twoFactorSessionIDV2. quak now takes the TOTP path in that case instead of failing with 'Passkey authentication is not supported'.
This commit is contained in:
@@ -73,8 +73,14 @@ const srpHandshake = async (
|
|||||||
|
|
||||||
srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64"));
|
srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64"));
|
||||||
|
|
||||||
if (verifyResponse.twoFactorSessionID) {
|
// twoFactorSessionIDV2 is set (instead of twoFactorSessionID) when the
|
||||||
return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID };
|
// 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) {
|
if (verifyResponse.passkeySessionID) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,13 +34,18 @@ export interface SRPAttributes {
|
|||||||
// The body of a successful login response. Exactly one of the following
|
// The body of a successful login response. Exactly one of the following
|
||||||
// situations applies, and the caller dispatches on the populated fields:
|
// situations applies, and the caller dispatches on the populated fields:
|
||||||
// - both keyAttributes and encryptedToken present: login is complete
|
// - both keyAttributes and encryptedToken present: login is complete
|
||||||
// - twoFactorSessionID present: caller must submit a TOTP code
|
// - twoFactorSessionID present: caller must submit a TOTP code (TOTP-only
|
||||||
// - passkeySessionID present: caller must complete a passkey ceremony
|
// 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 {
|
export interface AuthorizationResponse {
|
||||||
id: number;
|
id: number;
|
||||||
keyAttributes?: KeyAttributes;
|
keyAttributes?: KeyAttributes;
|
||||||
encryptedToken?: Base64;
|
encryptedToken?: Base64;
|
||||||
twoFactorSessionID?: string;
|
twoFactorSessionID?: string;
|
||||||
|
twoFactorSessionIDV2?: string;
|
||||||
passkeySessionID?: string;
|
passkeySessionID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const buildServerFixture = async (password: string) => {
|
|||||||
*/
|
*/
|
||||||
const buildMockFetch = (
|
const buildMockFetch = (
|
||||||
fixture: Awaited<ReturnType<typeof buildServerFixture>>,
|
fixture: Awaited<ReturnType<typeof buildServerFixture>>,
|
||||||
opts?: { requireTOTP?: boolean },
|
opts?: { requireTOTP?: boolean; requirePasskeyAndTOTP?: boolean },
|
||||||
) => {
|
) => {
|
||||||
let srpServer: SrpServer;
|
let srpServer: SrpServer;
|
||||||
const sessionID = "test-session-id";
|
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
srpM2: M2.toString("base64"),
|
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 () => {
|
it("rejects a wrong password (SRP M1 verification fails)", async () => {
|
||||||
const fixture = await buildServerFixture(TEST_PASSWORD);
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|||||||
Reference in New Issue
Block a user