Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lovely-ads-sleep.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 29 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 @@ -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<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>);
// 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<string, unknown>;
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<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
env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>);
} 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<string, unknown>;
} 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<string, unknown>;
}
}
const providers = getOAuthConfig(env);

if (!providers[provider]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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>);
// 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<string, unknown>;
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<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
env = runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>);
} 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<string, unknown>;
} 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<string, unknown>;
}
}
const providers = getOAuthConfig(env);

if (!providers[provider]) {
Expand Down
136 changes: 136 additions & 0 deletions packages/core/tests/unit/auth/oauth-callback-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>, 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<Database>;

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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[0]);

expect(response.status).toBe(302);
expect(captured.url).toContain("error=invalid_callback");
});
});
134 changes: 134 additions & 0 deletions packages/core/tests/unit/auth/oauth-provider-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>, runtime?: unknown) {
return { emdash: { db, config: {} }, ...(runtime !== undefined ? { runtime } : {}) };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("OAuth provider route — environment resolution", () => {
let db: Kysely<Database>;

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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[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<typeof GET>[0]["locals"],
redirect: makeRedirect(captured),
} as Parameters<typeof GET>[0]);

expect(response.status).toBe(302);
expect(captured.url).toContain("error=server_error");
});
});
Loading