@smittdev/next-jwt-auth 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +549 -0
  2. package/package.json +5 -1
package/README.md ADDED
@@ -0,0 +1,549 @@
1
+ # @smittdev/next-jwt-auth
2
+
3
+ Zero-config JWT authentication scaffolder for Next.js App Router, specifically designed for integrating with **3rd-party backend APIs**.
4
+
5
+ Run one command. Get a complete, production-ready auth system in your project — fully typed, fully yours.
6
+
7
+ ```bash
8
+ npx @smittdev/next-jwt-auth init
9
+ ```
10
+
11
+ ---
12
+
13
+ ## When to use this vs. Auth.js
14
+
15
+ [Auth.js (NextAuth)](https://authjs.dev/) is an incredible library and the gold standard for OAuth integrations (Google, GitHub, Apple, etc.). If your Next.js application *is* your backend and you need OAuth, you should use Auth.js.
16
+
17
+ However, **if you have a separate backend (Node, Go, Python, Java, etc.) that handles authentication and issues its own JWTs**, wiring it into Next.js can be tricky. This library exists to solve that specific problem.
18
+
19
+ It bridges the gap between your Next.js frontend and your external API server by:
20
+ - Managing the short-lived **access token** + long-lived **refresh token** lifecycle.
21
+ - Silently refreshing tokens before they expire using Next.js Middleware.
22
+ - Automatically synchronizing the user's session between Server Components and Client Components.
23
+
24
+ Instead of fighting an opinionated session format, this library scaffolds the plumbing and gets out of your way. You implement three adapter functions (`login`, `refreshToken`, `fetchUser`) that fetch from your API, and you own the resulting session.
25
+
26
+ ---
27
+
28
+ ## Philosophy
29
+
30
+ This is not an npm package you add as a dependency. It's a **code scaffolder** — like shadcn/ui, it copies a set of battle-tested TypeScript files into your project. You own the code from day one.
31
+
32
+ - No black boxes. No magic. Every line is in your codebase.
33
+ - Bring your own API. The library calls your adapter functions — you decide how tokens are issued and validated.
34
+ - No environment variables required. No secret keys managed by this library.
35
+ - Dual-token strategy (access + refresh) baked in.
36
+ - Full App Router support: Server Components, Server Actions, middleware, client hooks.
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ### 1. Scaffold
43
+
44
+ ```bash
45
+ npx @smittdev/next-jwt-auth init
46
+ ```
47
+
48
+ You'll be asked:
49
+ - Where to place the library (default: `lib/auth/` or `src/lib/auth/`)
50
+ - Whether to generate `middleware.ts` (or `proxy.ts` for Next.js 16+)
51
+ - Whether to install `zod` (the only peer dependency)
52
+
53
+ ### 2. Implement your adapter and configure
54
+
55
+ Open the generated `auth.ts` at your project root. Fill in the three required adapter functions and optionally tune the configuration:
56
+
57
+ ```typescript
58
+ // auth.ts
59
+ import { Auth } from "@/lib/auth";
60
+
61
+ export const auth = Auth({
62
+ // ── Adapter (required) ───────────────────────────────────────────────────
63
+ // Three functions are required. They call your backend API — you decide the
64
+ // shape of the request and response. The library only cares about the return
65
+ // types: TokenPair ({ accessToken, refreshToken }) and SessionUser ({ id, email, ... }).
66
+
67
+ adapter: {
68
+ // Called by loginAction() with whatever credentials you pass from the client.
69
+ async login(credentials) {
70
+ const res = await fetch("https://your-api.com/auth/login", {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify(credentials),
74
+ });
75
+ if (!res.ok) throw new Error("Invalid credentials");
76
+ return res.json(); // must return { accessToken, refreshToken }
77
+ },
78
+
79
+ // Called automatically by middleware and fetchSession when the access token
80
+ // is expired or within the refresh threshold. Never called on the client.
81
+ //
82
+ // ⚠️ Race condition warning: if the user has multiple tabs open, two tabs
83
+ // can call refreshToken() concurrently with the same refresh token. If your
84
+ // backend uses rotate-on-use (single-use) refresh tokens, one request will
85
+ // succeed and the other will receive a 401 — invalidating the session in
86
+ // that tab. To handle this gracefully your backend should either:
87
+ // a) Accept the same refresh token within a short reuse window (~5s), or
88
+ // b) Return the same new token pair for duplicate in-flight requests.
89
+ // If you use long-lived, multi-use refresh tokens this is not an issue.
90
+ async refreshToken(refreshToken) {
91
+ const res = await fetch("https://your-api.com/auth/refresh", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ refreshToken }),
95
+ });
96
+ if (!res.ok) throw new Error("Session expired");
97
+ return res.json(); // must return { accessToken, refreshToken }
98
+ },
99
+
100
+ // Called after login and during fetchSession to populate session.user.
101
+ // Return whatever user fields your app needs — extend SessionUser below
102
+ // via module augmentation to get full type safety.
103
+ async fetchUser(accessToken) {
104
+ const res = await fetch("https://your-api.com/me", {
105
+ headers: { Authorization: `Bearer ${accessToken}` },
106
+ });
107
+ if (!res.ok) throw new Error("Failed to fetch user");
108
+ return res.json(); // must return { id, email, ...any extra fields }
109
+ },
110
+
111
+ // Optional — called on logout to invalidate the refresh token server-side.
112
+ // If omitted, logout still clears cookies locally but skips the API call.
113
+ // Errors here are non-fatal — cookies are cleared regardless.
114
+ async logout({ refreshToken }) {
115
+ await fetch("https://your-api.com/auth/logout", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ refreshToken }),
119
+ });
120
+ },
121
+ },
122
+
123
+ // ── Cookies (optional) ───────────────────────────────────────────────────
124
+ // All token cookies are httpOnly and never accessible to JavaScript.
125
+ // The `name` value is used as a base — two cookies are created:
126
+ // <name>.access — short-lived access token
127
+ // <name>.refresh — long-lived refresh token
128
+
129
+ cookies: {
130
+ name: "auth-session", // default: "auth-session"
131
+ secure: true, // default: true in production, false in development
132
+ sameSite: "lax", // default: "lax" — use "strict" for stricter CSRF protection
133
+ path: "/", // default: "/"
134
+ // domain: "example.com", // optional — set for cross-subdomain sharing
135
+ },
136
+
137
+ // ── Token refresh (optional) ─────────────────────────────────────────────
138
+ // Controls when the middleware proactively refreshes an access token before
139
+ // it expires. If the token has less than `refreshThresholdSeconds` remaining,
140
+ // the middleware calls adapter.refreshToken() and writes new cookies.
141
+
142
+ refresh: {
143
+ refreshThresholdSeconds: 60, // default: 60 (refresh when < 60s remain on the access token)
144
+ // Increase to e.g. 3600 to refresh proactively when < 1 hour remains on the access token.
145
+ },
146
+
147
+ // ── Pages (optional) ─────────────────────────────────────────────────────
148
+ // Redirect targets used by requireSession(), loginAction(), and logoutAction().
149
+
150
+ pages: {
151
+ signIn: "/login", // default: "/login" — requireSession() + logoutAction() redirect here
152
+ home: "/", // default: "/" — loginAction() redirects here
153
+ },
154
+
155
+ // ── Debug (optional) ─────────────────────────────────────────────────────
156
+ // Logs token refresh decisions, session resolution, middleware activity,
157
+ // and action outcomes to the server console. Keep off in production.
158
+
159
+ debug: process.env.NODE_ENV === "development",
160
+ });
161
+
162
+ // ── Extending the user type (optional) ──────────────────────────────────────
163
+ // Declare extra fields on SessionUser via module augmentation.
164
+ // These fields will be typed everywhere: server helpers, useSession(), middleware.
165
+
166
+ declare module "@/lib/auth" {
167
+ interface SessionUser {
168
+ name: string;
169
+ role: "admin" | "user";
170
+ avatarUrl?: string;
171
+ }
172
+ }
173
+ ```
174
+
175
+ ### 3. Wrap your layout
176
+
177
+ ```tsx
178
+ // app/layout.tsx
179
+ import { auth } from "@/auth";
180
+ import { AuthProvider } from "@/lib/auth/client";
181
+
182
+ export default function RootLayout({ children }) {
183
+ return (
184
+ <html>
185
+ <body>
186
+ <AuthProvider actions={auth.actions}>{children}</AuthProvider>
187
+ </body>
188
+ </html>
189
+ );
190
+ }
191
+ ```
192
+
193
+ That's it. Auth is ready.
194
+
195
+ ---
196
+
197
+ ## CLI Commands
198
+
199
+ ### `init`
200
+
201
+ Scaffolds the auth library into your project. Detects your project setup (Next.js version, TypeScript, package manager, tsconfig alias) and runs interactively.
202
+
203
+ ```bash
204
+ npx @smittdev/next-jwt-auth init
205
+ ```
206
+
207
+ ### `update`
208
+
209
+ Updates the library files to the latest version without touching your `auth.ts` adapter implementation. Reports added, modified, and removed files.
210
+
211
+ ```bash
212
+ npx @smittdev/next-jwt-auth update
213
+
214
+ # Preview what would change without writing any files
215
+ npx @smittdev/next-jwt-auth update --dry-run
216
+ ```
217
+
218
+ ### `check`
219
+
220
+ Validates your project setup. Runs six checks and reports pass/warn/fail for each:
221
+
222
+ 1. Library directory is installed
223
+ 2. `auth.ts` exists
224
+ 3. Adapter functions are implemented (not stubs)
225
+ 4. `AuthProvider` is present in the root layout
226
+ 5. `middleware.ts` / `proxy.ts` exists and is configured correctly
227
+ 6. Import alias in `auth.ts` matches `tsconfig.json`
228
+
229
+ ```bash
230
+ npx @smittdev/next-jwt-auth check
231
+ ```
232
+
233
+ Exits with code `1` if any check fails.
234
+
235
+ ### `uninstall`
236
+
237
+ Removes the scaffolded auth files from your project. Interactively asks whether to delete the library directory, `auth.ts`, and `middleware.ts` / `proxy.ts` — so you can keep whatever you want.
238
+
239
+ ```bash
240
+ npx @smittdev/next-jwt-auth uninstall
241
+ ```
242
+
243
+ > `auth.ts` defaults to **no** when prompted — it contains your adapter implementation and is skipped unless you explicitly confirm.
244
+
245
+ ### `--version` / `--help`
246
+
247
+ ```bash
248
+ npx @smittdev/next-jwt-auth --version
249
+ npx @smittdev/next-jwt-auth --help
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Usage
255
+
256
+ ### Server Components
257
+
258
+ ```tsx
259
+ import { auth } from "@/auth";
260
+
261
+ // Get session (returns null if unauthenticated)
262
+ const session = await auth.getSession();
263
+
264
+ // Require session (redirects to /login if unauthenticated)
265
+ const session = await auth.requireSession();
266
+
267
+ // Require session and append ?callbackUrl= to the redirect
268
+ const session = await auth.requireSession({ includeCallbackUrl: true });
269
+
270
+ // Individual token/user helpers
271
+ const user = await auth.getUser();
272
+ const accessToken = await auth.getAccessToken();
273
+ const refreshToken = await auth.getRefreshToken();
274
+ ```
275
+
276
+ ### Client Components
277
+
278
+ ```tsx
279
+ "use client";
280
+ import { useSession, useAuth } from "@/lib/auth/client";
281
+
282
+ function MyComponent() {
283
+ const session = useSession();
284
+ const { login, logout, fetchSession } = useAuth();
285
+
286
+ if (session.status === "loading") return <Spinner />;
287
+ if (session.status === "unauthenticated") return <LoginButton />;
288
+
289
+ // session.status === "authenticated"
290
+ return <p>Hello, {session.user.email}</p>;
291
+ }
292
+ ```
293
+
294
+ ### Login Form
295
+
296
+ ```tsx
297
+ "use client";
298
+ import { useAuth } from "@/lib/auth/client";
299
+ import { useRouter } from "next/navigation";
300
+
301
+ export function LoginForm() {
302
+ const { login } = useAuth();
303
+ const router = useRouter();
304
+
305
+ async function handleSubmit(e) {
306
+ e.preventDefault();
307
+ const result = await login({ email, password });
308
+ if (result.success) router.push("/dashboard");
309
+ else setError(result.error);
310
+ }
311
+
312
+ return <form onSubmit={handleSubmit}>...</form>;
313
+ }
314
+ ```
315
+
316
+ Pass `redirect: false` to handle navigation yourself instead of letting the action redirect automatically:
317
+
318
+ ```typescript
319
+ await login(credentials, { redirect: false });
320
+ await login(credentials, { redirectTo: "/onboarding" });
321
+ ```
322
+
323
+ ### Middleware / Route Protection
324
+
325
+ The generated `middleware.ts` (or `proxy.ts` on Next.js 16+) runs on the edge before every request. Use it for **token refresh and coarse-grained routing** — it is not a replacement for per-page auth checks.
326
+
327
+ > **Important Limitation:** The Next.js middleware *only* runs when a page navigation happens or when users explicitly refresh the page. This library will silently refresh expired tokens dynamically *during those requests*. **However**, if you have long-lived client-side pages and make API requests with `axios` or `fetch`, the middleware will NOT run for those API requests. You must handle silent refreshes for client-side API calls inside an interceptor and then call `updateSessionToken(newToken)` to sync the new token into the cookies so the rest of the app can see it.
328
+
329
+ ```typescript
330
+ // middleware.ts
331
+ import { NextRequest, NextResponse } from "next/server";
332
+ import { auth } from "@/auth";
333
+
334
+ const resolveAuth = auth.createMiddleware();
335
+
336
+ export default async function middleware(request: NextRequest) {
337
+ const session = await resolveAuth(request);
338
+ const { pathname } = request.nextUrl;
339
+
340
+ const isProtected = auth.matchesPath(pathname, ["/dashboard/:path*", "/settings"]);
341
+ const isAuthPage = auth.matchesPath(pathname, ["/login", "/register"]);
342
+
343
+ // Redirect unauthenticated users away from protected routes
344
+ if (isProtected && !session.isAuthenticated) {
345
+ return session.redirect(new URL("/login", request.url));
346
+ }
347
+
348
+ // Redirect authenticated users away from auth pages
349
+ if (isAuthPage && session.isAuthenticated) {
350
+ return session.redirect(new URL("/dashboard", request.url));
351
+ }
352
+
353
+ return session.response(NextResponse.next());
354
+ }
355
+
356
+ export const config = {
357
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
358
+ };
359
+ ```
360
+
361
+ Then guard individual pages with `requireSession()` inside the page itself:
362
+
363
+ ```tsx
364
+ // app/dashboard/page.tsx
365
+ import { auth } from "@/auth";
366
+
367
+ export default async function DashboardPage() {
368
+ // This calls your adapter's fetchUser — confirms the session is real and fresh
369
+ const session = await auth.requireSession();
370
+
371
+ return <p>Welcome, {session.user.email}</p>;
372
+ }
373
+ ```
374
+
375
+ **Path pattern syntax:**
376
+
377
+ | Pattern | Matches |
378
+ |---------|---------|
379
+ | `/dashboard` | Exact path only |
380
+ | `/dashboard/:path*` | `/dashboard` and all sub-routes |
381
+ | `/user/:id` | `/user/123`, `/user/abc`, etc. |
382
+
383
+ ### SSR Hydration
384
+
385
+ Pass `initialSession` from the server to eliminate the loading flash on first render:
386
+
387
+ ```tsx
388
+ // app/layout.tsx
389
+ import { auth } from "@/auth";
390
+ import { AuthProvider } from "@/lib/auth/client";
391
+
392
+ export default async function RootLayout({ children }) {
393
+ const session = await auth.getSession();
394
+
395
+ return (
396
+ <html>
397
+ <body>
398
+ <AuthProvider actions={auth.actions} initialSession={session}>
399
+ {children}
400
+ </AuthProvider>
401
+ </body>
402
+ </html>
403
+ );
404
+ }
405
+ ```
406
+
407
+ - `initialSession={session}` — starts as `"authenticated"` with user data (no fetch on mount)
408
+ - `initialSession={null}` — starts as `"unauthenticated"` immediately (server confirmed no session)
409
+ - `initialSession` omitted — starts as `"loading"`, fetches on mount (default behavior)
410
+
411
+ > **Static rendering:** If you want your layout to be statically rendered at build time (e.g. for a marketing site or a public-facing shell), do **not** call `auth.getSession()` in the layout and pass it to `AuthProvider`. Reading cookies in the layout forces Next.js to opt the entire route into dynamic rendering. Instead, omit `initialSession` and let the client fetch the session on mount — your layout stays static and only the parts that need auth become dynamic.
412
+
413
+ ### Session Expiry Handling
414
+
415
+ Use `onSessionExpired` to react when a background revalidation discovers the session has ended:
416
+
417
+ ```tsx
418
+ <AuthProvider
419
+ actions={auth.actions}
420
+ onSessionExpired={() => {
421
+ toast.error("Your session expired. Please log in again.");
422
+ router.push("/login");
423
+ }}
424
+ >
425
+ {children}
426
+ </AuthProvider>
427
+ ```
428
+
429
+ Disable the automatic refresh-on-focus behavior if needed:
430
+
431
+ ```tsx
432
+ <AuthProvider actions={auth.actions} refreshOnFocus={false}>
433
+ {children}
434
+ </AuthProvider>
435
+ ```
436
+
437
+ ### Data Fetching Utilities
438
+
439
+ Run server-side data fetches that automatically receive the current session:
440
+
441
+ ```typescript
442
+ import { auth } from "@/auth";
443
+
444
+ // Run callback if session exists, return null otherwise
445
+ const data = await auth.withSession(async (session) => {
446
+ return fetchPublicFeed(session.user.id);
447
+ });
448
+
449
+ // Run callback or redirect to sign-in
450
+ const data = await auth.withRequiredSession(async (session) => {
451
+ return fetchProtectedData(session.accessToken);
452
+ });
453
+ ```
454
+
455
+ ---
456
+
457
+ ## File Structure
458
+
459
+ After running `init`, your project will have:
460
+
461
+ ```
462
+ auth.ts ← Your adapter + config (edit this)
463
+ middleware.ts ← Route protection (edit this; proxy.ts on Next.js 16+)
464
+ lib/auth/
465
+ .version ← Installed CLI version (do not edit — used by `update`)
466
+ index.ts ← Auth() factory + all public exports
467
+ types.ts ← All TypeScript types
468
+ config.ts ← Global config singleton (internal)
469
+ core/
470
+ jwt.ts ← JWT decode + expiry utilities
471
+ cookies.ts ← httpOnly cookie helpers
472
+ config.ts ← Config builder + defaults
473
+ server/
474
+ session.ts ← getSession(), requireSession(), etc.
475
+ actions.ts ← Server Actions (login, logout, fetchSession)
476
+ fetchers.ts ← withSession(), withRequiredSession()
477
+ middleware/
478
+ auth-middleware.ts ← Middleware resolver + matchesPath()
479
+ client/
480
+ provider.tsx ← <AuthProvider>, useSession(), useAuth()
481
+ ```
482
+
483
+ ---
484
+
485
+ ## API Reference
486
+
487
+ ### `Auth(config)` — `auth.ts`
488
+
489
+ | Option | Type | Default | Description |
490
+ |--------|------|---------|-------------|
491
+ | `adapter.login` | `(credentials) => Promise<TokenPair>` | required | Authenticate and return tokens |
492
+ | `adapter.refreshToken` | `(token) => Promise<TokenPair>` | required | Exchange refresh token for new pair |
493
+ | `adapter.fetchUser` | `(token) => Promise<SessionUser>` | required | Return user data for an access token |
494
+ | `adapter.logout` | `(tokens) => Promise<void>` | optional | Invalidate refresh token server-side |
495
+ | `cookies.name` | `string` | `"auth-session"` | Cookie base name |
496
+ | `cookies.secure` | `boolean` | `true` in prod | Secure cookie flag |
497
+ | `cookies.sameSite` | `string` | `"lax"` | SameSite cookie attribute |
498
+ | `refresh.refreshThresholdSeconds` | `number` | `60` | Seconds before expiry to proactively refresh. Refresh triggers when this many seconds remain on the access token |
499
+ | `pages.signIn` | `string` | `"/login"` | Sign-in page — used by `requireSession()` and post-logout redirect |
500
+ | `pages.home` | `string` | `"/"` | Post-login redirect |
501
+ | `debug` | `boolean` | `false` | Log debug info to console |
502
+
503
+ ### Server Helpers
504
+
505
+ | Function | Returns | Description |
506
+ |----------|---------|-------------|
507
+ | `auth.getSession()` | `Session \| null` | Current session or null |
508
+ | `auth.requireSession(opts?)` | `Session` | Session or redirect to sign-in |
509
+ | `auth.getUser()` | `SessionUser \| null` | Current user or null |
510
+ | `auth.getAccessToken()` | `string \| null` | Current access token or null |
511
+ | `auth.getRefreshToken()` | `string \| null` | Current refresh token or null |
512
+ | `auth.withSession(cb, default?)` | `TResult \| null` | Run callback if authenticated |
513
+ | `auth.withRequiredSession(cb)` | `TResult` | Run callback or redirect |
514
+
515
+ ### Middleware
516
+
517
+ | Function | Returns | Description |
518
+ |----------|---------|-------------|
519
+ | `auth.createMiddleware()` | `(req) => Promise<AuthMiddlewareResult>` | Creates middleware resolver with auto token refresh |
520
+ | `auth.matchesPath(pathname, patterns)` | `boolean` | Match pathname against wildcard patterns |
521
+
522
+ `AuthMiddlewareResult` has:
523
+ - `isAuthenticated: boolean` — valid, non-expired access token exists in cookie
524
+ - `accessToken: string \| null`
525
+ - `refreshToken: string \| null`
526
+ - `response(base: NextResponse): NextResponse` — applies refreshed cookies to response
527
+ - `redirect(url: URL): NextResponse` — redirects and clears token cookies
528
+
529
+ ### Client Hooks
530
+
531
+ | Hook | Returns | Description |
532
+ |------|---------|-------------|
533
+ | `useSession()` | `ClientSession` | Reactive session state (`"loading"` / `"authenticated"` / `"unauthenticated"`) |
534
+ | `useAuth()` | `{ login, logout, fetchSession, updateSessionToken }` | Auth action handlers. `fetchSession` syncs client state — silently rotates tokens if expired before returning. `updateSessionToken` allows injecting a new accessToken from outside the library (e.g. via an axios interceptor) and syncing it into the cookies. |
535
+
536
+ ### `<AuthProvider>` Props
537
+
538
+ | Prop | Type | Default | Description |
539
+ |------|------|---------|-------------|
540
+ | `actions` | `AuthActions` | required | Pass `auth.actions` from your `auth.ts` |
541
+ | `initialSession` | `Session \| null \| undefined` | `undefined` | Server session for SSR hydration |
542
+ | `onSessionExpired` | `() => void` | — | Called when background revalidation finds session gone |
543
+ | `refreshOnFocus` | `boolean` | `true` | Revalidate session when tab regains focus |
544
+
545
+ ---
546
+
547
+ ## License
548
+
549
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smittdev/next-jwt-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Zero-config JWT authentication scaffolder for Next.js App Router",
5
5
  "keywords": [
6
6
  "next.js",
@@ -12,6 +12,10 @@
12
12
  ],
13
13
  "author": "ss",
14
14
  "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/smitt2767/next-jwt-auth.git"
18
+ },
15
19
  "engines": {
16
20
  "node": ">=18.0.0"
17
21
  },