From 98f06310dc8e84dc615db8ece0325c5a691cf42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 29 May 2026 15:14:41 +0200 Subject: [PATCH 1/2] Add initial @clerk/eslint-plugin-next package and rule --- .changeset/eslint-plugin-next-initial.md | 5 + .github/labeler.yml | 4 + packages/eslint-plugin-next/README.md | 132 ++ packages/eslint-plugin-next/package.json | 75 + .../__snapshots__/plugin-shape.test.ts.snap | 9 + .../src/__tests__/file-info.test.ts | 53 + .../src/__tests__/match-folders.test.ts | 304 ++++ .../src/__tests__/plugin-shape.test.ts | 17 + .../__tests__/require-auth-protection.test.ts | 1269 +++++++++++++++++ packages/eslint-plugin-next/src/global.d.ts | 5 + packages/eslint-plugin-next/src/index.ts | 15 + .../eslint-plugin-next/src/lib/exports.ts | 158 ++ .../eslint-plugin-next/src/lib/file-info.ts | 81 ++ .../src/lib/match-folders.ts | 126 ++ .../src/lib/protection-checks.ts | 280 ++++ .../src/rules/require-auth-protection.ts | 214 +++ packages/eslint-plugin-next/tsconfig.json | 18 + packages/eslint-plugin-next/tsup.config.ts | 16 + packages/eslint-plugin-next/vitest.config.mts | 9 + packages/eslint-plugin-next/vitest.setup.mts | 1 + pnpm-lock.yaml | 118 +- 21 files changed, 2861 insertions(+), 48 deletions(-) create mode 100644 .changeset/eslint-plugin-next-initial.md create mode 100644 packages/eslint-plugin-next/README.md create mode 100644 packages/eslint-plugin-next/package.json create mode 100644 packages/eslint-plugin-next/src/__tests__/__snapshots__/plugin-shape.test.ts.snap create mode 100644 packages/eslint-plugin-next/src/__tests__/file-info.test.ts create mode 100644 packages/eslint-plugin-next/src/__tests__/match-folders.test.ts create mode 100644 packages/eslint-plugin-next/src/__tests__/plugin-shape.test.ts create mode 100644 packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts create mode 100644 packages/eslint-plugin-next/src/global.d.ts create mode 100644 packages/eslint-plugin-next/src/index.ts create mode 100644 packages/eslint-plugin-next/src/lib/exports.ts create mode 100644 packages/eslint-plugin-next/src/lib/file-info.ts create mode 100644 packages/eslint-plugin-next/src/lib/match-folders.ts create mode 100644 packages/eslint-plugin-next/src/lib/protection-checks.ts create mode 100644 packages/eslint-plugin-next/src/rules/require-auth-protection.ts create mode 100644 packages/eslint-plugin-next/tsconfig.json create mode 100644 packages/eslint-plugin-next/tsup.config.ts create mode 100644 packages/eslint-plugin-next/vitest.config.mts create mode 100644 packages/eslint-plugin-next/vitest.setup.mts diff --git a/.changeset/eslint-plugin-next-initial.md b/.changeset/eslint-plugin-next-initial.md new file mode 100644 index 00000000000..887944427d4 --- /dev/null +++ b/.changeset/eslint-plugin-next-initial.md @@ -0,0 +1,5 @@ +--- +'@clerk/eslint-plugin-next': minor +--- + +Add experimental ESLint plugin `@clerk/eslint-plugin-next`, with a single `require-auth-protection` rule for the Next.js App router. This rule helps enforce auth protections are present at the page/route/server function level. diff --git a/.github/labeler.yml b/.github/labeler.yml index 642ff9645ad..aeb5cbf74db 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -20,6 +20,10 @@ elements: - changed-files: - any-glob-to-any-file: packages/elements/** +eslint-plugin-next: + - changed-files: + - any-glob-to-any-file: packages/eslint-plugin-next/** + expo: - changed-files: - any-glob-to-any-file: packages/expo/** diff --git a/packages/eslint-plugin-next/README.md b/packages/eslint-plugin-next/README.md new file mode 100644 index 00000000000..c8ddd0b94b3 --- /dev/null +++ b/packages/eslint-plugin-next/README.md @@ -0,0 +1,132 @@ +

+ + + + + + +
+

+ +# @clerk/eslint-plugin-next + +
+ +[![Chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://clerk.com/discord) +[![Clerk documentation](https://img.shields.io/badge/documentation-clerk-green.svg)](https://clerk.com/docs?utm_source=github&utm_medium=clerk_eslint_plugin_next) +[![Follow on X](https://img.shields.io/twitter/follow/clerk?style=social)](https://x.com/intent/follow?screen_name=clerk) + +[Changelog](https://github.com/clerk/javascript/blob/main/packages/eslint-plugin-next/CHANGELOG.md) +· +[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml) +· +[Request a Feature](https://feedback.clerk.com/roadmap) +· +[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_eslint_plugin_next) + +
+ +## Overview + +> [!NOTE] +> This lint rule is experimental, but should already be working well. +> +> We encourage trying it out and getting in touch with us about your experience. + +ESLint rules to help with Clerk patterns in the Next.js App Router. + +Currently contains a single rule to help enforce protecting resources where they are used. Instead of relying on a proxy matcher, you declare which folders are protected and the `require-auth-protection` rule flags any `page`, `layout`, `template`, `default`, `route`, or Server Action under those folders that doesn't guard itself. + +The rule only detects protected or not, which corresponds to signed in/signed out. You are still responsible for making sure the checks are _correct_ and that the user has the correct permissions to access the resource. + +> The config **declares intent for tooling — it does not guard anything at runtime.** Protection only happens when each resource calls `await auth.protect()` (or an equivalent check). This rule verifies that it does. + +## Installation + +```sh +npm install --save-dev @clerk/eslint-plugin-next +``` + +Requires ESLint `>=9` (flat config). + +## Usage + +Register the plugin and declare your protected/public folder globs in `eslint.config.mjs`: + +```js +import clerkNext from '@clerk/eslint-plugin-next'; + +export default [ + { + plugins: { '@clerk/next': clerkNext }, + rules: { + '@clerk/next/require-auth-protection': [ + 'error', + { + protected: ['app/**'], + public: ['app/sign-in/**', 'app/sign-up/**'], + }, + ], + }, + }, +]; +``` + +## Options + +| Option | Type | Default | Description | +| ------------------- | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `protected` | `string[]` (required) | — | Folder globs whose resources must be guarded. | +| `public` | `string[]` | `[]` | Folder globs that are exempt. | +| `mixedScopeLayouts` | `'auto' \| string[]` | `'auto'` | Layouts/templates that intentionally wrap both protected and public descendants. `'auto'` allows them silently; a list requires each such folder to be acknowledged explicitly. | + +Globs use a minimal dialect — only `*` (single segment) and `**` (any depth). When a folder matches both `protected` and `public`, the most specific pattern wins, and `protected` wins ties. + +## What counts as protected + +The rule is satisfied when the relevant function guards itself at the top, either by calling `auth.protect()`: + +```ts +import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + await auth.protect(); + // ... +} +``` + +…or by an early-exit check derived from `auth()` that returns, throws, or calls `notFound()` / `redirect()`: + +```ts +import { auth } from '@clerk/nextjs/server'; + +export default async function Page() { + const { userId } = await auth(); + if (userId === null) notFound(); + // ... +} +``` + +Recognized checks include `!isAuthenticated`, `isAuthenticated === false`, `userId === null`, and `sessionId === null` (from `auth()` imported as `@clerk/nextjs/server`). Client components (`'use client'`) are skipped. + +General protection must happen at the top of the function, but additional narrowing auth checks can happen further down. + +## Support + +For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_eslint_plugin_next). + +## Contributing + +We're open to all community contributions! Please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md). + +## Security + +`@clerk/eslint-plugin-next` is a static analysis aid, not a runtime guard. It's provided to help you catch missing protections and it does error on the side of caution, but there are no guarantees there might not be edge cases it fails to detect. + +_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._ + +## License + +This project is licensed under the **MIT license**. + +See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/eslint-plugin-next/LICENSE) for more information. diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json new file mode 100644 index 00000000000..943cebf2884 --- /dev/null +++ b/packages/eslint-plugin-next/package.json @@ -0,0 +1,75 @@ +{ + "name": "@clerk/eslint-plugin-next", + "version": "0.0.0", + "description": "ESLint plugin for enforcing Clerk auth protection in Next.js App Router resources.", + "keywords": [ + "auth", + "authentication", + "eslint", + "eslintplugin", + "eslint-plugin", + "next", + "nextjs", + "clerk" + ], + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clerk/javascript.git", + "directory": "packages/eslint-plugin-next" + }, + "license": "MIT", + "author": "Clerk", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "clean": "rimraf ./dist", + "dev": "tsup --watch", + "format": "node ../../scripts/format-package.mjs", + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src", + "lint:attw": "attw --pack . --profile node16", + "lint:publint": "publint", + "test": "vitest run", + "test:watch": "vitest watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "eslint": "9.31.0", + "tsup": "catalog:repo", + "typescript": "catalog:repo", + "vitest": "3.2.4" + }, + "peerDependencies": { + "eslint": ">=9" + }, + "engines": { + "node": ">=20.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/eslint-plugin-next/src/__tests__/__snapshots__/plugin-shape.test.ts.snap b/packages/eslint-plugin-next/src/__tests__/__snapshots__/plugin-shape.test.ts.snap new file mode 100644 index 00000000000..2df0052c847 --- /dev/null +++ b/packages/eslint-plugin-next/src/__tests__/__snapshots__/plugin-shape.test.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`@clerk/eslint-plugin-next public shape > exposes a stable set of configs 1`] = `[]`; + +exports[`@clerk/eslint-plugin-next public shape > exposes a stable set of rules 1`] = ` +[ + "require-auth-protection", +] +`; diff --git a/packages/eslint-plugin-next/src/__tests__/file-info.test.ts b/packages/eslint-plugin-next/src/__tests__/file-info.test.ts new file mode 100644 index 00000000000..81c9908b90f --- /dev/null +++ b/packages/eslint-plugin-next/src/__tests__/file-info.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { getRelativeFolder } from '../lib/file-info.js'; + +describe('getRelativeFolder', () => { + it('roots at the `app` segment for a root-level App Router', () => { + expect(getRelativeFolder('/proj/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); + }); + + it('supports the `src/app` convention (the `src` segment is skipped)', () => { + expect(getRelativeFolder('/proj/src/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); + }); + + it('ignores a spurious `app` segment in the absolute prefix when `cwd` is provided', () => { + // Without cwd-relativization, the leading `/Users/app/...` would anchor the + // folder at the wrong `app`. Relativizing against the project root fixes it. + expect(getRelativeFolder('/Users/app/work/myproj/app/dashboard/page.tsx', '/Users/app/work/myproj')).toBe( + 'app/dashboard', + ); + }); + + it('roots at the shallowest `app` when an inner route folder is also named `app`', () => { + expect(getRelativeFolder('/proj/app/app/page.tsx', '/proj')).toBe('app/app'); + }); + + it('does not match segments that merely contain `app`', () => { + expect(getRelativeFolder('/proj/myapp/dashboard/page.tsx', '/proj')).toBe('myapp/dashboard'); + expect(getRelativeFolder('/proj/app-utils/foo.ts', '/proj')).toBe('app-utils'); + }); + + it('normalizes Windows-style separators', () => { + expect(getRelativeFolder('C:\\proj\\app\\dashboard\\page.tsx', 'C:\\proj')).toBe('app/dashboard'); + }); + + it('falls back to scanning the absolute path when the file is outside `cwd`', () => { + // Mirrors how RuleTester lints in-memory code: the filename is absolute and + // not under the real cwd, so the absolute path is scanned for `app`. + expect(getRelativeFolder('/elsewhere/app/dashboard/page.tsx', '/proj')).toBe('app/dashboard'); + }); + + it('returns the project-relative folder when there is no `app` segment but the file is under cwd', () => { + expect(getRelativeFolder('/proj/utils/foo.ts', '/proj')).toBe('utils'); + }); + + it('returns null when there is no `app` segment and the file is outside cwd', () => { + expect(getRelativeFolder('/elsewhere/utils/foo.ts', '/proj')).toBeNull(); + }); + + it('returns null for an empty filename', () => { + expect(getRelativeFolder(undefined, '/proj')).toBeNull(); + expect(getRelativeFolder('', '/proj')).toBeNull(); + }); +}); diff --git a/packages/eslint-plugin-next/src/__tests__/match-folders.test.ts b/packages/eslint-plugin-next/src/__tests__/match-folders.test.ts new file mode 100644 index 00000000000..8f4c29dc02d --- /dev/null +++ b/packages/eslint-plugin-next/src/__tests__/match-folders.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyFolder, hasDescendantsMatching, literalPrefix, matchPath, specificity } from '../lib/match-folders.js'; + +describe('matchPath', () => { + it('matches literal segments', () => { + expect(matchPath('app', 'app')).toBe(true); + expect(matchPath('app', 'foo')).toBe(false); + expect(matchPath('app/foo', 'app/foo')).toBe(true); + expect(matchPath('app/foo', 'app/bar')).toBe(false); + }); + + it('** matches zero or more whole segments', () => { + expect(matchPath('app/**', 'app')).toBe(true); + expect(matchPath('app/**', 'app/foo')).toBe(true); + expect(matchPath('app/**', 'app/foo/bar')).toBe(true); + expect(matchPath('app/**', 'other/foo')).toBe(false); + }); + + it('* matches a single segment, not separators', () => { + expect(matchPath('app/*', 'app/foo')).toBe(true); + expect(matchPath('app/*', 'app/foo/bar')).toBe(false); + expect(matchPath('app/*', 'app')).toBe(false); + }); + + it('treats parentheses in patterns literally (route groups)', () => { + expect(matchPath('app/(routes)/**', 'app/(routes)/foo')).toBe(true); + expect(matchPath('app/(routes)/**', 'app/foo')).toBe(false); + expect(matchPath('app/(routes)/(unauthenticated)/**', 'app/(routes)/(unauthenticated)/sign-in')).toBe(true); + }); + + it('treats brackets in patterns literally (dynamic segments)', () => { + expect(matchPath('app/[id]', 'app/[id]')).toBe(true); + expect(matchPath('app/[id]', 'app/foo')).toBe(false); + }); + + it('treats @-prefixed parallel slot folders as literal', () => { + expect(matchPath('app/(overview)/@production', 'app/(overview)/@production')).toBe(true); + }); +}); + +describe('matchPath with complex wildcard combinations', () => { + it('** at the start matches paths with any prefix', () => { + expect(matchPath('**/admin', 'admin')).toBe(true); + expect(matchPath('**/admin', 'app/admin')).toBe(true); + expect(matchPath('**/admin', 'app/foo/admin')).toBe(true); + expect(matchPath('**/admin', 'app/foo/bar/admin')).toBe(true); + expect(matchPath('**/admin', 'app/admin/users')).toBe(false); + expect(matchPath('**/admin', 'admin-route')).toBe(false); + }); + + it('** in the middle bridges arbitrary depth', () => { + expect(matchPath('app/**/admin', 'app/admin')).toBe(true); + expect(matchPath('app/**/admin', 'app/foo/admin')).toBe(true); + expect(matchPath('app/**/admin', 'app/foo/bar/admin')).toBe(true); + expect(matchPath('app/**/admin', 'app/admin/users')).toBe(false); + expect(matchPath('app/**/admin', 'other/admin')).toBe(false); + }); + + it('** at start AND end matches anything containing the literal segment', () => { + expect(matchPath('**/admin/**', 'admin')).toBe(true); + expect(matchPath('**/admin/**', 'admin/users')).toBe(true); + expect(matchPath('**/admin/**', 'app/admin/users')).toBe(true); + expect(matchPath('**/admin/**', 'a/b/admin/c/d')).toBe(true); + expect(matchPath('**/admin/**', 'app/users')).toBe(false); + }); + + it('multiple ** segments compose without backtracking issues', () => { + expect(matchPath('app/**/admin/**/details', 'app/admin/details')).toBe(true); + expect(matchPath('app/**/admin/**/details', 'app/foo/admin/bar/details')).toBe(true); + expect(matchPath('app/**/admin/**/details', 'app/foo/admin/bar/baz/details')).toBe(true); + expect(matchPath('app/**/admin/**/details', 'app/admin/details/extra')).toBe(false); + }); + + it('* matches mid-segment as a prefix or suffix wildcard', () => { + expect(matchPath('app/foo*', 'app/foo')).toBe(true); + expect(matchPath('app/foo*', 'app/foobar')).toBe(true); + expect(matchPath('app/foo*', 'app/foo/bar')).toBe(false); + expect(matchPath('app/*-route', 'app/admin-route')).toBe(true); + expect(matchPath('app/*-route', 'app/admin')).toBe(false); + expect(matchPath('app/[*]-route', 'app/[admin]-route')).toBe(true); + }); + + it('combines * and ** in the same pattern', () => { + expect(matchPath('app/*/admin/**', 'app/foo/admin/users')).toBe(true); + expect(matchPath('app/*/admin/**', 'app/admin/users')).toBe(false); + expect(matchPath('app/*/admin/**', 'app/foo/bar/admin/users')).toBe(false); + expect(matchPath('app/**/page-*', 'app/foo/page-home')).toBe(true); + expect(matchPath('app/**/page-*', 'app/page-home')).toBe(true); + }); + + it('** alone matches everything including the empty path', () => { + expect(matchPath('**', '')).toBe(true); + expect(matchPath('**', 'app')).toBe(true); + expect(matchPath('**', 'app/foo/bar')).toBe(true); + }); +}); + +describe('specificity', () => { + it('counts only literal (non-wildcard) segments', () => { + expect(specificity('app/**')).toBe(1); + expect(specificity('app/(routes)/**')).toBe(2); + expect(specificity('app/(routes)/(unauthenticated)/**')).toBe(3); + expect(specificity('app/*/foo')).toBe(2); + expect(specificity('**')).toBe(0); + expect(specificity('app/foo/bar')).toBe(3); + }); + + it('treats ** as zero specificity regardless of position', () => { + expect(specificity('**/admin')).toBe(1); + expect(specificity('app/**/admin')).toBe(2); + expect(specificity('app/**/admin/**')).toBe(2); + expect(specificity('**/admin/**/users')).toBe(2); + }); + + it('treats segments containing * as zero specificity', () => { + expect(specificity('app/foo*')).toBe(1); + expect(specificity('app/*-route')).toBe(1); + expect(specificity('app/foo*/bar')).toBe(2); + expect(specificity('*/admin')).toBe(1); + expect(specificity('app/*/*/foo')).toBe(2); + }); +}); + +describe('classifyFolder', () => { + it('returns unmatched when no list matches', () => { + expect(classifyFolder('other/foo', { protected: ['app/**'], public: [] })).toBe('unmatched'); + }); + + it('returns protected when only protect matches', () => { + expect(classifyFolder('app/foo', { protected: ['app/**'], public: [] })).toBe('protected'); + }); + + it('returns public when only public matches', () => { + expect(classifyFolder('app/foo', { protected: [], public: ['app/**'] })).toBe('public'); + }); + + it('most specific wins when both lists match (broad protect, narrow public)', () => { + expect( + classifyFolder('app/(routes)/(unauthenticated)/sign-in', { + protected: ['app/**'], + public: ['app/(routes)/(unauthenticated)/**'], + }), + ).toBe('public'); + }); + + it('most specific wins when both lists match (broad public, narrow protect)', () => { + expect( + classifyFolder('app/admin/users', { + protected: ['app/admin/**'], + public: ['app/**'], + }), + ).toBe('protected'); + }); + + it('protect wins on exact specificity tie (identical patterns)', () => { + expect( + classifyFolder('app/foo', { + protected: ['app/foo'], + public: ['app/foo'], + }), + ).toBe('protected'); + }); + + it('protect wins on exact specificity tie (different patterns, same literal count)', () => { + expect( + classifyFolder('app/foo', { + protected: ['app/**'], + public: ['app/**'], + }), + ).toBe('protected'); + }); + + it('handles ** in the middle of patterns when computing specificity', () => { + // Public has 2 literal segments (app + admin), protect has 1 (app). + // Public should win. + expect( + classifyFolder('app/foo/admin/users', { + protected: ['app/**'], + public: ['app/**/admin/**'], + }), + ).toBe('public'); + }); + + it('takes the highest-specificity match within each list', () => { + // Two protect patterns match; the most specific one (3) is what counts + // when comparing against public (2). + expect( + classifyFolder('app/admin/users/details', { + protected: ['app/**', 'app/admin/users/**'], + public: ['app/**/users/**'], + }), + ).toBe('protected'); + }); + + it('classifies correctly when only mid-pattern wildcards match', () => { + expect( + classifyFolder('app/marketing/sign-in', { + protected: ['app/**'], + public: ['app/**/sign-in'], + }), + ).toBe('public'); + }); + + it('** can match a folder identical to a literal-prefix pattern', () => { + // Both `app/admin` (specificity 2) and `app/**` (specificity 1) match + // `app/admin`. Most specific wins. + expect( + classifyFolder('app/admin', { + protected: ['app/admin'], + public: ['app/**'], + }), + ).toBe('protected'); + }); +}); + +describe('literalPrefix', () => { + it('returns segments up to the first wildcard', () => { + expect(literalPrefix('app/(routes)/(unauthenticated)/**')).toBe('app/(routes)/(unauthenticated)'); + expect(literalPrefix('app/admin/billing/**')).toBe('app/admin/billing'); + expect(literalPrefix('app/foo')).toBe('app/foo'); + expect(literalPrefix('app/*')).toBe('app'); + expect(literalPrefix('app/**/admin')).toBe('app'); + expect(literalPrefix('**')).toBe(''); + }); + + it('returns empty when the first segment is a wildcard', () => { + expect(literalPrefix('**/admin')).toBe(''); + expect(literalPrefix('*/admin')).toBe(''); + expect(literalPrefix('*')).toBe(''); + }); + + it('stops at mid-segment wildcards just like full-segment wildcards', () => { + expect(literalPrefix('app/foo*/bar')).toBe('app'); + expect(literalPrefix('app/*-route')).toBe('app'); + expect(literalPrefix('app/admin/page-*')).toBe('app/admin'); + }); + + it('preserves trailing literal segments after a literal run', () => { + // Pattern is fully literal — prefix is the whole thing. + expect(literalPrefix('app/admin/users/details')).toBe('app/admin/users/details'); + }); +}); + +describe('hasDescendantsMatching', () => { + it('returns true when a pattern lies strictly under the folder', () => { + expect(hasDescendantsMatching('app', ['app/(routes)/(unauthenticated)/**'])).toBe(true); + expect(hasDescendantsMatching('app/(routes)', ['app/(routes)/(unauthenticated)/**'])).toBe(true); + }); + + it('returns true when a pattern equals the folder', () => { + expect(hasDescendantsMatching('app/admin', ['app/admin'])).toBe(true); + }); + + it('returns false when no pattern lies under the folder', () => { + expect(hasDescendantsMatching('app/(routes)/(org-level)', ['app/(routes)/(unauthenticated)/**'])).toBe(false); + expect(hasDescendantsMatching('app/admin/users', ['app/feed/**'])).toBe(false); + }); + + it('ignores fully-wildcard patterns (empty literal prefix)', () => { + expect(hasDescendantsMatching('app/admin', ['**'])).toBe(false); + }); + + it('returns false on empty/missing pattern list', () => { + expect(hasDescendantsMatching('app', [])).toBe(false); + expect(hasDescendantsMatching('app', undefined)).toBe(false); + }); + + it('considers patterns whose entire literal prefix is "app/foo" descendants of "app"', () => { + // Pattern can match below `app`; folder is `app`. Returns true. + expect(hasDescendantsMatching('app', ['app/foo/bar/**'])).toBe(true); + // Pattern's prefix exactly matches the folder. Returns true. + expect(hasDescendantsMatching('app', ['app/**'])).toBe(true); + }); + + it('ignores patterns starting with a wildcard segment', () => { + // `**/foo` has empty literal prefix; `hasDescendantsMatching` ignores it + // because a fully-wildcard pattern technically matches everywhere and + // would make every folder "have descendants", which is not the question. + // This means a public pattern like `**/sign-in` would NOT be detected + // as creating a mixed-scope layout above it. Known limitation. + expect(hasDescendantsMatching('app/admin', ['**/sign-in'])).toBe(false); + expect(hasDescendantsMatching('app/admin', ['*/sign-in'])).toBe(false); + }); + + it('ignores patterns whose first wildcard appears at an ancestor of the folder', () => { + // Pattern `app/*/sign-in` could match `app/foo/sign-in`, which IS under + // `app/foo`. Ideally hasDescendantsMatching would return true, but the + // current implementation only inspects literal prefixes, so it returns + // false. Documented as a known limitation; in practice public lists are + // written with concrete prefixes (`app/(unauthenticated)/**`) rather + // than wildcard-in-the-middle, which sidesteps the issue. + expect(hasDescendantsMatching('app/foo', ['app/*/sign-in'])).toBe(false); + }); + + it('handles deeply nested folder paths against deeply nested patterns', () => { + expect(hasDescendantsMatching('app/(routes)', ['app/(routes)/(unauthenticated)/sign-in/[[...index]]/**'])).toBe( + true, + ); + expect( + hasDescendantsMatching('app/(routes)/(unauthenticated)/sign-in', ['app/(routes)/(unauthenticated)/**']), + ).toBe(false); // pattern's prefix is an ancestor of folder, not descendant + }); +}); diff --git a/packages/eslint-plugin-next/src/__tests__/plugin-shape.test.ts b/packages/eslint-plugin-next/src/__tests__/plugin-shape.test.ts new file mode 100644 index 00000000000..fc69fcfa127 --- /dev/null +++ b/packages/eslint-plugin-next/src/__tests__/plugin-shape.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import plugin from '../index.js'; + +describe('@clerk/eslint-plugin-next public shape', () => { + it('exposes a stable set of rules', () => { + expect(Object.keys(plugin.rules ?? {}).sort()).toMatchSnapshot(); + }); + + it('exposes a stable set of configs', () => { + expect(Object.keys(plugin.configs ?? {}).sort()).toMatchSnapshot(); + }); + + it('declares plugin meta', () => { + expect(plugin.meta?.name).toBe('@clerk/eslint-plugin-next'); + }); +}); diff --git a/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts b/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts new file mode 100644 index 00000000000..6edcd59b6bf --- /dev/null +++ b/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts @@ -0,0 +1,1269 @@ +import path from 'node:path'; + +import * as tsParser from '@typescript-eslint/parser'; +import type { Linter as LinterTypes } from 'eslint'; +import { Linter, RuleTester } from 'eslint'; +import { describe, expect, it } from 'vitest'; + +import rule, { type RuleOptions } from '../rules/require-auth-protection.js'; + +RuleTester.describe = describe; +RuleTester.it = it; + +// `RuleTester` lints in-memory code, so no fixture files need to exist on disk. +// The rule classifies a file by finding the `/app/` substring in its path +// (`getRelativeFolder` uses `indexOf('/app/')`), so filenames are anchored under +// a synthetic project root whose own path does not contain an `/app/` segment. +const projectRoot = '/clerk/apps/dashboard'; +const abs = (p: string) => path.posix.join(projectRoot, p); + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser as unknown as LinterTypes.Parser, + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, +}); + +const config = { + protected: ['app/**'], + public: ['app/(routes)/(unauthenticated)/**'], +}; + +ruleTester.run('require-auth-protection', rule, { + valid: [ + { + name: 'protected page with await auth.protect()', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + await auth.protect(); + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page with (await auth()).protect()', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + await (await auth()).protect(); + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page using arrow function default export', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async () => { + await auth.protect(); + return
Hello
; + }; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page destructuring auth.protect() return value', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { sessionId, orgId } = await auth.protect(); + return
{sessionId}-{orgId}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page assigning auth.protect() return value to a variable', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const result = await auth.protect(); + return
{result.userId}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page declaring function above and exporting identifier', + code: ` + import { auth } from '@clerk/nextjs/server'; + async function PageComponent() { + await auth.protect(); + return
; + } + export default PageComponent; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'protected page using const arrow assignment + identifier default export', + code: ` + import { auth } from '@clerk/nextjs/server'; + const PageComponent = async () => { + await auth.protect(); + return
; + }; + export default PageComponent; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: userId === null with redirect', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { redirect } from 'next/navigation'; + export default async function Page() { + const { userId } = await auth(); + if (userId === null) redirect('/sign-in'); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: userId === null in block, with notFound', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { notFound } from 'next/navigation'; + export default async function Page() { + const { userId } = await auth(); + if (userId === null) { + notFound(); + } + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: userId == null (loose equality) with return', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (userId == null) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: !isAuthenticated with return', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { isAuthenticated } = await auth(); + if (!isAuthenticated) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: isAuthenticated === false with throw', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { isAuthenticated } = await auth(); + if (isAuthenticated === false) throw new Error('unauth'); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: sessionId === null with redirect', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { redirect } from 'next/navigation'; + export default async function Page() { + const { sessionId } = await auth(); + if (sessionId === null) redirect('/sign-in'); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'manual check: multiple bindings, check is on one of them', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId, sessionId, isAuthenticated } = await auth(); + if (!isAuthenticated) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'leading directive is skipped (use cache before protect)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + 'use cache'; + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'leading TS type alias is skipped (compile-time only)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + type LocalParams = { id: string }; + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/[id]/page.tsx'), + options: [config], + }, + { + name: 'leading TS interface is skipped (compile-time only)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + interface Local { id: string } + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'leading directive + TS type alias before manual auth check', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + 'use strict'; + type LocalState = 'a' | 'b'; + const { userId } = await auth(); + if (userId === null) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'TS type alias between auth() destructure and guard is skipped', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + type LocalState = 'a' | 'b'; + if (userId === null) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: "'use client' page is skipped (protection happens on a server ancestor)", + code: ` + 'use client'; + import { useUser } from '@clerk/nextjs'; + export default function Page() { + const { user } = useUser(); + return
{user?.firstName}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: "'use client' layout is skipped", + code: ` + 'use client'; + export default function Layout({ children }) { + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/layout.tsx'), + options: [config], + }, + { + name: "'use client' template is skipped", + code: ` + 'use client'; + export default function Template({ children }) { + return
{children}
; + } + `, + filename: abs('app/(routes)/(org-level)/apps/template.tsx'), + options: [config], + }, + { + name: 'manual check: flipped binary expression (null === userId)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (null === userId) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + }, + { + name: 'public page in (unauthenticated) without protect call', + code: ` + export default function SignIn() { + return
Sign in
; + } + `, + filename: abs('app/(routes)/(unauthenticated)/sign-in/page.tsx'), + options: [config], + }, + { + name: 'public page with a protect call (over-protecting allowed)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function SignIn() { + await auth.protect(); + return
Sign in
; + } + `, + filename: abs('app/(routes)/(unauthenticated)/sign-in/page.tsx'), + options: [config], + }, + { + name: 'route handler with GET and POST, both protected', + code: ` + import { auth } from '@clerk/nextjs/server'; + export async function GET() { + await auth.protect(); + return new Response('ok'); + } + export async function POST() { + await auth.protect(); + return new Response('ok'); + } + `, + filename: abs('app/api/things/route.ts'), + options: [config], + }, + { + name: 'route handler exported as const arrow', + code: ` + import { auth } from '@clerk/nextjs/server'; + export const GET = async () => { + await auth.protect(); + return new Response('ok'); + }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + }, + { + name: 'route handler declared above and re-exported via specifier', + code: ` + import { auth } from '@clerk/nextjs/server'; + async function POST() { + await auth.protect(); + return new Response('ok'); + } + export { POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + }, + { + name: 'route handler declared with private name and renamed via `as`', + code: ` + import { auth } from '@clerk/nextjs/server'; + async function handlePost() { + await auth.protect(); + return new Response('ok'); + } + export { handlePost as POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + }, + { + name: 'route handler whose specifier export refers to a non-HTTP-method local', + code: ` + async function helper() { + return new Response('ok'); + } + export { helper }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + }, + { + name: 'server action module with use server, all exports protected', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + export async function deleteUser(id) { + await auth.protect(); + return id; + } + export async function updateUser(id) { + await auth.protect(); + return id; + } + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + }, + { + name: 'server action declared above and re-exported via specifier', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + async function deleteUser(id) { + await auth.protect(); + return id; + } + export { deleteUser }; + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + }, + { + name: 'server action exported under a different name via `as`', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + async function _deleteUser(id) { + await auth.protect(); + return id; + } + export { _deleteUser as deleteUser }; + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + }, + { + name: 'type-only exports via `export type { ... }` are not treated as server actions', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + export type { UserRecord }; + export async function deleteUser(id) { + await auth.protect(); + return id; + } + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + }, + { + name: 'type-only exports via `export { type ... }` are not treated as server actions', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + export { type UserRecord }; + export async function deleteUser(id) { + await auth.protect(); + return id; + } + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + }, + { + name: 'file outside app/ is ignored entirely', + code: ` + export default function Foo() { + return null; + } + `, + filename: abs('utils/foo.ts'), + options: [config], + }, + { + name: 'non-resource file in app/ is ignored', + code: ` + export function helper() { + return 1; + } + `, + filename: abs('app/dashboard/_helpers.ts'), + options: [config], + }, + { + name: 'mixed-scope root layout without protect call (auto mode, skipped silently)', + code: ` + export default function RootLayout({ children }) { + return {children}; + } + `, + filename: abs('app/layout.tsx'), + options: [config], + }, + { + name: 'mixed-scope intermediate layout without protect call (auto mode, skipped silently)', + code: ` + export default function RoutesLayout({ children }) { + return <>{children}; + } + `, + filename: abs('app/(routes)/layout.tsx'), + options: [config], + }, + { + name: 'mixed-scope layout listed in mixedScopeLayouts (skipped silently)', + code: ` + export default function RootLayout({ children }) { + return {children}; + } + `, + filename: abs('app/layout.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: ['app', 'app/(routes)'], + }, + ], + }, + { + name: 'non-mixed-scope layout (no public descendants) is unaffected by mixedScopeLayouts', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function AdminLayout({ children }) { + await auth.protect(); + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/layout.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: [], + }, + ], + }, + { + name: 'protected-only template with protect call', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function AdminTemplate({ children }) { + await auth.protect(); + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/template.tsx'), + options: [config], + }, + { + name: 'mixed-scope template listed in mixedScopeLayouts (skipped silently)', + code: ` + export default function RootTemplate({ children }) { + return <>{children}; + } + `, + filename: abs('app/template.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: ['app'], + }, + ], + }, + { + name: 'protected-only layout with protect call', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function AdminLayout({ children }) { + await auth.protect(); + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/layout.tsx'), + options: [config], + }, + { + name: 'intercepting route in protected folder with protect call (classified by source folder)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Modal() { + await auth.protect(); + return
modal
; + } + `, + filename: abs('app/feed/(.)photo/[id]/page.tsx'), + options: [ + { + protected: ['app/**'], + public: [], + }, + ], + }, + { + name: 'intercepting route in public folder, no protect call (classified by source folder)', + code: ` + export default async function Modal() { + return
modal
; + } + `, + filename: abs('app/(routes)/(unauthenticated)/feed/(.)photo/[id]/page.tsx'), + options: [config], + }, + ], + + invalid: [ + { + name: 'protected page missing protect call', + code: ` + export default async function Page() { + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'auth.protect() in a later declarator does not count — earlier code ran first', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const sideEffect = doWork(), ok = await auth.protect(); + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'await auth() in a later declarator does not count — earlier code ran first', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const sideEffect = doWork(), { userId } = await auth(); + if (userId === null) redirect('/sign-in'); + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'protected page with non-async default export', + code: ` + export default function Page() { + return
Hello
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'protected page with protect call inside Suspense (not top-level)', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { Suspense } from 'react'; + async function Inner() { + await auth.protect(); + return null; + } + export default async function Page() { + return ( + + + + ); + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'protected page with protect call after a return', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + return
Hello
; + await auth.protect(); + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'protected route handler with one method missing protect', + code: ` + import { auth } from '@clerk/nextjs/server'; + export async function GET() { + await auth.protect(); + return new Response('ok'); + } + export async function POST() { + return new Response('ok'); + } + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'route handler declared above and re-exported via specifier, missing protect', + code: ` + async function POST() { + return new Response('ok'); + } + export { POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [{ messageId: 'missingProtect', data: { subject: 'POST handler' } }], + }, + { + name: 'route handler renamed via `as`, local missing protect (reported under exported name)', + code: ` + async function handlePost() { + return new Response('ok'); + } + export { handlePost as POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [{ messageId: 'missingProtect', data: { subject: 'POST handler' } }], + }, + { + name: 'route handler re-exported from another module via specifier with source', + code: ` + export { POST } from './handlers'; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [ + { + messageId: 'exportImported', + data: { subject: 'POST handler', source: './handlers' }, + }, + ], + }, + { + name: 'route handler whose specifier export refers to an imported binding', + code: ` + import { POST } from './handlers'; + export { POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [ + { + messageId: 'exportImported', + data: { subject: 'POST handler', source: './handlers' }, + }, + ], + }, + { + name: 'route handler specifier export resolves to HOF (unknown), reported as unverifiable', + code: ` + import { withAuth } from '@/lib/with-auth'; + async function _POST() { return new Response('ok'); } + const POST = withAuth(_POST); + export { POST }; + `, + filename: abs('app/api/things/route.ts'), + options: [config], + errors: [{ messageId: 'unverifiableExport', data: { subject: 'POST handler' } }], + }, + { + name: 'protected server action module with one export missing protect', + code: ` + 'use server'; + import { auth } from '@clerk/nextjs/server'; + export async function safeAction() { + await auth.protect(); + } + export async function unsafeAction() { + // forgot the protect call + return 1; + } + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'server action declared above and re-exported via specifier, missing protect', + code: ` + 'use server'; + async function deleteUser(id) { + return id; + } + export { deleteUser }; + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + errors: [ + { + messageId: 'missingProtect', + data: { subject: "server action 'deleteUser'" }, + }, + ], + }, + { + name: 'server action specifier export resolves to HOF (unknown), reported as unverifiable', + code: ` + 'use server'; + import { withAuth } from '@/lib/with-auth'; + async function _deleteUser(id) { return id; } + const deleteUser = withAuth(_deleteUser); + export { deleteUser }; + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + errors: [ + { + messageId: 'unverifiableExport', + data: { subject: "server action 'deleteUser'" }, + }, + ], + }, + { + name: 'server action re-exported from another module via specifier with source', + code: ` + 'use server'; + export { deleteUser } from './implementations'; + `, + filename: abs('app/admin/users/actions.ts'), + options: [config], + errors: [ + { + messageId: 'exportImported', + data: { + subject: "server action 'deleteUser'", + source: './implementations', + }, + }, + ], + }, + { + name: 'protected-only layout without protect call', + code: ` + export default function AdminLayout({ children }) { + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/layout.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'protected-only template without protect call', + code: ` + export default function AdminTemplate({ children }) { + return <>{children}; + } + `, + filename: abs('app/(routes)/(org-level)/apps/template.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'mixed-scope template not listed in mixedScopeLayouts (explicit mode, warns)', + code: ` + export default function RootTemplate({ children }) { + return <>{children}; + } + `, + filename: abs('app/template.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: ['app/(routes)'], + }, + ], + errors: [ + { + messageId: 'unlistedMixedScopeLayout', + data: { folder: 'app', fileKind: 'template' }, + }, + ], + }, + { + name: 'mixed-scope layout not listed in mixedScopeLayouts (explicit mode, warns)', + code: ` + export default function RootLayout({ children }) { + return {children}; + } + `, + filename: abs('app/layout.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: ['app/(routes)'], + }, + ], + errors: [ + { + messageId: 'unlistedMixedScopeLayout', + data: { folder: 'app', fileKind: 'layout' }, + }, + ], + }, + { + name: 'await before protect is NOT accepted', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const data = await fetchSensitive(); + await auth.protect(); + return
{data}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'await params before protect is NOT accepted (no preamble allowlist)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page({ params }) { + const { id } = await params; + await auth.protect(); + return
{id}
; + } + `, + filename: abs('app/dashboard/[id]/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'await searchParams before protect is NOT accepted (no preamble allowlist)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page({ searchParams }) { + const { tab } = await searchParams; + await auth.protect(); + return
{tab}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'await headers() before protect is NOT accepted (no preamble allowlist)', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { headers } from 'next/headers'; + export default async function Page() { + const requestHeaders = await headers(); + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'directive followed by sync assignment before protect is NOT accepted', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + 'use strict'; + const queryClient = getQueryClient(); + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'voided prefetch before protect is NOT accepted (effectful, no await)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + void queryClient.prefetchQuery(getUsersQuery()); + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'synchronous assignment before protect is NOT accepted', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const queryClient = getQueryClient(); + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'statement between auth() destructure and guard is NOT accepted', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page({ searchParams }) { + const { userId } = await auth(); + const tab = searchParams?.tab ?? 'overview'; + if (userId === null) return null; + return
{tab}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'preamble matched but with mixed non-preamble in same VariableDeclaration', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page({ params }) { + const a = await params, b = await fetchSensitive(); + await auth.protect(); + return
; + } + `, + filename: abs('app/dashboard/[id]/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: !userId is NOT accepted (less explicit than === null)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (!userId) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: userId === undefined is NOT accepted (server userId is never undefined)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (userId === undefined) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: orgId === null is NOT accepted (orgId can be null while signed in)', + code: ` + import { auth } from '@clerk/nextjs/server'; + import { redirect } from 'next/navigation'; + export default async function Page() { + const { orgId } = await auth(); + if (orgId === null) redirect('/select-org'); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: consequent does not exit (just logs)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (userId === null) console.log('uh oh'); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: awaited work between destructure and guard', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + const data = await fetchSensitive(); + if (userId === null) return null; + return
{data}
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: inverted condition (userId !== null)', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(); + if (userId !== null) doSomething(); + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'manual check: aliased destructure not recognized', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId: uid } = await auth(); + if (uid === null) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'function declaration + identifier default export, function lacks protect', + code: ` + async function PageComponent() { + return
; + } + export default PageComponent; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, + { + name: 'imported and re-exported as default (rule cannot follow imports)', + code: ` + import PageComponent from './component'; + export default PageComponent; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [ + { + messageId: 'exportImported', + data: { subject: 'page', source: './component' }, + }, + ], + }, + { + name: 'HOF-wrapped default export resolves to unknown, reported as unverifiable', + code: ` + import { withAuth } from '@/lib/with-auth'; + function BasePage() { return
; } + const Page = withAuth(BasePage); + export default Page; + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'unverifiableExport', data: { subject: 'page' } }], + }, + { + name: 'mixed-scope layout under empty allowlist (max strictness, warns)', + code: ` + export default function RootLayout({ children }) { + return {children}; + } + `, + filename: abs('app/layout.tsx'), + options: [ + { + ...config, + mixedScopeLayouts: [], + }, + ], + errors: [{ messageId: 'unlistedMixedScopeLayout' }], + }, + { + name: 'intercepting route in protected folder without protect call (classified by source folder)', + code: ` + export default async function Modal() { + return
modal
; + } + `, + filename: abs('app/feed/(..)admin/page.tsx'), + options: [ + { + protected: ['app/**'], + public: [], + }, + ], + errors: [{ messageId: 'missingProtect' }], + }, + ], +}); + +describe('require-auth-protection schema validation', () => { + // Linter.verify throws synchronously on schema errors when no filename is + // passed (with a filename, configs that don't apply to the file are + // skipped before validation runs). RuleTester.run does not validate the + // schema at all, so we go through Linter directly. + const lintWithOptions = (options: RuleOptions | Record) => { + const linter = new Linter(); + return linter.verify('export default function X() {}', { + plugins: { + '@clerk/next': { + rules: { 'require-auth-protection': rule }, + }, + }, + rules: { + '@clerk/next/require-auth-protection': ['warn', options], + }, + }); + }; + + it('rejects configs missing `protected`', () => { + expect(() => lintWithOptions({ public: ['app/(unauthenticated)/**'] })).toThrow(/protected/); + }); + + it('rejects configs with `protected: []` (empty array)', () => { + expect(() => lintWithOptions({ protected: [] })).toThrow(/fewer than 1 items|minItems|protected/); + }); + + it('accepts configs with `protected` set and other options omitted', () => { + expect(() => lintWithOptions({ protected: ['app/**'] })).not.toThrow(); + }); +}); diff --git a/packages/eslint-plugin-next/src/global.d.ts b/packages/eslint-plugin-next/src/global.d.ts new file mode 100644 index 00000000000..2ca0c6ed541 --- /dev/null +++ b/packages/eslint-plugin-next/src/global.d.ts @@ -0,0 +1,5 @@ +declare global { + const PACKAGE_VERSION: string; +} + +export {}; diff --git a/packages/eslint-plugin-next/src/index.ts b/packages/eslint-plugin-next/src/index.ts new file mode 100644 index 00000000000..4d2cca88342 --- /dev/null +++ b/packages/eslint-plugin-next/src/index.ts @@ -0,0 +1,15 @@ +import type { ESLint } from 'eslint'; + +import requireAuthProtection from './rules/require-auth-protection.js'; + +const plugin: ESLint.Plugin = { + meta: { + name: '@clerk/eslint-plugin-next', + version: PACKAGE_VERSION, + }, + rules: { + 'require-auth-protection': requireAuthProtection, + }, +}; + +export default plugin; diff --git a/packages/eslint-plugin-next/src/lib/exports.ts b/packages/eslint-plugin-next/src/lib/exports.ts new file mode 100644 index 00000000000..c6c2c6ac3e7 --- /dev/null +++ b/packages/eslint-plugin-next/src/lib/exports.ts @@ -0,0 +1,158 @@ +/** + * Resolve a module's exports to the function target that will run when the framework dispatches. + */ + +import type { TSESTree } from '@typescript-eslint/utils'; + +export type FunctionNode = + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression; + +export type ExportTarget = + | { kind: 'function'; node: FunctionNode } + | { kind: 'imported'; source: string } + | { kind: 'unknown' }; + +export interface NamedExportItem { + name: string; + target: ExportTarget; + reportNode: TSESTree.Node; +} + +export function unwrapFunction(node: TSESTree.Node | null | undefined): FunctionNode | null { + if (!node) { + return null; + } + if ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) { + return node; + } + return null; +} + +export function resolveLocalIdentifierTarget(programNode: TSESTree.Program, name: string): ExportTarget { + for (const stmt of programNode.body) { + if (stmt.type === 'FunctionDeclaration' && stmt.id && stmt.id.name === name) { + return { kind: 'function', node: stmt }; + } + if (stmt.type === 'VariableDeclaration') { + for (const declarator of stmt.declarations) { + if (declarator.id.type !== 'Identifier' || declarator.id.name !== name) { + continue; + } + const initFn = unwrapFunction(declarator.init ?? undefined); + if (initFn) { + return { kind: 'function', node: initFn }; + } + return { kind: 'unknown' }; + } + } + if (stmt.type === 'ImportDeclaration') { + for (const spec of stmt.specifiers) { + if (spec.local && spec.local.name === name) { + return { + kind: 'imported', + source: stmt.source.value, + }; + } + } + } + } + return { kind: 'unknown' }; +} + +export function resolveDefaultExportTarget(programNode: TSESTree.Program, declaration: TSESTree.Node): ExportTarget { + const direct = unwrapFunction(declaration); + if (direct) { + return { kind: 'function', node: direct }; + } + + if (declaration.type !== 'Identifier') { + return { kind: 'unknown' }; + } + + return resolveLocalIdentifierTarget(programNode, declaration.name); +} + +function getExportedName(spec: TSESTree.ExportSpecifier): string | null { + const node = spec.exported; + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' && typeof node.value === 'string') { + return node.value; + } + return null; +} + +export function* iterateNamedExports(programNode: TSESTree.Program): Generator { + for (const stmt of programNode.body) { + if (stmt.type !== 'ExportNamedDeclaration') { + continue; + } + // `export type { Foo }` — the whole statement is type-only + if (stmt.exportKind === 'type') { + continue; + } + + if (stmt.declaration) { + const decl = stmt.declaration; + if (decl.type === 'FunctionDeclaration' && decl.id) { + yield { + name: decl.id.name, + target: { kind: 'function', node: decl }, + reportNode: stmt, + }; + } else if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.id.type !== 'Identifier') { + continue; + } + const fn = unwrapFunction(declarator.init ?? undefined); + yield { + name: declarator.id.name, + target: fn ? { kind: 'function', node: fn } : { kind: 'unknown' }, + reportNode: stmt, + }; + } + } + continue; + } + + for (const spec of stmt.specifiers) { + if (spec.type !== 'ExportSpecifier') { + continue; + } + // `export { type Foo }` — individual specifier is type-only + if (spec.exportKind === 'type') { + continue; + } + const exportedName = getExportedName(spec); + if (!exportedName) { + continue; + } + + if (stmt.source) { + yield { + name: exportedName, + target: { kind: 'imported', source: stmt.source.value }, + reportNode: stmt, + }; + continue; + } + + if (spec.local.type !== 'Identifier') { + continue; + } + yield { + name: exportedName, + target: resolveLocalIdentifierTarget(programNode, spec.local.name), + reportNode: stmt, + }; + } + } +} diff --git a/packages/eslint-plugin-next/src/lib/file-info.ts b/packages/eslint-plugin-next/src/lib/file-info.ts new file mode 100644 index 00000000000..604878c6817 --- /dev/null +++ b/packages/eslint-plugin-next/src/lib/file-info.ts @@ -0,0 +1,81 @@ +/** + * Utilities for classifying a file by path, kind, and module-level directives. + */ + +import path from 'node:path'; + +import type { TSESTree } from '@typescript-eslint/utils'; + +export type FileKind = 'page' | 'layout' | 'template' | 'default' | 'route'; + +const RESOURCE_FILES = new Set(['page', 'layout', 'template', 'default', 'route']); + +const RESOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/; + +export function getRelativeFolder(filename: string | undefined, cwd: string | undefined): string | null { + if (!filename) { + return null; + } + const normalizedFile = filename.replaceAll('\\', '/'); + + // Prefer a project-relative path so that noise in the absolute prefix (a home + // directory like `/Users/app/...`, a monorepo root, etc.) can't be mistaken + // for the Next.js App Router root. When the file lives outside `cwd` (e.g. in + // `RuleTester`, which lints in-memory code), fall back to the absolute path. + let candidate = normalizedFile; + if (cwd) { + const normalizedCwd = cwd.replaceAll('\\', '/'); + const rel = path.posix.relative(normalizedCwd, normalizedFile); + if (rel && !rel.startsWith('..')) { + candidate = rel; + } + } + + // The App Router root is the first path segment that is exactly `app` (this + // also covers the `src/app/` convention, where the leading `src` segment is + // simply skipped). Folder globs in config are rooted at `app/...`, so we + // re-root the returned folder there. Matching whole segments (rather than the + // `/app/` substring) avoids false positives like `myapp` or `app-utils`. + const segments = candidate.split('/'); + const appIdx = segments.findIndex(seg => seg === 'app'); + if (appIdx !== -1) { + return path.posix.dirname(segments.slice(appIdx).join('/')); + } + + // No `app` segment: only meaningful when we have a project-relative path. + if (candidate !== normalizedFile) { + return path.posix.dirname(candidate); + } + return null; +} + +export function getFileKind(filename: string | undefined): FileKind | null { + if (!filename) { + return null; + } + const base = path.basename(filename).replace(RESOURCE_EXTENSIONS, ''); + return RESOURCE_FILES.has(base as FileKind) ? (base as FileKind) : null; +} + +function hasTopLevelDirective(programNode: TSESTree.Program, name: string): boolean { + for (const stmt of programNode.body) { + if (stmt.type !== 'ExpressionStatement') { + break; + } + if (!('directive' in stmt)) { + break; + } + if (stmt.directive === name) { + return true; + } + } + return false; +} + +export function isServerActionModule(programNode: TSESTree.Program): boolean { + return hasTopLevelDirective(programNode, 'use server'); +} + +export function isClientModule(programNode: TSESTree.Program): boolean { + return hasTopLevelDirective(programNode, 'use client'); +} diff --git a/packages/eslint-plugin-next/src/lib/match-folders.ts b/packages/eslint-plugin-next/src/lib/match-folders.ts new file mode 100644 index 00000000000..33089a44136 --- /dev/null +++ b/packages/eslint-plugin-next/src/lib/match-folders.ts @@ -0,0 +1,126 @@ +/** + * Folder-glob matcher for the require-auth-protection rule. + */ + +export interface ClassifyOptions { + protected?: string[]; + public?: string[]; +} + +export type FolderClass = 'public' | 'protected' | 'unmatched'; + +const REGEX_SPECIAL = /[.+^${}()|[\]\\]/g; + +function segmentToRegex(seg: string): string { + let out = ''; + for (let i = 0; i < seg.length; i++) { + const ch = seg[i]; + if (ch === '*') { + out += '[^/]*'; + } else if (REGEX_SPECIAL.test(ch)) { + out += '\\' + ch; + } else { + out += ch; + } + REGEX_SPECIAL.lastIndex = 0; + } + return out; +} + +function segmentMatches(patternSeg: string, pathSeg: string): boolean { + if (patternSeg === pathSeg) { + return true; + } + if (!patternSeg.includes('*')) { + return false; + } + return new RegExp('^' + segmentToRegex(patternSeg) + '$').test(pathSeg); +} + +function matchSegments(patternSegs: string[], pi: number, pathSegs: string[], si: number): boolean { + while (pi < patternSegs.length) { + const seg = patternSegs[pi]; + if (seg === '**') { + if (pi === patternSegs.length - 1) { + return true; + } + for (let k = si; k <= pathSegs.length; k++) { + if (matchSegments(patternSegs, pi + 1, pathSegs, k)) { + return true; + } + } + return false; + } + if (si >= pathSegs.length) { + return false; + } + if (!segmentMatches(seg, pathSegs[si])) { + return false; + } + pi++; + si++; + } + return si === pathSegs.length; +} + +export function matchPath(pattern: string, path: string): boolean { + return matchSegments(pattern.split('/'), 0, path.split('/'), 0); +} + +export function specificity(pattern: string): number { + return pattern.split('/').filter(seg => seg.length > 0 && seg !== '**' && !seg.includes('*')).length; +} + +export function literalPrefix(pattern: string): string { + const segs = pattern.split('/'); + const result: string[] = []; + for (const seg of segs) { + if (seg === '**' || seg.includes('*')) { + break; + } + result.push(seg); + } + return result.join('/'); +} + +export function hasDescendantsMatching(folder: string, patterns: string[] | undefined): boolean { + if (!patterns || patterns.length === 0) { + return false; + } + for (const pattern of patterns) { + const prefix = literalPrefix(pattern); + if (!prefix) { + continue; + } + if (prefix === folder) { + return true; + } + if (prefix.startsWith(folder + '/')) { + return true; + } + } + return false; +} + +export function classifyFolder(folderPath: string, options: ClassifyOptions): FolderClass { + const protectPatterns = options.protected ?? []; + const publicPatterns = options.public ?? []; + + const protectMatches = protectPatterns.filter(p => matchPath(p, folderPath)); + const publicMatches = publicPatterns.filter(p => matchPath(p, folderPath)); + + if (protectMatches.length === 0 && publicMatches.length === 0) { + return 'unmatched'; + } + if (publicMatches.length === 0) { + return 'protected'; + } + if (protectMatches.length === 0) { + return 'public'; + } + + const maxProtect = Math.max(...protectMatches.map(specificity)); + const maxPublic = Math.max(...publicMatches.map(specificity)); + + return maxProtect >= maxPublic ? 'protected' : 'public'; +} diff --git a/packages/eslint-plugin-next/src/lib/protection-checks.ts b/packages/eslint-plugin-next/src/lib/protection-checks.ts new file mode 100644 index 00000000000..24935e34e17 --- /dev/null +++ b/packages/eslint-plugin-next/src/lib/protection-checks.ts @@ -0,0 +1,280 @@ +/** + * AST detection for "is this function protected at its top?" + */ + +import type { TSESTree } from '@typescript-eslint/utils'; + +import type { FunctionNode } from './exports.js'; + +const CLERK_AUTH_SOURCE = '@clerk/nextjs/server'; + +/** + * Collect the local names that `auth` is imported as from `@clerk/nextjs/server` + * in the given module. Usually returns `{'auth'}`, but handles aliased imports + * like `import { auth as clerkAuth } from '@clerk/nextjs/server'` correctly. + */ +export function findAuthLocalNames(programNode: TSESTree.Program): Set { + const names = new Set(); + for (const stmt of programNode.body) { + if (stmt.type !== 'ImportDeclaration') { + continue; + } + if (stmt.source.value !== CLERK_AUTH_SOURCE) { + continue; + } + for (const spec of stmt.specifiers) { + if (spec.type !== 'ImportSpecifier') { + continue; + } + const imported = spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value; + if (imported === 'auth') { + names.add(spec.local.name); + } + } + } + return names; +} + +type AuthField = 'userId' | 'sessionId' | 'isAuthenticated'; + +const AUTH_FIELDS = new Set(['userId', 'sessionId', 'isAuthenticated']); + +// We don't trace these to actual imports like with `auth`, as all we really care +// about is that the code stops executing at this point. Tracing these to imports +// would be complex, not worth the effort (at this point) and disallow any type +// of wrapper that eventually calls these functions behind the scenes. +// Essentially, if you name something to any of these, we'll credit you with an exit. +const EXIT_FUNCTIONS = new Set(['redirect', 'permanentRedirect', 'notFound', 'unauthorized', 'forbidden']); + +function isProtectCall(node: TSESTree.Node | null | undefined, authNames: Set): boolean { + if (!node || node.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type !== 'MemberExpression') { + return false; + } + if (callee.property.type !== 'Identifier' || callee.property.name !== 'protect') { + return false; + } + if (callee.object.type === 'Identifier' && authNames.has(callee.object.name)) { + return true; + } + if ( + callee.object.type === 'AwaitExpression' && + callee.object.argument.type === 'CallExpression' && + callee.object.argument.callee.type === 'Identifier' && + authNames.has(callee.object.argument.callee.name) + ) { + return true; + } + return false; +} + +function isProtectAwait(node: TSESTree.Node | null | undefined, authNames: Set): boolean { + return !!node && node.type === 'AwaitExpression' && isProtectCall(node.argument, authNames); +} + +function isProtectAwaitStatement(stmt: TSESTree.Statement, authNames: Set): boolean { + if (stmt.type === 'ExpressionStatement') { + return isProtectAwait(stmt.expression, authNames); + } + if (stmt.type === 'VariableDeclaration') { + // Only the first declarator counts: later declarators are preceded by + // earlier ones executing first, so `auth.protect()` wouldn't be at the top. + const first = stmt.declarations[0]; + return first != null && isProtectAwait(first.init ?? undefined, authNames); + } + return false; +} + +function capturedAuthBindings(stmt: TSESTree.Statement, authNames: Set): Set | null { + if (stmt.type !== 'VariableDeclaration') { + return null; + } + + // Only the first declarator counts: a later `await auth()` would be preceded + // by earlier declarators executing first. + const decl = stmt.declarations[0]; + if (!decl) { + return null; + } + if (decl.id.type !== 'ObjectPattern') { + return null; + } + if (!decl.init || decl.init.type !== 'AwaitExpression') { + return null; + } + const arg = decl.init.argument; + if (arg.type !== 'CallExpression') { + return null; + } + if (arg.callee.type !== 'Identifier' || !authNames.has(arg.callee.name)) { + return null; + } + + const bindings = new Set(); + for (const prop of decl.id.properties) { + if (prop.type !== 'Property') { + continue; + } + if (prop.key.type !== 'Identifier') { + continue; + } + const fieldName = prop.key.name; + if (!AUTH_FIELDS.has(fieldName as AuthField)) { + continue; + } + if (prop.value.type !== 'Identifier' || prop.value.name !== fieldName) { + continue; + } + bindings.add(fieldName as AuthField); + } + return bindings.size > 0 ? bindings : null; +} + +function isRecognizedAuthCheck(test: TSESTree.Expression, bindings: Set): boolean { + if (test.type === 'UnaryExpression' && test.operator === '!') { + if ( + test.argument.type === 'Identifier' && + test.argument.name === 'isAuthenticated' && + bindings.has('isAuthenticated') + ) { + return true; + } + return false; + } + + if (test.type !== 'BinaryExpression') { + return false; + } + if (test.operator !== '===' && test.operator !== '==') { + return false; + } + + let id: TSESTree.Identifier; + let other: TSESTree.Expression; + if (test.left.type === 'Identifier' && bindings.has(test.left.name as AuthField)) { + id = test.left; + other = test.right; + } else if (test.right.type === 'Identifier' && bindings.has(test.right.name as AuthField)) { + id = test.right; + other = test.left; + } else { + return false; + } + + const name = id.name as AuthField; + + if (name === 'userId' || name === 'sessionId') { + return other.type === 'Literal' && other.value === null; + } + if (name === 'isAuthenticated') { + return test.operator === '===' && other.type === 'Literal' && other.value === false; + } + return false; +} + +function isExitCall(expr: TSESTree.Expression | null | undefined): boolean { + if (!expr || expr.type !== 'CallExpression') { + return false; + } + if (expr.callee.type !== 'Identifier') { + return false; + } + return EXIT_FUNCTIONS.has(expr.callee.name); +} + +function consequentExits(consequent: TSESTree.Statement | null | undefined): boolean { + if (!consequent) { + return false; + } + if (consequent.type === 'ReturnStatement') { + return true; + } + if (consequent.type === 'ThrowStatement') { + return true; + } + if (consequent.type === 'ExpressionStatement') { + return isExitCall(consequent.expression); + } + if (consequent.type === 'BlockStatement') { + for (const stmt of consequent.body) { + if (stmt.type === 'ReturnStatement') { + return true; + } + if (stmt.type === 'ThrowStatement') { + return true; + } + if (stmt.type === 'ExpressionStatement' && isExitCall(stmt.expression)) { + return true; + } + } + } + return false; +} + +function isAuthGuardWithExit(stmt: TSESTree.Statement, bindings: Set): boolean { + if (stmt.type !== 'IfStatement') { + return false; + } + if (!isRecognizedAuthCheck(stmt.test, bindings)) { + return false; + } + return consequentExits(stmt.consequent); +} + +function isNonRuntimeStatement(stmt: TSESTree.Statement): boolean { + if (stmt.type === 'ExpressionStatement' && stmt.directive) { + return true; + } + if (stmt.type === 'TSTypeAliasDeclaration') { + return true; + } + if (stmt.type === 'TSInterfaceDeclaration') { + return true; + } + return false; +} + +function nextExecutable(stmts: TSESTree.Statement[], from: number): number { + let i = from; + while (i < stmts.length && isNonRuntimeStatement(stmts[i])) { + i++; + } + return i; +} + +export function hasProtectAtTop(fn: FunctionNode | null | undefined, authNames: Set): boolean { + if (!fn || !fn.async) { + return false; + } + + const body = fn.body; + if (body && body.type !== 'BlockStatement') { + return body.type === 'AwaitExpression' && isProtectCall(body.argument, authNames); + } + if (!body || body.type !== 'BlockStatement') { + return false; + } + + const stmts = body.body; + const first = nextExecutable(stmts, 0); + if (first >= stmts.length) { + return false; + } + + if (isProtectAwaitStatement(stmts[first], authNames)) { + return true; + } + + const captured = capturedAuthBindings(stmts[first], authNames); + if (captured) { + const second = nextExecutable(stmts, first + 1); + if (second < stmts.length && isAuthGuardWithExit(stmts[second], captured)) { + return true; + } + } + + return false; +} diff --git a/packages/eslint-plugin-next/src/rules/require-auth-protection.ts b/packages/eslint-plugin-next/src/rules/require-auth-protection.ts new file mode 100644 index 00000000000..5d07bc8ffd0 --- /dev/null +++ b/packages/eslint-plugin-next/src/rules/require-auth-protection.ts @@ -0,0 +1,214 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import type { Rule } from 'eslint'; + +import type { ExportTarget } from '../lib/exports.js'; +import { iterateNamedExports, resolveDefaultExportTarget } from '../lib/exports.js'; +import { + type FileKind, + getFileKind, + getRelativeFolder, + isClientModule, + isServerActionModule, +} from '../lib/file-info.js'; +import type { ClassifyOptions } from '../lib/match-folders.js'; +import { classifyFolder, hasDescendantsMatching } from '../lib/match-folders.js'; +import { findAuthLocalNames, hasProtectAtTop } from '../lib/protection-checks.js'; + +export type MessageId = 'missingProtect' | 'exportImported' | 'unverifiableExport' | 'unlistedMixedScopeLayout'; + +export interface RuleOptions { + /** Glob patterns that mark folders as protected. */ + protected: string[]; + /** Glob patterns that exempt folders from protection. */ + public?: string[]; + /** Layouts that wrap both protected and public descendants. */ + mixedScopeLayouts?: 'auto' | string[]; +} + +const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']); + +const rule: Rule.RuleModule = { + meta: { + type: 'problem', + docs: { + description: 'Require `await auth.protect()` in App Router resources under protected folders', + }, + schema: [ + { + type: 'object', + properties: { + protected: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + }, + public: { type: 'array', items: { type: 'string' } }, + mixedScopeLayouts: { + oneOf: [ + { type: 'string', enum: ['auto'] }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + required: ['protected'], + additionalProperties: false, + }, + ], + messages: { + missingProtect: + 'Expected `await auth.protect()` at the top of {{subject}} in a protected folder. Add the call to the top of the function, move the file into a public folder, or configure this folder as public.', + exportImported: + "This {{subject}} is exported from '{{source}}'. The rule cannot follow imports across files. Add a wrapper with `await auth.protect()`, or ensure the imported function calls it and add an eslint-disable comment with a reason.", + unverifiableExport: + 'This {{subject}} could not be verified as protected, likely because it is assigned from a call expression (e.g. `const handler = withAuth(impl)`). Inline a function literal that calls `await auth.protect()`, or add an eslint-disable comment with a reason.', + unlistedMixedScopeLayout: + "This {{fileKind}} at '{{folder}}/' wraps both protected and public descendants but is not listed in `mixedScopeLayouts`. Either add '{{folder}}' to the list to acknowledge the mixed scope, or restructure so the {{fileKind}} wraps only public or protected descendants.", + }, + }, + + create(context) { + const filename = context.filename || context.getFilename?.(); + const cwd = context.cwd || context.getCwd?.(); + const options = (context.options[0] ?? {}) as Partial; + const config: ClassifyOptions = { + protected: options.protected, + public: options.public ?? [], + }; + const mixedScopeLayoutsOption = options.mixedScopeLayouts === undefined ? 'auto' : options.mixedScopeLayouts; + + const folder = getRelativeFolder(filename, cwd); + if (!folder) { + return {}; + } + + const fileKind = getFileKind(filename); + + return { + Program(programNode) { + const ast = programNode as TSESTree.Program; + const isAction = isServerActionModule(ast); + if (!fileKind && !isAction) { + return; + } + + const isClient = isClientModule(ast); + if ( + isClient && + (fileKind === 'page' || fileKind === 'layout' || fileKind === 'template' || fileKind === 'default') + ) { + return; + } + + const sourceClass = classifyFolder(folder, config); + if (sourceClass !== 'protected') { + return; + } + + if ((fileKind === 'layout' || fileKind === 'template') && hasDescendantsMatching(folder, config.public)) { + checkUnacknowledgedMixedScope(context, ast, fileKind, folder, mixedScopeLayoutsOption); + return; + } + + const authNames = findAuthLocalNames(ast); + + if (fileKind === 'page' || fileKind === 'layout' || fileKind === 'template' || fileKind === 'default') { + checkDefaultExport(context, ast, fileKind, authNames); + } else if (fileKind === 'route') { + checkRouteHandlers(context, ast, authNames); + } else if (isAction) { + checkServerActions(context, ast, authNames); + } + }, + }; + }, +}; + +export default rule; + +function checkUnacknowledgedMixedScope( + context: Rule.RuleContext, + programNode: TSESTree.Program, + fileKind: 'layout' | 'template', + folder: string, + mixedScopeLayoutsOption: 'auto' | string[], +): void { + if (mixedScopeLayoutsOption === 'auto') { + return; + } + if (mixedScopeLayoutsOption.includes(folder)) { + return; + } + const defaultExport = programNode.body.find( + (n): n is TSESTree.ExportDefaultDeclaration => n.type === 'ExportDefaultDeclaration', + ); + context.report({ + node: defaultExport ?? programNode, + messageId: 'unlistedMixedScopeLayout', + data: { folder, fileKind }, + }); +} + +function checkMissingProtect( + context: Rule.RuleContext, + reportNode: TSESTree.Node, + target: ExportTarget, + subject: string, + authNames: Set, +): void { + if (target.kind === 'imported') { + context.report({ + node: reportNode, + messageId: 'exportImported', + data: { subject, source: target.source }, + }); + return; + } + if (target.kind === 'function') { + if (!hasProtectAtTop(target.node, authNames)) { + context.report({ + node: reportNode, + messageId: 'missingProtect', + data: { subject }, + }); + } + return; + } + context.report({ + node: reportNode, + messageId: 'unverifiableExport', + data: { subject }, + }); +} + +function checkRouteHandlers(context: Rule.RuleContext, programNode: TSESTree.Program, authNames: Set): void { + for (const { name, target, reportNode } of iterateNamedExports(programNode)) { + if (!HTTP_METHODS.has(name)) { + continue; + } + checkMissingProtect(context, reportNode, target, `${name} handler`, authNames); + } +} + +function checkServerActions(context: Rule.RuleContext, programNode: TSESTree.Program, authNames: Set): void { + for (const { name, target, reportNode } of iterateNamedExports(programNode)) { + checkMissingProtect(context, reportNode, target, `server action '${name}'`, authNames); + } +} + +function checkDefaultExport( + context: Rule.RuleContext, + programNode: TSESTree.Program, + fileKind: FileKind, + authNames: Set, +): void { + const defaultExport = programNode.body.find( + (n): n is TSESTree.ExportDefaultDeclaration => n.type === 'ExportDefaultDeclaration', + ); + if (!defaultExport) { + return; + } + + const target = resolveDefaultExportTarget(programNode, defaultExport.declaration); + + checkMissingProtect(context, defaultExport, target, fileKind, authNames); +} diff --git a/packages/eslint-plugin-next/tsconfig.json b/packages/eslint-plugin-next/tsconfig.json new file mode 100644 index 00000000000..4a298280721 --- /dev/null +++ b/packages/eslint-plugin-next/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "moduleResolution": "NodeNext", + "module": "NodeNext", + "lib": ["ES2021"], + "sourceMap": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "target": "ES2020", + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/packages/eslint-plugin-next/tsup.config.ts b/packages/eslint-plugin-next/tsup.config.ts new file mode 100644 index 00000000000..32b47ae3122 --- /dev/null +++ b/packages/eslint-plugin-next/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsup'; + +import { version } from './package.json'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + bundle: true, + clean: true, + minify: false, + sourcemap: true, + dts: true, + define: { + PACKAGE_VERSION: `"${version}"`, + }, +}); diff --git a/packages/eslint-plugin-next/vitest.config.mts b/packages/eslint-plugin-next/vitest.config.mts new file mode 100644 index 00000000000..02b0a9d09b8 --- /dev/null +++ b/packages/eslint-plugin-next/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + watch: false, + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/eslint-plugin-next/vitest.setup.mts b/packages/eslint-plugin-next/vitest.setup.mts new file mode 100644 index 00000000000..e414f4dabb8 --- /dev/null +++ b/packages/eslint-plugin-next/vitest.setup.mts @@ -0,0 +1 @@ +globalThis.PACKAGE_VERSION = '0.0.0-test'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57347ff6c20..5a61c53019d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,10 +483,10 @@ importers: version: 11.14.0(@types/react@18.3.28)(react@18.3.1) '@rsdoctor/rspack-plugin': specifier: ^0.4.13 - version: 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + version: 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) '@rspack/cli': specifier: catalog:rspack - version: 1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + version: 1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) '@rspack/core': specifier: catalog:rspack version: 1.7.11(@swc/helpers@0.5.21) @@ -530,6 +530,30 @@ importers: specifier: ^0.2.16 version: 0.2.16 + packages/eslint-plugin-next: + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.19.17 + '@typescript-eslint/parser': + specifier: 8.58.0 + version: 8.58.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': + specifier: 8.58.0 + version: 8.58.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) + eslint: + specifier: 9.31.0 + version: 9.31.0(jiti@2.6.1) + tsup: + specifier: catalog:repo + version: 8.5.1(jiti@2.6.1)(postcss@8.5.13)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.3) + typescript: + specifier: catalog:repo + version: 5.8.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.17)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.14.2(@types/node@22.19.17)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) + packages/expo: dependencies: '@clerk/clerk-js': @@ -905,7 +929,7 @@ importers: version: 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-start': specifier: 1.157.16 - version: 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7)) + version: 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1) esbuild-plugin-file-path-extensions: specifier: ^2.1.4 version: 2.1.4 @@ -991,10 +1015,10 @@ importers: version: 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@rsdoctor/rspack-plugin': specifier: ^0.4.13 - version: 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + version: 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) '@rspack/cli': specifier: catalog:rspack - version: 1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + version: 1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) '@rspack/core': specifier: catalog:rspack version: 1.7.11(@swc/helpers@0.5.21) @@ -19110,12 +19134,12 @@ snapshots: '@rsdoctor/client@0.4.13': {} - '@rsdoctor/core@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/core@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: - '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/sdk': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) + '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/sdk': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) + '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) axios: 1.13.2 enhanced-resolve: 5.12.0 filesize: 10.1.6 @@ -19133,10 +19157,10 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/graph@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/graph@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: - '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) + '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) + '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) lodash.unionby: 4.8.0 socket.io: 4.8.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) source-map: 0.7.6 @@ -19147,13 +19171,13 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/rspack-plugin@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/rspack-plugin@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: - '@rsdoctor/core': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/sdk': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) + '@rsdoctor/core': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/sdk': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) + '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) '@rspack/core': 1.7.11(@swc/helpers@0.5.21) lodash: 4.17.21 transitivePeerDependencies: @@ -19163,12 +19187,12 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/sdk@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/sdk@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: '@rsdoctor/client': 0.4.13 - '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) - '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) + '@rsdoctor/graph': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) + '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) + '@rsdoctor/utils': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) '@types/fs-extra': 11.0.4 body-parser: 1.20.3 cors: 2.8.5 @@ -19188,20 +19212,20 @@ snapshots: - utf-8-validate - webpack - '@rsdoctor/types@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/types@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1)': dependencies: '@types/connect': 3.4.38 '@types/estree': 1.0.5 '@types/tapable': 2.2.7 source-map: 0.7.6 - webpack: 5.102.1(esbuild@0.27.7) + webpack: 5.102.1 optionalDependencies: '@rspack/core': 1.7.11(@swc/helpers@0.5.21) - '@rsdoctor/utils@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7))': + '@rsdoctor/utils@0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1)': dependencies: '@babel/code-frame': 7.25.7 - '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1(esbuild@0.27.7)) + '@rsdoctor/types': 0.4.13(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.102.1) '@types/estree': 1.0.5 acorn: 8.16.0 acorn-import-assertions: 1.9.0(acorn@8.16.0) @@ -19267,11 +19291,11 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 1.7.11 '@rspack/binding-win32-x64-msvc': 1.7.11 - '@rspack/cli@1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rspack/cli@1.7.11(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: '@discoveryjs/json-ext': 0.5.7 '@rspack/core': 1.7.11(@swc/helpers@0.5.21) - '@rspack/dev-server': 1.1.5(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + '@rspack/dev-server': 1.1.5(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) exit-hook: 4.0.0 webpack-bundle-analyzer: 4.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -19291,13 +19315,13 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.21 - '@rspack/dev-server@1.1.5(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7))': + '@rspack/dev-server@1.1.5(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@types/express@4.17.25)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1)': dependencies: '@rspack/core': 1.7.11(@swc/helpers@0.5.21) chokidar: 5.0.0 http-proxy-middleware: 2.0.9(@types/express@4.17.25) p-retry: 6.2.1 - webpack-dev-server: 5.2.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)) + webpack-dev-server: 5.2.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1) ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - '@types/express' @@ -19924,14 +19948,14 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7))': + '@tanstack/react-start@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1)': dependencies: '@tanstack/react-router': 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-start-client': 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-start-server': 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/router-utils': 1.154.7 '@tanstack/start-client-core': 1.157.16 - '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7)) + '@tanstack/start-plugin-core': 1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1) '@tanstack/start-server-core': 1.157.16 pathe: 2.0.3 react: 18.3.1 @@ -19974,7 +19998,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7))': + '@tanstack/router-plugin@1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -19993,7 +20017,7 @@ snapshots: optionalDependencies: '@tanstack/react-router': 1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) vite: 7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) - webpack: 5.102.1(esbuild@0.27.7) + webpack: 5.102.1 transitivePeerDependencies: - supports-color @@ -20020,7 +20044,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7))': + '@tanstack/start-plugin-core@1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1)': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -20028,7 +20052,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.157.16 '@tanstack/router-generator': 1.157.16 - '@tanstack/router-plugin': 1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1(esbuild@0.27.7)) + '@tanstack/router-plugin': 1.157.16(@tanstack/react-router@1.157.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(webpack@5.102.1) '@tanstack/router-utils': 1.154.7 '@tanstack/start-client-core': 1.157.16 '@tanstack/start-server-core': 1.157.16 @@ -29988,15 +30012,13 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.5.0(esbuild@0.27.7)(webpack@5.102.1(esbuild@0.27.7)): + terser-webpack-plugin@5.5.0(webpack@5.102.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.46.2 - webpack: 5.102.1(esbuild@0.27.7) - optionalDependencies: - esbuild: 0.27.7 + webpack: 5.102.1 terser@5.46.2: dependencies: @@ -31102,7 +31124,7 @@ snapshots: - bufferutil - utf-8-validate - webpack-dev-middleware@7.4.5(webpack@5.102.1(esbuild@0.27.7)): + webpack-dev-middleware@7.4.5(webpack@5.102.1): dependencies: colorette: 2.0.20 memfs: 4.50.0 @@ -31111,9 +31133,9 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.102.1(esbuild@0.27.7) + webpack: 5.102.1 - webpack-dev-server@5.2.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1(esbuild@0.27.7)): + webpack-dev-server@5.2.2(bufferutil@4.1.0)(utf-8-validate@5.0.10)(webpack@5.102.1): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -31141,10 +31163,10 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.102.1(esbuild@0.27.7)) + webpack-dev-middleware: 7.4.5(webpack@5.102.1) ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: - webpack: 5.102.1(esbuild@0.27.7) + webpack: 5.102.1 transitivePeerDependencies: - bufferutil - debug @@ -31161,7 +31183,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.102.1(esbuild@0.27.7): + webpack@5.102.1: dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -31185,7 +31207,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.5.0(esbuild@0.27.7)(webpack@5.102.1(esbuild@0.27.7)) + terser-webpack-plugin: 5.5.0(webpack@5.102.1) watchpack: 2.5.1 webpack-sources: 3.4.0 transitivePeerDependencies: From e0d497e6cc51063df7a2dd32f59bb5b5a235579a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 29 May 2026 15:46:13 +0200 Subject: [PATCH 2/2] FIx multi-declarator statements --- .../src/__tests__/require-auth-protection.test.ts | 14 ++++++++++++++ .../src/lib/protection-checks.ts | 11 +++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts b/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts index 6edcd59b6bf..866f7c50ff4 100644 --- a/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts +++ b/packages/eslint-plugin-next/src/__tests__/require-auth-protection.test.ts @@ -1048,6 +1048,20 @@ ruleTester.run('require-auth-protection', rule, { options: [config], errors: [{ messageId: 'missingProtect' }], }, + { + name: 'side effect in same declaration as auth() destructure is NOT accepted', + code: ` + import { auth } from '@clerk/nextjs/server'; + export default async function Page() { + const { userId } = await auth(), side = doWork(); + if (userId === null) return null; + return
; + } + `, + filename: abs('app/dashboard/page.tsx'), + options: [config], + errors: [{ messageId: 'missingProtect' }], + }, { name: 'preamble matched but with mixed non-preamble in same VariableDeclaration', code: ` diff --git a/packages/eslint-plugin-next/src/lib/protection-checks.ts b/packages/eslint-plugin-next/src/lib/protection-checks.ts index 24935e34e17..1e61a172d26 100644 --- a/packages/eslint-plugin-next/src/lib/protection-checks.ts +++ b/packages/eslint-plugin-next/src/lib/protection-checks.ts @@ -93,8 +93,15 @@ function capturedAuthBindings(stmt: TSESTree.Statement, authNames: Set): return null; } - // Only the first declarator counts: a later `await auth()` would be preceded - // by earlier declarators executing first. + // Require a single declarator: a multi-declarator statement such as + // `const { userId } = await auth(), side = doWork()` runs `side = doWork()` + // after the destructure but before the guard, so it must not be treated as + // protected (matching the rejection of any statement between destructure and + // guard). + if (stmt.declarations.length !== 1) { + return null; + } + const decl = stmt.declarations[0]; if (!decl) { return null;