Add OTP fallback auth and progress handoff log
This commit is contained in:
+177
-25
@@ -1,13 +1,130 @@
|
|||||||
import { AuthError } from "../search/types.js";
|
|
||||||
import { decodeJwtExpiry, isJwtExpired } from "./jwt.js";
|
|
||||||
import { clearToken, loadToken, saveToken } from "./storage.js";
|
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { AuthError } from "../search/types.js";
|
||||||
|
import { decodeJwtExpiry, isJwtExpired } from "./jwt.js";
|
||||||
|
import { clearToken, loadToken, saveToken } from "./storage.js";
|
||||||
|
|
||||||
const DESKTOP_AUTH_HELP =
|
const DESKTOP_AUTH_HELP =
|
||||||
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
||||||
|
const OTP_AUTH_HELP =
|
||||||
|
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
|
||||||
|
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
|
||||||
|
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
||||||
|
const PERPLEXITY_API_VERSION = "2.18";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export interface AuthenticateOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
promptForEmail?: () => Promise<string | null | undefined>;
|
||||||
|
promptForOtp?: (email: string) => Promise<string | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Accept: "application/json",
|
||||||
|
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
|
||||||
|
"User-Agent": PERPLEXITY_USER_AGENT,
|
||||||
|
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenFromPayload(payload: unknown): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = payload as Record<string, unknown>;
|
||||||
|
const possible = [candidate.token, candidate.accessToken, candidate.jwt];
|
||||||
|
|
||||||
|
for (const token of possible) {
|
||||||
|
if (typeof token === "string" && token.split(".").length === 3) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonResponse(response: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await response.json()) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithEmailOtp(
|
||||||
|
email: string,
|
||||||
|
options: AuthenticateOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildAuthHeaders(),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!csrfResponse.ok) {
|
||||||
|
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfPayload = (await readJsonResponse(csrfResponse)) as { csrfToken?: unknown } | null;
|
||||||
|
const csrfToken =
|
||||||
|
csrfPayload && typeof csrfPayload.csrfToken === "string" ? csrfPayload.csrfToken : null;
|
||||||
|
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error("CSRF token missing from Perplexity auth response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: buildAuthHeaders(true),
|
||||||
|
body: JSON.stringify({ email, csrfToken }),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailResponse.ok) {
|
||||||
|
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otp =
|
||||||
|
normalizeInput(process.env.PI_PERPLEXITY_OTP) ??
|
||||||
|
normalizeInput(await options.promptForOtp?.(email));
|
||||||
|
|
||||||
|
if (!otp) {
|
||||||
|
throw new AuthError(
|
||||||
|
"NO_TOKEN",
|
||||||
|
`OTP code is required to complete Perplexity login. ${OTP_AUTH_HELP}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: buildAuthHeaders(true),
|
||||||
|
body: JSON.stringify({ email, otp, csrfToken }),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!otpResponse.ok) {
|
||||||
|
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpPayload = await readJsonResponse(otpResponse);
|
||||||
|
const token = extractTokenFromPayload(otpPayload);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Perplexity OTP response did not include a JWT token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
||||||
export async function extractFromDesktopApp(): Promise<string | null> {
|
export async function extractFromDesktopApp(): Promise<string | null> {
|
||||||
if (process.platform !== "darwin") {
|
if (process.platform !== "darwin") {
|
||||||
@@ -28,59 +145,94 @@ export async function extractFromDesktopApp(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Run MVP auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
|
/** Run MVP auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
|
||||||
export async function authenticate(): Promise<string> {
|
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
|
||||||
const cached = await loadToken();
|
const cached = await loadToken();
|
||||||
|
let sawExpiredToken = false;
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
if (!isJwtExpired(cached.access)) {
|
if (!isJwtExpired(cached.access)) {
|
||||||
return cached.access;
|
return cached.access;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sawExpiredToken = true;
|
||||||
await clearToken();
|
await clearToken();
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.PI_AUTH_NO_BORROW === "1") {
|
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
|
||||||
|
|
||||||
|
if (!borrowDisabled) {
|
||||||
|
let desktopToken: string | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
desktopToken = await extractFromDesktopApp();
|
||||||
|
} catch {
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXPIRED",
|
"EXTRACTION_FAILED",
|
||||||
`Cached token is expired. Re-authenticate in Perplexity desktop app, then retry. ${DESKTOP_AUTH_HELP}`,
|
`Failed to read token from the Perplexity desktop app. Ensure the app is installed and signed in. ${DESKTOP_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (desktopToken) {
|
||||||
|
if (isJwtExpired(desktopToken)) {
|
||||||
|
sawExpiredToken = true;
|
||||||
|
} else {
|
||||||
|
await saveToken({
|
||||||
|
type: "oauth",
|
||||||
|
access: desktopToken,
|
||||||
|
expires: decodeJwtExpiry(desktopToken),
|
||||||
|
});
|
||||||
|
|
||||||
|
return desktopToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.PI_AUTH_NO_BORROW === "1") {
|
const email =
|
||||||
|
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
||||||
|
normalizeInput(await options.promptForEmail?.());
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
if (sawExpiredToken) {
|
||||||
|
throw new AuthError(
|
||||||
|
"EXPIRED",
|
||||||
|
`Perplexity token is expired and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"NO_TOKEN",
|
"NO_TOKEN",
|
||||||
`No valid cached token found and desktop token borrowing is disabled. ${DESKTOP_AUTH_HELP}`,
|
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let desktopToken: string | null;
|
let otpToken: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
desktopToken = await extractFromDesktopApp();
|
otpToken = await loginWithEmailOtp(email, options);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXTRACTION_FAILED",
|
"EXTRACTION_FAILED",
|
||||||
`Failed to read token from the Perplexity desktop app. Ensure the app is installed and signed in. ${DESKTOP_AUTH_HELP}`,
|
`Email OTP authentication failed: ${(error as Error).message}. ${OTP_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!desktopToken) {
|
if (isJwtExpired(otpToken)) {
|
||||||
throw new AuthError(
|
|
||||||
"NO_TOKEN",
|
|
||||||
`Could not find a desktop token. Ensure Perplexity desktop app is installed and signed in. ${DESKTOP_AUTH_HELP}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isJwtExpired(desktopToken)) {
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
`Desktop token is expired. Open Perplexity desktop app and sign in again, then retry. ${DESKTOP_AUTH_HELP}`,
|
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveToken({
|
await saveToken({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: desktopToken,
|
access: otpToken,
|
||||||
expires: decodeJwtExpiry(desktopToken),
|
expires: decodeJwtExpiry(otpToken),
|
||||||
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
return desktopToken;
|
return otpToken;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -34,7 +34,19 @@ export default function (pi: ExtensionAPI) {
|
|||||||
details: { toolCallId },
|
details: { toolCallId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const jwt = await authenticate();
|
const promptInput = async (label: string, placeholder: string): Promise<string | null | undefined> => {
|
||||||
|
if (!ctx?.ui?.input) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.ui.input(label, placeholder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const jwt = await authenticate({
|
||||||
|
signal,
|
||||||
|
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
||||||
|
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
|
||||||
|
});
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
|
||||||
|
import { AuthError, type StoredToken } from "../../src/search/types.js";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||||
|
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||||
|
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
||||||
|
|
||||||
|
function createJwt(expiryMs: number): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||||
|
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(expiryMs / 1000) })).toString("base64url");
|
||||||
|
return `${header}.${payload}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importLoginModule() {
|
||||||
|
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(): void {
|
||||||
|
if (originalBorrow === undefined) {
|
||||||
|
delete process.env.PI_AUTH_NO_BORROW;
|
||||||
|
} else {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = originalBorrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalEmail === undefined) {
|
||||||
|
delete process.env.PI_PERPLEXITY_EMAIL;
|
||||||
|
} else {
|
||||||
|
process.env.PI_PERPLEXITY_EMAIL = originalEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalOtp === undefined) {
|
||||||
|
delete process.env.PI_PERPLEXITY_OTP;
|
||||||
|
} else {
|
||||||
|
process.env.PI_PERPLEXITY_OTP = originalOtp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
restoreEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth/login", () => {
|
||||||
|
test("extractFromDesktopApp returns null when defaults command fails", async () => {
|
||||||
|
const execFileMock = mock((...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as (
|
||||||
|
error: Error | null,
|
||||||
|
stdout?: string,
|
||||||
|
stderr?: string,
|
||||||
|
) => void;
|
||||||
|
callback(new Error("missing defaults entry"), "", "not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { extractFromDesktopApp } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await extractFromDesktopApp();
|
||||||
|
expect(token).toBeNull();
|
||||||
|
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractFromDesktopApp returns JWT from defaults output", async () => {
|
||||||
|
const desktopToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const execFileMock = mock((...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as (
|
||||||
|
error: Error | null,
|
||||||
|
stdout?: string,
|
||||||
|
stderr?: string,
|
||||||
|
) => void;
|
||||||
|
callback(null, `${desktopToken}\n`, "");
|
||||||
|
}) as unknown as typeof import("node:child_process").execFile;
|
||||||
|
|
||||||
|
(execFileMock as unknown as Record<symbol, unknown>)[
|
||||||
|
Symbol.for("nodejs.util.promisify.custom")
|
||||||
|
] = async () => ({ stdout: `${desktopToken}\n`, stderr: "" });
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { extractFromDesktopApp } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await extractFromDesktopApp();
|
||||||
|
expect(token).toBe(desktopToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate returns non-expired cached token without desktop or OTP calls", async () => {
|
||||||
|
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const loadTokenMock = mock(async () => ({
|
||||||
|
type: "oauth",
|
||||||
|
access: cachedToken,
|
||||||
|
expires: Date.now() + 60 * 60 * 1000,
|
||||||
|
}) satisfies StoredToken);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const execFileMock = mock((...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as (
|
||||||
|
error: Error | null,
|
||||||
|
stdout?: string,
|
||||||
|
stderr?: string,
|
||||||
|
) => void;
|
||||||
|
callback(new Error("should not run"), "", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate();
|
||||||
|
|
||||||
|
expect(token).toBe(cachedToken);
|
||||||
|
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
expect(execFileMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate uses OTP fallback when desktop borrowing is disabled", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const otpToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.endsWith("/csrf")) {
|
||||||
|
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-email")) {
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-otp")) {
|
||||||
|
return new Response(JSON.stringify({ token: otpToken }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate({
|
||||||
|
promptForEmail: async () => "user@example.com",
|
||||||
|
promptForOtp: async () => "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token).toBe(otpToken);
|
||||||
|
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const savedToken = saveTokenMock.mock.calls[0]?.[0] as StoredToken;
|
||||||
|
expect(savedToken.type).toBe("oauth");
|
||||||
|
expect(savedToken.access).toBe(otpToken);
|
||||||
|
expect(savedToken.email).toBe("user@example.com");
|
||||||
|
|
||||||
|
const signinEmailRequest = fetchMock.mock.calls[1]?.[1] as RequestInit;
|
||||||
|
const signinOtpRequest = fetchMock.mock.calls[2]?.[1] as RequestInit;
|
||||||
|
expect(JSON.parse(String(signinEmailRequest.body))).toEqual({
|
||||||
|
email: "user@example.com",
|
||||||
|
csrfToken: "csrf-token",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(String(signinOtpRequest.body))).toEqual({
|
||||||
|
email: "user@example.com",
|
||||||
|
otp: "123456",
|
||||||
|
csrfToken: "csrf-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("NO_TOKEN");
|
||||||
|
expect((thrown as AuthError).message).toContain("OTP fallback");
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user