Skip to content

Commit f19d4e4

Browse files
authored
Reuse bb Connect authentication in desktop (#607)
## Summary - exchange the stored bb Connect pairing credential for a short-lived signed desktop session - install the scoped HttpOnly cookie in Electron before opening Connect-managed servers - keep account ownership enforcement at the Connect gate, including stale GitHub-session fallback ## Testing - `pnpm exec turbo run test typecheck --filter=@bb/connect --filter=bb-plugin-connect --filter=@bb/desktop` - 296 focused tests passed across Connect worker, plugin, and desktop - `git diff --check`
1 parent 2fa241b commit f19d4e4

11 files changed

Lines changed: 747 additions & 108 deletions

File tree

‎apps/connect/src/servers.test.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
} from "@bb/connect-db";
1515

1616
import {
17+
createDesktopSessionCookie,
1718
listAccountServers,
1819
resolveAccountUserId,
20+
verifyDesktopSessionCookie,
1921
verifyServerCredential,
2022
} from "./servers.js";
2123
import { verifyMachineCredential } from "./session.js";
@@ -46,8 +48,13 @@ afterEach(() => {
4648
const now = new Date("2026-07-01T12:00:00.000Z");
4749

4850
async function sha256Hex(value: string): Promise<string> {
49-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
50-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
51+
const digest = await crypto.subtle.digest(
52+
"SHA-256",
53+
new TextEncoder().encode(value),
54+
);
55+
return [...new Uint8Array(digest)]
56+
.map((b) => b.toString(16).padStart(2, "0"))
57+
.join("");
5158
}
5259

5360
function seedUser(id: string): void {
@@ -86,6 +93,30 @@ function seedServer(over: {
8693
.run();
8794
}
8895

96+
describe("desktop session cookie", () => {
97+
it("round-trips account identity until expiry and rejects tampering", async () => {
98+
const expiresAt = now.getTime() + 60_000;
99+
const cookie = await createDesktopSessionCookie(
100+
"acct-a",
101+
"test-secret",
102+
expiresAt,
103+
);
104+
await expect(
105+
verifyDesktopSessionCookie(cookie, "test-secret", now.getTime()),
106+
).resolves.toBe("acct-a");
107+
await expect(
108+
verifyDesktopSessionCookie(cookie, "test-secret", expiresAt),
109+
).resolves.toBeNull();
110+
await expect(
111+
verifyDesktopSessionCookie(
112+
`${cookie.slice(0, -1)}x`,
113+
"test-secret",
114+
now.getTime(),
115+
),
116+
).resolves.toBeNull();
117+
});
118+
});
119+
89120
describe("listAccountServers", () => {
90121
it("returns only the authenticated account's rows with live from last_seen_at", async () => {
91122
seedUser("acct-a");
@@ -239,7 +270,11 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
239270
false,
240271
["sign"],
241272
);
242-
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(token));
273+
const sigBuf = await crypto.subtle.sign(
274+
"HMAC",
275+
key,
276+
new TextEncoder().encode(token),
277+
);
243278
const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
244279
const cookieValue = `${token}.${sig}`;
245280

‎apps/connect/src/servers.ts‎

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,97 @@ import {
1414
import type { Env } from "./tunnel-do.js";
1515

1616
const SESSION_COOKIE = "__Secure-better-auth.session_token";
17+
export const DESKTOP_SESSION_COOKIE = "__Secure-bb-connect.desktop_session";
18+
export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000;
19+
20+
function bytesToBase64Url(bytes: Uint8Array): string {
21+
return btoa(String.fromCharCode(...bytes))
22+
.replace(/\+/g, "-")
23+
.replace(/\//g, "_")
24+
.replace(/=+$/g, "");
25+
}
26+
27+
function stringToBase64Url(value: string): string {
28+
return bytesToBase64Url(new TextEncoder().encode(value));
29+
}
30+
31+
function base64UrlToString(value: string): string | null {
32+
try {
33+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
34+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
35+
return new TextDecoder().decode(
36+
Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)),
37+
);
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
async function signDesktopSessionPayload(
44+
payload: string,
45+
secret: string,
46+
): Promise<string> {
47+
const key = await crypto.subtle.importKey(
48+
"raw",
49+
new TextEncoder().encode(secret),
50+
{ name: "HMAC", hash: "SHA-256" },
51+
false,
52+
["sign"],
53+
);
54+
const signature = await crypto.subtle.sign(
55+
"HMAC",
56+
key,
57+
new TextEncoder().encode(payload),
58+
);
59+
return bytesToBase64Url(new Uint8Array(signature));
60+
}
61+
62+
export async function createDesktopSessionCookie(
63+
userId: string,
64+
secret: string,
65+
expiresAt: number,
66+
): Promise<string> {
67+
const payload = stringToBase64Url(JSON.stringify({ expiresAt, userId }));
68+
return `${payload}.${await signDesktopSessionPayload(payload, secret)}`;
69+
}
70+
71+
export async function verifyDesktopSessionCookie(
72+
cookieValue: string,
73+
secret: string,
74+
now: number = Date.now(),
75+
): Promise<string | null> {
76+
const dot = cookieValue.lastIndexOf(".");
77+
if (dot <= 0) return null;
78+
const payload = cookieValue.slice(0, dot);
79+
const signature = cookieValue.slice(dot + 1);
80+
const expected = await signDesktopSessionPayload(payload, secret);
81+
if (signature.length !== expected.length) return null;
82+
let mismatch = 0;
83+
for (let index = 0; index < signature.length; index += 1) {
84+
mismatch |= signature.charCodeAt(index) ^ expected.charCodeAt(index);
85+
}
86+
if (mismatch !== 0) return null;
87+
88+
const decoded = base64UrlToString(payload);
89+
if (decoded === null) return null;
90+
try {
91+
const value: unknown = JSON.parse(decoded);
92+
if (
93+
typeof value !== "object" ||
94+
value === null ||
95+
!("userId" in value) ||
96+
typeof value.userId !== "string" ||
97+
!("expiresAt" in value) ||
98+
typeof value.expiresAt !== "number" ||
99+
value.expiresAt <= now
100+
) {
101+
return null;
102+
}
103+
return value.userId;
104+
} catch {
105+
return null;
106+
}
107+
}
17108

18109
const serverCredentialCache = new Map<
19110
string,
@@ -22,8 +113,13 @@ const serverCredentialCache = new Map<
22113
const SERVER_CRED_TTL_MS = 20_000;
23114

24115
async function sha256Hex(value: string): Promise<string> {
25-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
26-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
116+
const digest = await crypto.subtle.digest(
117+
"SHA-256",
118+
new TextEncoder().encode(value),
119+
);
120+
return [...new Uint8Array(digest)]
121+
.map((b) => b.toString(16).padStart(2, "0"))
122+
.join("");
27123
}
28124

29125
/**
@@ -145,12 +241,19 @@ export async function handleListAccountServers(
145241
if (request.method !== "GET") {
146242
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
147243
status: 405,
148-
headers: { "content-type": "application/json; charset=utf-8", allow: "GET" },
244+
headers: {
245+
"content-type": "application/json; charset=utf-8",
246+
allow: "GET",
247+
},
149248
});
150249
}
151250

152251
const db = drizzle(env.DB, { schema });
153-
const userId = await resolveAccountUserId(request, env.BETTER_AUTH_SECRET, db);
252+
const userId = await resolveAccountUserId(
253+
request,
254+
env.BETTER_AUTH_SECRET,
255+
db,
256+
);
154257
if (!userId) {
155258
return new Response(JSON.stringify({ error: "unauthorized" }), {
156259
status: 401,
@@ -164,3 +267,51 @@ export async function handleListAccountServers(
164267
headers: { "content-type": "application/json; charset=utf-8" },
165268
});
166269
}
270+
271+
/** Exchange a durable pairing credential for a short-lived browser session. */
272+
export async function handleCreateDesktopSession(
273+
request: Request,
274+
env: Env,
275+
): Promise<Response> {
276+
if (request.method !== "POST") {
277+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
278+
status: 405,
279+
headers: {
280+
"content-type": "application/json; charset=utf-8",
281+
allow: "POST",
282+
},
283+
});
284+
}
285+
const db = drizzle(env.DB, { schema });
286+
const userId = await resolveAccountUserId(
287+
request,
288+
env.BETTER_AUTH_SECRET,
289+
db,
290+
);
291+
if (!userId) {
292+
return new Response(JSON.stringify({ error: "unauthorized" }), {
293+
status: 401,
294+
headers: { "content-type": "application/json; charset=utf-8" },
295+
});
296+
}
297+
const expiresAt = Date.now() + DESKTOP_SESSION_TTL_MS;
298+
const value = await createDesktopSessionCookie(
299+
userId,
300+
env.BETTER_AUTH_SECRET,
301+
expiresAt,
302+
);
303+
return new Response(
304+
JSON.stringify({
305+
cookie: {
306+
domain: `.${env.BASE_DOMAIN}`,
307+
expiresAt,
308+
name: DESKTOP_SESSION_COOKIE,
309+
value,
310+
},
311+
}),
312+
{
313+
status: 200,
314+
headers: { "content-type": "application/json; charset=utf-8" },
315+
},
316+
);
317+
}

0 commit comments

Comments
 (0)