Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ async function authBuilder() {
{
autoDetectIpAddress: true,
geolocationTracking: true,
cf: getCloudflareContext().cf, // Replace with how your framework accesses the Cloudflare context
d1: {
db: dbInstance, // Provide the D1 database instance
options: {
Expand Down Expand Up @@ -176,6 +177,7 @@ export const auth = betterAuth({
{
autoDetectIpAddress: true,
geolocationTracking: true,
cf: {},
// No actual database or KV instance is needed here, only schema-affecting options
},
{
Expand Down
3 changes: 3 additions & 0 deletions examples/opennextjs/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { withCloudflare } from "better-auth-cloudflare";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { openAPI } from "better-auth/plugins";
import { getDb } from "../db";
import { getCloudflareContext } from "@opennextjs/cloudflare";

// Define an asynchronous function to build your auth configuration
async function authBuilder() {
Expand All @@ -13,6 +14,7 @@ async function authBuilder() {
{
autoDetectIpAddress: true,
geolocationTracking: true,
cf: getCloudflareContext().cf,
d1: {
db: dbInstance as any, // to-do: fix this type mismatch
options: {
Expand Down Expand Up @@ -71,6 +73,7 @@ export const auth = betterAuth({
{
autoDetectIpAddress: true,
geolocationTracking: true,
cf: {},
// No actual database or KV instance is needed here, only schema-affecting options
},
{
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"format": "prettier --write ."
},
"dependencies": {
"@opennextjs/cloudflare": "^1.0.1",
"drizzle-orm": "^0.43.1",
"zod": "^3.24.2"
},
Expand Down
93 changes: 44 additions & 49 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { KVNamespace } from "@cloudflare/workers-types";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { type BetterAuthOptions, type BetterAuthPlugin, type SecondaryStorage, type Session } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
Expand All @@ -19,7 +18,7 @@ export const cloudflare = (options?: CloudflarePluginOptions) => {
const opts = options ?? {};

// Default geolocationTracking to true if not specified
const geolocationTrackingEnabled = opts.geolocationTracking === undefined || opts.geolocationTracking === true;
const geolocationTrackingEnabled = opts.geolocationTracking === undefined || opts.geolocationTracking;

return {
id: "cloudflare",
Expand All @@ -36,22 +35,13 @@ export const cloudflare = (options?: CloudflarePluginOptions) => {
return ctx.json({ error: "Unauthorized" }, { status: 401 });
}

const cf = getCloudflareContext().cf;
const cf = await Promise.resolve(opts.cf);
if (!cf) {
return ctx.json({ error: "Cloudflare context is not available" }, { status: 404 });
}

// Extract and validate Cloudflare geolocation data
const context: CloudflareGeolocation = {
timezone: cf.timezone as string,
city: cf.city as string,
country: cf.country as string,
region: cf.region as string,
regionCode: cf.regionCode as string,
colo: cf.colo,
latitude: cf.latitude,
longitude: cf.longitude,
};
const context = extractGeolocationData(cf);

return ctx.json(context);
}
Expand All @@ -68,16 +58,19 @@ export const cloudflare = (options?: CloudflarePluginOptions) => {
if (!geolocationTrackingEnabled) {
return s;
}
const cf = (await getCloudflareContext({ async: true })).cf;
s.timezone = cf?.timezone;
s.city = cf?.city;
s.country = cf?.country;
s.region = cf?.region;
s.regionCode = cf?.regionCode;
s.colo = cf?.colo;
s.latitude = cf?.latitude;
s.longitude = cf?.longitude;

const cf = await Promise.resolve(opts.cf);
if (!cf) {
return s;
}
const geoData = extractGeolocationData(cf);
s.timezone = geoData.timezone;
s.city = geoData.city;
s.country = geoData.country;
s.region = geoData.region;
s.regionCode = geoData.regionCode;
s.colo = geoData.colo;
s.latitude = geoData.latitude;
s.longitude = geoData.longitude;
return s;
},
},
Expand All @@ -89,6 +82,26 @@ export const cloudflare = (options?: CloudflarePluginOptions) => {
} satisfies BetterAuthPlugin;
};

/**
* Safely extracts CloudflareGeolocation data, ignoring undefined values or other fields
*/
function extractGeolocationData(input: CloudflareGeolocation): CloudflareGeolocation {
if (!input || typeof input !== "object") {
return {};
}

return {
timezone: input.timezone || undefined,
city: input.city || undefined,
country: input.country || undefined,
region: input.region || undefined,
regionCode: input.regionCode || undefined,
colo: input.colo || undefined,
latitude: input.latitude || undefined,
longitude: input.longitude || undefined,
};
}

/**
* Creates secondary storage using Cloudflare KV
*
Expand All @@ -109,32 +122,6 @@ export const createKVStorage = (kv: KVNamespace<string>): SecondaryStorage => {
};
};

/**
* Get geolocation data from Cloudflare context
*
* Includes: ipAddress, timezone, city, country, region, regionCode, colo,
* latitude, longitude
*
* @returns Cloudflare geolocation data
* @throws Error if Cloudflare context is not available
*/
export const getGeolocation = (): CloudflareGeolocation | undefined => {
const cf = getCloudflareContext().cf;
if (!cf) {
throw new Error("Cloudflare context is not available");
}
return {
timezone: cf.timezone || "Unknown",
city: cf.city || "Unknown",
country: cf.country || "Unknown",
region: cf.region || "Unknown",
regionCode: cf.regionCode || "Unknown",
colo: cf.colo || "Unknown",
latitude: cf.latitude || "Unknown",
longitude: cf.longitude || "Unknown",
};
};

/**
* Type helper to infer the enhanced auth type with Cloudflare plugin
*/
Expand All @@ -161,6 +148,14 @@ export const withCloudflare = <T extends BetterAuthOptions>(
const geolocationTrackingForSession =
cloudFlareOptions.geolocationTracking === undefined || cloudFlareOptions.geolocationTracking === true;

if (autoDetectIpEnabled || geolocationTrackingForSession) {
if (!cloudFlareOptions.cf) {
throw new Error(
"Cloudflare context is required for geolocation or IP detection features. Be sure to pass the `cf` option to the withCloudflare function."
);
}
}

let updatedAdvanced = { ...options.advanced };
if (autoDetectIpEnabled) {
updatedAdvanced.ipAddress = {
Expand Down
21 changes: 13 additions & 8 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export interface CloudflarePluginOptions {
* @default true
*/
geolocationTracking?: boolean;

/**
* Cloudflare geolocation context
*/
cf?: CloudflareGeolocation | Promise<CloudflareGeolocation | null> | null;
}

export interface WithCloudflareOptions extends CloudflarePluginOptions {
Expand Down Expand Up @@ -41,12 +46,12 @@ export interface WithCloudflareOptions extends CloudflarePluginOptions {
* Cloudflare geolocation data
*/
export interface CloudflareGeolocation {
timezone: string;
city: string;
country: string;
region: string;
regionCode: string;
colo: string;
latitude?: string;
longitude?: string;
timezone?: string | null;
city?: string | null;
country?: string | null;
region?: string | null;
regionCode?: string | null;
colo?: string | null;
latitude?: string | null;
longitude?: string | null;
}