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/dist/server.js ADDED
@@ -0,0 +1,353 @@
1
+ import 'server-only';
2
+ import { cookies } from 'next/headers';
3
+
4
+ /**
5
+ * Error types for next-sanctum. All failures are normalized to `SanctumError`
6
+ * so consumers can handle them consistently (see plan §10: errors must not leak).
7
+ */ /** Base error for all module failures. */ class SanctumError extends Error {
8
+ constructor(message, options){
9
+ super(message, {
10
+ cause: options.cause
11
+ });
12
+ this.name = "SanctumError";
13
+ this.kind = options.kind;
14
+ this.status = options.status;
15
+ this.data = options.data;
16
+ }
17
+ }
18
+ /** Invalid configuration — fail-fast on init (see resolveConfig). */ class ConfigError extends SanctumError {
19
+ constructor(message, cause){
20
+ super(message, {
21
+ kind: "config",
22
+ cause
23
+ });
24
+ this.name = "ConfigError";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Resolve the server-side base URL (`SANCTUM_BASE_URL`, falling back to the public var
30
+ * for dev). Shared by `server.ts`/`actions.ts`. Fails fast with a clear ConfigError.
31
+ */ function resolveServerBaseUrl(explicit, label = "server helpers") {
32
+ const baseUrl = explicit ?? process.env.SANCTUM_BASE_URL ?? process.env.NEXT_PUBLIC_SANCTUM_BASE_URL;
33
+ if (!baseUrl) {
34
+ throw new ConfigError(`SANCTUM_BASE_URL (server) is not set for next-sanctum ${label}.`);
35
+ }
36
+ return baseUrl.replace(/\/+$/, "");
37
+ }
38
+
39
+ /**
40
+ * Open-redirect protection. Only allows SAME-ORIGIN destinations (a relative path
41
+ * or an absolute URL with the same origin), with an optional path allowlist.
42
+ * See PRD §12.1 (Open redirect) & §18 (a unit test is required).
43
+ */ /** Reject backslashes and any control character (Tab/LF/CR are stripped by the URL
44
+ * parser, which could turn `/\t/evil.com` into `//evil.com` and bypass the `//` guard). */ function hasUnsafeChars(value) {
45
+ for(let i = 0; i < value.length; i++){
46
+ const code = value.charCodeAt(i);
47
+ if (code <= 0x1f || code === 0x7f) return true;
48
+ if (value[i] === "\\") return true;
49
+ }
50
+ return false;
51
+ }
52
+ /**
53
+ * Return `target` when it is safe (same-origin), otherwise `fallback`.
54
+ * Rejects: `//evil.com`, `https://evil.com`, the `javascript:` scheme, control-char
55
+ * injection (`/\t//evil.com`), and backslash tricks.
56
+ */ function safeRedirect(target, fallback, options = {}) {
57
+ if (!target) return fallback;
58
+ const trimmed = target.trim();
59
+ if (trimmed === "") return fallback;
60
+ if (hasUnsafeChars(trimmed)) return fallback;
61
+ let path = null;
62
+ if (trimmed.startsWith("/")) {
63
+ // Reject protocol-relative (//evil.com).
64
+ if (trimmed.startsWith("//")) return fallback;
65
+ path = trimmed;
66
+ } else if (options.origin) {
67
+ try {
68
+ const url = new URL(trimmed);
69
+ const base = new URL(options.origin);
70
+ if (url.origin === base.origin) {
71
+ path = url.pathname + url.search + url.hash;
72
+ }
73
+ } catch {
74
+ // not a valid URL → reject
75
+ }
76
+ }
77
+ if (path === null) return fallback;
78
+ // Defense-in-depth: resolve the path and confirm it stays same-origin.
79
+ try {
80
+ const base = options.origin ?? "https://sanctum.invalid";
81
+ if (new URL(path, base).origin !== new URL(base).origin) return fallback;
82
+ } catch {
83
+ return fallback;
84
+ }
85
+ if (options.allowList && options.allowList.length > 0) {
86
+ const safe = path;
87
+ const allowed = options.allowList.some((prefix)=>safe === prefix || safe.startsWith(prefix));
88
+ if (!allowed) return fallback;
89
+ }
90
+ return path;
91
+ }
92
+
93
+ /** HTTP methods that require CSRF protection in cookie mode. */ const STATEFUL_METHODS = new Set([
94
+ "POST",
95
+ "PUT",
96
+ "PATCH",
97
+ "DELETE"
98
+ ]);
99
+
100
+ /**
101
+ * Shared Set-Cookie parsing helpers (pure — no `next`/`server-only` imports, so this
102
+ * stays isomorphic and tree-shakes out of the client graph). Used by `server.ts` and
103
+ * `actions.ts` to mirror Laravel's Set-Cookie into Next's writable cookie store.
104
+ */ /**
105
+ * Read all Set-Cookie headers as an array. Prefers `Headers.getSetCookie()` (Node ≥18.14,
106
+ * all supported Next runtimes). The single-header fallback only handles one cookie — we do
107
+ * NOT attempt to comma-split, which is unreliable (Expires dates contain commas).
108
+ */ function getSetCookies(headers) {
109
+ const anyHeaders = headers;
110
+ if (typeof anyHeaders.getSetCookie === "function") return anyHeaders.getSetCookie();
111
+ const value = headers.get("set-cookie");
112
+ return value ? [
113
+ value
114
+ ] : [];
115
+ }
116
+ /** Parse a single Set-Cookie header. Validates attributes; returns null when unusable. */ function parseSetCookie(header) {
117
+ const segments = header.split(";");
118
+ const first = segments.shift();
119
+ if (!first) return null;
120
+ const eq = first.indexOf("=");
121
+ if (eq === -1) return null;
122
+ const name = first.slice(0, eq).trim();
123
+ if (name === "") return null;
124
+ let value = first.slice(eq + 1).trim();
125
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
126
+ value = value.slice(1, -1);
127
+ }
128
+ const options = {};
129
+ for (const segment of segments){
130
+ const idx = segment.indexOf("=");
131
+ const key = (idx === -1 ? segment : segment.slice(0, idx)).trim().toLowerCase();
132
+ const val = idx === -1 ? "" : segment.slice(idx + 1).trim();
133
+ switch(key){
134
+ case "path":
135
+ options.path = val;
136
+ break;
137
+ case "domain":
138
+ options.domain = val;
139
+ break;
140
+ case "max-age":
141
+ {
142
+ const n = Number(val);
143
+ if (val !== "" && Number.isFinite(n)) options.maxAge = n;
144
+ break;
145
+ }
146
+ case "expires":
147
+ {
148
+ const d = new Date(val);
149
+ if (!Number.isNaN(d.getTime())) options.expires = d;
150
+ break;
151
+ }
152
+ case "samesite":
153
+ {
154
+ const lower = val.toLowerCase();
155
+ if (lower === "lax" || lower === "strict" || lower === "none") {
156
+ options.sameSite = lower;
157
+ }
158
+ break;
159
+ }
160
+ case "secure":
161
+ options.secure = true;
162
+ break;
163
+ case "httponly":
164
+ options.httpOnly = true;
165
+ break;
166
+ }
167
+ }
168
+ return {
169
+ name,
170
+ value,
171
+ options
172
+ };
173
+ }
174
+ /** Mirror a response's Set-Cookie headers into a writable cookie store. */ function applySetCookies(store, response) {
175
+ for (const raw of getSetCookies(response.headers)){
176
+ const parsed = parseSetCookie(raw);
177
+ if (!parsed) continue;
178
+ try {
179
+ store.set(parsed.name, parsed.value, parsed.options);
180
+ } catch {
181
+ // store is read-only (e.g. called from a Server Component) — ignore.
182
+ }
183
+ }
184
+ }
185
+
186
+ const CSRF_COOKIE_ENDPOINT = "/sanctum/csrf-cookie";
187
+ /**
188
+ * Anti-SSRF: an absolute URL is only allowed when it matches the configured base
189
+ * origin, otherwise the (cookie-bearing) request could be aimed at an arbitrary host.
190
+ */ function buildServerUrl(baseUrl, path) {
191
+ if (/^https?:\/\//i.test(path)) {
192
+ if (new URL(path).origin !== new URL(baseUrl).origin) {
193
+ throw new ConfigError("serverFetch: an absolute URL must match the configured base URL origin (anti-SSRF).");
194
+ }
195
+ return path;
196
+ }
197
+ return `${baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
198
+ }
199
+ /**
200
+ * Authenticated fetch from a SERVER context (Server Component / Route Handler /
201
+ * Server Action). Forwards cookies from `await cookies()` to Laravel. For stateful
202
+ * requests it includes the CSRF header, bootstrapping the CSRF cookie when missing
203
+ * (the bootstrap can only persist cookies from a Server Action / Route Handler).
204
+ */ async function serverFetch(path, init = {}) {
205
+ const baseUrl = resolveServerBaseUrl(init.baseUrl);
206
+ const cookieStore = await cookies();
207
+ const { json, body: rawBody, baseUrl: _baseUrl, csrf, ...rest } = init;
208
+ const headers = new Headers(rest.headers);
209
+ if (!headers.has("accept")) headers.set("accept", "application/json");
210
+ // Sanctum's statefulApi() only treats a request as first-party (session/cookie
211
+ // auth) when its Origin/Referer matches a stateful domain. A server-side fetch
212
+ // carries no browser Origin, so present the API's own origin — which Sanctum
213
+ // always includes in its stateful domains by default — so SSR cookie auth
214
+ // (e.g. getUser() in a Server Component) is recognised instead of 401-ing.
215
+ if (!headers.has("origin")) headers.set("origin", new URL(baseUrl).origin);
216
+ const method = (rest.method ?? "GET").toUpperCase();
217
+ const csrfCookieName = csrf?.cookie ?? "XSRF-TOKEN";
218
+ const csrfHeaderName = csrf?.header ?? "X-XSRF-TOKEN";
219
+ if (STATEFUL_METHODS.has(method)) {
220
+ let token = cookieStore.get(csrfCookieName)?.value;
221
+ if (!token) {
222
+ const csrfResponse = await fetch(`${baseUrl}${CSRF_COOKIE_ENDPOINT}`, {
223
+ headers: {
224
+ cookie: cookieStore.toString(),
225
+ accept: "application/json"
226
+ },
227
+ cache: "no-store"
228
+ });
229
+ applySetCookies(cookieStore, csrfResponse);
230
+ token = cookieStore.get(csrfCookieName)?.value;
231
+ }
232
+ if (token && !headers.has(csrfHeaderName)) {
233
+ headers.set(csrfHeaderName, decodeURIComponent(token));
234
+ }
235
+ }
236
+ const cookieHeader = cookieStore.toString();
237
+ if (cookieHeader) headers.set("cookie", cookieHeader);
238
+ let body = rawBody ?? null;
239
+ if (json !== undefined) {
240
+ body = JSON.stringify(json);
241
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
242
+ }
243
+ return fetch(buildServerUrl(baseUrl, path), {
244
+ ...rest,
245
+ method,
246
+ headers,
247
+ body,
248
+ cache: rest.cache ?? "no-store"
249
+ });
250
+ }
251
+ /**
252
+ * Fetch the authenticated user on the server (forwards cookies). The result is passed
253
+ * as `initialUser` to SanctumProvider to prevent a hydration mismatch. Network/parse
254
+ * errors resolve to `null` (treated as logged-out) so SSR doesn't crash; a missing
255
+ * `SANCTUM_BASE_URL` still throws (fail-fast).
256
+ */ async function getUser(options = {}) {
257
+ try {
258
+ const response = await serverFetch(options.endpoint ?? "/api/user", {
259
+ method: "GET",
260
+ baseUrl: options.baseUrl
261
+ });
262
+ if (!response.ok) return null;
263
+ return await response.json();
264
+ } catch (error) {
265
+ if (error instanceof ConfigError) throw error;
266
+ return null;
267
+ }
268
+ }
269
+ const FORWARD_REQUEST_HEADERS = [
270
+ "accept",
271
+ "accept-language",
272
+ "content-type",
273
+ "authorization",
274
+ "x-xsrf-token",
275
+ "x-requested-with",
276
+ // Sanctum's SPA (cookie) auth only treats a request as "stateful" when it
277
+ // carries an Origin or Referer matching SANCTUM_STATEFUL_DOMAINS. Forwarding
278
+ // them lets the canonical `routes/api.php` + `auth:sanctum` pattern work
279
+ // through this proxy. Safe: `upstream` is pinned (anti-SSRF preserved).
280
+ "origin",
281
+ "referer"
282
+ ];
283
+ // Allowlist of response headers forwarded to the client — internal/debug headers
284
+ // (Server, X-Powered-By, X-Debug-*, rate-limit internals, …) are stripped by default.
285
+ const FORWARD_RESPONSE_HEADERS = [
286
+ "content-type",
287
+ "content-language",
288
+ "content-disposition",
289
+ "cache-control",
290
+ "etag",
291
+ "expires",
292
+ "last-modified",
293
+ "location",
294
+ "vary",
295
+ "www-authenticate"
296
+ ];
297
+ /**
298
+ * Create a catch-all Route Handler (`app/api/sanctum/[...path]/route.ts`) that
299
+ * forwards requests to Laravel via the Next domain. **Anti-SSRF**: `upstream` is pinned,
300
+ * path traversal (`..`, `://`, backslash) is rejected, and only an allowlist of
301
+ * response headers (plus Set-Cookie) is forwarded — internal headers are not leaked.
302
+ */ function createSanctumRouteProxy(options) {
303
+ const upstream = options.upstream.replace(/\/+$/, "");
304
+ if (!/^https?:\/\//i.test(upstream)) {
305
+ throw new ConfigError("createSanctumRouteProxy: `upstream` must be an absolute http(s) URL (anti-SSRF).");
306
+ }
307
+ const forwardCookies = options.forwardCookies ?? true;
308
+ return async function handler(request, context) {
309
+ const { path = [] } = await context.params;
310
+ for (const segment of path){
311
+ if (segment === ".." || segment === "." || segment.includes("\\") || segment.includes("://")) {
312
+ return new Response("Bad request", {
313
+ status: 400
314
+ });
315
+ }
316
+ }
317
+ const suffix = path.map(encodeURIComponent).join("/");
318
+ const search = new URL(request.url).search;
319
+ const target = `${upstream}/${suffix}${search}`;
320
+ const headers = new Headers();
321
+ for (const name of FORWARD_REQUEST_HEADERS){
322
+ const value = request.headers.get(name);
323
+ if (value) headers.set(name, value);
324
+ }
325
+ if (forwardCookies) {
326
+ const cookie = request.headers.get("cookie");
327
+ if (cookie) headers.set("cookie", cookie);
328
+ }
329
+ const method = request.method.toUpperCase();
330
+ const hasBody = method !== "GET" && method !== "HEAD";
331
+ const body = hasBody ? await request.arrayBuffer() : undefined;
332
+ const upstreamResponse = await fetch(target, {
333
+ method,
334
+ headers,
335
+ body,
336
+ redirect: "manual"
337
+ });
338
+ const responseHeaders = new Headers();
339
+ for (const name of FORWARD_RESPONSE_HEADERS){
340
+ const value = upstreamResponse.headers.get(name);
341
+ if (value) responseHeaders.set(name, value);
342
+ }
343
+ for (const cookie of getSetCookies(upstreamResponse.headers)){
344
+ responseHeaders.append("set-cookie", cookie);
345
+ }
346
+ return new Response(upstreamResponse.body, {
347
+ status: upstreamResponse.status,
348
+ headers: responseHeaders
349
+ });
350
+ };
351
+ }
352
+
353
+ export { createSanctumRouteProxy, getUser, safeRedirect, serverFetch };
package/package.json ADDED
@@ -0,0 +1,140 @@
1
+ {
2
+ "name": "next-sanctum",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "author": "aliziodev",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/aliziodev/next-sanctum.git"
10
+ },
11
+ "homepage": "https://github.com/aliziodev/next-sanctum#readme",
12
+ "bugs": "https://github.com/aliziodev/next-sanctum/issues",
13
+ "description": "Complete Laravel Fortify + Sanctum authentication for Next.js — cookie/CSRF SPA + token, SSR + Client, 2FA, passkeys, Proxy route protection.",
14
+ "keywords": [
15
+ "next",
16
+ "nextjs",
17
+ "laravel",
18
+ "sanctum",
19
+ "fortify",
20
+ "authentication",
21
+ "csrf",
22
+ "2fa",
23
+ "passkeys",
24
+ "app-router"
25
+ ],
26
+ "sideEffects": false,
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18.18.0"
34
+ },
35
+ "exports": {
36
+ ".": {
37
+ "import": {
38
+ "types": "./dist/index.d.ts",
39
+ "default": "./dist/index.js"
40
+ },
41
+ "require": {
42
+ "types": "./dist/index.d.cts",
43
+ "default": "./dist/index.cjs"
44
+ }
45
+ },
46
+ "./server": {
47
+ "import": {
48
+ "types": "./dist/server.d.ts",
49
+ "default": "./dist/server.js"
50
+ },
51
+ "require": {
52
+ "types": "./dist/server.d.cts",
53
+ "default": "./dist/server.cjs"
54
+ }
55
+ },
56
+ "./proxy": {
57
+ "import": {
58
+ "types": "./dist/proxy.d.ts",
59
+ "default": "./dist/proxy.js"
60
+ },
61
+ "require": {
62
+ "types": "./dist/proxy.d.cts",
63
+ "default": "./dist/proxy.cjs"
64
+ }
65
+ },
66
+ "./actions": {
67
+ "import": {
68
+ "types": "./dist/actions.d.ts",
69
+ "default": "./dist/actions.js"
70
+ },
71
+ "require": {
72
+ "types": "./dist/actions.d.cts",
73
+ "default": "./dist/actions.cjs"
74
+ }
75
+ },
76
+ "./package.json": "./package.json"
77
+ },
78
+ "main": "./dist/index.cjs",
79
+ "module": "./dist/index.js",
80
+ "types": "./dist/index.d.ts",
81
+ "typesVersions": {
82
+ "*": {
83
+ "server": [
84
+ "./dist/server.d.ts"
85
+ ],
86
+ "proxy": [
87
+ "./dist/proxy.d.ts"
88
+ ],
89
+ "actions": [
90
+ "./dist/actions.d.ts"
91
+ ]
92
+ }
93
+ },
94
+ "scripts": {
95
+ "build": "bunchee",
96
+ "dev": "bunchee --watch",
97
+ "clean": "rimraf dist",
98
+ "typecheck": "tsc --noEmit",
99
+ "lint": "eslint .",
100
+ "test": "vitest run",
101
+ "test:watch": "vitest",
102
+ "test:coverage": "vitest run --coverage",
103
+ "attw": "attw --pack .",
104
+ "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run attw"
105
+ },
106
+ "peerDependencies": {
107
+ "@laravel/passkeys": "*",
108
+ "next": ">=15.0.0 <17.0.0",
109
+ "react": "^18.2.0 || ^19.0.0",
110
+ "react-dom": "^18.2.0 || ^19.0.0"
111
+ },
112
+ "peerDependenciesMeta": {
113
+ "@laravel/passkeys": {
114
+ "optional": true
115
+ }
116
+ },
117
+ "dependencies": {
118
+ "server-only": "^0.0.1"
119
+ },
120
+ "devDependencies": {
121
+ "@arethetypeswrong/cli": "^0.18.0",
122
+ "@eslint/js": "^9.0.0",
123
+ "@testing-library/react": "^16.0.0",
124
+ "@types/node": "^20.0.0",
125
+ "@types/react": "^19.0.0",
126
+ "@types/react-dom": "^19.0.0",
127
+ "@vitest/coverage-v8": "^3.2.6",
128
+ "bunchee": "^6.4.0",
129
+ "eslint": "^9.0.0",
130
+ "happy-dom": "^15.0.0",
131
+ "msw": "^2.7.0",
132
+ "next": "16.2.6",
133
+ "react": "19.2.4",
134
+ "react-dom": "19.2.4",
135
+ "rimraf": "^6.0.0",
136
+ "typescript": "^5.6.0",
137
+ "typescript-eslint": "^8.0.0",
138
+ "vitest": "^3.0.0"
139
+ }
140
+ }