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/server/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Database (Neon — runtime until D1 cutover)
# Database
DATABASE_URL=

# D1 tooling (`drizzle-kit push` / `studio` against the Worker D1 binding)
Expand Down
33 changes: 16 additions & 17 deletions apps/server/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { defineConfig } from "drizzle-kit";
import { env } from "./src/db/env";

// 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.
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
const databaseId = process.env.CLOUDFLARE_DATABASE_ID;
const token = process.env.CLOUDFLARE_D1_TOKEN;

export default defineConfig({
schema: "./src/db/models/index.ts",
out: "./src/db/migrations",
dialect: "sqlite",
...(accountId && databaseId && token
const dbConfig =
env.DB_TYPE === "remote"
? {
driver: "d1-http" as const,
dbCredentials: { accountId, databaseId, token },
dbCredentials: {
accountId: env.CLOUDFLARE_ACCOUNT_ID,
databaseId: env.CLOUDFLARE_DATABASE_ID,
token: env.CLOUDFLARE_D1_TOKEN,
},
}
: {
dbCredentials: {
url: process.env.D1_LOCAL_DB ?? "file:./.local/d1/cyrus.sqlite",
url: env.D1_LOCAL_DB,
},
}),
};

export default defineConfig({
schema: "./src/db/models/index.ts",
out: "./src/db/migrations",
dialect: "sqlite",
...dbConfig,
});
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/cli.ts --output src/db/models/auth.ts"
},
"dependencies": {
"@better-auth/drizzle-adapter": "1.6.21",
"@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": "0.3.1",
"better-result": "catalog:core",
"drizzle-orm": "catalog:database",
"evlog": "catalog:observability",
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/auth/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { betterAuth } from "better-auth";
import { withCloudflare } from "better-auth-cloudflare";
import { authOptions } from "./options";

// This if for CLI schema generation (`auth:generate`)
export const auth = betterAuth({
...withCloudflare(
{
autoDetectIpAddress: false,
geolocationTracking: false,
},
authOptions
),
});
154 changes: 154 additions & 0 deletions apps/server/src/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { exports } from "cloudflare:workers";
import { describe, expect, test } from "vitest";

const worker = exports.default;
const ORIGIN = "https://cyrus.soorya-u.dev";
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 worker.fetch(
"https://cyrus.soorya-u.dev/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 worker.fetch(
"https://cyrus.soorya-u.dev/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 worker.fetch(
"https://cyrus.soorya-u.dev/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 worker.fetch(
"https://cyrus.soorya-u.dev/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 worker.fetch(
`https://cyrus.soorya-u.dev/api/auth/device?user_code=${encodeURIComponent(formattedUserCode)}`,
{ headers: authHeaders({ cookie: session.sessionCookie }) }
);
expect(claim.ok).toBe(true);

const approve = await worker.fetch(
"https://cyrus.soorya-u.dev/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 worker.fetch(
"https://cyrus.soorya-u.dev/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();
});
});
74 changes: 17 additions & 57 deletions apps/server/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,23 @@
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 { env } from "cloudflare:workers";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins";
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 { withCloudflare } from "better-auth-cloudflare";
import { drizzle } from "drizzle-orm/d1";
import { models as schema } from "../db/models";
import { authOptions } from "./options";

const emailAndPassword =
env.NODE_ENV === "production"
? {}
: {
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
};
const db = drizzle(env.DB);

export const auth = betterAuth({
appName: "Cyrus",
basePath: "/api/auth",
database: drizzleAdapter(db, { provider: "pg", schema }),
...emailAndPassword,
trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL],
socialProviders: {
github: {
clientId: env.OAUTH_GITHUB_CLIENT_ID,
clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET,
...withCloudflare(
{
autoDetectIpAddress: true,
geolocationTracking: false,
cf: {},
d1: {
db: db as never,
options: { schema },
},
},
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.WEB_APP_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "lax",
httpOnly: true,
secure: env.NODE_ENV === "production",
},
},
logger: {
log: (level, message, ...args) => log[level]({ message, ...args }),
level: env.LOG_LEVEL,
},
plugins: [
expo(),
betterAuthDesktop({
clientID: "cyrus-desktop",
webCallbackUrl: `${env.WEB_APP_URL}/auth/callback`,
}),
oAuthProxy({
productionURL: env.PRODUCTION_URL,
secret: env.OAUTH_PROXY_SECRET,
}),
deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }),
bearer(),
wsTicketPlugin(),
],
authOptions
),
});
61 changes: 61 additions & 0 deletions apps/server/src/auth/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 type { BetterAuthOptions } from "better-auth";
import { bearer, deviceAuthorization, oAuthProxy } from "better-auth/plugins";
import { log } from "evlog";
import { env } from "../config/env";

const emailAndPassword =
env.NODE_ENV === "production"
? {}
: {
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
};

export const authOptions = {
appName: "Cyrus",
basePath: "/api/auth",
...emailAndPassword,
trustedOrigins: [...env.ALLOWED_ORIGINS, env.PRODUCTION_URL],
socialProviders: {
github: {
clientId: env.OAUTH_GITHUB_CLIENT_ID,
clientSecret: env.OAUTH_GITHUB_CLIENT_SECRET,
},
},
secret: env.BETTER_AUTH_SECRET,
baseURL: env.WEB_APP_URL,
advanced: {
defaultCookieAttributes: {
sameSite: "lax" as const,
httpOnly: true,
secure: env.NODE_ENV === "production",
},
},
logger: {
log: (
level: "debug" | "info" | "warn" | "error",
message: string,
...args: unknown[]
) => log[level]({ message, args }),
level: env.LOG_LEVEL,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
plugins: [
expo(),
betterAuthDesktop({
clientID: "cyrus-desktop",
webCallbackUrl: `${env.WEB_APP_URL}/auth/callback`,
}),
oAuthProxy({
productionURL: env.PRODUCTION_URL,
secret: env.OAUTH_PROXY_SECRET,
}),
deviceAuthorization({ verificationUri: `${env.WEB_APP_URL}/auth/device` }),
bearer(),
wsTicketPlugin(),
],
} satisfies BetterAuthOptions;
2 changes: 2 additions & 0 deletions apps/server/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
import { env as dbEnv } from "../db/env";

export const env = createEnv({
server: {
Expand All @@ -26,6 +27,7 @@ export const env = createEnv({
: []
),
},
extends: [dbEnv],
runtimeEnv: process.env,
emptyStringAsUndefined: true,
});
Loading