Skip to content
5 changes: 5 additions & 0 deletions .changeset/bright-socks-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes GitHub and Google OAuth login on Cloudflare Workers deployments using Astro v6.
8 changes: 2 additions & 6 deletions packages/core/src/astro/routes/api/auth/oauth/[provider].ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const prerender = false;
import { createAuthorizationUrl, type OAuthConsumerConfig } from "@emdash-cms/auth";

import { getPublicOrigin } from "#api/public-url.js";
import { getOAuthEnv } from "#auth/oauth-env.js";
import { createOAuthStateStore } from "#auth/oauth-state-store.js";

type ProviderName = "github" | "google";
Expand Down Expand Up @@ -93,12 +94,7 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
try {
const url = new URL(request.url);

// Get OAuth providers from environment
// Access via locals.runtime for Cloudflare, or import.meta.env for Node
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional
const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } };
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record<string, unknown> for getOAuthConfig
const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>);
const env = await getOAuthEnv();
const providers = getOAuthConfig(env);

if (!providers[provider]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";

import { getPublicOrigin } from "#api/public-url.js";
import { finalizeSetup } from "#api/setup-complete.js";
import { getOAuthEnv } from "#auth/oauth-env.js";
import { createOAuthStateStore } from "#auth/oauth-state-store.js";
import { OptionsRepository } from "#db/repositories/options.js";

Expand Down Expand Up @@ -115,11 +116,7 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
}

try {
// Get OAuth providers from environment
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- locals.runtime is injected by the Cloudflare adapter at runtime; not declared on App.Locals since the adapter is optional
const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } };
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record<string, unknown> for getOAuthConfig
const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>);
const env = await getOAuthEnv();
const providers = getOAuthConfig(env);

if (!providers[provider]) {
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/auth/oauth-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This file adds a new helper used by OAuth routes, but the PR includes no test for it. AGENTS.md requires TDD for bugs: "Failing test -> fix -> verify. A bug without a reproducing test is not fixed." Add at least a minimal unit test that verifies getOAuthEnv falls back to import.meta.env when the cloudflare:workers dynamic import throws. Vitest supports module-level mocking via vi.mock or temporal import interception for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The PR changes a published package (packages/core) but does not include a changeset. Per AGENTS.md: "A changeset is release notes a user reads while upgrading... lead with a present-tense verb (Fixes, Adds, Updates, Removes), describe the observable effect, and leave out internal mechanics." Add a changeset describing the fix for Cloudflare Workers OAuth login.

* Resolve runtime environment for OAuth providers.
*
* Astro v6 removed `locals.runtime.env`. On Cloudflare Workers, bindings are
* now read from `cloudflare:workers`. On Node-based adapters that module does
* not exist, so fall back to `import.meta.env`.
*/

type OAuthEnvLoader = () => Promise<Record<string, unknown>>;

function hasEnv(value: unknown): value is { env: Record<string, unknown> } {
if (typeof value !== "object" || value === null || !("env" in value)) return false;
return typeof value.env === "object" && value.env !== null;
}

async function loadCloudflareOAuthEnv(): Promise<Record<string, unknown>> {
// Keep the Cloudflare virtual module out of Node-target bundles. Otherwise
// non-Cloudflare builds try to resolve it before the runtime fallback runs.
const moduleUrl = `data:text/javascript,${encodeURIComponent(
'export { env } from "cloudflare:workers";',
)}`;
const module = await import(/* @vite-ignore */ moduleUrl);
return hasEnv(module) ? module.env : {};
}

function isMissingCloudflareWorkersModule(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message;
return (
message.includes("cloudflare:workers") &&
(message.includes("Cannot find package") ||
message.includes("ERR_MODULE_NOT_FOUND") ||
message.includes("Failed to resolve") ||
message.includes("Could not resolve") ||
message.includes("No such module"))
);
}

export async function getOAuthEnv(
loadEnv: OAuthEnvLoader = loadCloudflareOAuthEnv,
): Promise<Record<string, unknown>> {
try {
return await loadEnv();
} catch (error) {
if (!isMissingCloudflareWorkersModule(error)) throw error;
return import.meta.env;
}
}
26 changes: 26 additions & 0 deletions packages/core/tests/unit/auth/oauth-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";

import { getOAuthEnv } from "../../../src/auth/oauth-env.js";

describe("getOAuthEnv", () => {
it("returns env from the provided loader", async () => {
const env = { EMDASH_OAUTH_GITHUB_CLIENT_ID: "abc" };
await expect(getOAuthEnv(async () => env)).resolves.toBe(env);
});

it("falls back to import.meta.env when cloudflare:workers is unavailable", async () => {
const env = await getOAuthEnv(async () => {
throw new Error("Cannot find package 'cloudflare:workers' imported from test");
});

expect(env).toBe(import.meta.env);
});

it("rethrows unexpected loader errors", async () => {
await expect(
getOAuthEnv(async () => {
throw new Error("Unexpected runtime failure");
}),
).rejects.toThrow("Unexpected runtime failure");
});
});
Loading