Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/lib/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ describe("account status cache", () => {

unsubscribe();
});

it("coerces a null bridge response to the signed-out account shape", async () => {
const status = vi.fn(async () => null);
Object.defineProperty(globalThis.window, "ade", {
configurable: true,
writable: true,
value: {
account: { status },
} as unknown as typeof window.ade,
});

const { fetchAccountStatus, SIGNED_OUT_ACCOUNT } = await import("./account");

const normalized = await fetchAccountStatus({ force: true });

expect(normalized).toBe(SIGNED_OUT_ACCOUNT);
expect(normalized).toMatchObject({ signedIn: false, userId: null, email: null });
expect(status).toHaveBeenCalledOnce();
});
});

describe("account avatar presentation", () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/renderer/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ export async function fetchAccountStatus(options?: { force?: boolean }): Promise
inFlight = api
.status()
.then((status) => {
if (serial === fetchSerial) emit(status);
return status;
const normalized = status ?? SIGNED_OUT_ACCOUNT;
if (serial === fetchSerial) emit(normalized);
return normalized;
})
.catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT)
.finally(() => {
Expand Down
174 changes: 170 additions & 4 deletions apps/desktop/src/renderer/webclient/account/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_ADE_ACCOUNT_DIRECTORY_URL,
Expand All @@ -7,7 +8,9 @@ import {
DEVELOPMENT_ADE_CLERK_ISSUER,
DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID,
} from "../../../shared/accountDirectory";
import { MemoryStorage } from "../sync/envStore";
import { BrowserAccountClient, readBrowserAccountConfig } from "./client";
import { BrowserAccountSessionStore } from "./sessionStore";

class MemorySessionStorage {
readonly values = new Map<string, string>();
Expand All @@ -25,10 +28,13 @@ class MemorySessionStorage {
}
}

function accessToken(subject = "user_1"): string {
function accessToken(
subject = "user_1",
extraClaims: Record<string, unknown> = { email: "user@example.test" },
): string {
const claims = Buffer.from(JSON.stringify({
sub: subject,
email: "user@example.test",
...extraClaims,
})).toString("base64url");
return `header.${claims}.signature`;
}
Expand Down Expand Up @@ -78,7 +84,7 @@ describe("BrowserAccountClient", () => {
});
});

it("keeps tokens in memory, scrubs the callback, and surfaces directory auth expiry", async () => {
it("keeps tokens out of web storage and URLs, scrubs the callback, and surfaces directory auth expiry", async () => {
const storage = new MemorySessionStorage();
const assigned: string[] = [];
const location = browserLocation(assigned);
Expand Down Expand Up @@ -108,6 +114,7 @@ describe("BrowserAccountClient", () => {
location,
history: { replaceState: (_data, _unused, url) => replaced.push(String(url)) },
storage,
sessionStore: new BrowserAccountSessionStore(new MemoryStorage()),
});

await client.startSignIn();
Expand All @@ -126,6 +133,7 @@ describe("BrowserAccountClient", () => {
expect(storage.values.size).toBe(0);
expect(requests.map((request) => request.url)).toEqual([
"https://clerk.example/oauth/token",
"https://clerk.example/oauth/userinfo",
"https://directory.example/account/machines",
]);
expect(requests.every((request) => !request.url.includes(token) && !request.url.includes("refresh-secret"))).toBe(true);
Expand Down Expand Up @@ -160,6 +168,9 @@ describe("BrowserAccountClient", () => {
if (url === "https://directory.example/account/machines") {
return new Response(JSON.stringify({ machines: [] }), { status: 200 });
}
if (url === "https://clerk.example/oauth/userinfo") {
return new Response(JSON.stringify({ email: "user@example.test" }), { status: 200 });
}
const body = new URLSearchParams(String(init?.body ?? ""));
if (body.get("grant_type") === "authorization_code") {
return new Response(JSON.stringify({
Expand Down Expand Up @@ -188,6 +199,7 @@ describe("BrowserAccountClient", () => {
location,
history: { replaceState: () => {} },
storage,
sessionStore: new BrowserAccountSessionStore(new MemoryStorage()),
now: () => now,
});
await completeSignIn(client, location, assigned);
Expand Down Expand Up @@ -220,9 +232,13 @@ describe("BrowserAccountClient", () => {
const location = browserLocation(assigned);
let now = 0;
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (String(input) === "https://directory.example/account/machines") {
const url = String(input);
if (url === "https://directory.example/account/machines") {
return new Response(JSON.stringify({ machines: [] }), { status: 200 });
}
if (url === "https://clerk.example/oauth/userinfo") {
return new Response(JSON.stringify({ email: "user@example.test" }), { status: 200 });
}
const body = new URLSearchParams(String(init?.body ?? ""));
if (body.get("grant_type") === "authorization_code") {
return new Response(JSON.stringify({
Expand All @@ -233,6 +249,8 @@ describe("BrowserAccountClient", () => {
}
return new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 });
}) as typeof fetch;
const persistedStorage = new MemoryStorage();
const sessionStore = new BrowserAccountSessionStore(persistedStorage);
const client = new BrowserAccountClient({
config: {
issuer: "https://clerk.example",
Expand All @@ -244,12 +262,160 @@ describe("BrowserAccountClient", () => {
location,
history: { replaceState: () => {} },
storage,
sessionStore,
now: () => now,
});
await completeSignIn(client, location, assigned);
now = 3_500_000;

await expect(client.getAccessToken()).rejects.toThrow("session expired");
expect(client.getSnapshot()).toMatchObject({ state: "auth_expired", machines: [] });
await expect(sessionStore.load()).resolves.toBeNull();
});

it("refreshes from the JWT exp claim before using a stale access token", async () => {
const storage = new MemorySessionStorage();
const assigned: string[] = [];
const location = browserLocation(assigned);
const now = 1_800_000_000_000;
const staleToken = accessToken("user_exp", {
exp: Math.floor((now - 60_000) / 1000),
});
const freshToken = accessToken("user_exp", {
exp: Math.floor((now + 3_600_000) / 1000),
});
const requestOrder: string[] = [];
const userinfoTokens: string[] = [];
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
if (url === "https://clerk.example/oauth/token") {
const grantType = new URLSearchParams(String(init?.body ?? "")).get("grant_type");
requestOrder.push(`token:${grantType}`);
return new Response(JSON.stringify({
access_token: grantType === "refresh_token" ? freshToken : staleToken,
refresh_token: "refresh-secret",
// The JWT exp must win over this optimistic OAuth lifetime.
expires_in: 86_400,
}), { status: 200 });
}
if (url === "https://clerk.example/oauth/userinfo") {
requestOrder.push("userinfo");
userinfoTokens.push(new Headers(init?.headers).get("authorization") ?? "");
return new Response(JSON.stringify({ email: "exp@example.test" }), { status: 200 });
}
requestOrder.push("directory");
return new Response(JSON.stringify({ machines: [] }), { status: 200 });
}) as typeof fetch;
const client = new BrowserAccountClient({
config: {
issuer: "https://clerk.example",
clientId: "client_ade",
directoryBaseUrl: "https://directory.example",
relayBaseUrls: ["wss://relay.example"],
},
fetchImpl,
location,
history: { replaceState: () => {} },
storage,
sessionStore: new BrowserAccountSessionStore(new MemoryStorage()),
now: () => now,
});

await completeSignIn(client, location, assigned);

expect(requestOrder).toEqual([
"token:authorization_code",
"token:refresh_token",
"userinfo",
"directory",
]);
expect(userinfoTokens).toEqual([`Bearer ${freshToken}`]);
expect(client.getSnapshot()).toMatchObject({
state: "signed_in",
email: "exp@example.test",
});
});

it("persists, restores, refreshes, and explicitly clears the browser session", async () => {
const persistedStorage = new MemoryStorage();
const sessionStore = new BrowserAccountSessionStore(persistedStorage);
const pendingStorage = new MemorySessionStorage();
const assigned: string[] = [];
const location = browserLocation(assigned);
const requestOrder: string[] = [];
const initialToken = accessToken("user_3GYkProfileOnly", {});
const restoredToken = accessToken("user_3GYkProfileOnly", {});
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
if (url === "https://clerk.example/oauth/token") {
const grantType = new URLSearchParams(String(init?.body ?? "")).get("grant_type");
requestOrder.push(`token:${grantType}`);
return new Response(JSON.stringify({
access_token: grantType === "refresh_token" ? restoredToken : initialToken,
refresh_token: grantType === "refresh_token" ? "refresh-rotated" : "refresh-original",
expires_in: 3600,
}), { status: 200 });
}
if (url === "https://clerk.example/oauth/userinfo") {
requestOrder.push("userinfo");
return new Response(JSON.stringify({
sub: "user_3GYkProfileOnly",
email: "profile@example.test",
name: "Profile Owner",
picture: "https://img.clerk.com/profile.png",
}), { status: 200 });
}
requestOrder.push("directory");
return new Response(JSON.stringify({ machines: [] }), { status: 200 });
}) as typeof fetch;
const config = {
issuer: "https://clerk.example",
clientId: "client_ade",
directoryBaseUrl: "https://directory.example",
relayBaseUrls: ["wss://relay.example"],
};
const firstClient = new BrowserAccountClient({
config,
fetchImpl,
location,
history: { replaceState: () => {} },
storage: pendingStorage,
sessionStore,
});

await completeSignIn(firstClient, location, assigned);
await expect(sessionStore.load()).resolves.toMatchObject({
refreshToken: "refresh-original",
issuer: config.issuer,
clientId: config.clientId,
email: "profile@example.test",
imageUrl: "https://img.clerk.com/profile.png",
});

requestOrder.length = 0;
const restoredClient = new BrowserAccountClient({
config,
fetchImpl,
location: browserLocation([]),
history: { replaceState: () => {} },
storage: new MemorySessionStorage(),
sessionStore,
});
const restored = await restoredClient.bootstrap();

expect(requestOrder).toEqual(["token:refresh_token", "userinfo", "directory"]);
expect(restored).toMatchObject({
state: "signed_in",
userId: "user_3GYkProfileOnly",
email: "profile@example.test",
imageUrl: "https://img.clerk.com/profile.png",
});
await expect(sessionStore.load()).resolves.toMatchObject({ refreshToken: "refresh-rotated" });
const hostedHeaders = readFileSync(new URL("../public/_headers", import.meta.url), "utf8");
expect(hostedHeaders).toContain("img-src 'self' data: blob: https://img.clerk.com");

await restoredClient.signOut();
await expect(sessionStore.load()).resolves.toBeNull();
expect(restoredClient.getSnapshot()).toMatchObject({ state: "signed_out", userId: null });
});
});
Loading