Skip to content

fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes#763

Open
kamine81 wants to merge 13 commits into
emdash-cms:mainfrom
kamine81:fix/oauth-astro-v6-locals-runtime-env
Open

fix(auth): handle Astro v6 removal of locals.runtime.env in OAuth routes#763
kamine81 wants to merge 13 commits into
emdash-cms:mainfrom
kamine81:fix/oauth-astro-v6-locals-runtime-env

Conversation

@kamine81
Copy link
Copy Markdown
Contributor

@kamine81 kamine81 commented Apr 25, 2026

What does this PR do?

Fixes GitHub/Google OAuth login when using @astrojs/cloudflare v13+ (Astro v6).

The @astrojs/cloudflare adapter removed Astro.locals.runtime.env in Astro v6, replacing it with import { env } from "cloudflare:workers". The OAuth initiation ([provider].ts) and callback ([provider]/callback.ts) routes were still reading from locals.runtime.env, which now throws at runtime — causing every OAuth login attempt to silently redirect to a generic oauth_error page.

Resolution order after this fix:

  1. locals.runtime.env — Astro v5 + @astrojs/cloudflare (unchanged behaviour)
  2. cloudflare:workers — Astro v6 + @astrojs/cloudflare. New fallback via a dynamic import() whose module specifier is built at runtime (["cloudflare", "workers"].join(":")), so it is not statically analyzable.
  3. import.meta.env — Node.js / Vite dev server (unchanged fallback)

Why the specifier is computed: A literal import("cloudflare:workers") is statically resolved by Rollup at build time. On Node-adapter templates (templates/marketing, templates/blog, …) there is no plugin to externalize it, so the template build fails with "Rollup failed to resolve import cloudflare:workers". Computing the specifier at runtime keeps it out of static analysis:

  • On Cloudflare, @astrojs/cloudflare externalizes via a resolveId filter { id: /^cloudflare:/ } (a build-time regex on the specifier string). A computed specifier never hits that filter, so Rollup leaves the import() as a runtime expression and workerd resolves cloudflare:workers natively. (Verified: the marketing-cloudflare template build keeps the runtime import intact in dist/server/chunks/.)
  • On Node, the runtime import throws and is caught, falling through to import.meta.env.

Related issues: #464, #249

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

✓ tests/unit/auth/oauth-provider-route.test.ts > redirects to GitHub authorization URL when env vars are in import.meta.env
✓ tests/unit/auth/oauth-provider-route.test.ts > redirects to error page when provider is not configured
✓ tests/unit/auth/oauth-provider-route.test.ts > falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)
✓ tests/unit/auth/oauth-provider-route.test.ts > redirects to error page for an unknown provider
✓ tests/unit/auth/oauth-provider-route.test.ts > redirects to error page when db is not available
✓ tests/unit/auth/oauth-callback-route.test.ts > resolves env from import.meta.env and reaches OAuth state validation
✓ tests/unit/auth/oauth-callback-route.test.ts > redirects to provider_not_configured when env vars are absent
✓ tests/unit/auth/oauth-callback-route.test.ts > falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)
✓ tests/unit/auth/oauth-callback-route.test.ts > redirects to invalid_callback when code or state is missing

Test Files  2 passed (2)
Tests       9 passed (9)

@astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env,
causing OAuth login to fail silently with a generic error redirect.

Resolution order for env vars:
  1. locals.runtime.env  — Astro v5 + @astrojs/cloudflare
  2. cloudflare:workers  — Astro v6 + @astrojs/cloudflare
  3. import.meta.env     — Node.js / Vite dev server fallback

Adds unit tests for all three resolution paths and the error cases.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 25, 2026

🦋 Changeset detected

Latest commit: 680d02f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 25, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@763

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@763

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@763

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@763

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@763

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@763

emdash

npm i https://pkg.pr.new/emdash@763

create-emdash

npm i https://pkg.pr.new/create-emdash@763

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@763

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@763

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@763

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@763

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@763

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@763

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@763

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@763

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@763

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@763

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@763

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@763

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@763

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@763

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@763

commit: 680d02f

Rollup statically resolves string-literal dynamic imports at build time.
Using a variable (`const cfWorkersModId = "cloudflare:workers"`) defers
resolution to runtime, avoiding build failures in Node.js fixture builds.
@github-actions github-actions Bot added size/L and removed size/M labels Apr 25, 2026
@github-actions
Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added the stale label May 16, 2026
kamine81 added 3 commits May 30, 2026 06:42
Bring the branch up to date with upstream/main. The only conflicts were
in the OAuth routes ([provider].ts, [provider]/callback.ts); resolve them
by taking the upstream version so this commit is a clean main integration.
The Astro v6 locals.runtime.env fix is re-applied in the following commit.
@astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, so the
OAuth initiation and callback routes threw when reading env. Resolve env
through a fallback chain: locals.runtime.env (Astro v5) -> cloudflare:workers
(Astro v6) -> import.meta.env (Node). The cloudflare:workers import is a
literal dynamic import; the core build preserves it as a runtime-only
import, so no static-analysis workaround is needed.

Matches the production-validated patch in kamine81/my-emdash-site#100.
The callback route ([provider]/callback.ts) received the same
locals.runtime.env -> cloudflare:workers -> import.meta.env fallback as
the initiation route, but had no test. Add a unit test mirroring the
provider-route suite, including the regression case where the Astro v6
locals.runtime.env getter throws and must be caught (error=invalid_state,
not error=oauth_error).
@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet and removed needs-rebase labels May 29, 2026
…solve

The literal `import("cloudflare:workers")` in the OAuth routes broke
non-Cloudflare (Node adapter) template builds: Rollup statically
resolves the specifier at build time and fails because the module only
exists on workerd. Build the module id at runtime
(`["cloudflare", "workers"].join(":")` + @vite-ignore) so neither this
package's bundler nor the downstream Astro/Rollup template build
resolves it statically.

On Cloudflare the dynamic import is left as a runtime expression and
resolves natively on workerd (verified: the adapter externalizes via a
`/^cloudflare:/` resolveId filter, and the marketing-cloudflare build
keeps the runtime import intact). On Node it throws and is caught,
falling through to import.meta.env.
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label May 31, 2026
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

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

Approach judgment

This is the right fix for the right problem. @astrojs/cloudflare v13+ (Astro v6) removed locals.runtime.env, and the OAuth routes were the only place in core still relying on it. Using a runtime-computed module specifier for the cloudflare:workers dynamic import is a pragmatic way to keep Rollup from statically resolving it on Node builds, and the resolution order (v5 locals → v6 cloudflare:workers → Vite/Node import.meta.env) is sensible.

What I checked

  • Diff, both changed route files, and the new unit tests.
  • Grepped the tree for other locals.runtime.env usages — only these two OAuth routes are affected.
  • Checked the changeset, which is correctly scoped as a patch.
  • Verified no SQL, UI, i18n, or authorization concerns (no user-facing strings, no DB schema changes, no new queries against content tables).

Headline conclusion

The code is correct and the tests cover the Node-side fallback paths well. I have three non-blocking suggestions:

  1. Duplicated env-resolution block — the same ~23-line try/catch/import fallback is copy-pasted in both [provider].ts and [provider]/callback.ts (on top of the pre-existing duplication of getOAuthConfig and envString). A shared helper would prevent drift.
  2. Silent error swallowing — all three catch blocks in the resolution chain swallow errors with no logging. A warning log would help distinguish an expected fallback from a real runtime problem.
  3. No test for actual cloudflare:workers resolution — the Astro v6 test only works because import("cloudflare:workers") also throws in the Node test environment; it never asserts that the module resolves and its env export is used when actually present on Workers.

None of these block merge, but addressing (1) in particular would improve maintainability.

Comment on lines +96 to +120
// 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
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.

[suggestion] The exact same environment-resolution fallback is copy-pasted verbatim in [provider]/callback.ts. Together with the pre-existing duplication of getOAuthConfig and envString across both files, this means any future runtime change needs edits in two places. Consider extracting a shared helper (e.g. #auth/oauth-env.ts) so the resolution order lives in one place.

Suggested change
// 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
// Example extraction in a shared module:
export async function resolveOAuthEnv(
locals: App.Locals,
): Promise<Record<string, unknown>> {
try {
const runtimeLocals = locals as unknown as { runtime?: { env?: Record<string, unknown> } };
return runtimeLocals.runtime?.env ?? (import.meta.env as Record<string, unknown>);
} catch {
try {
const cfWorkersModId = ["cloudflare", "workers"].join(":");
const { env: cfEnv } = await import(/* @vite-ignore */ cfWorkersModId);
return cfEnv as Record<string, unknown>;
} catch {
return import.meta.env as Record<string, unknown>;
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in f80880f. Extracted the resolution chain into #auth/oauth-env.ts (resolveOAuthEnv(locals)), now called by both [provider].ts and [provider]/callback.ts, so the order lives in one place.

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 {
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.

[suggestion] The catch blocks in the env-resolution chain swallow every error silently. If locals.runtime.env throws for an unexpected reason, or if the cloudflare:workers import fails due to a real runtime issue rather than "not on Cloudflare", there is no diagnostic. Consider adding a console.warn so operators can tell the difference between an intentional fallback and a real problem:

Suggested change
} catch {
} catch (error) {
console.warn("[oauth] locals.runtime.env unavailable, trying cloudflare:workers", error);
try {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in f80880f. The shared helper now logs console.warn("[oauth] locals.runtime.env unavailable, trying cloudflare:workers", error) before falling through, so an intentional Astro v6 fallback is distinguishable from a real runtime failure.

});

it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => {
vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id");
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.

[suggestion] This test title says "Astro v6 Cloudflare", but it never actually exercises a successful cloudflare:workers resolution. In the Node test environment import("cloudflare:workers") throws, so the code falls through to import.meta.env. Consider mocking the module so the test also asserts that the env export from cloudflare:workers is used when available:

Suggested change
vi.stubEnv("EMDASH_OAUTH_GITHUB_CLIENT_ID", "test-client-id");
it("falls back gracefully when locals.runtime.env throws (Astro v6 Cloudflare)", async () => {
// In Node tests the dynamic import of cloudflare:workers throws,
// so we verify the triple-fallback chain: locals.runtime.env →
// cloudflare:workers (throws) → import.meta.env. A successful
// cloudflare:workers path can be verified in integration tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in f80880f. Added a comment clarifying that in Node the cloudflare:workers import throws, so this test exercises the triple-fallback chain (locals.runtime.env → cloudflare:workers → import.meta.env); the successful cloudflare:workers path is left to integration tests.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels May 31, 2026
Address PR review suggestions on emdash-cms#763:
- Extract the duplicated env-resolution fallback chain (locals.runtime.env
  -> cloudflare:workers -> import.meta.env) into #auth/oauth-env.js so the
  resolution order lives in one place and the OAuth start/callback routes
  cannot drift.
- Log a console.warn when locals.runtime.env is unavailable so operators can
  distinguish an intentional Astro v6 fallback from a real runtime problem.
- Clarify the Astro v6 fallback test with a comment explaining it exercises
  the triple-fallback chain (cloudflare:workers throws in Node).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels May 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants