next-sanctum 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alizio Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,647 @@
1
+ # next-sanctum
2
+
3
+ [![CI](https://github.com/aliziodev/next-sanctum/actions/workflows/ci.yml/badge.svg)](https://github.com/aliziodev/next-sanctum/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/next-sanctum.svg)](https://www.npmjs.com/package/next-sanctum)
5
+ [![npm downloads](https://img.shields.io/npm/dm/next-sanctum.svg)](https://www.npmjs.com/package/next-sanctum)
6
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/next-sanctum)](https://bundlephobia.com/package/next-sanctum)
7
+ [![license](https://img.shields.io/npm/l/next-sanctum.svg)](https://github.com/aliziodev/next-sanctum/blob/main/LICENSE)
8
+
9
+ A complete **Laravel (Fortify + Sanctum)** authentication client for the **Next.js** App Router.
10
+ Cookie/CSRF SPA + token/Bearer modes, SSR & CSR, route protection via `proxy.ts`, **2FA**,
11
+ **passkeys**, the full set of Fortify flows, and an authenticated client for **CRUD beyond auth**.
12
+
13
+ - ✅ Cookie/CSRF SPA (default) & token/Bearer
14
+ - ✅ SSR (Server Component, Route Handler, Server Action) + Client hooks
15
+ - ✅ Authenticated data fetching — reads (`useApi`), mutations (`useClient`), server (`serverFetch`)
16
+ - ✅ Full Fortify flows · 2FA TOTP · Passkeys (interop with `@laravel/passkeys`)
17
+ - ✅ TypeScript-first, dual ESM/CJS, tree-shakeable, **zero runtime deps** (~10 kB gzip)
18
+
19
+ > Compatible with **Next.js 15/16**, **React 18/19**, **Node 18.18+**.
20
+
21
+ > 🚀 **Want a ready-made app?** The [**Laravel + Next.js starter kit**](https://github.com/aliziodev/laravel-next-starter-kit) is built on next-sanctum — a full decoupled app (login, registration, 2FA, passkeys, settings, dark mode) scaffolded in one command:
22
+ > `laravel new my-app --using=aliziodev/laravel-next-starter-kit`
23
+
24
+ ## Table of contents
25
+
26
+ - [Installation](#installation) · [Quick start](#quick-start-cookie-mode)
27
+ - [Hooks](#hooks-client) · [**Authenticated data & CRUD**](#authenticated-data--crud-beyond-auth)
28
+ - [Server helpers](#server-helpers-next-sanctumserver) · [Route guard](#route-guard-proxyts) · [Server Actions](#login-via-server-action-next-sanctumactions)
29
+ - [2FA](#2fa-fortify) · [Passkeys](#passkeys-interop) · [Token mode](#token-mode-bearer) · [Catch-all proxy](#catch-all-server-proxy-anti-ssrf)
30
+ - [**Configuration reference**](#configuration-reference) · [**Responses & return types**](#responses--return-types) · [**Error handling**](#error-handling)
31
+ - [Events & interceptors](#events--interceptors) · [TypeScript](#typescript-the-user-model) · [Security](#security)
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pnpm add next-sanctum
37
+ # optional (only if you use passkeys):
38
+ pnpm add @laravel/passkeys
39
+ ```
40
+
41
+ ```env
42
+ NEXT_PUBLIC_SANCTUM_BASE_URL=https://api.domain.com # client (public)
43
+ SANCTUM_BASE_URL=https://api.domain.com # server (do NOT make public)
44
+ ```
45
+
46
+ ## Quick start (cookie mode)
47
+
48
+ Prefetch the user on the server → seed the provider (prevents a hydration mismatch):
49
+
50
+ ```tsx
51
+ // app/layout.tsx (Server Component)
52
+ import { getUser } from "next-sanctum/server"
53
+ import { Providers } from "./providers"
54
+
55
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
56
+ const initialUser = await getUser()
57
+ return (
58
+ <html lang="en">
59
+ <body><Providers initialUser={initialUser}>{children}</Providers></body>
60
+ </html>
61
+ )
62
+ }
63
+ ```
64
+
65
+ ```tsx
66
+ // app/providers.tsx (Client)
67
+ "use client"
68
+ import { SanctumProvider } from "next-sanctum"
69
+
70
+ export function Providers({ children, initialUser }: { children: React.ReactNode; initialUser?: unknown }) {
71
+ return (
72
+ <SanctumProvider
73
+ config={{ baseUrl: process.env.NEXT_PUBLIC_SANCTUM_BASE_URL!, mode: "cookie" }}
74
+ initialUser={initialUser}
75
+ >
76
+ {children}
77
+ </SanctumProvider>
78
+ )
79
+ }
80
+ ```
81
+
82
+ ```tsx
83
+ "use client"
84
+ import { useAuth } from "next-sanctum"
85
+
86
+ export function LoginForm() {
87
+ const { login } = useAuth()
88
+ async function onSubmit(email: string, password: string) {
89
+ const result = await login({ email, password })
90
+ if (result.status === "two-factor-required") {
91
+ // redirect to the 2FA challenge screen → useTwoFactor().challenge({ code })
92
+ } else {
93
+ // result.status === "authenticated"; result.user is populated
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ## Hooks (client)
100
+
101
+ | Hook | Returns |
102
+ |---|---|
103
+ | `useAuth<T>()` | `user`, `isAuthenticated`, `isLoading`, `login`, `logout`, `refresh`, `register`, `forgotPassword`, `resetPassword`, `confirmPassword`, `confirmedPasswordStatus`, `updatePassword`, `updateProfile`, `resendEmailVerification` |
104
+ | `useUser<T>()` | the reactive user (`T \| null`) |
105
+ | `useApi<T>(path, opts?)` | a GET resource → `{ data, error, isLoading, refetch }` (auto-fetches) |
106
+ | `useClient()` | the authenticated client for imperative requests → `{ request, raw, ensureCsrf, config }` |
107
+ | `useResource<T>(base)` | typed REST CRUD over `useClient` → `{ list, get, create, update, patch, delete }` |
108
+ | `useMutation(fn, opts?)` | imperative mutation + loading + lifecycle → `{ mutate, mutateAsync, isPending, error, data, reset }` |
109
+ | `useTwoFactor()` | `challenge`, `enable`, `confirm`, `disable`, `getQrCode`, `getSecretKey`, `getRecoveryCodes`, `regenerateRecoveryCodes` |
110
+ | `usePasskeys()` | `isSupported`, `register`, `login`, `confirmPassword`, `delete` (requires `@laravel/passkeys`) |
111
+
112
+ ## Authenticated data & CRUD (beyond auth)
113
+
114
+ > **Credentials are automatic.** Every request via `useApi` / `useClient` / `useResource` /
115
+ > `serverFetch` attaches the base URL plus — in **cookie mode** — `credentials: include` (session
116
+ > cookie) and the `X-XSRF-TOKEN` CSRF header on stateful methods, or — in **token mode** — the
117
+ > `Authorization: Bearer <token>` header. You never wire credentials manually.
118
+
119
+ ### Read — `useApi` (auto-fetch)
120
+
121
+ ```tsx
122
+ "use client"
123
+ import { useApi } from "next-sanctum"
124
+
125
+ type Post = { id: number; title: string }
126
+
127
+ function Posts() {
128
+ const { data, error, isLoading, refetch } = useApi<Post[]>("/api/posts")
129
+ if (isLoading) return <p>Loading…</p>
130
+ if (error) return <p>{error.message}</p>
131
+ return (
132
+ <>
133
+ <button onClick={() => refetch()}>Reload</button>
134
+ <ul>{data?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
135
+ </>
136
+ )
137
+ }
138
+ ```
139
+
140
+ `useApi` fetches on mount and whenever `path` / `method` / `json` / `body` change. For SWR or
141
+ TanStack Query, build on `useClient()` instead.
142
+
143
+ ### Create / Update / Delete — `useClient` (imperative)
144
+
145
+ ```tsx
146
+ "use client"
147
+ import { useClient } from "next-sanctum"
148
+
149
+ type Post = { id: number; title: string }
150
+
151
+ function usePosts() {
152
+ const { request } = useClient() // request<T>() returns parsed JSON; raw() returns a Response
153
+
154
+ return {
155
+ create: (title: string) =>
156
+ request<Post>("/api/posts", { method: "POST", json: { title } }),
157
+ update: (id: number, title: string) =>
158
+ request<Post>(`/api/posts/${id}`, { method: "PUT", json: { title } }),
159
+ remove: (id: number) =>
160
+ request<void>(`/api/posts/${id}`, { method: "DELETE" }),
161
+ get: (id: number) => request<Post>(`/api/posts/${id}`),
162
+ }
163
+ }
164
+ ```
165
+
166
+ - `request<T>(path, init?)` → `Promise<T>` — parsed JSON (or `undefined` for `204`/empty).
167
+ - `raw(path, init?)` → `Promise<Response>` — when you need the status/headers.
168
+ - `init` (`SanctumRequestInit`) extends `RequestInit` with `json?: unknown` (serializes + sets `content-type`). Non-2xx responses throw a [`SanctumError`](#error-handling).
169
+
170
+ ### REST resource — `useResource` (CRUD sugar)
171
+
172
+ ```tsx
173
+ "use client"
174
+ import { useResource } from "next-sanctum"
175
+
176
+ type Post = { id: number; title: string }
177
+
178
+ function PostsAdmin() {
179
+ const posts = useResource<Post>("/api/posts")
180
+ // posts.list() → GET /api/posts
181
+ // posts.get(id) → GET /api/posts/:id
182
+ // posts.create(data) → POST /api/posts
183
+ // posts.update(id, data) → PUT /api/posts/:id
184
+ // posts.patch(id, data) → PATCH /api/posts/:id
185
+ // posts.delete(id) → DELETE /api/posts/:id
186
+ }
187
+ ```
188
+
189
+ For paginated Laravel resources, type the list shape:
190
+ `useResource<Post, { data: Post[]; meta: Meta }>("/api/posts")`.
191
+
192
+ ### Mutations with loading & lifecycle (Inertia-style)
193
+
194
+ `useClient` / `useResource` are imperative, so wrap them in **`useMutation`** for `isPending` +
195
+ `onBefore` / `onSuccess` / `onError` / `onFinish`:
196
+
197
+ ```tsx
198
+ import { useClient, useMutation } from "next-sanctum"
199
+
200
+ const { request } = useClient()
201
+ const create = useMutation(
202
+ (vars: { title: string }) => request<Post>("/api/posts", { method: "POST", json: vars }),
203
+ { onSuccess: () => toast("Saved"), onError: (e) => toast(e.message), onFinish: () => {} },
204
+ )
205
+
206
+ <button disabled={create.isPending} onClick={() => create.mutate({ title })}>Save</button>
207
+ // create.isPending · create.error · create.data · create.reset()
208
+ ```
209
+
210
+ **Forms & Laravel 422 validation** — catch `ValidationError` to render field errors:
211
+
212
+ ```tsx
213
+ import { useState } from "react"
214
+ import { useClient, useMutation, ValidationError } from "next-sanctum"
215
+
216
+ function PostForm() {
217
+ const { request } = useClient()
218
+ const [title, setTitle] = useState("")
219
+ const [errors, setErrors] = useState<Record<string, string[]>>({})
220
+
221
+ const save = useMutation(() => request("/api/posts", { method: "POST", json: { title } }), {
222
+ onBefore: () => setErrors({}),
223
+ onError: (e) => { if (e instanceof ValidationError) setErrors(e.errors) },
224
+ })
225
+
226
+ return (
227
+ <form onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
228
+ <input value={title} onChange={(e) => setTitle(e.target.value)} />
229
+ {errors.title && <p>{errors.title[0]}</p>}
230
+ <button disabled={save.isPending}>Save</button>
231
+ </form>
232
+ )
233
+ }
234
+ ```
235
+
236
+ > For richer form state (dirty tracking, field arrays, schema validation), pair `useClient` /
237
+ > `useMutation` with a dedicated form library — **react-hook-form** or **TanStack Form**.
238
+ > next-sanctum stays focused on auth.
239
+
240
+ > **`onProgress`?** `fetch` doesn't expose upload progress (Inertia uses XHR under the hood). The other lifecycle callbacks are supported; upload progress would need a separate XHR-based path.
241
+
242
+ ### Uploads & raw responses
243
+
244
+ ```tsx
245
+ const { request, raw } = useClient()
246
+ // FormData upload — do NOT set content-type; the browser adds the multipart boundary:
247
+ await request("/api/avatar", { method: "POST", body: formData })
248
+ // need status / headers / a binary body? use raw():
249
+ const res = await raw("/api/report.pdf")
250
+ const blob = await res.blob()
251
+ ```
252
+
253
+ ### Server-side (Server Component / Route Handler / Server Action)
254
+
255
+ ```tsx
256
+ import { serverFetch, getUser } from "next-sanctum/server"
257
+
258
+ export default async function Dashboard() {
259
+ const posts = await serverFetch("/api/posts").then((r) => r.json())
260
+ return <PostList posts={posts} />
261
+ }
262
+
263
+ // mutation inside a Server Action:
264
+ async function createPost(title: string) {
265
+ "use server"
266
+ const res = await serverFetch("/api/posts", { method: "POST", json: { title } })
267
+ return res.json()
268
+ }
269
+ ```
270
+
271
+ ### Using with SWR / TanStack Query
272
+
273
+ The built-in `useApi` / `useResource` are intentionally minimal (no caching/revalidation) — great for
274
+ simple apps. For caching, deduplication, and background revalidation, use **`useClient` as the
275
+ fetcher** and let the query library own the cache (next-sanctum adds no such dependency):
276
+
277
+ ```tsx
278
+ // SWR
279
+ import useSWR from "swr"
280
+ import { useClient } from "next-sanctum"
281
+
282
+ function Posts() {
283
+ const { request } = useClient()
284
+ const { data, isLoading } = useSWR("/api/posts", (url) => request<Post[]>(url))
285
+ }
286
+ ```
287
+
288
+ ```tsx
289
+ // TanStack Query
290
+ import { useQuery } from "@tanstack/react-query"
291
+ import { useClient } from "next-sanctum"
292
+
293
+ const { request } = useClient()
294
+ const { data } = useQuery({ queryKey: ["posts"], queryFn: () => request<Post[]>("/api/posts") })
295
+ // mutations: useMutation({ mutationFn: (d) => request("/api/posts", { method: "POST", json: d }) })
296
+ ```
297
+
298
+ ## Server helpers (`next-sanctum/server`)
299
+
300
+ ```ts
301
+ import { getUser, serverFetch, safeRedirect, createSanctumRouteProxy } from "next-sanctum/server"
302
+ ```
303
+
304
+ - `getUser<T>(opts?)` → `Promise<T | null>` — the authenticated user (forwards cookies). Network/parse errors resolve to `null`; a missing `SANCTUM_BASE_URL` throws.
305
+ - `serverFetch(path, init?)` → `Promise<Response>` — server fetch forwarding cookies + CSRF (bootstraps the CSRF cookie for stateful requests). Rejects absolute URLs whose origin ≠ base (anti-SSRF).
306
+ - `safeRedirect(target, fallback, { origin?, allowList? })` → `string` — same-origin only (anti open-redirect).
307
+ - `createSanctumRouteProxy({ upstream })` — anti-SSRF catch-all proxy ([below](#catch-all-server-proxy-anti-ssrf)).
308
+
309
+ ```tsx
310
+ // app/dashboard/page.tsx — secure check close to the data source
311
+ import { redirect } from "next/navigation"
312
+ import { getUser } from "next-sanctum/server"
313
+
314
+ export default async function Dashboard() {
315
+ const user = await getUser<{ name: string }>()
316
+ if (!user) redirect("/login")
317
+ return <h1>Hello, {user.name}</h1>
318
+ }
319
+ ```
320
+
321
+ ## Route guard (`proxy.ts`)
322
+
323
+ ```ts
324
+ // proxy.ts (root) — modern Next.js, NOT middleware.ts
325
+ import { createSanctumProxy } from "next-sanctum/proxy"
326
+
327
+ export default createSanctumProxy({
328
+ authOnly: ["/dashboard/:path*", "/account"],
329
+ guestOnly: ["/login", "/register"],
330
+ sessionCookie: "laravel_session", // optimistic check (default)
331
+ redirect: { onAuthOnly: "/login", onGuestOnly: "/", keepRequestedRoute: true },
332
+ })
333
+
334
+ export const config = {
335
+ matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)"],
336
+ }
337
+ ```
338
+
339
+ > The proxy is **optimistic only** (reads the session cookie). Real authorization MUST live in a Server Component/Action (`getUser`).
340
+
341
+ ## Login via Server Action (`next-sanctum/actions`)
342
+
343
+ `server-only` helpers that you **wrap** in your own Server Action (they write Laravel's Set-Cookie):
344
+
345
+ ```ts
346
+ // app/actions/auth.ts
347
+ "use server"
348
+ import { z } from "zod"
349
+ import { redirect } from "next/navigation"
350
+ import * as auth from "next-sanctum/actions"
351
+ import { safeRedirect } from "next-sanctum/server"
352
+
353
+ const Schema = z.object({ email: z.email(), password: z.string().min(1) })
354
+
355
+ export async function loginAction(_prev: unknown, formData: FormData) {
356
+ const parsed = Schema.safeParse({ email: formData.get("email"), password: formData.get("password") })
357
+ if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
358
+
359
+ const result = await auth.login(parsed.data) // → ActionResult
360
+ if (!result.ok) return { message: "Invalid email or password.", errors: result.errors }
361
+ if (result.twoFactor) redirect("/two-factor-challenge")
362
+ redirect(safeRedirect(formData.get("redirect")?.toString(), "/dashboard"))
363
+ }
364
+ ```
365
+
366
+ Available: `login`, `logout`, `register`, `twoFactorChallenge`, `forgotPassword`, `resetPassword`, `confirmPassword` — each `(payload, config?) → Promise<ActionResult>`.
367
+
368
+ ## 2FA (Fortify)
369
+
370
+ ```tsx
371
+ "use client"
372
+ import { useTwoFactor } from "next-sanctum"
373
+
374
+ const tf = useTwoFactor()
375
+ await tf.enable() // requires password confirmation first
376
+ const { svg } = await tf.getQrCode() // → { svg: string }
377
+ const codes = await tf.getRecoveryCodes() // → string[]
378
+ await tf.confirm("123456")
379
+ // when login returns "two-factor-required":
380
+ await tf.challenge({ code: "123456" }) // or { recovery_code }
381
+ ```
382
+
383
+ ## Passkeys (interop)
384
+
385
+ ```tsx
386
+ "use client"
387
+ import { usePasskeys } from "next-sanctum"
388
+
389
+ const pk = usePasskeys() // requires the @laravel/passkeys package
390
+ if (await pk.isSupported()) {
391
+ await pk.register("MacBook Pro") // → { id, name }
392
+ await pk.login() // passwordless login
393
+ }
394
+ ```
395
+
396
+ ## Token mode (Bearer)
397
+
398
+ ```tsx
399
+ import { SanctumProvider, MemoryStorage } from "next-sanctum"
400
+
401
+ <SanctumProvider config={{
402
+ baseUrl: process.env.NEXT_PUBLIC_SANCTUM_BASE_URL!,
403
+ mode: "token",
404
+ storage: new MemoryStorage(), // default; or LocalStorage (opt-in) / CookieTokenStorage
405
+ }}>{children}</SanctumProvider>
406
+ ```
407
+
408
+ > 2FA during login is cookie-mode only (Fortify's challenge establishes a session, not a token).
409
+
410
+ ## Catch-all server proxy (anti-SSRF)
411
+
412
+ Make Next + Laravel look same-origin: the browser talks only to your Next domain.
413
+
414
+ ```ts
415
+ // app/api/sanctum/[...path]/route.ts
416
+ import { createSanctumRouteProxy } from "next-sanctum/server"
417
+
418
+ const handler = createSanctumRouteProxy({ upstream: process.env.SANCTUM_BASE_URL! })
419
+ export const GET = handler
420
+ export const POST = handler
421
+ export const PUT = handler
422
+ export const PATCH = handler
423
+ export const DELETE = handler
424
+ ```
425
+
426
+ `upstream` is pinned, path traversal & absolute URLs are rejected, only an allowlist of response headers (plus Set-Cookie) is forwarded.
427
+
428
+ ## Configuration reference
429
+
430
+ ### Centralizing config (one place)
431
+
432
+ The full config object **can't live in `next.config.ts`** — the App Router has no runtime config
433
+ from there (`publicRuntimeConfig` / `serverRuntimeConfig` are deprecated and don't apply). Instead:
434
+
435
+ - **Base URL** → env (`.env.local`, or `next.config.ts`'s `env` field): `NEXT_PUBLIC_SANCTUM_BASE_URL` (client) and `SANCTUM_BASE_URL` (server, private).
436
+ - **Everything else** → a shared module you import once:
437
+
438
+ ```ts
439
+ // lib/sanctum.ts
440
+ import type { SanctumConfig } from "next-sanctum"
441
+
442
+ export const sanctumConfig = {
443
+ baseUrl: process.env.NEXT_PUBLIC_SANCTUM_BASE_URL!,
444
+ mode: "cookie",
445
+ endpoints: { user: "/api/me" },
446
+ redirect: { onLogin: "/dashboard" },
447
+ } satisfies SanctumConfig
448
+ ```
449
+
450
+ ```tsx
451
+ // app/providers.tsx
452
+ import { sanctumConfig } from "@/lib/sanctum"
453
+ <SanctumProvider config={sanctumConfig} initialUser={initialUser}>{children}</SanctumProvider>
454
+ ```
455
+
456
+ > Server helpers (`getUser`, `serverFetch`) read the base URL from the **`SANCTUM_BASE_URL`** env on their own — keep the private server URL there (it may differ from the public client one).
457
+
458
+ ### All options
459
+
460
+ Only `baseUrl` is required. Pass any of these to `SanctumProvider`'s `config` (or `createSanctumClient`):
461
+
462
+ ```tsx
463
+ import { SanctumProvider } from "next-sanctum"
464
+
465
+ <SanctumProvider config={{
466
+ baseUrl: process.env.NEXT_PUBLIC_SANCTUM_BASE_URL!, // required
467
+ mode: "cookie", // "cookie" (default) | "token"
468
+ origin: "https://app.domain.com", // for safeRedirect / Referer; default window.location.origin
469
+
470
+ // Toggle features (mirrors Laravel Fortify's `features`)
471
+ features: {
472
+ registration: true,
473
+ resetPasswords: true,
474
+ emailVerification: true,
475
+ updateProfileInformation: true,
476
+ updatePasswords: true,
477
+ twoFactorAuthentication: { confirm: true, confirmPassword: true }, // or `false`
478
+ passkeys: false, // requires @laravel/passkeys; `true` / { confirmPassword }
479
+ },
480
+
481
+ // Override any endpoint (deep-merged over the Fortify/Sanctum defaults)
482
+ endpoints: {
483
+ login: "/api/login",
484
+ user: "/api/me",
485
+ twoFactor: { challenge: "/api/2fa/challenge" },
486
+ },
487
+
488
+ csrf: { cookie: "XSRF-TOKEN", header: "X-XSRF-TOKEN" }, // defaults shown
489
+
490
+ redirect: {
491
+ onLogin: "/dashboard",
492
+ onLogout: "/",
493
+ onAuthOnly: "/login",
494
+ onGuestOnly: "/",
495
+ keepRequestedRoute: false, // append ?redirect= (same-origin)
496
+ },
497
+
498
+ initialRequest: true, // fetch the user on mount (when no initialUser)
499
+ retryOnCsrfMismatch: true, // refresh CSRF + retry once on 419
500
+ redirectIfUnauthenticated: "/login", // on a 401 while authenticated → clear + redirect (default false)
501
+ logLevel: 3, // 0 silent · 1 error · 2 warn · 3 info · 4 debug · 5 verbose
502
+ storage: undefined, // token mode: MemoryStorage (default) | LocalStorage | CookieTokenStorage
503
+ fetch: undefined, // custom fetch implementation
504
+ interceptors: { request: [], response: [] },
505
+ events: { onLogin: ({ user }) => {}, onLogout: () => {} },
506
+ }} />
507
+ ```
508
+
509
+ ### Defaults
510
+
511
+ | Option | Default | Option | Default |
512
+ |---|---|---|---|
513
+ | `mode` | `"cookie"` | `csrf.cookie` / `csrf.header` | `XSRF-TOKEN` / `X-XSRF-TOKEN` |
514
+ | `endpoints.login` | `/login` | `endpoints.logout` | `/logout` |
515
+ | `endpoints.user` | `/api/user` | `endpoints.csrf` | `/sanctum/csrf-cookie` |
516
+ | `endpoints.register` | `/register` | `endpoints.confirmPassword` | `/user/confirm-password` |
517
+ | `redirect.onAuthOnly` | `/login` | `redirect.onLogin` | `/` |
518
+ | `initialRequest` | `true` | `retryOnCsrfMismatch` | `true` |
519
+ | `logLevel` | `3` | `redirectIfUnauthenticated` | `false` |
520
+
521
+ 2FA endpoints default to `/user/two-factor-authentication`, `/two-factor-challenge`, `/user/two-factor-qr-code`, `/user/two-factor-secret-key`, `/user/two-factor-recovery-codes`. Passkey endpoints default to `/passkeys/login(/options)`, `/passkeys/confirm(/options)`, `/user/passkeys(/options)`.
522
+
523
+ ## Responses & return types
524
+
525
+ ### `useAuth()`
526
+
527
+ ```ts
528
+ const { user, isAuthenticated, isLoading, login, logout, refresh, /* …account actions */ } = useAuth<User>()
529
+ ```
530
+
531
+ `login(credentials)` returns a **discriminated** result — always check `status`:
532
+
533
+ ```ts
534
+ type LoginResult<User> =
535
+ | { status: "authenticated"; user: User }
536
+ | { status: "two-factor-required" }
537
+ ```
538
+
539
+ Other actions: `logout(): Promise<void>` · `refresh(): Promise<User | null>` ·
540
+ `register(payload): Promise<void>` · `forgotPassword/resetPassword/confirmPassword/updatePassword/updateProfile/resendEmailVerification: Promise<void>` ·
541
+ `confirmedPasswordStatus(): Promise<boolean>`.
542
+
543
+ ### `useApi()` / `useClient()`
544
+
545
+ ```ts
546
+ // useApi(path)
547
+ { data: T | undefined; error: SanctumError | null; isLoading: boolean; refetch: () => Promise<void> }
548
+
549
+ // useClient()
550
+ { request<T>(path, init?): Promise<T>; raw(path, init?): Promise<Response>; ensureCsrf(force?): Promise<void>; config }
551
+ ```
552
+
553
+ Example API response (your Laravel `GET /api/user`):
554
+
555
+ ```jsonc
556
+ // what getUser() / useUser() resolve to — your Sanctum user resource, e.g.
557
+ { "id": 1, "name": "Budi", "email": "budi@example.com", "email_verified_at": "2026-01-02T03:04:05Z" }
558
+ ```
559
+
560
+ ### Server Actions — `ActionResult`
561
+
562
+ ```ts
563
+ interface ActionResult {
564
+ ok: boolean
565
+ status: number
566
+ twoFactor?: boolean // login: true → redirect to the 2FA challenge
567
+ errors?: Record<string, string[]> // 422 → Laravel validation errors
568
+ }
569
+ ```
570
+
571
+ ## Error handling
572
+
573
+ Every non-2xx response throws a normalized `SanctumError` (network failures too):
574
+
575
+ ```ts
576
+ class SanctumError extends Error {
577
+ kind: "config" | "network" | "unauthorized" | "forbidden" | "csrf" | "validation" | "http" | "unknown"
578
+ status?: number
579
+ data?: unknown // the parsed response body
580
+ }
581
+ class ValidationError extends SanctumError { errors: Record<string, string[]> } // 422
582
+ ```
583
+
584
+ ```tsx
585
+ import { SanctumError, ValidationError, useClient } from "next-sanctum"
586
+
587
+ const { request } = useClient()
588
+ try {
589
+ await request("/api/posts", { method: "POST", json: { title: "" } })
590
+ } catch (err) {
591
+ if (err instanceof ValidationError) {
592
+ // err.errors → { title: ["The title field is required."] }
593
+ } else if (err instanceof SanctumError && err.kind === "unauthorized") {
594
+ // 401 — session expired (the provider also clears state reactively)
595
+ }
596
+ }
597
+ ```
598
+
599
+ ## Events & interceptors
600
+
601
+ ```tsx
602
+ <SanctumProvider config={{
603
+ baseUrl,
604
+ // keyed by event name (not "onLogin")
605
+ events: {
606
+ login: ({ user }) => analytics.identify(user),
607
+ logout: () => analytics.reset(),
608
+ error: ({ error }) => console.error(error.kind, error.status),
609
+ },
610
+ interceptors: {
611
+ // return a new Request (its headers are immutable in place)
612
+ request: [
613
+ (req) => {
614
+ const headers = new Headers(req.headers)
615
+ headers.set("X-Tenant", getTenant())
616
+ return new Request(req, { headers })
617
+ },
618
+ ],
619
+ response: [(res) => res],
620
+ },
621
+ }}>{children}</SanctumProvider>
622
+ ```
623
+
624
+ Event names: `init`, `login`, `logout`, `refresh`, `two-factor-required`, `error`, `redirect`, `request`, `response`. Payloads: `login`/`init`/`refresh` → `{ user }`, `error` → `{ error }`, `redirect` → `{ to, reason }`. A throwing handler is isolated and won't break the auth flow.
625
+
626
+ ## TypeScript: the User model
627
+
628
+ Pass your model as a generic — it flows through `getUser`, `useUser`, `useAuth`, and the `login` result:
629
+
630
+ ```ts
631
+ interface User { id: number; name: string; email: string }
632
+
633
+ const user = await getUser<User>() // User | null (server)
634
+ const { user, login } = useAuth<User>() // user: User | null
635
+ const me = useUser<User>() // User | null
636
+ ```
637
+
638
+ ## Security
639
+
640
+ - Cookie/CSRF: `XSRF-TOKEN` is **URL-decoded** → `X-XSRF-TOKEN`; `credentials: include`; one retry on 419.
641
+ - The token default is **not** localStorage; use an HttpOnly cookie + the catch-all proxy in production.
642
+ - `safeRedirect()` rejects cross-origin / control-char targets. The catch-all proxy is anti-SSRF (`upstream` pinned).
643
+ - Verify auth in **every** Server Action (the proxy is optimistic only).
644
+
645
+ ## License
646
+
647
+ MIT © Alizio Dev