-
Notifications
You must be signed in to change notification settings - Fork 0
Wire better-auth onto D1 via better-auth-cloudflare #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
73544dc
Wire better-auth onto D1 via better-auth-cloudflare (#110).
soorya-u eee4056
Fix knip findings for D1 auth test setup and CLI auth export.
soorya-u 88fa506
Merge origin/main into issue-110-better-auth-d1.
soorya-u 32f6967
Address PR review: type authOptions and keep Cloudflare deps server-l…
soorya-u 8d6fbe3
Simplify auth caching to a single lazy instance.
soorya-u a72dc5e
Use a cloudflare:workers auth singleton like the D1 db client.
soorya-u c84970f
Colocate Vitest D1 migration apply with Drizzle SQL.
soorya-u f4affa1
Validate D1 drizzle credentials as local vs remote env.
soorya-u dff4c53
Colocate ImportMeta.glob types with the D1 migration setup.
soorya-u 0e8a9d4
Use exports.default.fetch and the real app origin in auth tests.
soorya-u 52b2fcd
Address PR review: fix auth logger args and tighten Knip/tsconfig.
soorya-u File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { betterAuth } from "better-auth"; | ||
| import { withCloudflare } from "better-auth-cloudflare"; | ||
| import { authOptions } from "./options"; | ||
|
|
||
| /** | ||
| * CLI schema generation (`auth:generate`) — no D1 binding outside the Worker. | ||
| * | ||
| * @public | ||
| */ | ||
| export const auth = betterAuth({ | ||
| ...withCloudflare( | ||
| { | ||
| autoDetectIpAddress: false, | ||
| geolocationTracking: false, | ||
| }, | ||
| authOptions | ||
| ), | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| import { SELF } from "cloudflare:test"; | ||
| 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(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,63 +1,33 @@ | ||
| 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"; | ||
| import { withCloudflare } from "better-auth-cloudflare"; | ||
| import { drizzle } from "drizzle-orm/d1"; | ||
| // biome-ignore lint/performance/noNamespaceImport: drizzle adapter requires schema as namespace | ||
| import * as schema from "../db/neon/schema"; | ||
| import * as schema from "../db/models"; | ||
| import { authOptions } from "./options"; | ||
|
|
||
| const emailAndPassword = | ||
| env.NODE_ENV === "production" | ||
| ? {} | ||
| : { | ||
| emailAndPassword: { | ||
| enabled: true, | ||
| autoSignIn: true, | ||
| }, | ||
| }; | ||
| // drizzle-orm 1.0 dropped the client `schema` option; the adapter still needs it. | ||
| 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, | ||
| }, | ||
| }, | ||
| secret: env.BETTER_AUTH_SECRET, | ||
| baseURL: env.WEB_APP_URL, | ||
| advanced: { | ||
| defaultCookieAttributes: { | ||
| sameSite: "lax", | ||
| httpOnly: true, | ||
| secure: env.NODE_ENV === "production", | ||
| ...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, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| 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 | ||
| ), | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
|
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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
soorya-u marked this conversation as resolved.
Outdated
|
||
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.