diff --git a/.changeset/floppy-buckets-help.md b/.changeset/floppy-buckets-help.md new file mode 100644 index 00000000000..df357898e2b --- /dev/null +++ b/.changeset/floppy-buckets-help.md @@ -0,0 +1,5 @@ +--- +"@clerk/astro": major +--- + +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`. 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. +``` 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/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/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/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); diff --git a/integration/tests/astro/compatibility.test.ts b/integration/tests/astro/compatibility.test.ts new file mode 100644 index 00000000000..9932f13de48 --- /dev/null +++ b/integration/tests/astro/compatibility.test.ts @@ -0,0 +1,36 @@ +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', '^10.1.4') + .addDependency('@astrojs/react', '^5.0.7') + .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.toHaveLength(0); + expect(app.buildOutput).not.toContain('Illegal return statement'); + }); +}); diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index b6112155a30..d3481a6a963 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(); @@ -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 a7796ae842c..8ad3d79606a 100644 --- a/integration/tests/astro/middleware.test.ts +++ b/integration/tests/astro/middleware.test.ts @@ -3,26 +3,52 @@ 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', () => { +// 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; @@ -50,96 +76,25 @@ test.describe('custom middleware @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 - // 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). - 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 — Astro's file-based 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('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('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. - 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 - const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).toBe(401); - }); - - 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 - 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 - 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 middleware', 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 - 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, - // 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); + } }); }); -test.describe('custom middleware @astro (production build)', () => { +test.describe('resource-based route protection @astro (production build)', () => { test.describe.configure({ mode: 'serial' }); let app: Application; @@ -168,87 +123,19 @@ test.describe('custom middleware @astro (production build)', () => { expect(res.status).toBe(401); }); - 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) - 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 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 - 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. - 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 - const res = await fetch(app.serverUrl + '/api/admin%00/users'); - expect(res.status).toBe(401); - }); - - 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 - const res = await fetch(app.serverUrl + '/api/%zz/users'); - expect(res.status).toBe(400); + 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 + 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('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 - 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 middleware', 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 - 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, - // 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); + } }); }); diff --git a/packages/astro/package.json b/packages/astro/package.json index 0bba7c6c573..98ad1acb642 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" + "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); -};