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 +21 -0
- package/README.md +647 -0
- package/dist/actions.cjs +236 -0
- package/dist/actions.d.cts +81 -0
- package/dist/actions.d.ts +81 -0
- package/dist/actions.js +228 -0
- package/dist/index.cjs +1395 -0
- package/dist/index.d.cts +508 -0
- package/dist/index.d.ts +508 -0
- package/dist/index.js +1379 -0
- package/dist/proxy.cjs +49 -0
- package/dist/proxy.d.cts +29 -0
- package/dist/proxy.d.ts +29 -0
- package/dist/proxy.js +47 -0
- package/dist/server.cjs +358 -0
- package/dist/server.d.cts +78 -0
- package/dist/server.d.ts +78 -0
- package/dist/server.js +353 -0
- package/package.json +140 -0
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
|
+
[](https://github.com/aliziodev/next-sanctum/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/next-sanctum)
|
|
5
|
+
[](https://www.npmjs.com/package/next-sanctum)
|
|
6
|
+
[](https://bundlephobia.com/package/next-sanctum)
|
|
7
|
+
[](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
|