diff --git a/.changeset/lovely-ads-sleep.md b/.changeset/lovely-ads-sleep.md new file mode 100644 index 000000000..5bd524975 --- /dev/null +++ b/.changeset/lovely-ads-sleep.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes GitHub/Google OAuth login when using `@astrojs/cloudflare` v13+ (Astro v6). The `@astrojs/cloudflare` adapter removed `locals.runtime.env` in Astro v6; the OAuth initiation and callback routes now fall back to `cloudflare:workers` env when `locals.runtime.env` throws, then to `import.meta.env` for Node.js environments. diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts index a9706888f..681249471 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -93,12 +93,35 @@ 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 } }; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // 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 } }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // The module id is held in a variable so Rollup cannot statically resolve it: in the + // Node template builds the specifier does not exist, and a literal import would fail + // the build. It resolves at runtime only on Cloudflare Workers. + try { + // Built at runtime (not a string literal) so neither this package's bundler nor + // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". + const cfWorkersModId = ["cloudflare", "workers"].join(":"); + const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript/no-unsafe-type-assertion + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index e49c2acdc..860040f11 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -115,11 +115,35 @@ 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 } }; - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig - const env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + // Get OAuth providers from environment. + // Resolution order: + // 1. locals.runtime.env — Astro v5 + @astrojs/cloudflare + // 2. cloudflare:workers — Astro v6 + @astrojs/cloudflare (locals.runtime.env was removed) + // 3. import.meta.env — Node.js / Vite dev server fallback + let env: Record; + try { + // 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 } }; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is typed as ImportMetaEnv but we need Record for getOAuthConfig + env = runtimeLocals.runtime?.env ?? (import.meta.env as Record); + } catch { + // Astro v6: locals.runtime.env accessor throws — import from cloudflare:workers instead. + // The module id is held in a variable so Rollup cannot statically resolve it: in the + // Node template builds the specifier does not exist, and a literal import would fail + // the build. It resolves at runtime only on Cloudflare Workers. + try { + // Built at runtime (not a string literal) so neither this package's bundler nor + // the downstream Astro/Rollup template build statically resolves "cloudflare:workers". + const cfWorkersModId = ["cloudflare", "workers"].join(":"); + const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- cloudflare:workers env is typed as Cloudflare.Env; cast to generic record for getOAuthConfig + env = cfEnv as Record; + } catch { + // Not running on Cloudflare Workers — fall back to Vite's import.meta.env + // eslint-disable-next-line typescript/no-unsafe-type-assertion + env = import.meta.env as Record; + } + } const providers = getOAuthConfig(env); if (!providers[provider]) { diff --git a/packages/core/tests/unit/auth/oauth-callback-route.test.ts b/packages/core/tests/unit/auth/oauth-callback-route.test.ts new file mode 100644 index 000000000..0bec42f9d --- /dev/null +++ b/packages/core/tests/unit/auth/oauth-callback-route.test.ts @@ -0,0 +1,136 @@ +/** + * OAuth callback route unit tests + * + * Covers the GET /_emdash/api/auth/oauth/[provider]/callback handler, focusing on + * environment variable resolution across runtimes: + * - Astro v5 Cloudflare: locals.runtime.env + * - Astro v6 Cloudflare: cloudflare:workers (locals.runtime.env throws) + * - Node.js / Vite: import.meta.env + * + * The callback shares the exact env-resolution fallback added to the + * initiation route ([provider].ts); these tests guard against the Astro v6 + * regression where reading `locals.runtime.env` throws. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "../../../src/astro/routes/api/auth/oauth/[provider]/callback.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRedirect(captured: { url?: string }) { + return (url: string) => { + captured.url = url; + return new Response(null, { status: 302, headers: { Location: url } }); + }; +} + +function makeLocals(db: Kysely, runtime?: unknown) { + return { emdash: { db, config: {} }, ...(runtime !== undefined ? { runtime } : {}) }; +} + +/** Build a callback request with code + state so it gets past param validation. */ +function callbackRequest() { + return new Request( + "http://localhost:4321/_emdash/api/auth/oauth/github/callback?code=test-code&state=test-state", + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OAuth callback route — environment resolution", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + vi.unstubAllEnvs(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.unstubAllEnvs(); + }); + + it("resolves env from import.meta.env and reaches OAuth state validation", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // Provider is configured, so it reaches handleOAuthCallback, which rejects + // the unknown state — proving env resolved with the GitHub credentials. + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=provider_not_configured"); + expect(captured.url).toContain("error=invalid_state"); + }); + + it("redirects to provider_not_configured when env vars are absent", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=provider_not_configured"); + }); + + it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + // Simulate Astro v6: locals.runtime exists but .env getter throws + const runtimeWithThrowingEnv = { + get env(): never { + throw new Error( + "Astro.locals.runtime.env has been removed in Astro v6. " + + "Use 'import { env } from \"cloudflare:workers\"' instead.", + ); + }, + }; + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: callbackRequest(), + locals: makeLocals(db, runtimeWithThrowingEnv) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // The thrown getter must be caught by the env fallback, NOT bubble up to the + // outer catch (which would yield error=oauth_error). With env resolved, the + // provider is configured and the flow reaches OAuth state validation. + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=oauth_error"); + expect(captured.url).not.toContain("error=provider_not_configured"); + expect(captured.url).toContain("error=invalid_state"); + }); + + it("redirects to invalid_callback when code or state is missing", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github/callback"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=invalid_callback"); + }); +}); diff --git a/packages/core/tests/unit/auth/oauth-provider-route.test.ts b/packages/core/tests/unit/auth/oauth-provider-route.test.ts new file mode 100644 index 000000000..dad1b25cc --- /dev/null +++ b/packages/core/tests/unit/auth/oauth-provider-route.test.ts @@ -0,0 +1,134 @@ +/** + * OAuth provider route unit tests + * + * Covers the GET /_emdash/api/auth/oauth/[provider] handler, focusing on + * environment variable resolution across runtimes: + * - Astro v5 Cloudflare: locals.runtime.env + * - Astro v6 Cloudflare: cloudflare:workers (locals.runtime.env throws) + * - Node.js / Vite: import.meta.env + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "../../../src/astro/routes/api/auth/oauth/[provider].js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRedirect(captured: { url?: string }) { + return (url: string) => { + captured.url = url; + return new Response(null, { status: 302, headers: { Location: url } }); + }; +} + +function makeLocals(db: Kysely, runtime?: unknown) { + return { emdash: { db, config: {} }, ...(runtime !== undefined ? { runtime } : {}) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("OAuth provider route — environment resolution", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + vi.unstubAllEnvs(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.unstubAllEnvs(); + }); + + it("redirects to GitHub authorization URL when env vars are in import.meta.env", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("github.com/login/oauth/authorize"); + expect(captured.url).toContain("client_id=test-client-id"); + }); + + it("redirects to error page when provider is not configured", async () => { + // No env vars set + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=provider_not_configured"); + }); + + it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => { + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id"); + vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "test-client-secret"); + + // Simulate Astro v6: locals.runtime exists but .env getter throws + const runtimeWithThrowingEnv = { + get env(): never { + throw new Error( + "Astro.locals.runtime.env has been removed in Astro v6. " + + "Use 'import { env } from \"cloudflare:workers\"' instead.", + ); + }, + }; + + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: makeLocals(db, runtimeWithThrowingEnv) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + // Must redirect to GitHub OAuth, NOT to the oauth_error page + expect(response.status).toBe(302); + expect(captured.url).not.toContain("error=oauth_error"); + expect(captured.url).toContain("github.com/login/oauth/authorize"); + }); + + it("redirects to error page for an unknown provider", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "unknown" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/unknown"), + locals: makeLocals(db) as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=invalid_provider"); + }); + + it("redirects to error page when db is not available", async () => { + const captured: { url?: string } = {}; + const response = await GET({ + params: { provider: "github" }, + request: new Request("http://localhost:4321/_emdash/api/auth/oauth/github"), + locals: { emdash: null } as unknown as Parameters[0]["locals"], + redirect: makeRedirect(captured), + } as Parameters[0]); + + expect(response.status).toBe(302); + expect(captured.url).toContain("error=server_error"); + }); +});