Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.71.0",
"version": "2.72.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
18 changes: 11 additions & 7 deletions apps/desktop/src/main/cloud-auth-loopback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export class CloudAuthLoopbackServer {
): Promise<void> {
const callbackUrl = this.callbackUrl;
if (!callbackUrl) {
this.respond(response, 410, "This ZenNotes sign-in request has expired.");
this.respond(
response,
410,
"This sign-in has expired. Return to ZenNotes and choose Connect ZenNotes Cloud again.",
);
return;
}

Expand All @@ -93,20 +97,20 @@ export class CloudAuthLoopbackServer {

const expectedUrl = new URL(callbackUrl);
if (host !== expectedUrl.host || !requestUrl.startsWith("/")) {
this.respond(response, 400, "Invalid ZenNotes callback.");
this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again.");
return;
}

let parsed: URL;
try {
parsed = new URL(requestUrl, callbackUrl);
} catch {
this.respond(response, 400, "Invalid ZenNotes callback.");
this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again.");
return;
}

if (parsed.origin !== expectedUrl.origin) {
this.respond(response, 404, "ZenNotes callback not found.");
this.respond(response, 404, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again.");
return;
}

Expand Down Expand Up @@ -134,7 +138,7 @@ export class CloudAuthLoopbackServer {
}

if (parsed.pathname !== CALLBACK_PATH) {
this.respond(response, 404, "ZenNotes callback not found.");
this.respond(response, 404, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again.");
return;
}

Expand All @@ -152,15 +156,15 @@ export class CloudAuthLoopbackServer {
!/^[A-Za-z0-9._-]+$/.test(state) ||
state.length > 128
) {
this.respond(response, 400, "Invalid ZenNotes callback.");
this.respond(response, 400, "This sign-in link isn't valid. Return to ZenNotes and choose Connect ZenNotes Cloud again.");
return;
}

if (this.callbackConsumed) {
this.respond(
response,
410,
"This ZenNotes sign-in request was already used.",
"This sign-in is already finished. You can close this tab.",
);
return;
}
Expand Down
82 changes: 80 additions & 2 deletions apps/desktop/src/main/cloud-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
normalizeCloudBaseUrl,
resolveCloudBaseUrl,
} from "./cloud-auth";
import {
CLOUD_SIGN_IN_OFFLINE_MESSAGE,
CLOUD_SIGN_IN_TROUBLE_MESSAGE,
describeCloudError,
} from "@zennotes/shared-domain/cloud-error-copy";

const temporaryDirectories: string[] = [];

Expand All @@ -21,6 +26,7 @@ afterEach(async () => {
async function setup(
allowInsecureLocalDevelopment = false,
revokeTimeoutMs?: number,
keyringAnswers = true,
) {
const storageDirectory = await mkdtemp(
path.join(os.tmpdir(), "zennotes-cloud-auth-"),
Expand All @@ -37,6 +43,7 @@ async function setup(
fetchImplementation,
getSecret: async (baseUrl) => secrets.get(baseUrl) ?? null,
setSecret: async (baseUrl, token) => {
if (!keyringAnswers) return false;
secrets.set(baseUrl, token);
return true;
},
Expand Down Expand Up @@ -148,8 +155,79 @@ describe("CloudAuthManager", () => {

await expect(
manager.complete({ code: "OneTimeCode", state: "other-state" }),
).rejects.toThrow("invalid or has expired");
).rejects.toThrow("This sign-in has expired.");
expect(fetchImplementation).not.toHaveBeenCalled();
// A stale link leaves the request this device is waiting on alone.
expect((await manager.status()).state).toBe("connecting");
});

it("words a refused exchange, keeps the server's text as the detail, and stops waiting", async () => {
const { manager, fetchImplementation } = await setup();
await manager.connect("https://zennotes.org");
fetchImplementation.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Invalid or expired code." }), {
status: 422,
headers: { "Content-Type": "application/json" },
}),
);

const failure = await manager
.complete({ code: "OneTimeCode", state: "fixed-state" })
.catch((error: unknown) => error as Error);
expect(describeCloudError(failure)).toEqual({
message: expect.stringMatching(/^This sign-in didn't go through/),
detail: "HTTP 422 · Invalid or expired code.",
kind: "other",
});
expect(await manager.status()).toEqual({ state: "disconnected", account: null });
});

it("asks to connect again when Cloud is busy or unreachable during sign-in, never promising a retry", async () => {
const { manager, fetchImplementation } = await setup();
await manager.connect("https://zennotes.org");
fetchImplementation.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Server Error" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
);
const trouble = await manager
.complete({ code: "OneTimeCode", state: "fixed-state" })
.catch((error: unknown) => error);
expect(describeCloudError(trouble)).toEqual({
message: CLOUD_SIGN_IN_TROUBLE_MESSAGE,
detail: "HTTP 503 · Server Error",
kind: "other",
});
expect(await manager.status()).toEqual({ state: "disconnected", account: null });

await manager.connect("https://zennotes.org");
fetchImplementation.mockRejectedValueOnce(new TypeError("fetch failed"));
const offline = await manager
.complete({ code: "OneTimeCode", state: "fixed-state" })
.catch((error: unknown) => error);
expect(describeCloudError(offline).message).toBe(CLOUD_SIGN_IN_OFFLINE_MESSAGE);
expect(await manager.status()).toEqual({ state: "disconnected", account: null });
});

it("says plainly when the keyring cannot keep the sign-in, and stops waiting", async () => {
const { manager, fetchImplementation } = await setup(false, undefined, false);
await manager.connect("https://zennotes.org");
fetchImplementation.mockResolvedValueOnce(
new Response(
JSON.stringify({
token: "secret-token",
user: { name: "Ada", email: "ada@example.com" },
device: { id: "device-1", name: "Test Mac", platform: "desktop" },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);

await expect(
manager.complete({ code: "OneTimeCode", state: "fixed-state" }),
).rejects.toThrow(/^ZenNotes couldn't save your sign-in safely/);
expect((await manager.status()).state).toBe("disconnected");
});

it("clears pending authorization when the browser cannot open", async () => {
Expand Down Expand Up @@ -199,7 +277,7 @@ describe("CloudAuthManager", () => {
});
await expect(
manager.complete({ code: "OneTimeCode", state: "fixed-state" }),
).rejects.toThrow("invalid or has expired");
).rejects.toThrow("This sign-in has expired.");
});
});

Expand Down
117 changes: 88 additions & 29 deletions apps/desktop/src/main/cloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import type {
CloudAccountStatus,
} from "@zennotes/bridge-contract/cloud-sync";
import type { CloudAuthDeepLinkRequest } from "./deep-links";
import {
CLOUD_SIGN_IN_EXPIRED_MESSAGE,
CLOUD_SIGN_IN_REFUSED_MESSAGE,
CLOUD_SIGN_IN_UNEXPECTED_MESSAGE,
cloudSignInExchangeError,
encodeCloudErrorMessage,
} from "@zennotes/shared-domain/cloud-error-copy";
import { CloudServiceRequestError } from "./cloud-sync-client";

const PENDING_AUTH_FILE = "cloud-auth-pending.json";
const CLOUD_ACCOUNT_FILE = "cloud-account.json";
Expand Down Expand Up @@ -136,51 +144,84 @@ export class CloudAuthManager {
): Promise<CloudAccountStatus> {
const pending = await this.readPending();
if (!pending || !safeEquals(request.state, pending.state)) {
throw new Error(
"This ZenNotes Cloud sign-in request is invalid or has expired.",
);
throw new Error(CLOUD_SIGN_IN_EXPIRED_MESSAGE);
}
try {
return await this.exchange(pending, request);
} catch (error) {
// The code is spent and the browser says the sign-in failed, so this
// request can no longer finish: stop showing "Finish signing in in
// your browser" for the minutes it has left.
await this.removeFile(PENDING_AUTH_FILE);
throw error;
}
}

const response = await this.fetchImplementation(
`${pending.base_url}/api/v1/app/exchange`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
private async exchange(
pending: PendingCloudAuth,
request: CloudAuthDeepLinkRequest,
): Promise<CloudAccountStatus> {
let response: Response;
try {
response = await this.fetchImplementation(
`${pending.base_url}/api/v1/app/exchange`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
code: request.code,
state: request.state,
code_verifier: pending.code_verifier,
device_name: this.dependencies.deviceName,
platform: "desktop",
app_version: this.dependencies.appVersion,
}),
signal: AbortSignal.timeout(30_000),
},
body: JSON.stringify({
code: request.code,
state: request.state,
code_verifier: pending.code_verifier,
device_name: this.dependencies.deviceName,
platform: "desktop",
app_version: this.dependencies.appVersion,
}),
signal: AbortSignal.timeout(30_000),
},
);
);
} catch (error) {
throw cloudSignInExchangeError(error);
}
const payload = await parseExchangeResponse(response);
if (!response.ok) {
const refusal = exchangeRefusal(payload);
// Busy or broken service: "connect again", since sign-in never retries.
if (response.status === 429 || response.status >= 500) {
throw cloudSignInExchangeError(
new CloudServiceRequestError(
refusal.message ?? `ZenNotes Cloud sign-in failed (${response.status}).`,
response.status,
refusal.code,
),
);
}
throw new Error(
typeof payload.message === "string"
? payload.message
: "ZenNotes Cloud rejected this sign-in request.",
encodeCloudErrorMessage({
message: CLOUD_SIGN_IN_REFUSED_MESSAGE,
detail: `HTTP ${response.status} · ${refusal.message ?? "no message"}`,
kind: "other",
}),
);
}
if (!isExchangeResponse(payload)) {
throw new Error("ZenNotes Cloud returned an invalid sign-in response.");
throw new Error(CLOUD_SIGN_IN_UNEXPECTED_MESSAGE);
}
if (!(await this.dependencies.setSecret(pending.base_url, payload.token))) {
// On Linux this almost always means Chromium settled on its plaintext
// key store because it did not recognize the desktop environment, so
// point at the override instead of leaving a dead end.
throw new Error(
process.platform === "linux"
? "ZenNotes could not store the cloud credential securely on this device. " +
"If a Secret Service keyring (such as gnome-keyring) is running, " +
"launch ZenNotes with --password-store=gnome-libsecret and sign in again."
: "ZenNotes could not store the cloud credential securely on this device.",
? "ZenNotes couldn't save your sign-in safely, so this device is not signed in. " +
"ZenNotes keeps it in your system keyring, and none answered. Make sure a " +
"keyring app such as GNOME Keyring is running. If it is, quit ZenNotes, start " +
"it with the option --password-store=gnome-libsecret, and sign in again."
: "ZenNotes couldn't save your sign-in safely in this computer's keychain, so " +
"this device is not signed in. Quit and reopen ZenNotes, then choose Connect " +
"ZenNotes Cloud again.",
);
}

Expand Down Expand Up @@ -407,6 +448,24 @@ function isLoopbackCallbackUrl(value: string): boolean {
return Number.isInteger(port) && port <= 65_535;
}

/** The exchange answers in Laravel's flat `{message}` shape for a bad code
* and in the API's `{error: {code, message}}` shape for everything else. */
function exchangeRefusal(payload: Record<string, unknown>): {
message: string | null;
code: string | null;
} {
const nested =
payload.error && typeof payload.error === "object"
? (payload.error as Record<string, unknown>)
: null;
const message = nested?.message ?? payload.message;
const code = nested?.code ?? payload.code;
return {
message: typeof message === "string" && message ? message : null,
code: typeof code === "string" && code ? code : null,
};
}

async function parseExchangeResponse(
response: Response,
): Promise<Record<string, unknown>> {
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { chmod, mkdtemp, open, rm, stat } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee'
import { cloudProtocolError } from '@zennotes/shared-domain/cloud-error-copy'
import type { CloudVaultCipher, CloudVaultCipherSource } from '@zennotes/shared-domain/cloud-e2ee-vault'
import type {
CloudPublishUploadTarget,
Expand Down Expand Up @@ -269,7 +270,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient {
if (source.bytes.byteLength !== source.byteLength) throw directUploadSizeMismatch()
body = source.bytes as FetchBody
} else {
throw new Error('A publish upload needs a file or its bytes.')
throw cloudProtocolError('A publish upload needs a file or its bytes.')
}

let response: Response
Expand Down Expand Up @@ -319,7 +320,7 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient {
}
}

throw new Error('ZenNotes Cloud upload completion ended unexpectedly.')
throw new CloudServiceRequestError('ZenNotes Cloud upload completion ended unexpectedly.', 0, 'INVALID_DIRECT_UPLOAD_RESPONSE')
}
}

Expand Down Expand Up @@ -497,7 +498,7 @@ async function prepareEncryptedDirectUpload(
let offset = 0
while (offset < piece.byteLength) {
const written = await file.write(piece, offset)
if (written.bytesWritten === 0) throw new Error('Encrypted upload staging made no progress.')
if (written.bytesWritten === 0) throw Object.assign(new Error('Encrypted upload staging made no progress.'), { code: 'EIO' })
offset += written.bytesWritten
}
}
Expand Down
Loading
Loading