From 393cc26709b5006f1ec352d9cb65142666bb8375 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 23 Jun 2026 10:18:45 -0700 Subject: [PATCH 01/12] feat(astro): Support Astro 7 --- .changeset/fresh-astro-menu-items.md | 5 ++ packages/astro/package.json | 2 +- .../UserButton/MenuItemRenderer.astro | 79 +++++++++---------- 3 files changed, 45 insertions(+), 41 deletions(-) create mode 100644 .changeset/fresh-astro-menu-items.md diff --git a/.changeset/fresh-astro-menu-items.md b/.changeset/fresh-astro-menu-items.md new file mode 100644 index 00000000000..2fa5b65bc33 --- /dev/null +++ b/.changeset/fresh-astro-menu-items.md @@ -0,0 +1,5 @@ +--- +'@clerk/astro': patch +--- + +Fixes custom UserButton menu items failing to compile in Astro 7. diff --git a/packages/astro/package.json b/packages/astro/package.json index 36d58b1fe7e..0b1a14be769 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -100,7 +100,7 @@ "astro": "^6.0.0" }, "peerDependencies": { - "astro": "^4.15.0 || ^5.0.0 || ^6.0.0" + "astro": "^4.15.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "engines": { "node": ">=20.9.0" diff --git a/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro b/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro index f86f5d1d679..50337a8e9dc 100644 --- a/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro +++ b/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro @@ -29,54 +29,53 @@ const isDevMode = import.meta.env.DEV; `Clerk: component can only accept and as its children. Any other provided component will be ignored.`, ); } - return; - } - - // Get the user button map from window that we set in the ``. - const userButtonComponentMap = window.__astro_clerk_component_props.get('user-button'); - - let userButton; - if (parent) { - userButton = document.querySelector(`[data-clerk-id="clerk-user-button-${parent}"]`); } else { - userButton = document.querySelector('[data-clerk-id^="clerk-user-button"]'); - } + // Get the user button map from window that we set in the ``. + const userButtonComponentMap = window.__astro_clerk_component_props.get('user-button'); - const safeId = userButton.getAttribute('data-clerk-id'); - const currentOptions = userButtonComponentMap.get(safeId); + let userButton; + if (parent) { + userButton = document.querySelector(`[data-clerk-id="clerk-user-button-${parent}"]`); + } else { + userButton = document.querySelector('[data-clerk-id^="clerk-user-button"]'); + } - const reorderItemsLabels = ['manageAccount', 'signOut']; - const isReorderItem = reorderItemsLabels.includes(label); + const safeId = userButton.getAttribute('data-clerk-id'); + const currentOptions = userButtonComponentMap.get(safeId); - let newMenuItem = { - label, - }; + const reorderItemsLabels = ['manageAccount', 'signOut']; + const isReorderItem = reorderItemsLabels.includes(label); - if (!isReorderItem) { - newMenuItem = { - ...newMenuItem, - mountIcon: el => { - el.innerHTML = labelIcon; - }, - unmountIcon: () => { - /* What to clean up? */ - }, + let newMenuItem = { + label, }; - if (href) { - newMenuItem.href = href; - } else if (open) { - newMenuItem.open = open.startsWith('/') ? open : `/${open}`; - } else if (clickIdentifier) { - const clickEvent = new CustomEvent('clerk:menu-item-click', { detail: clickIdentifier }); - newMenuItem.onClick = () => { - document.dispatchEvent(clickEvent); + if (!isReorderItem) { + newMenuItem = { + ...newMenuItem, + mountIcon: el => { + el.innerHTML = labelIcon; + }, + unmountIcon: () => { + /* What to clean up? */ + }, }; + + if (href) { + newMenuItem.href = href; + } else if (open) { + newMenuItem.open = open.startsWith('/') ? open : `/${open}`; + } else if (clickIdentifier) { + const clickEvent = new CustomEvent('clerk:menu-item-click', { detail: clickIdentifier }); + newMenuItem.onClick = () => { + document.dispatchEvent(clickEvent); + }; + } } - } - userButtonComponentMap.set(safeId, { - ...currentOptions, - customMenuItems: [...(currentOptions?.customMenuItems ?? []), newMenuItem], - }); + userButtonComponentMap.set(safeId, { + ...currentOptions, + customMenuItems: [...(currentOptions?.customMenuItems ?? []), newMenuItem], + }); + } From 2037de9350974728a1982b23824b110f208ac1ee Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 23 Jun 2026 13:42:06 -0700 Subject: [PATCH 02/12] feat(astro): Support Astro 7 --- .../templates/astro-node/astro.config.mjs | 3 -- integration/templates/astro-node/package.json | 10 ++--- .../templates/astro-node/tailwind.config.cjs | 38 ------------------- integration/tests/astro/compatibility.test.ts | 35 +++++++++++++++++ 4 files changed, 39 insertions(+), 47 deletions(-) delete mode 100644 integration/templates/astro-node/tailwind.config.cjs create mode 100644 integration/tests/astro/compatibility.test.ts diff --git a/integration/templates/astro-node/astro.config.mjs b/integration/templates/astro-node/astro.config.mjs index 54bd79e7f1c..0cb685125e7 100644 --- a/integration/templates/astro-node/astro.config.mjs +++ b/integration/templates/astro-node/astro.config.mjs @@ -3,8 +3,6 @@ import node from '@astrojs/node'; import clerk from '@clerk/astro'; import react from '@astrojs/react'; -import tailwind from '@astrojs/tailwind'; - export default defineConfig({ output: 'server', adapter: node({ @@ -19,7 +17,6 @@ export default defineConfig({ }, }), react(), - tailwind(), ], server: { port: process.env.PORT ? Number(process.env.PORT) : undefined, diff --git a/integration/templates/astro-node/package.json b/integration/templates/astro-node/package.json index 9642a60ceac..c7406a2e6b7 100644 --- a/integration/templates/astro-node/package.json +++ b/integration/templates/astro-node/package.json @@ -10,16 +10,14 @@ "start": "astro dev --port $PORT" }, "dependencies": { - "@astrojs/check": "^0.9.4", - "@astrojs/node": "^9.0.0", - "@astrojs/react": "^4.0.0", - "@astrojs/tailwind": "^5.1.3", + "@astrojs/check": "^0.9.9", + "@astrojs/node": "^11.0.0", + "@astrojs/react": "^6.0.0", "@types/react": "18.3.7", "@types/react-dom": "18.3.0", - "astro": "^5.15.9", + "astro": "^7.0.2", "react": "18.3.1", "react-dom": "18.3.1", - "tailwindcss": "^3.4.17", "typescript": "^5.7.3" } } diff --git a/integration/templates/astro-node/tailwind.config.cjs b/integration/templates/astro-node/tailwind.config.cjs deleted file mode 100644 index d2be3156b2d..00000000000 --- a/integration/templates/astro-node/tailwind.config.cjs +++ /dev/null @@ -1,38 +0,0 @@ -const defaultTheme = require('tailwindcss/defaultTheme'); - -module.exports = { - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], - theme: { - extend: { - fontSize: { - '3xl': '1.953rem', - '4xl': '2.441rem', - '5xl': '3.052rem', - }, - - fontFamily: { - serif: ['Lora', ...defaultTheme.fontFamily.serif], - }, - - colors: { - newGray: { - 25: '#FAFAFB', - 50: '#F7F7F8', - 100: '#EEEEF0', - 150: '#E3E3E7', - 200: '#D9D9DE', - 300: '#B7B8C2', - 400: '#9394A1', - 500: '#747686', - 600: '#5E5F6E', - 700: '#42434D', - 750: '#373840', - 800: '#2F3037', - 850: '#27272D', - 900: '#212126', - 950: '#131316', - }, - }, - }, - }, -}; diff --git a/integration/tests/astro/compatibility.test.ts b/integration/tests/astro/compatibility.test.ts new file mode 100644 index 00000000000..4629739b8f3 --- /dev/null +++ b/integration/tests/astro/compatibility.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; + +test.describe('Astro version compatibility @astro', () => { + test.describe.configure({ mode: 'serial' }); + + let app: Application; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + app = await appConfigs.astro.node + .clone() + .setName('astro-node-v6-smoke') + .addDependency('astro', '^6.4.8') + .addDependency('@astrojs/node', '^9.5.5') + .addDependency('@astrojs/react', '^4.4.2') + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withCustomRoles); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('builds with Astro 6 and custom UserButton menu items', async () => { + await app.build(); + + expect(app.buildOutput).not.toContain('Illegal return statement'); + }); +}); From 9ee675c829550d287626bdebc723f30c1b8574aa Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 23 Jun 2026 13:43:58 -0700 Subject: [PATCH 03/12] chore: use Astro 6 compatible official integrations in smoke test --- integration/tests/astro/compatibility.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/astro/compatibility.test.ts b/integration/tests/astro/compatibility.test.ts index 4629739b8f3..3bb0e326595 100644 --- a/integration/tests/astro/compatibility.test.ts +++ b/integration/tests/astro/compatibility.test.ts @@ -15,8 +15,8 @@ test.describe('Astro version compatibility @astro', () => { .clone() .setName('astro-node-v6-smoke') .addDependency('astro', '^6.4.8') - .addDependency('@astrojs/node', '^9.5.5') - .addDependency('@astrojs/react', '^4.4.2') + .addDependency('@astrojs/node', '^10.1.4') + .addDependency('@astrojs/react', '^5.0.7') .commit(); await app.setup(); From fecc5e805e795e8b5ab62e8b811e8751ed3d116e Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 23 Jun 2026 13:44:19 -0700 Subject: [PATCH 04/12] chore: add minor changeset --- .changeset/floppy-buckets-help.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/floppy-buckets-help.md diff --git a/.changeset/floppy-buckets-help.md b/.changeset/floppy-buckets-help.md new file mode 100644 index 00000000000..f7c194870ab --- /dev/null +++ b/.changeset/floppy-buckets-help.md @@ -0,0 +1,5 @@ +--- +"@clerk/astro": minor +--- + +Add support for Astro 7. From 8d1a37677c70cbb18565fbd7927f13e246b7f323 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 23 Jun 2026 14:35:17 -0700 Subject: [PATCH 05/12] chore: address comments --- integration/tests/astro/compatibility.test.ts | 1 + integration/tests/astro/middleware.test.ts | 7 +- .../UserButton/MenuItemRenderer.astro | 64 ++++++++++--------- 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/integration/tests/astro/compatibility.test.ts b/integration/tests/astro/compatibility.test.ts index 3bb0e326595..9932f13de48 100644 --- a/integration/tests/astro/compatibility.test.ts +++ b/integration/tests/astro/compatibility.test.ts @@ -30,6 +30,7 @@ test.describe('Astro version compatibility @astro', () => { test('builds with Astro 6 and custom UserButton menu items', async () => { await app.build(); + expect(app.buildOutput).not.toHaveLength(0); expect(app.buildOutput).not.toContain('Illegal return statement'); }); }); diff --git a/integration/tests/astro/middleware.test.ts b/integration/tests/astro/middleware.test.ts index a7796ae842c..40454bc20c1 100644 --- a/integration/tests/astro/middleware.test.ts +++ b/integration/tests/astro/middleware.test.ts @@ -180,8 +180,11 @@ test.describe('custom middleware @astro (production build)', () => { }); test('double-encoded URLs do not match route (Astro router rejects)', async () => { - // %2561 decodes one layer to %61 — Astro's file-based router does not - // match %2561dmin to the admin/ directory, returning 404 + test.skip( + true, + 'Astro 7 production now routes this double-encoded path to the admin endpoint; createPathMatcher needs follow-up to align with Astro routing normalization.', + ); + const res = await fetch(app.serverUrl + '/api/%2561dmin/users'); expect(res.status).toBe(404); }); diff --git a/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro b/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro index 50337a8e9dc..47a732781e6 100644 --- a/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro +++ b/packages/astro/src/astro-components/interactive/UserButton/MenuItemRenderer.astro @@ -31,7 +31,7 @@ const isDevMode = import.meta.env.DEV; } } else { // Get the user button map from window that we set in the ``. - const userButtonComponentMap = window.__astro_clerk_component_props.get('user-button'); + const userButtonComponentMap = window.__astro_clerk_component_props?.get('user-button'); let userButton; if (parent) { @@ -40,42 +40,44 @@ const isDevMode = import.meta.env.DEV; userButton = document.querySelector('[data-clerk-id^="clerk-user-button"]'); } - const safeId = userButton.getAttribute('data-clerk-id'); - const currentOptions = userButtonComponentMap.get(safeId); + const safeId = userButton?.getAttribute('data-clerk-id'); + if (userButtonComponentMap && safeId) { + const currentOptions = userButtonComponentMap.get(safeId); - const reorderItemsLabels = ['manageAccount', 'signOut']; - const isReorderItem = reorderItemsLabels.includes(label); + const reorderItemsLabels = ['manageAccount', 'signOut']; + const isReorderItem = reorderItemsLabels.includes(label); - let newMenuItem = { - label, - }; - - if (!isReorderItem) { - newMenuItem = { - ...newMenuItem, - mountIcon: el => { - el.innerHTML = labelIcon; - }, - unmountIcon: () => { - /* What to clean up? */ - }, + let newMenuItem = { + label, }; - if (href) { - newMenuItem.href = href; - } else if (open) { - newMenuItem.open = open.startsWith('/') ? open : `/${open}`; - } else if (clickIdentifier) { - const clickEvent = new CustomEvent('clerk:menu-item-click', { detail: clickIdentifier }); - newMenuItem.onClick = () => { - document.dispatchEvent(clickEvent); + if (!isReorderItem) { + newMenuItem = { + ...newMenuItem, + mountIcon: el => { + el.innerHTML = labelIcon; + }, + unmountIcon: () => { + /* What to clean up? */ + }, }; + + if (href) { + newMenuItem.href = href; + } else if (open) { + newMenuItem.open = open.startsWith('/') ? open : `/${open}`; + } else if (clickIdentifier) { + const clickEvent = new CustomEvent('clerk:menu-item-click', { detail: clickIdentifier }); + newMenuItem.onClick = () => { + document.dispatchEvent(clickEvent); + }; + } } - } - userButtonComponentMap.set(safeId, { - ...currentOptions, - customMenuItems: [...(currentOptions?.customMenuItems ?? []), newMenuItem], - }); + userButtonComponentMap.set(safeId, { + ...currentOptions, + customMenuItems: [...(currentOptions?.customMenuItems ?? []), newMenuItem], + }); + } } From a72ad2364de8c130bc624dac2e9ca112baca4696 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 23 Jun 2026 14:48:53 -0700 Subject: [PATCH 06/12] fix machine tests --- integration/testUtils/machineAuthHelpers.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/integration/testUtils/machineAuthHelpers.ts b/integration/testUtils/machineAuthHelpers.ts index ea541c2d0f2..b96d7252137 100644 --- a/integration/testUtils/machineAuthHelpers.ts +++ b/integration/testUtils/machineAuthHelpers.ts @@ -262,6 +262,7 @@ export const registerApiKeyAuthTests = (adapter: MachineAuthTestAdapter): void = test('should handle multiple token types', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); const url = new URL(adapter.apiKey.path, app.serverUrl).toString(); + const origin = new URL(app.serverUrl).origin; await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); @@ -271,14 +272,16 @@ export const registerApiKeyAuthTests = (adapter: MachineAuthTestAdapter): void = const getRes = await u.page.request.get(url); expect(getRes.status()).toBe(401); - const postWithSessionRes = await u.page.request.post(url); + const postWithSessionRes = await u.page.request.post(url, { + headers: { Origin: origin }, + }); const sessionData = await postWithSessionRes.json(); expect(postWithSessionRes.status()).toBe(200); expect(sessionData.userId).toBe(fakeBapiUser.id); expect(sessionData.tokenType).toBe(TokenType.SessionToken); const postWithApiKeyRes = await u.page.request.post(url, { - headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + headers: { Authorization: `Bearer ${fakeAPIKey.secret}`, Origin: origin }, }); const apiKeyData = await postWithApiKeyRes.json(); expect(postWithApiKeyRes.status()).toBe(200); From 56bc8499df7c9c78f29640af0059eb05e6a30931 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 11:07:53 -0700 Subject: [PATCH 07/12] chore: Remove createRouteMatcher --- .../templates/astro-node/src/middleware.ts | 30 +---- .../src/pages/api/protected/current-org.ts | 7 +- .../src/pages/api/protected/only-admin.ts | 6 + .../astro-node/src/pages/discover.astro | 6 + .../templates/astro-node/src/pages/user.astro | 6 + integration/tests/astro/components.test.ts | 2 +- integration/tests/astro/middleware.test.ts | 122 +++++++++--------- packages/astro/package.json | 2 +- packages/astro/src/server/index.ts | 1 - packages/astro/src/server/route-matcher.ts | 43 ------ 10 files changed, 84 insertions(+), 141 deletions(-) delete mode 100644 packages/astro/src/server/route-matcher.ts diff --git a/integration/templates/astro-node/src/middleware.ts b/integration/templates/astro-node/src/middleware.ts index 6984169db1b..b02a547ac44 100644 --- a/integration/templates/astro-node/src/middleware.ts +++ b/integration/templates/astro-node/src/middleware.ts @@ -1,35 +1,9 @@ -import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'; - -const unautorized = () => - new Response(JSON.stringify({ error: 'unathorized access' }), { - status: 401, - }); - -/** - * 3. Support handler - */ -const isProtectedPage = createRouteMatcher(['/user(.*)', '/discover(.*)']); - -const isProtectedApiRoute = createRouteMatcher(['/api/protected(.*)']); +import { clerkMiddleware } from '@clerk/astro/server'; export const onRequest = clerkMiddleware((auth, context, next) => { const requestURL = new URL(context.request.url); - if (['/sign-in', '/', '/sign-up'].includes(requestURL.pathname)) { - return next(); - } - - if (isProtectedApiRoute(context.request) && !auth().userId) { - return unautorized(); - } - - if (isProtectedPage(context.request) && !auth().userId) { - return auth().redirectToSignIn(); - } - if (!auth().orgId && requestURL.pathname !== '/discover' && requestURL.pathname === '/organization') { - if (!auth().userId) { - return next(); - } + if (requestURL.pathname === '/organization' && auth().userId && !auth().orgId) { const searchParams = new URLSearchParams({ redirectUrl: requestURL.href, }); diff --git a/integration/templates/astro-node/src/pages/api/protected/current-org.ts b/integration/templates/astro-node/src/pages/api/protected/current-org.ts index eede15db2b0..7e7fcaadc8f 100644 --- a/integration/templates/astro-node/src/pages/api/protected/current-org.ts +++ b/integration/templates/astro-node/src/pages/api/protected/current-org.ts @@ -1,14 +1,13 @@ import type { APIRoute } from 'astro'; import { clerkClient } from '@clerk/astro/server'; -const empty = () => new Response(null); - export const GET: APIRoute = async context => { const { locals } = context; const { userId, orgId } = locals.auth(); if (!userId) { - // We are handling this at the middleware level - return empty(); + return new Response(JSON.stringify({ error: 'unauthorized access' }), { + status: 401, + }); } if (!orgId) { diff --git a/integration/templates/astro-node/src/pages/api/protected/only-admin.ts b/integration/templates/astro-node/src/pages/api/protected/only-admin.ts index 6beacf4c214..bac76cfd842 100644 --- a/integration/templates/astro-node/src/pages/api/protected/only-admin.ts +++ b/integration/templates/astro-node/src/pages/api/protected/only-admin.ts @@ -5,6 +5,12 @@ export const GET: APIRoute = async context => { const { locals } = context; const { auth } = locals; + if (!auth().userId) { + return new Response(JSON.stringify({ error: 'unauthorized access' }), { + status: 401, + }); + } + if (auth().has({ role: 'org:admin' })) { return new Response( JSON.stringify( diff --git a/integration/templates/astro-node/src/pages/discover.astro b/integration/templates/astro-node/src/pages/discover.astro index 5eac49ec4fa..29a12fb49ad 100644 --- a/integration/templates/astro-node/src/pages/discover.astro +++ b/integration/templates/astro-node/src/pages/discover.astro @@ -1,6 +1,12 @@ --- import { OrganizationList } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; + +const { userId, redirectToSignIn } = Astro.locals.auth(); + +if (!userId) { + return redirectToSignIn(); +} --- diff --git a/integration/templates/astro-node/src/pages/user.astro b/integration/templates/astro-node/src/pages/user.astro index 1140854405f..7a6a1e515a5 100644 --- a/integration/templates/astro-node/src/pages/user.astro +++ b/integration/templates/astro-node/src/pages/user.astro @@ -2,6 +2,12 @@ import { UserProfile } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; import StreamUser from '../components/StreamUser.astro'; + +const { userId, redirectToSignIn } = Astro.locals.auth(); + +if (!userId) { + return redirectToSignIn(); +} --- diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index b6112155a30..2ddc0a0f2a4 100644 --- a/integration/tests/astro/components.test.ts +++ b/integration/tests/astro/components.test.ts @@ -416,7 +416,7 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f }); // ----- redirect - test('redirects to sign-in when unauthenticated (middleware)', async ({ page, context }) => { + test('redirects to sign-in when unauthenticated (user page)', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); await u.page.getByRole('link', { name: 'User', exact: true }).click(); diff --git a/integration/tests/astro/middleware.test.ts b/integration/tests/astro/middleware.test.ts index 40454bc20c1..daf9b3f1f45 100644 --- a/integration/tests/astro/middleware.test.ts +++ b/integration/tests/astro/middleware.test.ts @@ -3,26 +3,25 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -const middlewareFile = () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'; +const middlewareFile = () => `import { clerkMiddleware } from '@clerk/astro/server'; -const isProtectedRoute = createRouteMatcher(['/api/admin(.*)']); - -export const onRequest = clerkMiddleware((auth, context, next) => { - if (isProtectedRoute(context.request) && !auth().userId) { - return new Response(null, { status: 401, statusText: 'Unauthorized' }); - } - return next(); -}); +export const onRequest = clerkMiddleware(); `; const apiRouteFile = () => `import type { APIRoute } from 'astro'; -export const GET: APIRoute = () => { +export const GET: APIRoute = ({ locals }) => { + const { userId } = locals.auth(); + + if (!userId) { + return new Response(null, { status: 401, statusText: 'Unauthorized' }); + } + return Response.json({ status: 'ok' }); }; `; -test.describe('custom middleware @astro', () => { +test.describe('resource-based route protection @astro', () => { test.describe.configure({ mode: 'serial' }); let app: Application; @@ -52,10 +51,9 @@ test.describe('custom middleware @astro', () => { test('handle percent-encoded URL on protected routes', async () => { // %61 = 'a': /api/%61dmin/users decodes to /api/admin/users - // Note: Astro's dev server normalizes percent-encoded URLs before - // the middleware runs, so this test validates the full pipeline. - // The decodeURIComponent in createPathMatcher provides defense-in-depth - // for environments that don't normalize (e.g., raw Node.js, Edge). + // Astro's dev server normalizes percent-encoded URLs before routing, + // so the request reaches the admin endpoint where the auth check + // rejects it with 401 const encodedRes = await fetch(app.serverUrl + '/api/%61dmin/users'); expect(encodedRes.status).toBe(401); @@ -65,58 +63,57 @@ test.describe('custom middleware @astro', () => { }); test('double-encoded URLs do not match route (Astro router rejects)', async () => { - // %2561 decodes one layer to %61 — Astro's file-based router does not + // %2561 decodes one layer to %61, and Astro's dev router does not // match %2561dmin to the admin/ directory, returning 404 const res = await fetch(app.serverUrl + '/api/%2561dmin/users'); expect(res.status).toBe(404); }); test('encoded slash is not decoded into a path separator', async () => { - // %2F is a reserved delimiter — decodeURI preserves it, so the matcher - // sees /api%2Fadmin/users which does not match /api/admin(.*). - // The router also treats %2F as a literal segment char, not a separator. + // %2F is a reserved delimiter: the router treats it as a literal + // segment char, not a separator, so /api%2Fadmin/users matches no route const res = await fetch(app.serverUrl + '/api%2Fadmin/users'); expect(res.status).not.toBe(200); }); - test('null byte in path is caught by middleware as protected route', async () => { - // %00 decodes to a null char — /api/admin\0/users still matches - // /api/admin(.*) so our middleware correctly blocks it with 401 + test('null byte in path cannot access protected route', async () => { + // %00 decodes to a null char: /api/admin\0/users is not the admin/ + // directory; if a router ever resolved it to the admin endpoint anyway, + // the endpoint's own auth check would still return 401 const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).toBe(401); + expect(res.status).not.toBe(200); }); - test('malformed percent-encoding is rejected (Astro dev server rejects before middleware)', async () => { - // %zz is not valid percent-encoding — Astro's Vite dev server crashes - // on decodeURI() in the trailing-slash plugin before our middleware runs, - // returning 500 + test('malformed percent-encoding is rejected (Astro dev server rejects before routing)', async () => { + // %zz is not valid percent-encoding: Astro's Vite dev server crashes + // on decodeURI() in the trailing-slash plugin, returning 500 const res = await fetch(app.serverUrl + '/api/%zz/users'); expect(res.status).toBe(500); }); - test('encoded dot-current segment is caught by middleware', async () => { - // %2e = '.' — /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users - // Our middleware matches the resolved path as protected + test('encoded dot-current segment is caught by resource check', async () => { + // %2e = '.': /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users + // The request reaches the admin endpoint, whose auth check returns 401 const res = await fetch(app.serverUrl + '/api/%2e/admin/users'); expect(res.status).toBe(401); }); test('encoded dot-parent segment does not reach protected route', async () => { - // %2e%2e = '..' — /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users + // %2e%2e = '..': /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users // This doesn't match any route, returning 404 const res = await fetch(app.serverUrl + '/api/%2e%2e/admin/users'); expect(res.status).toBe(404); }); - test('encoded dot-parent traversal through fake segment is caught by middleware', async () => { + test('encoded dot-parent traversal through fake segment is caught by resource check', async () => { // /api/foo/%2e%2e/admin/users resolves to /api/foo/../admin/users → /api/admin/users - // Our middleware matches the resolved path as protected, returning 401 + // The request reaches the admin endpoint, whose auth check returns 401 const res = await fetch(app.serverUrl + '/api/foo/%2e%2e/admin/users'); expect(res.status).toBe(401); }); test('fully encoded dot segments with encoded slash are rejected', async () => { - // %2e%2f = './', %2e%2e%2f = '../' — when the slash is also encoded, + // %2e%2f = './', %2e%2e%2f = '../': when the slash is also encoded, // the entire sequence is treated as a single path segment by the router const dotSlashCurrent = await fetch(app.serverUrl + '/api%2f%2e%2fadmin/users'); expect(dotSlashCurrent.status).toBe(404); @@ -139,7 +136,7 @@ test.describe('custom middleware @astro', () => { }); }); -test.describe('custom middleware @astro (production build)', () => { +test.describe('resource-based route protection @astro (production build)', () => { test.describe.configure({ mode: 'serial' }); let app: Application; @@ -169,9 +166,8 @@ test.describe('custom middleware @astro (production build)', () => { }); test('handle percent-encoded URL on protected routes', async () => { - // Unlike the dev server (Vite), the production Node adapter does NOT - // normalize percent-encoded URLs — this test relies on our - // decodeURIComponent fix in createPathMatcher (verified to fail without it) + // The production router resolves percent-encoded paths to the admin + // endpoint, where the auth check rejects the unauthenticated request const encodedRes = await fetch(app.serverUrl + '/api/%61dmin/users'); expect(encodedRes.status).toBe(401); @@ -179,61 +175,61 @@ test.describe('custom middleware @astro (production build)', () => { expect(encodedRes2.status).toBe(401); }); - test('double-encoded URLs do not match route (Astro router rejects)', async () => { - test.skip( - true, - 'Astro 7 production now routes this double-encoded path to the admin endpoint; createPathMatcher needs follow-up to align with Astro routing normalization.', - ); - + test('double-encoded URLs cannot access protected route', async () => { + // Astro 7's production router resolves %2561dmin to the admin endpoint, + // which previously diverged from middleware path matching and could + // bypass it. With the auth check on the endpoint itself, routing + // normalization no longer matters: a request that reaches the endpoint + // unauthenticated gets 401, and one that doesn't gets 404 const res = await fetch(app.serverUrl + '/api/%2561dmin/users'); - expect(res.status).toBe(404); + expect(res.status).not.toBe(200); }); test('encoded slash is not decoded into a path separator', async () => { - // %2F is a reserved delimiter — decodeURI preserves it, so the matcher - // sees /api%2Fadmin/users which does not match /api/admin(.*). - // The router also treats %2F as a literal segment char, not a separator. + // %2F is a reserved delimiter: the router treats it as a literal + // segment char, not a separator, so /api%2Fadmin/users matches no route const res = await fetch(app.serverUrl + '/api%2Fadmin/users'); expect(res.status).not.toBe(200); }); - test('null byte in path is caught by middleware as protected route', async () => { - // %00 decodes to a null char — /api/admin\0/users still matches - // /api/admin(.*) so our middleware correctly blocks it with 401 + test('null byte in path cannot access protected route', async () => { + // %00 decodes to a null char: /api/admin\0/users is not the admin/ + // directory; if a router ever resolved it to the admin endpoint anyway, + // the endpoint's own auth check would still return 401 const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).toBe(401); + expect(res.status).not.toBe(200); }); - test('malformed percent-encoding returns 400 (clerkMiddleware catches MalformedURLError)', async () => { - // %zz is not valid percent-encoding — createPathMatcher throws - // MalformedURLError, which handleControlFlowErrors catches and returns 400 + test('malformed percent-encoding cannot access protected route', async () => { + // %zz is not valid percent-encoding: it cannot resolve to the admin + // endpoint, and even if it did, the auth check would return 401 const res = await fetch(app.serverUrl + '/api/%zz/users'); - expect(res.status).toBe(400); + expect(res.status).not.toBe(200); }); - test('encoded dot-current segment is caught by middleware', async () => { - // %2e = '.' — /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users - // Our middleware matches the resolved path as protected + test('encoded dot-current segment is caught by resource check', async () => { + // %2e = '.': /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users + // The request reaches the admin endpoint, whose auth check returns 401 const res = await fetch(app.serverUrl + '/api/%2e/admin/users'); expect(res.status).toBe(401); }); test('encoded dot-parent segment does not reach protected route', async () => { - // %2e%2e = '..' — /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users + // %2e%2e = '..': /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users // This doesn't match any route, returning 404 const res = await fetch(app.serverUrl + '/api/%2e%2e/admin/users'); expect(res.status).toBe(404); }); - test('encoded dot-parent traversal through fake segment is caught by middleware', async () => { + test('encoded dot-parent traversal through fake segment is caught by resource check', async () => { // /api/foo/%2e%2e/admin/users resolves to /api/foo/../admin/users → /api/admin/users - // Our middleware matches the resolved path as protected, returning 401 + // The request reaches the admin endpoint, whose auth check returns 401 const res = await fetch(app.serverUrl + '/api/foo/%2e%2e/admin/users'); expect(res.status).toBe(401); }); test('fully encoded dot segments with encoded slash are rejected', async () => { - // %2e%2f = './', %2e%2e%2f = '../' — when the slash is also encoded, + // %2e%2f = './', %2e%2e%2f = '../': when the slash is also encoded, // the entire sequence is treated as a single path segment by the router const dotSlashCurrent = await fetch(app.serverUrl + '/api%2f%2e%2fadmin/users'); expect(dotSlashCurrent.status).toBe(404); diff --git a/packages/astro/package.json b/packages/astro/package.json index 82e74183c3d..8868ceec5e2 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -100,7 +100,7 @@ "astro": "^6.4.8" }, "peerDependencies": { - "astro": "^4.15.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "astro": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "engines": { "node": ">=20.9.0" diff --git a/packages/astro/src/server/index.ts b/packages/astro/src/server/index.ts index bd4dc4b367c..0595595944f 100644 --- a/packages/astro/src/server/index.ts +++ b/packages/astro/src/server/index.ts @@ -34,7 +34,6 @@ export type { } from '@clerk/backend'; export { clerkMiddleware } from './clerk-middleware'; -export { createRouteMatcher } from './route-matcher'; export { clerkClient } from './clerk-client'; /** diff --git a/packages/astro/src/server/route-matcher.ts b/packages/astro/src/server/route-matcher.ts deleted file mode 100644 index 58116a8929e..00000000000 --- a/packages/astro/src/server/route-matcher.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { deprecated } from '@clerk/shared/deprecated'; -import { createPathMatcher, type PathMatcherParam } from '@clerk/shared/pathMatcher'; - -export type RouteMatcherParam = PathMatcherParam; - -/** - * Returns a function that accepts a `Request` object and returns whether the request matches the list of - * predefined routes that can be passed in as the first argument. - * - * You can use glob patterns to match multiple routes or a function to match against the request object. - * Path patterns and regular expressions are supported, for example: `['/foo', '/bar(.*)'] or `[/^\/foo\/.*$/]` - * For more information, see: https://clerk.com/docs - * - * @deprecated This function will be removed in the next major version. Use resource-based auth checks instead. - * Move auth checks into each Astro page, API route, or server-side handler that accesses protected data. - * Middleware-based auth checks rely on path matching, which can diverge from how Astro routes requests and - * leave protected resources reachable. - * - * Instead of protecting routes only from middleware, protect the resource itself: - * - * ```ts - * import type { APIRoute } from 'astro'; - * - * export const GET: APIRoute = ({ locals }) => { - * const { userId } = locals.auth(); - * - * if (!userId) { - * return new Response('Unauthorized', { status: 401 }); - * } - * - * return Response.json({ userId }); - * }; - * ``` - */ -export const createRouteMatcher = (routes: RouteMatcherParam) => { - deprecated( - 'createRouteMatcher', - 'Use resource-based auth checks instead. Move auth checks into each Astro page, API route, or server-side handler that accesses protected data. Middleware-based auth checks rely on path matching, which can diverge from how Astro routes requests and leave protected resources reachable.', - ); - - const matcher = createPathMatcher(routes); - return (req: Request) => matcher(new URL(req.url).pathname); -}; From fb781f77877d1edc9b131e4be65d9b86f49280b4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 11:37:56 -0700 Subject: [PATCH 08/12] chore: add changeset --- .../major-astro-remove-route-matcher.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .changeset/major-astro-remove-route-matcher.md diff --git a/.changeset/major-astro-remove-route-matcher.md b/.changeset/major-astro-remove-route-matcher.md new file mode 100644 index 00000000000..6c2727e58e3 --- /dev/null +++ b/.changeset/major-astro-remove-route-matcher.md @@ -0,0 +1,43 @@ +--- +'@clerk/astro': major +--- + +Remove `createRouteMatcher` from `@clerk/astro/server`. Use resource-based auth checks instead: move auth checks into each Astro page, API route, or server-side handler that accesses protected data. Middleware-based auth checks rely on path matching, which can diverge from how Astro routes requests and leave protected resources reachable. + +```ts +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = ({ locals }) => { + const { userId } = locals.auth(); + + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json({ userId }); +}; +``` + +If you want to hand this work to a coding agent, use this migration prompt: + +```md +Migrate my Astro project away from Clerk's removed `createRouteMatcher` API. + +1. Open `src/middleware.ts` (or `src/middleware.js`) and find every matcher created + with `createRouteMatcher` from `@clerk/astro/server`, along with the middleware + logic that uses it (returning 401s, calling `auth().redirectToSignIn()`, etc.). +2. For every route those matchers protected, move the auth check into the resource itself: + - In `.astro` pages, add this to the frontmatter: + const { userId, redirectToSignIn } = Astro.locals.auth(); + if (!userId) return redirectToSignIn(); + - In API routes and server handlers, add this at the top of the handler: + const { userId } = locals.auth(); + if (!userId) return new Response('Unauthorized', { status: 401 }); + - Keep any role or permission checks (`auth().has(...)`) with the resource as well. +3. Remove the `createRouteMatcher` import and calls from the middleware. Keep + `clerkMiddleware()` itself. Middleware logic unrelated to auth protection + (locale redirects, headers, etc.) may stay, using plain `URL`/pathname checks. +4. Make sure every page and endpoint previously covered by a matcher pattern + (including glob patterns like `/dashboard(.*)`) now has its own check, then + verify the project builds. +``` From afe16c35240578310776f1fc5a1ca5ec48f9be5d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 11:38:46 -0700 Subject: [PATCH 09/12] chore: delete unused changeset --- .changeset/fresh-astro-menu-items.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/fresh-astro-menu-items.md diff --git a/.changeset/fresh-astro-menu-items.md b/.changeset/fresh-astro-menu-items.md deleted file mode 100644 index 2fa5b65bc33..00000000000 --- a/.changeset/fresh-astro-menu-items.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@clerk/astro': patch ---- - -Fixes custom UserButton menu items failing to compile in Astro 7. From 15586a3916e015781ea55c24409178eae7873fc1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 11:41:33 -0700 Subject: [PATCH 10/12] chore: update changeset --- .changeset/floppy-buckets-help.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/floppy-buckets-help.md b/.changeset/floppy-buckets-help.md index f7c194870ab..a348c8298ea 100644 --- a/.changeset/floppy-buckets-help.md +++ b/.changeset/floppy-buckets-help.md @@ -1,5 +1,5 @@ --- -"@clerk/astro": minor +"@clerk/astro": major --- -Add support for Astro 7. +Add support for Astro 7 and drop support for Astro 4. The supported `astro` peer dependency range is now `^5.0.0 || ^6.0.0 || ^7.0.0`. If you are still on Astro 4, follow the [Astro upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v6/) to upgrade your project before updating `@clerk/astro`. From 1357f5c7d1f3fca3105caf905acd33baa73d5c05 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 20 Jul 2026 11:43:04 -0700 Subject: [PATCH 11/12] chore: update changeset --- .changeset/floppy-buckets-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/floppy-buckets-help.md b/.changeset/floppy-buckets-help.md index a348c8298ea..df357898e2b 100644 --- a/.changeset/floppy-buckets-help.md +++ b/.changeset/floppy-buckets-help.md @@ -2,4 +2,4 @@ "@clerk/astro": major --- -Add support for Astro 7 and drop support for Astro 4. The supported `astro` peer dependency range is now `^5.0.0 || ^6.0.0 || ^7.0.0`. If you are still on Astro 4, follow the [Astro upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v6/) to upgrade your project before updating `@clerk/astro`. +Add support for Astro 7 and drop support for Astro 4. If you are still on Astro 4, follow the [Astro upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v6/) to upgrade your project before updating `@clerk/astro`. From 2cb9fa4a41d7ad0bb26d89de84b7d5674bc40be3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 21 Jul 2026 10:47:50 -0700 Subject: [PATCH 12/12] test: slim middeware tests now that cRM is removed --- integration/tests/astro/components.test.ts | 7 + integration/tests/astro/middleware.test.ts | 214 +++++---------------- 2 files changed, 58 insertions(+), 163 deletions(-) diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index 2ddc0a0f2a4..d3481a6a963 100644 --- a/integration/tests/astro/components.test.ts +++ b/integration/tests/astro/components.test.ts @@ -432,6 +432,13 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f ); }); + test('redirects to sign-in when unauthenticated (discover page)', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/discover'); + await u.page.waitForURL(`${app.serverUrl}/sign-in?redirect_url=${encodeURIComponent(`${app.serverUrl}/discover`)}`); + await u.po.signIn.waitForMounted(); + }); + // ---- protect test('only admin', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); diff --git a/integration/tests/astro/middleware.test.ts b/integration/tests/astro/middleware.test.ts index daf9b3f1f45..8ad3d79606a 100644 --- a/integration/tests/astro/middleware.test.ts +++ b/integration/tests/astro/middleware.test.ts @@ -21,6 +21,33 @@ export const GET: APIRoute = ({ locals }) => { }; `; +// Paths using URL encoding tricks that historically diverged between +// middleware path matching and Astro's routing normalization. With the auth +// check on the endpoint itself, the exact router outcome (401 vs 404 vs 500) +// is Astro's business; what must always hold is that none of these serve the +// protected resource. +const trickPaths = [ + // double-encoded + '/api/%2561dmin/users', + // encoded slash is not a path separator + '/api%2Fadmin/users', + // null byte + '/api/admin%00/users', + // malformed percent-encoding + '/api/%zz/users', + // encoded dot segments and traversal + '/api/%2e/admin/users', + '/api/%2e%2e/admin/users', + '/api/foo/%2e%2e/admin/users', + // fully encoded './' and '../' + '/api%2f%2e%2fadmin/users', + '/api%2f%2e%2e%2fadmin/users', + '/api/foo%2f%2e%2e%2fadmin/users', + // double slashes + '//api/admin/users', + '/api//admin/users', +]; + test.describe('resource-based route protection @astro', () => { test.describe.configure({ mode: 'serial' }); let app: Application; @@ -49,90 +76,21 @@ test.describe('resource-based route protection @astro', () => { expect(res.status).toBe(401); }); - test('handle percent-encoded URL on protected routes', async () => { - // %61 = 'a': /api/%61dmin/users decodes to /api/admin/users - // Astro's dev server normalizes percent-encoded URLs before routing, - // so the request reaches the admin endpoint where the auth check - // rejects it with 401 - const encodedRes = await fetch(app.serverUrl + '/api/%61dmin/users'); - expect(encodedRes.status).toBe(401); - - // %64 = 'd': /api/a%64min/users decodes to /api/admin/users - const encodedRes2 = await fetch(app.serverUrl + '/api/a%64min/users'); - expect(encodedRes2.status).toBe(401); - }); - - test('double-encoded URLs do not match route (Astro router rejects)', async () => { - // %2561 decodes one layer to %61, and Astro's dev router does not - // match %2561dmin to the admin/ directory, returning 404 - const res = await fetch(app.serverUrl + '/api/%2561dmin/users'); - expect(res.status).toBe(404); - }); - - test('encoded slash is not decoded into a path separator', async () => { - // %2F is a reserved delimiter: the router treats it as a literal - // segment char, not a separator, so /api%2Fadmin/users matches no route - const res = await fetch(app.serverUrl + '/api%2Fadmin/users'); - expect(res.status).not.toBe(200); - }); - - test('null byte in path cannot access protected route', async () => { - // %00 decodes to a null char: /api/admin\0/users is not the admin/ - // directory; if a router ever resolved it to the admin endpoint anyway, - // the endpoint's own auth check would still return 401 - const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).not.toBe(200); - }); - - test('malformed percent-encoding is rejected (Astro dev server rejects before routing)', async () => { - // %zz is not valid percent-encoding: Astro's Vite dev server crashes - // on decodeURI() in the trailing-slash plugin, returning 500 - const res = await fetch(app.serverUrl + '/api/%zz/users'); - expect(res.status).toBe(500); - }); - - test('encoded dot-current segment is caught by resource check', async () => { - // %2e = '.': /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users - // The request reaches the admin endpoint, whose auth check returns 401 - const res = await fetch(app.serverUrl + '/api/%2e/admin/users'); - expect(res.status).toBe(401); - }); - - test('encoded dot-parent segment does not reach protected route', async () => { - // %2e%2e = '..': /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users - // This doesn't match any route, returning 404 - const res = await fetch(app.serverUrl + '/api/%2e%2e/admin/users'); - expect(res.status).toBe(404); - }); - - test('encoded dot-parent traversal through fake segment is caught by resource check', async () => { - // /api/foo/%2e%2e/admin/users resolves to /api/foo/../admin/users → /api/admin/users - // The request reaches the admin endpoint, whose auth check returns 401 - const res = await fetch(app.serverUrl + '/api/foo/%2e%2e/admin/users'); - expect(res.status).toBe(401); + test('percent-encoded paths that resolve to the protected route return 401', async () => { + // %61 = 'a', %64 = 'd': the dev server normalizes these to + // /api/admin/users, so the request reaches the admin endpoint where the + // auth check rejects it + for (const path of ['/api/%61dmin/users', '/api/a%64min/users']) { + const res = await fetch(app.serverUrl + path); + expect(res.status, `expected 401 for ${path}`).toBe(401); + } }); - test('fully encoded dot segments with encoded slash are rejected', async () => { - // %2e%2f = './', %2e%2e%2f = '../': when the slash is also encoded, - // the entire sequence is treated as a single path segment by the router - const dotSlashCurrent = await fetch(app.serverUrl + '/api%2f%2e%2fadmin/users'); - expect(dotSlashCurrent.status).toBe(404); - - const dotSlashParent = await fetch(app.serverUrl + '/api%2f%2e%2e%2fadmin/users'); - expect(dotSlashParent.status).toBe(404); - - const dotSlashTraversal = await fetch(app.serverUrl + '/api/foo%2f%2e%2e%2fadmin/users'); - expect(dotSlashTraversal.status).toBe(404); - }); - - test('double slashes cannot bypass protected route', async () => { - // Double slashes before the protected segment - const res1 = await fetch(app.serverUrl + '//api/admin/users'); - expect(res1.status).not.toBe(200); - - // Double slashes in the middle of the path - const res2 = await fetch(app.serverUrl + '/api//admin/users'); - expect(res2.status).not.toBe(200); + test('no URL encoding trick can access the protected route', async () => { + for (const path of trickPaths) { + const res = await fetch(app.serverUrl + path); + expect(res.status, `expected non-200 for ${path}`).not.toBe(200); + } }); }); @@ -165,89 +123,19 @@ test.describe('resource-based route protection @astro (production build)', () => expect(res.status).toBe(401); }); - test('handle percent-encoded URL on protected routes', async () => { - // The production router resolves percent-encoded paths to the admin + test('percent-encoded paths that resolve to the protected route return 401', async () => { + // The production router resolves these encoded paths to the admin // endpoint, where the auth check rejects the unauthenticated request - const encodedRes = await fetch(app.serverUrl + '/api/%61dmin/users'); - expect(encodedRes.status).toBe(401); - - const encodedRes2 = await fetch(app.serverUrl + '/api/a%64min/users'); - expect(encodedRes2.status).toBe(401); - }); - - test('double-encoded URLs cannot access protected route', async () => { - // Astro 7's production router resolves %2561dmin to the admin endpoint, - // which previously diverged from middleware path matching and could - // bypass it. With the auth check on the endpoint itself, routing - // normalization no longer matters: a request that reaches the endpoint - // unauthenticated gets 401, and one that doesn't gets 404 - const res = await fetch(app.serverUrl + '/api/%2561dmin/users'); - expect(res.status).not.toBe(200); - }); - - test('encoded slash is not decoded into a path separator', async () => { - // %2F is a reserved delimiter: the router treats it as a literal - // segment char, not a separator, so /api%2Fadmin/users matches no route - const res = await fetch(app.serverUrl + '/api%2Fadmin/users'); - expect(res.status).not.toBe(200); - }); - - test('null byte in path cannot access protected route', async () => { - // %00 decodes to a null char: /api/admin\0/users is not the admin/ - // directory; if a router ever resolved it to the admin endpoint anyway, - // the endpoint's own auth check would still return 401 - const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).not.toBe(200); - }); - - test('malformed percent-encoding cannot access protected route', async () => { - // %zz is not valid percent-encoding: it cannot resolve to the admin - // endpoint, and even if it did, the auth check would return 401 - const res = await fetch(app.serverUrl + '/api/%zz/users'); - expect(res.status).not.toBe(200); - }); - - test('encoded dot-current segment is caught by resource check', async () => { - // %2e = '.': /api/%2e/admin/users resolves to /api/./admin/users → /api/admin/users - // The request reaches the admin endpoint, whose auth check returns 401 - const res = await fetch(app.serverUrl + '/api/%2e/admin/users'); - expect(res.status).toBe(401); - }); - - test('encoded dot-parent segment does not reach protected route', async () => { - // %2e%2e = '..': /api/%2e%2e/admin/users resolves to /api/../admin/users → /admin/users - // This doesn't match any route, returning 404 - const res = await fetch(app.serverUrl + '/api/%2e%2e/admin/users'); - expect(res.status).toBe(404); - }); - - test('encoded dot-parent traversal through fake segment is caught by resource check', async () => { - // /api/foo/%2e%2e/admin/users resolves to /api/foo/../admin/users → /api/admin/users - // The request reaches the admin endpoint, whose auth check returns 401 - const res = await fetch(app.serverUrl + '/api/foo/%2e%2e/admin/users'); - expect(res.status).toBe(401); + for (const path of ['/api/%61dmin/users', '/api/a%64min/users']) { + const res = await fetch(app.serverUrl + path); + expect(res.status, `expected 401 for ${path}`).toBe(401); + } }); - test('fully encoded dot segments with encoded slash are rejected', async () => { - // %2e%2f = './', %2e%2e%2f = '../': when the slash is also encoded, - // the entire sequence is treated as a single path segment by the router - const dotSlashCurrent = await fetch(app.serverUrl + '/api%2f%2e%2fadmin/users'); - expect(dotSlashCurrent.status).toBe(404); - - const dotSlashParent = await fetch(app.serverUrl + '/api%2f%2e%2e%2fadmin/users'); - expect(dotSlashParent.status).toBe(404); - - const dotSlashTraversal = await fetch(app.serverUrl + '/api/foo%2f%2e%2e%2fadmin/users'); - expect(dotSlashTraversal.status).toBe(404); - }); - - test('double slashes cannot bypass protected route', async () => { - // Double slashes before the protected segment - const res1 = await fetch(app.serverUrl + '//api/admin/users'); - expect(res1.status).not.toBe(200); - - // Double slashes in the middle of the path - const res2 = await fetch(app.serverUrl + '/api//admin/users'); - expect(res2.status).not.toBe(200); + test('no URL encoding trick can access the protected route', async () => { + for (const path of trickPaths) { + const res = await fetch(app.serverUrl + path); + expect(res.status, `expected non-200 for ${path}`).not.toBe(200); + } }); });