Merge: Phase 3b SRP login flow
Complete login flow: beginLogin (SRP-6a with 4096-bit group via fast-srp-hap, with email-OTP and TOTP 2FA fallback paths), submitTOTP, requestEmailOTP, submitEmailOTP. Built test-first with a mock server performing real SRP math; 7 new tests, 64 total.
This commit is contained in:
12
README.md
12
README.md
@@ -606,16 +606,16 @@ Phase 2: crypto primitives
|
|||||||
|
|
||||||
Phase 3: SRP + auth
|
Phase 3: SRP + auth
|
||||||
|
|
||||||
- [ ] SRP-6a client using `secure-remote-password` with the same group as the
|
- [x] SRP-6a client using `fast-srp-hap` with the 4096-bit group (matching the
|
||||||
server
|
upstream Ente web client)
|
||||||
- [ ] `beginLogin(email, password)` returning a `LoginChallenge`
|
- [x] `beginLogin(api, email, password)` returning a `LoginChallenge`
|
||||||
- [ ] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
|
- [x] `requestEmailOTP` and `submitEmailOTP` for accounts without SRP
|
||||||
- [ ] `submitTOTP(sessionID, code)`
|
- [x] `submitTOTP(api, sessionID, code)`
|
||||||
- [x] `unwrapAuth(response, password)` returning master key, secret key, public
|
- [x] `unwrapAuth(response, password)` returning master key, secret key, public
|
||||||
key, and decrypted token (URL-safe-no-padding base64)
|
key, and decrypted token (URL-safe-no-padding base64)
|
||||||
- [x] `src/auth/types.ts` with `KeyAttributes`, `SRPAttributes`,
|
- [x] `src/auth/types.ts` with `KeyAttributes`, `SRPAttributes`,
|
||||||
`AuthorizationResponse`, and `LoginChallenge`
|
`AuthorizationResponse`, and `LoginChallenge`
|
||||||
- [ ] Tests against recorded HTTP fixtures
|
- [x] Tests with mock SRP server performing real 4096-bit math end-to-end
|
||||||
|
|
||||||
Phase 4: HTTP client + endpoints
|
Phase 4: HTTP client + endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"vitest": "2.1.9"
|
"vitest": "2.1.9"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"fast-srp-hap": "2.0.4",
|
||||||
"libsodium-wrappers-sumo": "0.8.4"
|
"libsodium-wrappers-sumo": "0.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/auth/login.ts
Normal file
130
src/auth/login.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { SRP, SrpClient } from "fast-srp-hap";
|
||||||
|
import type { ApiClient } from "../api/client.js";
|
||||||
|
import { deriveKEK, deriveLoginSubkey, fromBase64 } from "../crypto/index.js";
|
||||||
|
import type {
|
||||||
|
AuthorizationResponse,
|
||||||
|
LoginChallenge,
|
||||||
|
SRPAttributes,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
// Ente uses the 4096-bit SRP group. Both client and server must agree.
|
||||||
|
const SRP_PARAMS = SRP.params["4096"];
|
||||||
|
|
||||||
|
// Wrap fast-srp-hap's callback-based genKey into a Promise.
|
||||||
|
const genKey = (): Promise<Buffer> => SRP.genKey();
|
||||||
|
|
||||||
|
// Create an SRP client from the derived login subkey and SRP attributes.
|
||||||
|
const makeSrpClient = async (
|
||||||
|
srpSalt: Buffer,
|
||||||
|
srpUserID: string,
|
||||||
|
loginSubKey: Uint8Array,
|
||||||
|
): Promise<SrpClient> => {
|
||||||
|
const clientKey = await genKey();
|
||||||
|
return new SrpClient(
|
||||||
|
SRP_PARAMS,
|
||||||
|
srpSalt,
|
||||||
|
Buffer.from(srpUserID),
|
||||||
|
Buffer.from(loginSubKey),
|
||||||
|
clientKey,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform the two-round SRP handshake against the Ente API.
|
||||||
|
const srpHandshake = async (
|
||||||
|
api: ApiClient,
|
||||||
|
srpAttributes: SRPAttributes,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginChallenge> => {
|
||||||
|
const kek = await deriveKEK(
|
||||||
|
password,
|
||||||
|
fromBase64(srpAttributes.kekSalt),
|
||||||
|
srpAttributes.opsLimit,
|
||||||
|
srpAttributes.memLimit,
|
||||||
|
);
|
||||||
|
const loginSubKey = deriveLoginSubkey(kek);
|
||||||
|
|
||||||
|
const srpClient = await makeSrpClient(
|
||||||
|
Buffer.from(fromBase64(srpAttributes.srpSalt)),
|
||||||
|
srpAttributes.srpUserID,
|
||||||
|
loginSubKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const srpA = srpClient.computeA().toString("base64");
|
||||||
|
|
||||||
|
const { sessionID, srpB } = await api.postJSON<{
|
||||||
|
sessionID: string;
|
||||||
|
srpB: string;
|
||||||
|
}>("/users/srp/create-session", {
|
||||||
|
srpUserID: srpAttributes.srpUserID,
|
||||||
|
srpA,
|
||||||
|
});
|
||||||
|
|
||||||
|
srpClient.setB(Buffer.from(srpB, "base64"));
|
||||||
|
const srpM1 = srpClient.computeM1().toString("base64");
|
||||||
|
|
||||||
|
const verifyResponse = await api.postJSON<
|
||||||
|
AuthorizationResponse & { srpM2: string }
|
||||||
|
>("/users/srp/verify-session", {
|
||||||
|
sessionID,
|
||||||
|
srpUserID: srpAttributes.srpUserID,
|
||||||
|
srpM1,
|
||||||
|
});
|
||||||
|
|
||||||
|
srpClient.checkM2(Buffer.from(verifyResponse.srpM2, "base64"));
|
||||||
|
|
||||||
|
if (verifyResponse.twoFactorSessionID) {
|
||||||
|
return { kind: "totp", sessionID: verifyResponse.twoFactorSessionID };
|
||||||
|
}
|
||||||
|
if (verifyResponse.passkeySessionID) {
|
||||||
|
return {
|
||||||
|
kind: "passkey",
|
||||||
|
sessionID: verifyResponse.passkeySessionID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "complete", response: verifyResponse };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const beginLogin = async (
|
||||||
|
api: ApiClient,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginChallenge> => {
|
||||||
|
const { attributes } = await api.getJSON<{
|
||||||
|
attributes: SRPAttributes;
|
||||||
|
}>("/users/srp/attributes", { email });
|
||||||
|
|
||||||
|
if (attributes.isEmailMFAEnabled) {
|
||||||
|
return { kind: "emailOTP" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return srpHandshake(api, attributes, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitTOTP = async (
|
||||||
|
api: ApiClient,
|
||||||
|
sessionID: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<AuthorizationResponse> =>
|
||||||
|
api.postJSON<AuthorizationResponse>("/users/two-factor/verify", {
|
||||||
|
sessionID,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestEmailOTP = async (
|
||||||
|
api: ApiClient,
|
||||||
|
email: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await api.postJSON("/users/ott", { email, purpose: "login" });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitEmailOTP = async (
|
||||||
|
api: ApiClient,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<AuthorizationResponse> =>
|
||||||
|
api.postJSON<AuthorizationResponse>("/users/verify-email", {
|
||||||
|
email,
|
||||||
|
ott: code,
|
||||||
|
});
|
||||||
373
test/auth/login.test.ts
Normal file
373
test/auth/login.test.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the login flow functions: `beginLogin`, `submitTOTP`,
|
||||||
|
* `requestEmailOTP`, and `submitEmailOTP`.
|
||||||
|
*
|
||||||
|
* These compose the pieces built so far (crypto KDF, SRP-6a via
|
||||||
|
* fast-srp-hap, and ApiClient) into the complete authentication
|
||||||
|
* handshake with an Ente server.
|
||||||
|
*
|
||||||
|
* The server side is simulated using fast-srp-hap's SrpServer class, so
|
||||||
|
* the test exercises real SRP math end-to-end without network access.
|
||||||
|
*
|
||||||
|
* Login has three entry paths depending on the user's configuration:
|
||||||
|
*
|
||||||
|
* 1. **SRP (default)**: GET /users/srp/attributes, then a two-round SRP
|
||||||
|
* handshake via POST /users/srp/create-session and
|
||||||
|
* POST /users/srp/verify-session. The final response is either a
|
||||||
|
* completed AuthorizationResponse or a 2FA challenge.
|
||||||
|
*
|
||||||
|
* 2. **Email OTP (isEmailMFAEnabled)**: if the SRP attributes indicate
|
||||||
|
* email MFA is enabled, beginLogin returns `{ kind: "emailOTP" }` and
|
||||||
|
* the caller is responsible for calling requestEmailOTP / submitEmailOTP
|
||||||
|
* to complete the handshake.
|
||||||
|
*
|
||||||
|
* 3. **TOTP 2FA**: if the SRP handshake succeeds but the server requires
|
||||||
|
* a second factor, beginLogin returns `{ kind: "totp", sessionID }`.
|
||||||
|
* The caller calls submitTOTP with the sessionID and a 6-digit code
|
||||||
|
* to obtain the AuthorizationResponse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers-sumo";
|
||||||
|
import { SRP, SrpServer } from "fast-srp-hap";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { ApiClient } from "../../src/api/client.js";
|
||||||
|
import {
|
||||||
|
init,
|
||||||
|
toBase64,
|
||||||
|
deriveKEK,
|
||||||
|
deriveLoginSubkey,
|
||||||
|
} from "../../src/crypto/index.js";
|
||||||
|
import {
|
||||||
|
beginLogin,
|
||||||
|
submitTOTP,
|
||||||
|
requestEmailOTP,
|
||||||
|
submitEmailOTP,
|
||||||
|
} from "../../src/auth/login.js";
|
||||||
|
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared test fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_PASSWORD = "correct horse battery staple";
|
||||||
|
const TEST_EMAIL = "test@example.com";
|
||||||
|
const TEST_OPS = 2;
|
||||||
|
const TEST_MEM = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds everything needed to simulate an Ente server that has a user
|
||||||
|
* registered with the given password. Returns:
|
||||||
|
* - srpAttributes: what GET /users/srp/attributes returns
|
||||||
|
* - verifier: the SRP verifier the server stores
|
||||||
|
* - loginSubKey: the SRP password (base64) for building SrpServer
|
||||||
|
* - keyAttributes + encryptedToken: for the auth response
|
||||||
|
* - masterKey, secretKey, publicKey, tokenBytes: ground truth
|
||||||
|
*/
|
||||||
|
const buildServerFixture = async (password: string) => {
|
||||||
|
await sodium.ready;
|
||||||
|
|
||||||
|
const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||||
|
const kek = await deriveKEK(password, kekSalt, TEST_OPS, TEST_MEM);
|
||||||
|
const loginSubKeyBytes = deriveLoginSubkey(kek);
|
||||||
|
|
||||||
|
const srpUserID = "test-srp-user-id";
|
||||||
|
const srpSalt = sodium.randombytes_buf(16);
|
||||||
|
|
||||||
|
const verifier = SRP.computeVerifier(
|
||||||
|
SRP.params["4096"],
|
||||||
|
Buffer.from(srpSalt),
|
||||||
|
Buffer.from(srpUserID),
|
||||||
|
Buffer.from(loginSubKeyBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build key attributes (same approach as unwrap.test.ts fixture)
|
||||||
|
const masterKey = sodium.randombytes_buf(32);
|
||||||
|
const keyDecryptionNonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const encryptedKey = sodium.crypto_secretbox_easy(
|
||||||
|
masterKey,
|
||||||
|
keyDecryptionNonce,
|
||||||
|
kek,
|
||||||
|
);
|
||||||
|
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
const secretKeyDecryptionNonce = sodium.randombytes_buf(
|
||||||
|
sodium.crypto_secretbox_NONCEBYTES,
|
||||||
|
);
|
||||||
|
const encryptedSecretKey = sodium.crypto_secretbox_easy(
|
||||||
|
kp.privateKey,
|
||||||
|
secretKeyDecryptionNonce,
|
||||||
|
masterKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenBytes = sodium.randombytes_buf(64);
|
||||||
|
const encryptedToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey);
|
||||||
|
|
||||||
|
const keyAttributes: KeyAttributes = {
|
||||||
|
kekSalt: toBase64(kekSalt),
|
||||||
|
encryptedKey: toBase64(encryptedKey),
|
||||||
|
keyDecryptionNonce: toBase64(keyDecryptionNonce),
|
||||||
|
publicKey: toBase64(kp.publicKey),
|
||||||
|
encryptedSecretKey: toBase64(encryptedSecretKey),
|
||||||
|
secretKeyDecryptionNonce: toBase64(secretKeyDecryptionNonce),
|
||||||
|
memLimit: TEST_MEM,
|
||||||
|
opsLimit: TEST_OPS,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
srpAttributes: {
|
||||||
|
srpUserID,
|
||||||
|
srpSalt: toBase64(srpSalt),
|
||||||
|
memLimit: TEST_MEM,
|
||||||
|
opsLimit: TEST_OPS,
|
||||||
|
kekSalt: toBase64(kekSalt),
|
||||||
|
isEmailMFAEnabled: false,
|
||||||
|
},
|
||||||
|
verifier,
|
||||||
|
srpSalt: Buffer.from(srpSalt),
|
||||||
|
loginSubKeyBytes,
|
||||||
|
keyAttributes,
|
||||||
|
encryptedToken: toBase64(encryptedToken),
|
||||||
|
masterKey,
|
||||||
|
secretKey: kp.privateKey,
|
||||||
|
publicKey: kp.publicKey,
|
||||||
|
tokenBytes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a mock fetch that simulates the Ente server's SRP endpoints.
|
||||||
|
* The mock performs real SRP math via fast-srp-hap's SrpServer.
|
||||||
|
*
|
||||||
|
* @param fixture The server fixture from buildServerFixture
|
||||||
|
* @param opts.requireTOTP If true, verify-session returns a 2FA challenge
|
||||||
|
*/
|
||||||
|
const buildMockFetch = (
|
||||||
|
fixture: Awaited<ReturnType<typeof buildServerFixture>>,
|
||||||
|
opts?: { requireTOTP?: boolean },
|
||||||
|
) => {
|
||||||
|
let srpServer: SrpServer;
|
||||||
|
const sessionID = "test-session-id";
|
||||||
|
|
||||||
|
const mockFetch = async (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const path = parsed.pathname;
|
||||||
|
|
||||||
|
if (path === "/users/srp/attributes") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ attributes: fixture.srpAttributes }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/users/srp/create-session") {
|
||||||
|
const body = JSON.parse(init?.body as string);
|
||||||
|
const serverKey = await SRP.genKey();
|
||||||
|
srpServer = new SrpServer(
|
||||||
|
SRP.params["4096"],
|
||||||
|
fixture.verifier,
|
||||||
|
serverKey,
|
||||||
|
);
|
||||||
|
const B = srpServer.computeB();
|
||||||
|
srpServer.setA(Buffer.from(body.srpA, "base64"));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sessionID,
|
||||||
|
srpB: B.toString("base64"),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/users/srp/verify-session") {
|
||||||
|
const body = JSON.parse(init?.body as string);
|
||||||
|
srpServer.checkM1(Buffer.from(body.srpM1, "base64"));
|
||||||
|
const M2 = srpServer.computeM2();
|
||||||
|
|
||||||
|
if (opts?.requireTOTP) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
srpM2: M2.toString("base64"),
|
||||||
|
twoFactorSessionID: "totp-session-999",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
srpM2: M2.toString("base64"),
|
||||||
|
id: 42,
|
||||||
|
keyAttributes: fixture.keyAttributes,
|
||||||
|
encryptedToken: fixture.encryptedToken,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/users/two-factor/verify") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 42,
|
||||||
|
keyAttributes: fixture.keyAttributes,
|
||||||
|
encryptedToken: fixture.encryptedToken,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/users/ott") {
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/users/verify-email") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 42,
|
||||||
|
keyAttributes: fixture.keyAttributes,
|
||||||
|
encryptedToken: fixture.encryptedToken,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return mockFetch as typeof globalThis.fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("beginLogin via SRP", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes SRP login and returns { kind: 'complete' }", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD);
|
||||||
|
|
||||||
|
expect(challenge.kind).toBe("complete");
|
||||||
|
if (challenge.kind === "complete") {
|
||||||
|
expect(challenge.response.id).toBe(42);
|
||||||
|
expect(challenge.response.keyAttributes).toBeDefined();
|
||||||
|
expect(challenge.response.encryptedToken).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { kind: 'totp' } when 2FA is required", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({
|
||||||
|
fetch: buildMockFetch(fixture, { requireTOTP: 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-999");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a wrong password (SRP M1 verification fails)", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
beginLogin(api, TEST_EMAIL, "wrong password"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("beginLogin with email MFA fallback", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { kind: 'emailOTP' } when isEmailMFAEnabled", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
fixture.srpAttributes.isEmailMFAEnabled = true;
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
const challenge = await beginLogin(api, TEST_EMAIL, TEST_PASSWORD);
|
||||||
|
|
||||||
|
expect(challenge.kind).toBe("emailOTP");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submitTOTP", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends code to /users/two-factor/verify and returns AuthorizationResponse", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
const response = await submitTOTP(api, "totp-session-999", "123456");
|
||||||
|
|
||||||
|
expect(response.id).toBe(42);
|
||||||
|
expect(response.keyAttributes).toBeDefined();
|
||||||
|
expect(response.encryptedToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requestEmailOTP / submitEmailOTP", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requestEmailOTP sends to /users/ott without throwing", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
await expect(requestEmailOTP(api, TEST_EMAIL)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitEmailOTP sends to /users/verify-email and returns AuthorizationResponse", async () => {
|
||||||
|
const fixture = await buildServerFixture(TEST_PASSWORD);
|
||||||
|
const api = new ApiClient({ fetch: buildMockFetch(fixture) });
|
||||||
|
|
||||||
|
const response = await submitEmailOTP(api, TEST_EMAIL, "654321");
|
||||||
|
|
||||||
|
expect(response.id).toBe(42);
|
||||||
|
expect(response.keyAttributes).toBeDefined();
|
||||||
|
expect(response.encryptedToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -879,6 +879,11 @@ fast-levenshtein@^2.0.6:
|
|||||||
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
|
||||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||||
|
|
||||||
|
fast-srp-hap@^2.0.4:
|
||||||
|
version "2.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-srp-hap/-/fast-srp-hap-2.0.4.tgz#9db296e21a5143951310f99e5a74290106467811"
|
||||||
|
integrity sha512-lHRYYaaIbMrhZtsdGTwPN82UbqD9Bv8QfOlKs+Dz6YRnByZifOh93EYmf2iEWFtkOEIqR2IK8cFD0UN5wLIWBQ==
|
||||||
|
|
||||||
fastq@^1.6.0:
|
fastq@^1.6.0:
|
||||||
version "1.20.1"
|
version "1.20.1"
|
||||||
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
|
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
|
||||||
|
|||||||
Reference in New Issue
Block a user