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