Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eslint-plugin-auth-protect-suggestion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/eslint-plugin': minor
---

The `require-auth-protection` rule now offers an editor quick-fix suggestion for unprotected resources that inserts `await auth.protect()` at the top of the function. Suggestions are opt-in and are not applied by `eslint --fix`.
5 changes: 5 additions & 0 deletions .changeset/eslint-plugin-fix-auth-protect-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/eslint-plugin': minor
---

Add a bulk auto-fixer for the `require-auth-protection` rule, available both as the `clerk-fix-auth-protection` command and as a `fixAuthProtection()` function exported from `@clerk/eslint-plugin/fix-auth-protection`. It lints with your existing ESLint config and applies the `await auth.protect()` suggestion to every resource it can safely fix, reporting the rest as needing manual attention. Supports `--dry-run`. Additionally, when a flagged function already starts with `await auth()`, the fix now rewrites that call to `await auth.protect()` instead of inserting a duplicate call.
96 changes: 92 additions & 4 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,96 @@ export default async function Page() {
}
```

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.

## Suggestions

For each unprotected resource it flags, the rule offers an editor quick-fix suggestion that inserts `await auth.protect()` at the top of the function (making it `async` and adding the `import { auth } from '@clerk/nextjs/server'` import if needed). Suggestions are opt-in: they appear in your editor's quick-fix menu and are not applied by `eslint --fix`, since adding a protection check changes runtime behavior.

## Bulk auto-fixing

> [!WARNING]
> Applying these fixes changes the runtime behavior of your application — `await auth.protect()` enforces authentication where there potentially was none, or might override custom auth checks that were already in place. Always review the changes and test your application afterwards.

Because the protection insertion is a suggestion rather than an autofix, `eslint --fix` deliberately won't apply it. To apply it across many files at once, use the bundled command, which lints with your existing ESLint config (so your protected/public globs are honored) and applies the suggestion to every resource it can safely fix:

```sh
# Fix everything under the current directory
npx clerk-fix-auth-protection

# Scope it, or preview without writing
npx clerk-fix-auth-protection "app/**"
npx clerk-fix-auth-protection --dry-run
```

Resources the rule can't safely fix on its own (imported/wrapped exports, unacknowledged mixed-scope layouts) are listed as needing manual attention, and the command exits non-zero when any remain (or when `--dry-run` would make changes), so it can gate CI.

The same logic is available programmatically:

```ts
import { fixAuthProtection } from '@clerk/eslint-plugin/fix-auth-protection';

const { fixed, unresolved } = await fixAuthProtection({
patterns: ['app/**'],
dryRun: false,
});

## Implementation details

This section describes the exact details of how the lint rule works. You normally do no need to read or understand this if you only want to use the rule.

Within folders that are configured as protected (and that eslint covers), this rule checks:

- No files with `'use client'` at the top - Early bailout for these
- The default export of `page`, `layout`, `template`, `default` files
- All http verb exports of `route` files (`GET`, `POST` etc - API endpoints)
- All exports of files with `'use server'` at the top (Server Functions)
- All inline functions that has `'use server'` at the top of the function (Inline Server Functions)

Notably, it does not:

- Check `loading` or `error` files
- These normally don't use privileged resources, but if yours do, make sure you protect them
- Check arbitrary Server Components
- Only the different page entrypoints listed above are checked
- If you are importing a Server Component doing privileged access into a non-protected page, like an admin panel on an otherwise public page, it should be guarded but the lint rule does not detect this

At the top of the relevant async function, after any directives or TypeScript-only declarations, to count as protected the rule accepts these patterns:

```tsx
// -- Using the default .protect() behavior --
await auth.protect()
await (await auth()).protect()
// Any kind of variable declaration is okay
const { userId } = await auth.protect();

// -- Custom handling --
const { isAuthenticated, userId, sessionId } = await auth();
// Any of these checks are okay
// Note: For useAuth() on the client !userId can also mean
// "loading", but here it's fine
if (
!userId || userId == null || userId === null ||
!sessionId || sessionId == null || sessionId === null ||
!isAuthenticated
) {
// It is fine to have arbitrary code here:
console.log('Unauthenticated');
// To count as protected, the function needs to have an
// unconditional "exit" at the top level, these count:
return;
throw;
// The Next.js versions of these functions throw errors
// and counts as exits, note that we match these by name,
// we do not currently trace them back to the real imports
redirect();
permanentRedirect();
notFound();
unauthorized();
forbidden();
}
```

## Support

For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_eslint_plugin).
Expand All @@ -125,9 +211,11 @@ We're open to all community contributions! Please read [our contribution guideli

## Security

`@clerk/eslint-plugin` 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.
`@clerk/eslint-plugin` 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 it will catch everything, there might be edge cases it does not catch.

We aim to fix bugs leading to false negatives promptly, but they are not considered vulnerabilities and will not lead to us posting advisories. You are free to file lint rule bugs via normal [GitHub issues](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml).

_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
_For more information and to report what you think is a security issue, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._

## License

Expand Down
13 changes: 13 additions & 0 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,22 @@
"default": "./dist/index.js"
}
},
"./fix-auth-protection": {
"import": {
"types": "./dist/fix-auth-protection.d.mts",
"default": "./dist/fix-auth-protection.mjs"
},
"require": {
"types": "./dist/fix-auth-protection.d.ts",
"default": "./dist/fix-auth-protection.js"
}
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"bin": {
"clerk-fix-auth-protection": "./dist/cli.js"
},
"files": [
"dist"
],
Expand Down
178 changes: 178 additions & 0 deletions packages/eslint-plugin/src/__tests__/fix-auth-protection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import * as tsParser from '@typescript-eslint/parser';
import { ESLint, type Linter as LinterTypes } from 'eslint';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { fixAuthProtection } from '../fix-auth-protection';
import rule from '../rules/next/require-auth-protection';

// Register the rule directly (rather than importing the `next` plugin barrel,
// which references the build-time `PACKAGE_VERSION` global) so the runner sees
// a `.../require-auth-protection` rule id.
function createESLint(cwd: string): ESLint {
return new ESLint({
cwd,
overrideConfigFile: true,
overrideConfig: {
files: ['**/*.{ts,tsx}'],
plugins: { '@clerk/next': { rules: { 'require-auth-protection': rule } } },
languageOptions: {
parser: tsParser as unknown as LinterTypes.Parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: {
'@clerk/next/require-auth-protection': ['error', { protected: ['app/**'], public: ['app/sign-in/**'] }],
},
},
});
}

let projectRoot: string;

async function write(relPath: string, contents: string): Promise<string> {
const abs = path.join(projectRoot, relPath);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, contents, 'utf8');
return abs;
}

beforeEach(async () => {
projectRoot = await mkdtemp(path.join(tmpdir(), 'clerk-fix-auth-'));
});

afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
});

describe('fixAuthProtection', () => {
it('protects flagged resources and leaves public/protected ones alone', async () => {
const page = await write(
'app/dashboard/page.tsx',
`export default function Page() {
return <div>Hello</div>;
}
`,
);
const signIn = await write(
'app/sign-in/page.tsx',
`export default function Page() {
return <div>Sign in</div>;
}
`,
);
const alreadyProtected = await write(
'app/settings/page.tsx',
`import { auth } from '@clerk/nextjs/server';

export default async function Page() {
await auth.protect();
return <div>Settings</div>;
}
`,
);

const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) });

expect(result.unresolved).toEqual([]);
expect(result.fixed).toEqual([{ filePath: page, protections: 1 }]);

expect(await readFile(page, 'utf8')).toBe(`import { auth } from '@clerk/nextjs/server';
export default async function Page() {
await auth.protect();
return <div>Hello</div>;
}
`);
// Public + already-protected files are untouched.
expect(await readFile(signIn, 'utf8')).toContain('Sign in');
expect(await readFile(signIn, 'utf8')).not.toContain('auth.protect');
expect(await readFile(alreadyProtected, 'utf8')).toMatch(/await auth\.protect\(\);[\s\S]*Settings/);
});

it('protects multiple resources in a single file (shared import added once)', async () => {
const route = await write(
'app/api/widgets/route.ts',
`export async function GET() {
return new Response('ok');
}

export async function POST() {
return new Response('ok');
}
`,
);

const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) });

expect(result.fixed).toEqual([{ filePath: route, protections: 2 }]);

const output = await readFile(route, 'utf8');
expect(output).toBe(`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');
}
`);
// The import is added exactly once even though two resources were fixed.
expect(output.match(/@clerk\/nextjs\/server/g)).toHaveLength(1);
});

it('merges into an existing await auth() call instead of duplicating it', async () => {
const page = await write(
'app/dashboard/page.tsx',
`import { auth } from '@clerk/nextjs/server';

export default async function Page() {
const { userId } = await auth();
return <div>{userId}</div>;
}
`,
);

await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) });

expect(await readFile(page, 'utf8')).toBe(`import { auth } from '@clerk/nextjs/server';

export default async function Page() {
const { userId } = await auth.protect();
return <div>{userId}</div>;
}
`);
});

it('does not write files in dryRun but still reports them', async () => {
const original = `export default function Page() {
return <div>Hello</div>;
}
`;
const page = await write('app/dashboard/page.tsx', original);

const result = await fixAuthProtection({
cwd: projectRoot,
patterns: ['.'],
dryRun: true,
eslint: createESLint(projectRoot),
});

expect(result.fixed).toEqual([{ filePath: page, protections: 1 }]);
expect(await readFile(page, 'utf8')).toBe(original);
});

it('reports resources that have no safe automatic fix as unresolved', async () => {
const reexport = await write('app/dashboard/page.tsx', `export { default } from './impl';\n`);

const result = await fixAuthProtection({ cwd: projectRoot, patterns: ['.'], eslint: createESLint(projectRoot) });

expect(result.fixed).toEqual([]);
expect(result.unresolved).toHaveLength(1);
expect(result.unresolved[0].filePath).toBe(reexport);
expect(result.unresolved[0].issues[0].message).toContain("exported from './impl'");
});
});
Loading
Loading