Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Database (Neon — runtime until D1 cutover)
# Database (Neon env var retained until #112; runtime persistence is D1)
DATABASE_URL=

# D1 tooling (`drizzle-kit push` / `studio` against the Worker D1 binding)
Expand All @@ -9,7 +9,7 @@ CLOUDFLARE_D1_TOKEN=
# Local sqlite file used when Cloudflare D1 credentials are unset
D1_LOCAL_DB=file:./.local/d1/cyrus.sqlite

# Better Auth
# Better Auth (tables live on D1 via better-auth-cloudflare)
Comment thread
soorya-u marked this conversation as resolved.
Outdated
BETTER_AUTH_SECRET=

# Server Settings
Expand Down
10 changes: 5 additions & 5 deletions apps/server/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { defineConfig } from "drizzle-kit";

// D1 tooling config. Application repositories use the Worker D1 binding (#109);
// better-auth remains on Neon until cutover (#110 / #112).
// `generate` needs only the sqlite dialect. `push`/`studio` use the D1 HTTP driver
// when Cloudflare credentials are set; otherwise a local sqlite file so the
// commands still work without a Cloudflare account.
// D1 tooling config. Auth (#110) and application repositories (#109) both use
// the Worker D1 binding; Neon removal is #112. `generate` needs only the sqlite
// dialect. `push`/`studio` use the D1 HTTP driver when Cloudflare credentials
// are set; otherwise a local sqlite file so the commands still work without an
// account.
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const databaseId = process.env.CLOUDFLARE_DATABASE_ID;
const token = process.env.CLOUDFLARE_D1_TOKEN;
Expand Down
4 changes: 3 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
"wrangler:types": "wrangler types --cwd src",
"db:studio": "dotenvx run -- drizzle-kit studio",
"db:migrate": "dotenvx run -- drizzle-kit migrate",
"auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/index.ts --output src/db/neon/schema.ts"
"auth:generate": "dotenvx run -- bun x @better-auth/cli generate -y --config src/auth/index.ts --output src/db/models/auth.ts"
},
"dependencies": {
"@better-auth/drizzle-adapter": "catalog:auth",
"@better-auth/expo": "catalog:auth",
"@cyrus/connections": "workspace:*",
"@cyrus/schemas": "workspace:*",
Expand All @@ -23,6 +24,7 @@
"@soorya-u/better-auth-ws-ticket": "catalog:auth",
"@t3-oss/env-core": "catalog:env",
"better-auth": "catalog:auth",
"better-auth-cloudflare": "catalog:auth",
"better-result": "catalog:core",
"drizzle-orm": "catalog:database",
"evlog": "catalog:observability",
Expand Down
153 changes: 153 additions & 0 deletions apps/server/src/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { SELF } from "cloudflare:test";
Comment thread
soorya-u marked this conversation as resolved.
Outdated
import { describe, expect, test } from "vitest";

const ORIGIN = "https://example.com";
const CLIENT_ID = "cyrusd";
const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const SESSION_COOKIE_PATTERN =
/(?:__Secure-)?better-auth\.session_token=([^;]+)/;

function authHeaders(
extra: Record<string, string> = {}
): Record<string, string> {
return {
origin: ORIGIN,
referer: `${ORIGIN}/`,
...extra,
};
}

function sessionCookieFromResponse(response: Response): string {
const setCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: [response.headers.get("set-cookie")].filter((value): value is string =>
Boolean(value)
);

for (const header of setCookies) {
const match = header.match(SESSION_COOKIE_PATTERN);
if (match?.[0] && match[1]) {
// Preserve __Secure- prefix when present — better-auth sets it on HTTPS.
const nameAndValue = match[0];
return nameAndValue;
}
}

throw new Error(
`Missing session cookie. set-cookie headers: ${JSON.stringify(setCookies)}`
);
}

async function signUpAndSignIn(email: string, password: string) {
const signUp = await SELF.fetch(
"https://example.com/api/auth/sign-up/email",
{
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ email, name: "D1 Auth User", password }),
}
);
expect(signUp.ok || signUp.status === 422).toBe(true);

const signIn = await SELF.fetch(
"https://example.com/api/auth/sign-in/email",
{
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({ email, password }),
}
);
expect(signIn.ok).toBe(true);

const body = (await signIn.json()) as { user?: { id: string } };
const userId = body.user?.id;
expect(userId).toBeTruthy();
if (!userId) {
throw new Error("sign-in response missing user id");
}

const sessionCookie = sessionCookieFromResponse(signIn);

const sessionCheck = await SELF.fetch(
"https://example.com/api/auth/get-session",
{ headers: authHeaders({ cookie: sessionCookie }) }
);
const sessionBody = (await sessionCheck.json()) as {
user?: { id: string };
} | null;
expect(sessionBody?.user?.id).toBe(userId);

return {
sessionCookie,
userId,
};
}

describe("device authorization against D1", () => {
test("completes request → approve → token against D1-backed auth data", async () => {
const email = `d1-auth-${crypto.randomUUID()}@cyrus.test`;
const password = "d1-auth-test-password-32chars-min";
const session = await signUpAndSignIn(email, password);

const codeResponse = await SELF.fetch(
"https://example.com/api/auth/device/code",
{
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "openid profile email",
}),
}
);
expect(codeResponse.ok).toBe(true);

const codeBody = (await codeResponse.json()) as {
device_code: string;
user_code: string;
};
expect(codeBody.device_code).toBeTruthy();
expect(codeBody.user_code).toBeTruthy();

const formattedUserCode = codeBody.user_code.replace(/-/g, "");

const claim = await SELF.fetch(
`https://example.com/api/auth/device?user_code=${encodeURIComponent(formattedUserCode)}`,
{ headers: authHeaders({ cookie: session.sessionCookie }) }
);
expect(claim.ok).toBe(true);

const approve = await SELF.fetch(
"https://example.com/api/auth/device/approve",
{
method: "POST",
headers: authHeaders({
"content-type": "application/json",
cookie: session.sessionCookie,
}),
body: JSON.stringify({ userCode: formattedUserCode }),
}
);
expect(approve.ok).toBe(true);

const tokenResponse = await SELF.fetch(
"https://example.com/api/auth/device/token",
{
method: "POST",
headers: authHeaders({ "content-type": "application/json" }),
body: JSON.stringify({
grant_type: GRANT_TYPE,
device_code: codeBody.device_code,
client_id: CLIENT_ID,
}),
}
);
expect(tokenResponse.ok).toBe(true);

const tokenBody = (await tokenResponse.json()) as {
access_token?: string;
};
expect(tokenBody.access_token).toBeTruthy();
});
});
77 changes: 69 additions & 8 deletions apps/server/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { expo } from "@better-auth/expo";
import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server";
import { wsTicketPlugin } from "@soorya-u/better-auth-ws-ticket/server";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins";
import { withCloudflare } from "better-auth-cloudflare";
import { drizzle } from "drizzle-orm/d1";
import { log } from "evlog";
import { env } from "../config/env";
// Neon path (expand): keep Postgres until auth D1 cutover (#110 / #112).
import { db } from "../db/neon";
// biome-ignore lint/performance/noNamespaceImport: drizzle adapter requires schema as namespace
import * as schema from "../db/neon/schema";
import * as schema from "../db/models";

const emailAndPassword =
env.NODE_ENV === "production"
Expand All @@ -21,10 +20,9 @@ const emailAndPassword =
},
};

export const auth = betterAuth({
const authOptions = {
Comment thread
soorya-u marked this conversation as resolved.
Outdated
appName: "Cyrus",
basePath: "/api/auth",
database: drizzleAdapter(db, { provider: "pg", schema }),
...emailAndPassword,
trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL],
socialProviders: {
Expand All @@ -37,13 +35,17 @@ export const auth = betterAuth({
baseURL: env.WEB_APP_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "lax",
sameSite: "lax" as const,
httpOnly: true,
secure: env.NODE_ENV === "production",
},
},
logger: {
log: (level, message, ...args) => log[level]({ message, ...args }),
log: (
level: "debug" | "info" | "warn" | "error",
message: string,
...args: unknown[]
) => log[level]({ message, ...args }),
level: env.LOG_LEVEL,
},
plugins: [
Expand All @@ -60,4 +62,63 @@ export const auth = betterAuth({
bearer(),
wsTicketPlugin(),
],
};

type AuthInstance = ReturnType<typeof createAuth>;

const authByDb = new WeakMap<D1Database, AuthInstance>();

/** Runtime auth bound to the Worker's D1 binding via withCloudflare + Drizzle. */
function createAuth(d1: D1Database) {
// drizzle-orm 1.0 dropped the client `schema` option; the adapter still needs it.
const db = drizzle(d1);

return betterAuth({
...withCloudflare(
{
autoDetectIpAddress: true,
// Session table has no geolocation columns — keep schema as-is (#110).
geolocationTracking: false,
cf: {},
d1: {
// better-auth-cloudflare types against drizzle-orm ^0.45; this
// workspace pins 1.0 — DrizzleD1Database is structurally the same
// at runtime but distinct across the two package instances.
db: db as never,
options: {
schema,
// D1 has no interactive transactions.
transaction: false,
},
},
},
authOptions
),
Comment thread
soorya-u marked this conversation as resolved.
Outdated
});
}

/** Cached per-isolate auth instance for the given D1 binding. */
export function getAuth(d1: D1Database) {
const cached = authByDb.get(d1);
if (cached) return cached;

const instance = createAuth(d1);
authByDb.set(d1, instance);
return instance;
}

/**
* CLI schema generation (`auth:generate`) — no D1 binding available.
* Runtime callers must use `getAuth(env.DB)`.
*
* @public
*/
export const auth = betterAuth({
...withCloudflare(
{
autoDetectIpAddress: false,
geolocationTracking: false,
},
authOptions
),
});
8 changes: 8 additions & 0 deletions apps/server/src/db/apply-migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { applyD1Migrations, env } from "cloudflare:test";
import type { D1Migration } from "@cloudflare/vitest-pool-workers";

type TestEnv = Cloudflare.Env & { TEST_MIGRATIONS: D1Migration[] };

// Setup runs outside isolated storage and may run multiple times.
// applyD1Migrations only applies migrations that haven't already been applied.
await applyD1Migrations(env.DB, (env as TestEnv).TEST_MIGRATIONS);
Comment thread
soorya-u marked this conversation as resolved.
Outdated
15 changes: 0 additions & 15 deletions apps/server/src/db/neon/index.ts

This file was deleted.

Loading