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: 2 additions & 0 deletions apps/connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
},
"devDependencies": {
"@bb/tsconfig": "workspace:*",
"@better-auth/drizzle-adapter": "^1.6.23",
"@cloudflare/workers-types": "^4.20260610.0",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.0.0",
"better-sqlite3": "12.10.0",
"better-auth": "^1.6.23",
"esbuild": "^0.28.1",
"miniflare": "^4.20260701.0",
"typescript": "npm:@typescript/typescript6@^6.0.2",
Expand Down
50 changes: 50 additions & 0 deletions apps/connect/src/account-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
type AuthFetch = (request: Request) => Promise<Response>;

interface HeadersWithGetSetCookie extends Headers {
getSetCookie(): string[];
}

function hasGetSetCookie(headers: Headers): headers is HeadersWithGetSetCookie {
return (
"getSetCookie" in headers && typeof headers.getSetCookie === "function"
);
}

function getSetCookies(headers: Headers): string[] {
// Node implements the standard API; the Workerd types/runtime expose the
// older getAll API for preserving separate Set-Cookie field values.
return hasGetSetCookie(headers)
? headers.getSetCookie()
: headers.getAll("set-cookie");
}

/** Let the account worker's Better Auth route own refresh and cookie policy. */
export async function refreshAccountSessionCookies(
cookieHeader: string,
accountAppUrl: string,
authFetch: AuthFetch,
): Promise<string[] | null> {
try {
const url = new URL("/api/auth/get-session", accountAppUrl);
url.searchParams.set("disableCookieCache", "true");
const response = await authFetch(
new Request(url, { headers: { cookie: cookieHeader } }),
);
if (!response.ok) return null;

const body: unknown = await response.json();
if (
typeof body !== "object" ||
body === null ||
!("session" in body) ||
!("user" in body)
) {
return null;
}
const setCookies = getSetCookies(response.headers);
return setCookies.length === 0 ? null : setCookies;
} catch (error) {
console.error("bb connect: session refresh failed", error);
return null;
}
}
18 changes: 13 additions & 5 deletions apps/connect/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ function isCacheable(resp: Response): boolean {
return maxAge ? Number(maxAge[1]) >= MIN_CACHEABLE_MAX_AGE : false;
}

export interface CacheResult {
/** True for both edge-cache hits and cacheable origin misses. */
cacheable: boolean;
response: Response;
}

/**
* Serve `request` from the edge cache when possible, else run `fetchOrigin`
* (the tunnel) and populate the cache when the response is cacheable.
Expand All @@ -42,8 +48,10 @@ export async function serveWithCache(
namespace: string,
ctx: ExecutionContext,
fetchOrigin: () => Promise<Response>,
): Promise<Response> {
if (request.method !== "GET") return fetchOrigin();
): Promise<CacheResult> {
if (request.method !== "GET") {
return { cacheable: false, response: await fetchOrigin() };
}

const url = new URL(request.url);
const key = cacheKey(namespace, url);
Expand All @@ -57,7 +65,7 @@ export async function serveWithCache(
// labelled text/html. This is NOT symmetric with the miss path below.
const r = rebuiltResponse(hit.body, hit);
r.headers.set("x-bb-cache", "hit");
return r;
return { cacheable: true, response: r };
}

const resp = await fetchOrigin();
Expand All @@ -70,7 +78,7 @@ export async function serveWithCache(
// this one pre-encoded would advertise a gzip body that isn't gzipped.
const r = new Response(resp.body, resp);
r.headers.set("x-bb-cache", "miss");
return r;
return { cacheable: true, response: r };
}
return resp;
return { cacheable: false, response: resp };
}
200 changes: 200 additions & 0 deletions apps/connect/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@ import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { betterAuth } from "better-auth/minimal";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
CONNECT_SESSION_EXPIRES_IN_SECONDS,
CONNECT_SESSION_UPDATE_AGE_SECONDS,
labelClaim,
machine,
profile,
schema,
server,
session,
user,
} from "@bb/connect-db";

Expand All @@ -19,7 +24,9 @@ import {
markMachineSeen,
resolveLabel,
verifyMachineCredentialDetails,
verifySessionCookieDetails,
} from "./session.js";
import { refreshAccountSessionCookies } from "./account-session.js";
import { assignMachineLabel } from "./machine-label.js";

// Real in-memory SQLite (never mock the DB). resolveLabel accepts any Drizzle
Expand Down Expand Up @@ -113,6 +120,26 @@ function seedMachine(over: {
}
}

async function signedSessionCookie(
token: string,
secret: string,
): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(token),
);
const encoded = btoa(String.fromCharCode(...new Uint8Array(signature)));
return encodeURIComponent(`${token}.${encoded}`);
}

describe("resolveLabel — label → server row (multi-server)", () => {
it("resolves the primary bb by its backfilled handle-label subdomain", async () => {
seedUser("acct-a");
Expand Down Expand Up @@ -347,6 +374,179 @@ describe("resolveLabel — label → server row (multi-server)", () => {
});
});

describe("account session refresh", () => {
const secret = "test-better-auth-secret-32-chars";
const expiresInMs = CONNECT_SESSION_EXPIRES_IN_SECONDS * 1000;
const updateAgeMs = CONNECT_SESSION_UPDATE_AGE_SECONDS * 1000;

function seedSession(
token: string,
refreshAt: number,
expiresAt: number,
): void {
seedUser(`user-${token}`);
db.insert(session)
.values({
id: `id-${token}`,
token,
expiresAt: new Date(expiresAt),
userId: `user-${token}`,
createdAt: new Date(refreshAt - updateAgeMs),
updatedAt: new Date(refreshAt - updateAgeMs),
})
.run();
}

function createAuthFetch(baseURL: string, baseDomain: string) {
const auth = betterAuth({
secret,
baseURL,
database: drizzleAdapter(db, {
provider: "sqlite",
schema: { session, user },
}),
session: {
expiresIn: CONNECT_SESSION_EXPIRES_IN_SECONDS,
updateAge: CONNECT_SESSION_UPDATE_AGE_SECONDS,
},
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: `.${baseDomain}`,
},
},
});
return (request: Request) => auth.handler(request);
}

it("reports Better Auth's update-age boundary from the session expiry", async () => {
const checkedAt = Date.now();
const freshToken = `fresh-${crypto.randomUUID()}`;
const dueToken = `due-${crypto.randomUUID()}`;
seedSession(freshToken, checkedAt, checkedAt + expiresInMs);
seedSession(
dueToken,
checkedAt,
checkedAt + expiresInMs - updateAgeMs - 1000,
);

await expect(
verifySessionCookieDetails(
await signedSessionCookie(freshToken, secret),
secret,
db,
),
).resolves.toEqual({
userId: `user-${freshToken}`,
needsRefresh: false,
});
await expect(
verifySessionCookieDetails(
await signedSessionCookie(dueToken, secret),
secret,
db,
),
).resolves.toEqual({
userId: `user-${dueToken}`,
needsRefresh: true,
});
});

it("lets Better Auth renew the database session and production cookie", async () => {
const refreshAt = Date.now();
const token = `session-${crypto.randomUUID()}`;
const oldExpiresAt = refreshAt + expiresInMs - updateAgeMs;
seedSession(token, refreshAt, oldExpiresAt);
const cookie = await signedSessionCookie(token, secret);
const beforeRefresh = Date.now();

const setCookies = await refreshAccountSessionCookies(
`__Secure-better-auth.session_token=${cookie}`,
"https://getbb.app",
createAuthFetch("https://getbb.app", "getbb.app"),
);
const afterRefresh = Date.now();
const refreshed = db
.select()
.from(session)
.where(eq(session.token, token))
.get();
expect(refreshed?.expiresAt.getTime()).toBeGreaterThanOrEqual(
beforeRefresh + expiresInMs,
);
expect(refreshed?.expiresAt.getTime()).toBeLessThanOrEqual(
afterRefresh + expiresInMs,
);
expect(setCookies).toHaveLength(1);
expect(setCookies?.[0]).toContain("__Secure-better-auth.session_token=");
expect(setCookies?.[0]).toContain("Max-Age=604800");
expect(setCookies?.[0]).toContain("Domain=.getbb.app");
expect(setCookies?.[0]).toContain("Secure");
});

it("leaves a fresh session unchanged before the update-age boundary", async () => {
const refreshAt = Date.now();
const token = `session-${crypto.randomUUID()}`;
const expiresAt = refreshAt + expiresInMs;
seedSession(token, refreshAt, expiresAt);
const cookie = await signedSessionCookie(token, secret);

await expect(
refreshAccountSessionCookies(
`__Secure-better-auth.session_token=${cookie}`,
"https://getbb.app",
createAuthFetch("https://getbb.app", "getbb.app"),
),
).resolves.toBeNull();
expect(
db
.select({ expiresAt: session.expiresAt })
.from(session)
.where(eq(session.token, token))
.get()
?.expiresAt.getTime(),
).toBe(expiresAt);
});

it("passes through Better Auth's non-secure local Cloud cookie", async () => {
const refreshAt = Date.now();
const token = `session-${crypto.randomUUID()}`;
seedSession(token, refreshAt, refreshAt + expiresInMs - updateAgeMs);
const cookie = await signedSessionCookie(token, secret);

const setCookies = await refreshAccountSessionCookies(
`better-auth.session_token=${cookie}`,
"http://bb.localhost:42745",
createAuthFetch("http://bb.localhost:42745", "bb.localhost"),
);
expect(setCookies).toHaveLength(1);
expect(setCookies?.[0]).toContain("better-auth.session_token=");
expect(setCookies?.[0]).toContain("Domain=.bb.localhost");
expect(setCookies?.[0]).not.toContain("Secure");
});

it("preserves multiple Better Auth cookies as separate values", async () => {
const headers = new Headers();
headers.append("set-cookie", "session=renewed; Path=/; HttpOnly");
headers.append("set-cookie", "session-data=cached; Path=/; HttpOnly");

await expect(
refreshAccountSessionCookies(
"session=old",
"https://getbb.app",
async () =>
Response.json(
{ session: { id: "session" }, user: { id: "user" } },
{ headers },
),
),
).resolves.toEqual([
"session=renewed; Path=/; HttpOnly",
"session-data=cached; Path=/; HttpOnly",
]);
});
});

describe("machine credential presence", () => {
it("verifies the owning machine and throttles lastSeenAt writes", async () => {
seedUser("acct-machine");
Expand Down
Loading
Loading