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/dist/index.cjs
ADDED
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3
|
+
|
|
4
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
5
|
+
var react = require('react');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Error types for next-sanctum. All failures are normalized to `SanctumError`
|
|
9
|
+
* so consumers can handle them consistently (see plan §10: errors must not leak).
|
|
10
|
+
*/ /** Base error for all module failures. */ class SanctumError extends Error {
|
|
11
|
+
constructor(message, options){
|
|
12
|
+
super(message, {
|
|
13
|
+
cause: options.cause
|
|
14
|
+
});
|
|
15
|
+
this.name = "SanctumError";
|
|
16
|
+
this.kind = options.kind;
|
|
17
|
+
this.status = options.status;
|
|
18
|
+
this.data = options.data;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Invalid configuration — fail-fast on init (see resolveConfig). */ class ConfigError extends SanctumError {
|
|
22
|
+
constructor(message, cause){
|
|
23
|
+
super(message, {
|
|
24
|
+
kind: "config",
|
|
25
|
+
cause
|
|
26
|
+
});
|
|
27
|
+
this.name = "ConfigError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* HTTP 422 from Laravel. Exposes field errors (`{ field: string[] }`) so
|
|
32
|
+
* consumers can map them to their forms.
|
|
33
|
+
*/ class ValidationError extends SanctumError {
|
|
34
|
+
constructor(message, errors, data){
|
|
35
|
+
super(message, {
|
|
36
|
+
kind: "validation",
|
|
37
|
+
status: 422,
|
|
38
|
+
data
|
|
39
|
+
});
|
|
40
|
+
this.name = "ValidationError";
|
|
41
|
+
this.errors = errors;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null;
|
|
46
|
+
}
|
|
47
|
+
/** Try to read the response body as JSON; return undefined if it isn't JSON. */ async function readBody(response) {
|
|
48
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
49
|
+
try {
|
|
50
|
+
if (contentType.includes("application/json")) {
|
|
51
|
+
return await response.json();
|
|
52
|
+
}
|
|
53
|
+
const text = await response.text();
|
|
54
|
+
return text.length > 0 ? text : undefined;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function kindForStatus(status) {
|
|
60
|
+
switch(status){
|
|
61
|
+
case 401:
|
|
62
|
+
return "unauthorized";
|
|
63
|
+
case 403:
|
|
64
|
+
return "forbidden";
|
|
65
|
+
case 419:
|
|
66
|
+
return "csrf";
|
|
67
|
+
case 422:
|
|
68
|
+
return "validation";
|
|
69
|
+
default:
|
|
70
|
+
return "http";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build a `SanctumError` from a non-2xx Response. The message is taken from the
|
|
75
|
+
* Laravel body (`message`) when present, without leaking the stack or internal details.
|
|
76
|
+
*/ async function errorFromResponse(response) {
|
|
77
|
+
const data = await readBody(response);
|
|
78
|
+
const body = isRecord(data) ? data : {};
|
|
79
|
+
const message = body.message ?? `Request failed with status ${response.status}.`;
|
|
80
|
+
if (response.status === 422) {
|
|
81
|
+
return new ValidationError(message, body.errors ?? {}, data);
|
|
82
|
+
}
|
|
83
|
+
return new SanctumError(message, {
|
|
84
|
+
kind: kindForStatus(response.status),
|
|
85
|
+
status: response.status,
|
|
86
|
+
data
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Wrap a network error (fetch rejection) into a SanctumError. */ function networkError(cause) {
|
|
90
|
+
const message = cause instanceof Error ? cause.message : "Network request failed.";
|
|
91
|
+
return new SanctumError(message, {
|
|
92
|
+
kind: "network",
|
|
93
|
+
cause
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Typed lifecycle emitter (see SanctumEventMap). */ class SanctumEventEmitter {
|
|
98
|
+
on(event, handler) {
|
|
99
|
+
let set = this.handlers.get(event);
|
|
100
|
+
if (!set) {
|
|
101
|
+
set = new Set();
|
|
102
|
+
this.handlers.set(event, set);
|
|
103
|
+
}
|
|
104
|
+
const fn = handler;
|
|
105
|
+
set.add(fn);
|
|
106
|
+
return ()=>{
|
|
107
|
+
set.delete(fn);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
emit(event, payload) {
|
|
111
|
+
const set = this.handlers.get(event);
|
|
112
|
+
if (!set) return;
|
|
113
|
+
// Snapshot + isolate: a throwing consumer handler must not break the auth/request
|
|
114
|
+
// flow this is emitted from, nor skip the remaining handlers.
|
|
115
|
+
for (const handler of [
|
|
116
|
+
...set
|
|
117
|
+
]){
|
|
118
|
+
try {
|
|
119
|
+
handler(payload);
|
|
120
|
+
} catch {
|
|
121
|
+
// swallow consumer handler errors
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Register many handlers at once (used by the provider from config.events). */ register(handlers) {
|
|
126
|
+
for (const key of Object.keys(handlers)){
|
|
127
|
+
const handler = handlers[key];
|
|
128
|
+
if (!handler) continue;
|
|
129
|
+
let set = this.handlers.get(key);
|
|
130
|
+
if (!set) {
|
|
131
|
+
set = new Set();
|
|
132
|
+
this.handlers.set(key, set);
|
|
133
|
+
}
|
|
134
|
+
set.add(handler);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
constructor(){
|
|
138
|
+
this.handlers = new Map();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const PREFIX = "[next-sanctum]";
|
|
143
|
+
/** Leveled logger. logLevel 0 = silent; 3 = info (default). */ function createLogger(level) {
|
|
144
|
+
const enabled = (threshold)=>level >= threshold;
|
|
145
|
+
return {
|
|
146
|
+
error: (...args)=>{
|
|
147
|
+
if (enabled(1)) console.error(PREFIX, ...args);
|
|
148
|
+
},
|
|
149
|
+
warn: (...args)=>{
|
|
150
|
+
if (enabled(2)) console.warn(PREFIX, ...args);
|
|
151
|
+
},
|
|
152
|
+
info: (...args)=>{
|
|
153
|
+
if (enabled(3)) console.info(PREFIX, ...args);
|
|
154
|
+
},
|
|
155
|
+
debug: (...args)=>{
|
|
156
|
+
if (enabled(4)) console.debug(PREFIX, ...args);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEFAULT_ENDPOINTS = {
|
|
162
|
+
csrf: "/sanctum/csrf-cookie",
|
|
163
|
+
login: "/login",
|
|
164
|
+
logout: "/logout",
|
|
165
|
+
user: "/api/user",
|
|
166
|
+
register: "/register",
|
|
167
|
+
forgotPassword: "/forgot-password",
|
|
168
|
+
resetPassword: "/reset-password",
|
|
169
|
+
emailVerificationNotification: "/email/verification-notification",
|
|
170
|
+
verifyEmail: "/email/verify",
|
|
171
|
+
confirmPassword: "/user/confirm-password",
|
|
172
|
+
confirmedPasswordStatus: "/user/confirmed-password-status",
|
|
173
|
+
profileInformation: "/user/profile-information",
|
|
174
|
+
updatePassword: "/user/password",
|
|
175
|
+
twoFactor: {
|
|
176
|
+
challenge: "/two-factor-challenge",
|
|
177
|
+
enable: "/user/two-factor-authentication",
|
|
178
|
+
confirm: "/user/confirmed-two-factor-authentication",
|
|
179
|
+
disable: "/user/two-factor-authentication",
|
|
180
|
+
qrCode: "/user/two-factor-qr-code",
|
|
181
|
+
secretKey: "/user/two-factor-secret-key",
|
|
182
|
+
recoveryCodes: "/user/two-factor-recovery-codes"
|
|
183
|
+
},
|
|
184
|
+
passkeys: {
|
|
185
|
+
loginOptions: "/passkeys/login/options",
|
|
186
|
+
login: "/passkeys/login",
|
|
187
|
+
confirmOptions: "/passkeys/confirm/options",
|
|
188
|
+
confirm: "/passkeys/confirm",
|
|
189
|
+
registerOptions: "/user/passkeys/options",
|
|
190
|
+
register: "/user/passkeys",
|
|
191
|
+
delete: "/user/passkeys"
|
|
192
|
+
},
|
|
193
|
+
sessions: {
|
|
194
|
+
list: "/api/sessions",
|
|
195
|
+
logoutOthers: "/api/sessions/others",
|
|
196
|
+
logout: "/api/sessions"
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
function resolveTwoFactor(value) {
|
|
200
|
+
if (value === false) return false;
|
|
201
|
+
if (value === undefined || value === true) {
|
|
202
|
+
return {
|
|
203
|
+
confirm: true,
|
|
204
|
+
confirmPassword: true
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
confirm: value.confirm ?? true,
|
|
209
|
+
confirmPassword: value.confirmPassword ?? true
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function resolvePasskeys(value) {
|
|
213
|
+
if (value === undefined || value === false) return false;
|
|
214
|
+
if (value === true) return {
|
|
215
|
+
confirmPassword: true
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
confirmPassword: value.confirmPassword ?? true
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function defaultOrigin(input) {
|
|
222
|
+
if (input) return input;
|
|
223
|
+
if (typeof window !== "undefined") return window.location.origin;
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
function resolveFetch(input) {
|
|
227
|
+
const impl = input ?? globalThis.fetch;
|
|
228
|
+
if (typeof impl !== "function") {
|
|
229
|
+
throw new ConfigError("Native `fetch` is not available. Provide `config.fetch` (Node < 18) or use a modern runtime.");
|
|
230
|
+
}
|
|
231
|
+
return impl.bind(globalThis);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Validate + fill in config defaults. Fail-fast when `baseUrl` is missing to avoid
|
|
235
|
+
* an SSR loop / fetching the URL `undefined` (see plan §10 risks).
|
|
236
|
+
*/ function resolveConfig(input) {
|
|
237
|
+
if (!input || typeof input.baseUrl !== "string" || input.baseUrl.trim() === "") {
|
|
238
|
+
throw new ConfigError("`baseUrl` is required (the Laravel API URL, e.g. https://api.domain.com).");
|
|
239
|
+
}
|
|
240
|
+
const features = input.features ?? {};
|
|
241
|
+
const endpoints = input.endpoints ?? {};
|
|
242
|
+
return {
|
|
243
|
+
baseUrl: input.baseUrl.replace(/\/+$/, ""),
|
|
244
|
+
mode: input.mode ?? "cookie",
|
|
245
|
+
origin: defaultOrigin(input.origin),
|
|
246
|
+
features: {
|
|
247
|
+
registration: features.registration ?? true,
|
|
248
|
+
resetPasswords: features.resetPasswords ?? true,
|
|
249
|
+
emailVerification: features.emailVerification ?? true,
|
|
250
|
+
updateProfileInformation: features.updateProfileInformation ?? true,
|
|
251
|
+
updatePasswords: features.updatePasswords ?? true,
|
|
252
|
+
deviceSessions: features.deviceSessions ?? false,
|
|
253
|
+
twoFactorAuthentication: resolveTwoFactor(features.twoFactorAuthentication),
|
|
254
|
+
passkeys: resolvePasskeys(features.passkeys)
|
|
255
|
+
},
|
|
256
|
+
endpoints: {
|
|
257
|
+
...DEFAULT_ENDPOINTS,
|
|
258
|
+
...endpoints,
|
|
259
|
+
twoFactor: {
|
|
260
|
+
...DEFAULT_ENDPOINTS.twoFactor,
|
|
261
|
+
...endpoints.twoFactor
|
|
262
|
+
},
|
|
263
|
+
passkeys: {
|
|
264
|
+
...DEFAULT_ENDPOINTS.passkeys,
|
|
265
|
+
...endpoints.passkeys
|
|
266
|
+
},
|
|
267
|
+
sessions: {
|
|
268
|
+
...DEFAULT_ENDPOINTS.sessions,
|
|
269
|
+
...endpoints.sessions
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
csrf: {
|
|
273
|
+
cookie: input.csrf?.cookie ?? "XSRF-TOKEN",
|
|
274
|
+
header: input.csrf?.header ?? "X-XSRF-TOKEN"
|
|
275
|
+
},
|
|
276
|
+
redirect: {
|
|
277
|
+
onLogin: input.redirect?.onLogin ?? "/",
|
|
278
|
+
onLogout: input.redirect?.onLogout ?? "/",
|
|
279
|
+
onAuthOnly: input.redirect?.onAuthOnly ?? "/login",
|
|
280
|
+
onGuestOnly: input.redirect?.onGuestOnly ?? "/",
|
|
281
|
+
keepRequestedRoute: input.redirect?.keepRequestedRoute ?? false
|
|
282
|
+
},
|
|
283
|
+
logLevel: input.logLevel ?? 3,
|
|
284
|
+
initialRequest: input.initialRequest ?? true,
|
|
285
|
+
retryOnCsrfMismatch: input.retryOnCsrfMismatch ?? true,
|
|
286
|
+
storage: input.storage,
|
|
287
|
+
interceptors: {
|
|
288
|
+
request: input.interceptors?.request ?? [],
|
|
289
|
+
response: input.interceptors?.response ?? []
|
|
290
|
+
},
|
|
291
|
+
events: input.events ?? {},
|
|
292
|
+
redirectIfUnauthenticated: input.redirectIfUnauthenticated ?? false,
|
|
293
|
+
fetch: resolveFetch(input.fetch)
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Open-redirect protection. Only allows SAME-ORIGIN destinations (a relative path
|
|
299
|
+
* or an absolute URL with the same origin), with an optional path allowlist.
|
|
300
|
+
* See PRD §12.1 (Open redirect) & §18 (a unit test is required).
|
|
301
|
+
*/ /** Reject backslashes and any control character (Tab/LF/CR are stripped by the URL
|
|
302
|
+
* parser, which could turn `/\t/evil.com` into `//evil.com` and bypass the `//` guard). */ function hasUnsafeChars(value) {
|
|
303
|
+
for(let i = 0; i < value.length; i++){
|
|
304
|
+
const code = value.charCodeAt(i);
|
|
305
|
+
if (code <= 0x1f || code === 0x7f) return true;
|
|
306
|
+
if (value[i] === "\\") return true;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Return `target` when it is safe (same-origin), otherwise `fallback`.
|
|
312
|
+
* Rejects: `//evil.com`, `https://evil.com`, the `javascript:` scheme, control-char
|
|
313
|
+
* injection (`/\t//evil.com`), and backslash tricks.
|
|
314
|
+
*/ function safeRedirect(target, fallback, options = {}) {
|
|
315
|
+
if (!target) return fallback;
|
|
316
|
+
const trimmed = target.trim();
|
|
317
|
+
if (trimmed === "") return fallback;
|
|
318
|
+
if (hasUnsafeChars(trimmed)) return fallback;
|
|
319
|
+
let path = null;
|
|
320
|
+
if (trimmed.startsWith("/")) {
|
|
321
|
+
// Reject protocol-relative (//evil.com).
|
|
322
|
+
if (trimmed.startsWith("//")) return fallback;
|
|
323
|
+
path = trimmed;
|
|
324
|
+
} else if (options.origin) {
|
|
325
|
+
try {
|
|
326
|
+
const url = new URL(trimmed);
|
|
327
|
+
const base = new URL(options.origin);
|
|
328
|
+
if (url.origin === base.origin) {
|
|
329
|
+
path = url.pathname + url.search + url.hash;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// not a valid URL → reject
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (path === null) return fallback;
|
|
336
|
+
// Defense-in-depth: resolve the path and confirm it stays same-origin.
|
|
337
|
+
try {
|
|
338
|
+
const base = options.origin ?? "https://sanctum.invalid";
|
|
339
|
+
if (new URL(path, base).origin !== new URL(base).origin) return fallback;
|
|
340
|
+
} catch {
|
|
341
|
+
return fallback;
|
|
342
|
+
}
|
|
343
|
+
if (options.allowList && options.allowList.length > 0) {
|
|
344
|
+
const safe = path;
|
|
345
|
+
const allowed = options.allowList.some((prefix)=>safe === prefix || safe.startsWith(prefix));
|
|
346
|
+
if (!allowed) return fallback;
|
|
347
|
+
}
|
|
348
|
+
return path;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** HTTP methods that require CSRF protection in cookie mode. */ const STATEFUL_METHODS = new Set([
|
|
352
|
+
"POST",
|
|
353
|
+
"PUT",
|
|
354
|
+
"PATCH",
|
|
355
|
+
"DELETE"
|
|
356
|
+
]);
|
|
357
|
+
/** Join baseUrl + path. Absolute paths (http/https) are passed through as-is. */ function joinUrl(baseUrl, path) {
|
|
358
|
+
if (/^https?:\/\//i.test(path)) return path;
|
|
359
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
360
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
361
|
+
return base + suffix;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Parse the response body as JSON. Returns `undefined` (cast to T)
|
|
365
|
+
* for 204 / an empty body so the caller doesn't need a manual try/catch.
|
|
366
|
+
*/ async function parseJson(response) {
|
|
367
|
+
if (response.status === 204) return undefined;
|
|
368
|
+
const text = await response.text();
|
|
369
|
+
if (text.length === 0) return undefined;
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(text);
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
throw new SanctumError("Failed to parse the JSON response body.", {
|
|
374
|
+
kind: "unknown",
|
|
375
|
+
status: response.status,
|
|
376
|
+
cause
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* CSRF cookie reading (browser). Sanctum stores `XSRF-TOKEN` in URL-encoded
|
|
383
|
+
* form, so its value MUST be decoded before being sent as the
|
|
384
|
+
* `X-XSRF-TOKEN` header (the most common source of Sanctum integration bugs).
|
|
385
|
+
*/ /** Read a single cookie from document.cookie. Null on the server (no document). */ function readCookie(name) {
|
|
386
|
+
if (typeof document === "undefined") return null;
|
|
387
|
+
const cookies = document.cookie ? document.cookie.split("; ") : [];
|
|
388
|
+
for (const cookie of cookies){
|
|
389
|
+
const eq = cookie.indexOf("=");
|
|
390
|
+
const key = eq === -1 ? cookie : cookie.slice(0, eq);
|
|
391
|
+
if (key === name) {
|
|
392
|
+
return eq === -1 ? "" : cookie.slice(eq + 1);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
/** Read the XSRF token from the cookie, then URL-decode it. */ function readXsrfToken(cookieName) {
|
|
398
|
+
const raw = readCookie(cookieName);
|
|
399
|
+
if (raw === null) return null;
|
|
400
|
+
try {
|
|
401
|
+
return decodeURIComponent(raw);
|
|
402
|
+
} catch {
|
|
403
|
+
return raw;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The native-fetch core used by every feature: attaches CSRF (cookie) or
|
|
409
|
+
* Bearer (token), credentials, base URL, interceptors, a single 419 retry, and
|
|
410
|
+
* normalizes errors.
|
|
411
|
+
*/ function createSanctumClient(config, deps = {}) {
|
|
412
|
+
const fetchImpl = config.fetch;
|
|
413
|
+
const { logger, emitter } = deps;
|
|
414
|
+
async function fetchCsrfCookie() {
|
|
415
|
+
const url = joinUrl(config.baseUrl, config.endpoints.csrf);
|
|
416
|
+
logger?.debug("GET", url);
|
|
417
|
+
try {
|
|
418
|
+
await fetchImpl(url, {
|
|
419
|
+
method: "GET",
|
|
420
|
+
credentials: "include",
|
|
421
|
+
headers: {
|
|
422
|
+
accept: "application/json"
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
} catch (cause) {
|
|
426
|
+
throw networkError(cause);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// De-duplicate concurrent CSRF-cookie fetches so parallel stateful requests on a
|
|
430
|
+
// fresh page don't each fire (and race) a `GET /sanctum/csrf-cookie`.
|
|
431
|
+
let csrfInFlight = null;
|
|
432
|
+
async function ensureCsrf(force = false) {
|
|
433
|
+
if (config.mode !== "cookie") return;
|
|
434
|
+
if (!force && readXsrfToken(config.csrf.cookie)) return;
|
|
435
|
+
if (!force && csrfInFlight) return csrfInFlight;
|
|
436
|
+
const pending = fetchCsrfCookie().finally(()=>{
|
|
437
|
+
if (csrfInFlight === pending) csrfInFlight = null;
|
|
438
|
+
});
|
|
439
|
+
csrfInFlight = pending;
|
|
440
|
+
return pending;
|
|
441
|
+
}
|
|
442
|
+
async function buildRequest(path, init) {
|
|
443
|
+
const url = joinUrl(config.baseUrl, path);
|
|
444
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
445
|
+
const headers = new Headers(init.headers);
|
|
446
|
+
if (!headers.has("accept")) headers.set("accept", "application/json");
|
|
447
|
+
const { json, body: rawBody, ...rest } = init;
|
|
448
|
+
let body = rawBody ?? null;
|
|
449
|
+
if (json !== undefined) {
|
|
450
|
+
body = JSON.stringify(json);
|
|
451
|
+
if (!headers.has("content-type")) {
|
|
452
|
+
headers.set("content-type", "application/json");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (config.mode === "cookie") {
|
|
456
|
+
const token = readXsrfToken(config.csrf.cookie);
|
|
457
|
+
if (token && !headers.has(config.csrf.header)) {
|
|
458
|
+
headers.set(config.csrf.header, token);
|
|
459
|
+
}
|
|
460
|
+
} else if (deps.getToken) {
|
|
461
|
+
const token = await deps.getToken();
|
|
462
|
+
if (token && !headers.has("authorization")) {
|
|
463
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const credentials = config.mode === "cookie" ? "include" : init.credentials ?? "same-origin";
|
|
467
|
+
let request = new Request(url, {
|
|
468
|
+
...rest,
|
|
469
|
+
method,
|
|
470
|
+
headers,
|
|
471
|
+
body,
|
|
472
|
+
credentials
|
|
473
|
+
});
|
|
474
|
+
for (const interceptor of config.interceptors.request){
|
|
475
|
+
request = await interceptor(request);
|
|
476
|
+
}
|
|
477
|
+
return request;
|
|
478
|
+
}
|
|
479
|
+
async function send(path, init, isRetry) {
|
|
480
|
+
const request = await buildRequest(path, init);
|
|
481
|
+
emitter?.emit("request", {
|
|
482
|
+
url: request.url,
|
|
483
|
+
init
|
|
484
|
+
});
|
|
485
|
+
let response;
|
|
486
|
+
try {
|
|
487
|
+
response = await fetchImpl(request);
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
const error = networkError(cause);
|
|
490
|
+
emitter?.emit("error", {
|
|
491
|
+
error
|
|
492
|
+
});
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
for (const interceptor of config.interceptors.response){
|
|
496
|
+
response = await interceptor(response, request);
|
|
497
|
+
}
|
|
498
|
+
emitter?.emit("response", {
|
|
499
|
+
url: request.url,
|
|
500
|
+
response
|
|
501
|
+
});
|
|
502
|
+
if (response.status === 419 && config.mode === "cookie" && config.retryOnCsrfMismatch && !isRetry) {
|
|
503
|
+
logger?.warn("CSRF mismatch (419) — refresh token & retry once");
|
|
504
|
+
await ensureCsrf(true);
|
|
505
|
+
return send(path, init, true);
|
|
506
|
+
}
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
const error = await errorFromResponse(response);
|
|
509
|
+
emitter?.emit("error", {
|
|
510
|
+
error
|
|
511
|
+
});
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
return response;
|
|
515
|
+
}
|
|
516
|
+
async function raw(path, init = {}) {
|
|
517
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
518
|
+
if (config.mode === "cookie" && STATEFUL_METHODS.has(method)) {
|
|
519
|
+
await ensureCsrf();
|
|
520
|
+
}
|
|
521
|
+
return send(path, init, false);
|
|
522
|
+
}
|
|
523
|
+
async function request(path, init = {}) {
|
|
524
|
+
const response = await raw(path, init);
|
|
525
|
+
return parseJson(response);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
config,
|
|
529
|
+
ensureCsrf,
|
|
530
|
+
raw,
|
|
531
|
+
request
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Core auth API (framework-agnostic). The React provider wraps it to
|
|
537
|
+
* manage state. Login detects `two_factor` BEFORE fetching the user
|
|
538
|
+
* so consumers cannot forget to handle 2FA (discriminated result).
|
|
539
|
+
*/ function createAuthApi(client, config, deps = {}) {
|
|
540
|
+
const { emitter } = deps;
|
|
541
|
+
async function refreshIdentity() {
|
|
542
|
+
try {
|
|
543
|
+
const user = await client.request(config.endpoints.user, {
|
|
544
|
+
method: "GET"
|
|
545
|
+
});
|
|
546
|
+
const resolved = user ?? null;
|
|
547
|
+
emitter?.emit("refresh", {
|
|
548
|
+
user: resolved
|
|
549
|
+
});
|
|
550
|
+
return resolved;
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error instanceof SanctumError && error.kind === "unauthorized") {
|
|
553
|
+
emitter?.emit("refresh", {
|
|
554
|
+
user: null
|
|
555
|
+
});
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function login(credentials) {
|
|
562
|
+
const data = await client.request(config.endpoints.login, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
json: credentials
|
|
565
|
+
});
|
|
566
|
+
if (data?.two_factor) {
|
|
567
|
+
// Token mode + 2FA is not completable via the standard Fortify flow: the
|
|
568
|
+
// `/two-factor-challenge` endpoint establishes a session and returns no token.
|
|
569
|
+
// Fail loud instead of leaving the user silently unauthenticated.
|
|
570
|
+
if (config.mode === "token") {
|
|
571
|
+
throw new ConfigError("Two-factor authentication during login is only supported in cookie mode. " + "In token mode, mint the token from a 2FA-aware endpoint.");
|
|
572
|
+
}
|
|
573
|
+
emitter?.emit("two-factor-required", {});
|
|
574
|
+
return {
|
|
575
|
+
status: "two-factor-required"
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (config.mode === "token" && data?.token) {
|
|
579
|
+
await deps.setToken?.(data.token);
|
|
580
|
+
}
|
|
581
|
+
const user = await refreshIdentity();
|
|
582
|
+
if (!user) {
|
|
583
|
+
throw new SanctumError("Login succeeded but failed to fetch the user data. Check the user endpoint & configuration.", {
|
|
584
|
+
kind: "unknown"
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
emitter?.emit("login", {
|
|
588
|
+
user
|
|
589
|
+
});
|
|
590
|
+
return {
|
|
591
|
+
status: "authenticated",
|
|
592
|
+
user
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async function logout() {
|
|
596
|
+
try {
|
|
597
|
+
await client.raw(config.endpoints.logout, {
|
|
598
|
+
method: "POST"
|
|
599
|
+
});
|
|
600
|
+
} finally{
|
|
601
|
+
if (config.mode === "token") await deps.clearToken?.();
|
|
602
|
+
emitter?.emit("logout", {});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
login,
|
|
607
|
+
logout,
|
|
608
|
+
refreshIdentity
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Registration (Fortify `POST /register`). On success → a login session is created by the backend. */ function createRegistrationApi(client, config) {
|
|
613
|
+
return {
|
|
614
|
+
async register (payload) {
|
|
615
|
+
if (!config.features.registration) {
|
|
616
|
+
throw new ConfigError('The "registration" feature is disabled in config.');
|
|
617
|
+
}
|
|
618
|
+
await client.raw(config.endpoints.register, {
|
|
619
|
+
method: "POST",
|
|
620
|
+
json: payload
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function createPasswordApi(client, config) {
|
|
627
|
+
const ep = config.endpoints;
|
|
628
|
+
return {
|
|
629
|
+
async forgotPassword (payload) {
|
|
630
|
+
await client.raw(ep.forgotPassword, {
|
|
631
|
+
method: "POST",
|
|
632
|
+
json: payload
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
async resetPassword (payload) {
|
|
636
|
+
await client.raw(ep.resetPassword, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
json: payload
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
async confirmPassword (payload) {
|
|
642
|
+
await client.raw(ep.confirmPassword, {
|
|
643
|
+
method: "POST",
|
|
644
|
+
json: payload
|
|
645
|
+
});
|
|
646
|
+
},
|
|
647
|
+
async confirmedPasswordStatus () {
|
|
648
|
+
const data = await client.request(ep.confirmedPasswordStatus, {
|
|
649
|
+
method: "GET"
|
|
650
|
+
});
|
|
651
|
+
return Boolean(data?.confirmed);
|
|
652
|
+
},
|
|
653
|
+
async updatePassword (payload) {
|
|
654
|
+
await client.raw(ep.updatePassword, {
|
|
655
|
+
method: "PUT",
|
|
656
|
+
json: payload
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function createProfileApi(client, config) {
|
|
663
|
+
return {
|
|
664
|
+
async updateProfileInformation (payload) {
|
|
665
|
+
await client.raw(config.endpoints.profileInformation, {
|
|
666
|
+
method: "PUT",
|
|
667
|
+
json: payload
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function createEmailVerificationApi(client, config) {
|
|
674
|
+
return {
|
|
675
|
+
async resendEmailVerification () {
|
|
676
|
+
await client.raw(config.endpoints.emailVerificationNotification, {
|
|
677
|
+
method: "POST"
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
async verifyEmail (payload) {
|
|
681
|
+
const query = new URLSearchParams({
|
|
682
|
+
expires: String(payload.expires),
|
|
683
|
+
signature: payload.signature
|
|
684
|
+
}).toString();
|
|
685
|
+
const path = `${config.endpoints.verifyEmail}/${encodeURIComponent(String(payload.id))}/${encodeURIComponent(payload.hash)}?${query}`;
|
|
686
|
+
await client.request(path, {
|
|
687
|
+
method: "GET"
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function createTwoFactorApi(client, config) {
|
|
694
|
+
const ep = config.endpoints.twoFactor;
|
|
695
|
+
function ensureEnabled() {
|
|
696
|
+
if (config.features.twoFactorAuthentication === false) {
|
|
697
|
+
throw new ConfigError('The "twoFactorAuthentication" feature is disabled in config.');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
async challenge (payload) {
|
|
702
|
+
// Challenge may run even when 2FA management is disabled (login flow).
|
|
703
|
+
await client.raw(ep.challenge, {
|
|
704
|
+
method: "POST",
|
|
705
|
+
json: payload
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
async enable () {
|
|
709
|
+
ensureEnabled();
|
|
710
|
+
await client.raw(ep.enable, {
|
|
711
|
+
method: "POST"
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
async confirm (code) {
|
|
715
|
+
ensureEnabled();
|
|
716
|
+
await client.raw(ep.confirm, {
|
|
717
|
+
method: "POST",
|
|
718
|
+
json: {
|
|
719
|
+
code
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
async disable () {
|
|
724
|
+
ensureEnabled();
|
|
725
|
+
await client.raw(ep.disable, {
|
|
726
|
+
method: "DELETE"
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
async getQrCode () {
|
|
730
|
+
ensureEnabled();
|
|
731
|
+
return client.request(ep.qrCode, {
|
|
732
|
+
method: "GET"
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
async getSecretKey () {
|
|
736
|
+
ensureEnabled();
|
|
737
|
+
return client.request(ep.secretKey, {
|
|
738
|
+
method: "GET"
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
async getRecoveryCodes () {
|
|
742
|
+
ensureEnabled();
|
|
743
|
+
return client.request(ep.recoveryCodes, {
|
|
744
|
+
method: "GET"
|
|
745
|
+
});
|
|
746
|
+
},
|
|
747
|
+
async regenerateRecoveryCodes () {
|
|
748
|
+
ensureEnabled();
|
|
749
|
+
await client.raw(ep.recoveryCodes, {
|
|
750
|
+
method: "POST"
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function loadPasskeys() {
|
|
757
|
+
try {
|
|
758
|
+
const mod = await import('@laravel/passkeys');
|
|
759
|
+
return mod.Passkeys;
|
|
760
|
+
} catch (cause) {
|
|
761
|
+
throw new ConfigError("The @laravel/passkeys package is not installed. Run: pnpm add @laravel/passkeys", cause);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Passkeys interop (Fortify). The WebAuthn ceremony is delegated to @laravel/passkeys
|
|
766
|
+
* (dynamic import, browser-only, optional peer). We map the endpoints from config
|
|
767
|
+
* & set credentials/CSRF so the request is authenticated.
|
|
768
|
+
*/ function createPasskeysApi(client, config) {
|
|
769
|
+
const ep = config.endpoints.passkeys;
|
|
770
|
+
const abs = (path)=>joinUrl(config.baseUrl, path);
|
|
771
|
+
function ensureEnabled() {
|
|
772
|
+
if (config.features.passkeys === false) {
|
|
773
|
+
throw new ConfigError('The "passkeys" feature is disabled in config.');
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function configured() {
|
|
777
|
+
ensureEnabled();
|
|
778
|
+
const Passkeys = await loadPasskeys();
|
|
779
|
+
await client.ensureCsrf();
|
|
780
|
+
const xsrf = readXsrfToken(config.csrf.cookie);
|
|
781
|
+
Passkeys.configure({
|
|
782
|
+
fetch: {
|
|
783
|
+
credentials: "include",
|
|
784
|
+
headers: xsrf ? {
|
|
785
|
+
[config.csrf.header]: xsrf
|
|
786
|
+
} : {}
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return Passkeys;
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
async isSupported () {
|
|
793
|
+
const Passkeys = await loadPasskeys();
|
|
794
|
+
return Passkeys.isSupported();
|
|
795
|
+
},
|
|
796
|
+
async register (name) {
|
|
797
|
+
const Passkeys = await configured();
|
|
798
|
+
return Passkeys.register({
|
|
799
|
+
name,
|
|
800
|
+
routes: {
|
|
801
|
+
options: abs(ep.registerOptions),
|
|
802
|
+
submit: abs(ep.register)
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
async login () {
|
|
807
|
+
const Passkeys = await configured();
|
|
808
|
+
await Passkeys.verify({
|
|
809
|
+
routes: {
|
|
810
|
+
options: abs(ep.loginOptions),
|
|
811
|
+
submit: abs(ep.login)
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
async confirmPassword () {
|
|
816
|
+
const Passkeys = await configured();
|
|
817
|
+
await Passkeys.verify({
|
|
818
|
+
routes: {
|
|
819
|
+
options: abs(ep.confirmOptions),
|
|
820
|
+
submit: abs(ep.confirm)
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
},
|
|
824
|
+
async delete (id) {
|
|
825
|
+
ensureEnabled();
|
|
826
|
+
await client.raw(`${ep.delete}/${encodeURIComponent(id)}`, {
|
|
827
|
+
method: "DELETE"
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** In-memory token storage (lost on reload). Default for token mode. */ class MemoryStorage {
|
|
834
|
+
get() {
|
|
835
|
+
return this.token;
|
|
836
|
+
}
|
|
837
|
+
set(token) {
|
|
838
|
+
this.token = token;
|
|
839
|
+
}
|
|
840
|
+
remove() {
|
|
841
|
+
this.token = null;
|
|
842
|
+
}
|
|
843
|
+
constructor(){
|
|
844
|
+
this.token = null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const DEFAULT_KEY = "sanctum.token";
|
|
849
|
+
let warned$1 = false;
|
|
850
|
+
/** Token storage in localStorage. OPT-IN — vulnerable to XSS (see PRD §12). */ class LocalStorage {
|
|
851
|
+
constructor(key = DEFAULT_KEY){
|
|
852
|
+
this.key = key;
|
|
853
|
+
}
|
|
854
|
+
warnOnce() {
|
|
855
|
+
if (warned$1) return;
|
|
856
|
+
warned$1 = true;
|
|
857
|
+
console.warn("[next-sanctum] LocalStorage token storage is vulnerable to XSS. Use it only when necessary (e.g. Capacitor); for the web, prefer HttpOnly cookies + a server proxy.");
|
|
858
|
+
}
|
|
859
|
+
get() {
|
|
860
|
+
if (typeof window === "undefined") return null;
|
|
861
|
+
this.warnOnce();
|
|
862
|
+
return window.localStorage.getItem(this.key);
|
|
863
|
+
}
|
|
864
|
+
set(token) {
|
|
865
|
+
if (typeof window === "undefined") return;
|
|
866
|
+
this.warnOnce();
|
|
867
|
+
window.localStorage.setItem(this.key, token);
|
|
868
|
+
}
|
|
869
|
+
remove() {
|
|
870
|
+
if (typeof window === "undefined") return;
|
|
871
|
+
window.localStorage.removeItem(this.key);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
let warned = false;
|
|
876
|
+
/**
|
|
877
|
+
* Cookie-based token storage written by the client. NOTE: cookies written by
|
|
878
|
+
* JS CANNOT be HttpOnly. For true HttpOnly, set the cookie via a Route Handler/Server
|
|
879
|
+
* Action and then attach the Bearer on the server (catch-all proxy). This storage is for
|
|
880
|
+
* simple persistence, NOT a replacement for HttpOnly.
|
|
881
|
+
*/ class CookieTokenStorage {
|
|
882
|
+
constructor(options = {}){
|
|
883
|
+
this.name = options.name ?? "sanctum_token";
|
|
884
|
+
this.maxAge = options.maxAge ?? 60 * 60 * 24 * 14;
|
|
885
|
+
this.sameSite = options.sameSite ?? "Strict";
|
|
886
|
+
this.secure = options.secure ?? true;
|
|
887
|
+
this.path = options.path ?? "/";
|
|
888
|
+
}
|
|
889
|
+
warnOnce() {
|
|
890
|
+
if (warned) return;
|
|
891
|
+
warned = true;
|
|
892
|
+
console.warn("[next-sanctum] CookieTokenStorage writes a non-HttpOnly cookie that is readable by JS (XSS-exposed). For production web apps, prefer an HttpOnly cookie set server-side + the catch-all proxy.");
|
|
893
|
+
}
|
|
894
|
+
get() {
|
|
895
|
+
const raw = readCookie(this.name);
|
|
896
|
+
if (raw === null || raw === "") return null;
|
|
897
|
+
this.warnOnce();
|
|
898
|
+
try {
|
|
899
|
+
return decodeURIComponent(raw);
|
|
900
|
+
} catch {
|
|
901
|
+
return raw;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
set(token) {
|
|
905
|
+
if (typeof document === "undefined") return;
|
|
906
|
+
this.warnOnce();
|
|
907
|
+
const parts = [
|
|
908
|
+
`${this.name}=${encodeURIComponent(token)}`,
|
|
909
|
+
`Path=${this.path}`,
|
|
910
|
+
`Max-Age=${this.maxAge}`,
|
|
911
|
+
`SameSite=${this.sameSite}`
|
|
912
|
+
];
|
|
913
|
+
if (this.secure) parts.push("Secure");
|
|
914
|
+
document.cookie = parts.join("; ");
|
|
915
|
+
}
|
|
916
|
+
remove() {
|
|
917
|
+
if (typeof document === "undefined") return;
|
|
918
|
+
document.cookie = `${this.name}=; Path=${this.path}; Max-Age=0`;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Effective storage: config.storage, or the default MemoryStorage for token mode. */ function resolveTokenStorage(config) {
|
|
923
|
+
if (config.storage) return config.storage;
|
|
924
|
+
if (config.mode === "token") return new MemoryStorage();
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const SanctumContext = /*#__PURE__*/ react.createContext(null);
|
|
929
|
+
/** Get the context; throws a clear error if used outside the provider. */ function useSanctumContext() {
|
|
930
|
+
const ctx = react.useContext(SanctumContext);
|
|
931
|
+
if (!ctx) {
|
|
932
|
+
throw new Error("next-sanctum hooks must be used inside <SanctumProvider>.");
|
|
933
|
+
}
|
|
934
|
+
return ctx;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function SanctumProvider({ config, initialUser, children }) {
|
|
938
|
+
// Config resolution + client/feature-api creation happens once (on mount).
|
|
939
|
+
const instanceRef = react.useRef(null);
|
|
940
|
+
if (instanceRef.current === null) {
|
|
941
|
+
const resolved = resolveConfig(config);
|
|
942
|
+
const logger = createLogger(resolved.logLevel);
|
|
943
|
+
const emitter = new SanctumEventEmitter();
|
|
944
|
+
emitter.register(resolved.events);
|
|
945
|
+
const storage = resolveTokenStorage(resolved);
|
|
946
|
+
const client = createSanctumClient(resolved, {
|
|
947
|
+
logger,
|
|
948
|
+
emitter,
|
|
949
|
+
getToken: storage ? ()=>storage.get() : undefined
|
|
950
|
+
});
|
|
951
|
+
const auth = createAuthApi(client, resolved, {
|
|
952
|
+
emitter,
|
|
953
|
+
setToken: storage ? (token)=>storage.set(token) : undefined,
|
|
954
|
+
clearToken: storage ? ()=>storage.remove() : undefined
|
|
955
|
+
});
|
|
956
|
+
instanceRef.current = {
|
|
957
|
+
config: resolved,
|
|
958
|
+
client,
|
|
959
|
+
emitter,
|
|
960
|
+
auth,
|
|
961
|
+
registration: createRegistrationApi(client, resolved),
|
|
962
|
+
password: createPasswordApi(client, resolved),
|
|
963
|
+
profile: createProfileApi(client, resolved),
|
|
964
|
+
emailVerification: createEmailVerificationApi(client, resolved),
|
|
965
|
+
twoFactor: createTwoFactorApi(client, resolved),
|
|
966
|
+
passkeys: createPasskeysApi(client, resolved)
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
const { config: resolved, client, emitter, auth, registration, password, profile, emailVerification, twoFactor: rawTwoFactor, passkeys: rawPasskeys } = instanceRef.current;
|
|
970
|
+
const [user, setUser] = react.useState(()=>initialUser ?? null);
|
|
971
|
+
const [status, setStatus] = react.useState(()=>initialUser !== undefined ? initialUser ? "authenticated" : "unauthenticated" : resolved.initialRequest ? "loading" : "unauthenticated");
|
|
972
|
+
const userRef = react.useRef(user);
|
|
973
|
+
react.useEffect(()=>{
|
|
974
|
+
userRef.current = user;
|
|
975
|
+
}, [
|
|
976
|
+
user
|
|
977
|
+
]);
|
|
978
|
+
// De-duplicate concurrent login() calls (double-submit) by sharing the in-flight promise.
|
|
979
|
+
const loginInFlight = react.useRef(null);
|
|
980
|
+
const login = react.useCallback((credentials)=>{
|
|
981
|
+
if (loginInFlight.current) return loginInFlight.current;
|
|
982
|
+
const pending = (async ()=>{
|
|
983
|
+
setStatus("loading");
|
|
984
|
+
try {
|
|
985
|
+
const result = await auth.login(credentials);
|
|
986
|
+
if (result.status === "authenticated") {
|
|
987
|
+
setUser(result.user);
|
|
988
|
+
setStatus("authenticated");
|
|
989
|
+
} else {
|
|
990
|
+
setStatus("unauthenticated");
|
|
991
|
+
}
|
|
992
|
+
return result;
|
|
993
|
+
} catch (error) {
|
|
994
|
+
setStatus(userRef.current ? "authenticated" : "unauthenticated");
|
|
995
|
+
throw error;
|
|
996
|
+
} finally{
|
|
997
|
+
loginInFlight.current = null;
|
|
998
|
+
}
|
|
999
|
+
})();
|
|
1000
|
+
loginInFlight.current = pending;
|
|
1001
|
+
return pending;
|
|
1002
|
+
}, [
|
|
1003
|
+
auth
|
|
1004
|
+
]);
|
|
1005
|
+
const logout = react.useCallback(async ()=>{
|
|
1006
|
+
try {
|
|
1007
|
+
await auth.logout();
|
|
1008
|
+
} finally{
|
|
1009
|
+
setUser(null);
|
|
1010
|
+
setStatus("unauthenticated");
|
|
1011
|
+
}
|
|
1012
|
+
}, [
|
|
1013
|
+
auth
|
|
1014
|
+
]);
|
|
1015
|
+
const refresh = react.useCallback(async ()=>{
|
|
1016
|
+
const next = await auth.refreshIdentity();
|
|
1017
|
+
setUser(next);
|
|
1018
|
+
setStatus(next ? "authenticated" : "unauthenticated");
|
|
1019
|
+
return next;
|
|
1020
|
+
}, [
|
|
1021
|
+
auth
|
|
1022
|
+
]);
|
|
1023
|
+
// Actions that change identity → refresh state after success.
|
|
1024
|
+
const register = react.useCallback(async (payload)=>{
|
|
1025
|
+
await registration.register(payload);
|
|
1026
|
+
await refresh().catch(()=>{});
|
|
1027
|
+
}, [
|
|
1028
|
+
registration,
|
|
1029
|
+
refresh
|
|
1030
|
+
]);
|
|
1031
|
+
const updateProfile = react.useCallback(async (payload)=>{
|
|
1032
|
+
await profile.updateProfileInformation(payload);
|
|
1033
|
+
await refresh().catch(()=>{});
|
|
1034
|
+
}, [
|
|
1035
|
+
profile,
|
|
1036
|
+
refresh
|
|
1037
|
+
]);
|
|
1038
|
+
const verifyEmail = react.useCallback(async (payload)=>{
|
|
1039
|
+
await emailVerification.verifyEmail(payload);
|
|
1040
|
+
await refresh().catch(()=>{});
|
|
1041
|
+
}, [
|
|
1042
|
+
emailVerification,
|
|
1043
|
+
refresh
|
|
1044
|
+
]);
|
|
1045
|
+
const twoFactor = react.useMemo(()=>({
|
|
1046
|
+
...rawTwoFactor,
|
|
1047
|
+
challenge: async (payload)=>{
|
|
1048
|
+
await rawTwoFactor.challenge(payload);
|
|
1049
|
+
await refresh().catch(()=>{});
|
|
1050
|
+
}
|
|
1051
|
+
}), [
|
|
1052
|
+
rawTwoFactor,
|
|
1053
|
+
refresh
|
|
1054
|
+
]);
|
|
1055
|
+
const passkeys = react.useMemo(()=>({
|
|
1056
|
+
...rawPasskeys,
|
|
1057
|
+
login: async ()=>{
|
|
1058
|
+
await rawPasskeys.login();
|
|
1059
|
+
await refresh().catch(()=>{});
|
|
1060
|
+
}
|
|
1061
|
+
}), [
|
|
1062
|
+
rawPasskeys,
|
|
1063
|
+
refresh
|
|
1064
|
+
]);
|
|
1065
|
+
// Reactive logout on 401 (expired session) + optional redirect.
|
|
1066
|
+
react.useEffect(()=>{
|
|
1067
|
+
const off = emitter.on("error", ({ error })=>{
|
|
1068
|
+
if (error.kind !== "unauthorized") return;
|
|
1069
|
+
// Only react when we currently believe we're authenticated (session expiry) —
|
|
1070
|
+
// not on the initial "am I logged in?" probe 401 for a guest on a public page.
|
|
1071
|
+
if (!userRef.current) return;
|
|
1072
|
+
setUser(null);
|
|
1073
|
+
setStatus("unauthenticated");
|
|
1074
|
+
const target = resolved.redirectIfUnauthenticated;
|
|
1075
|
+
if (target && typeof window !== "undefined") {
|
|
1076
|
+
const safe = safeRedirect(target, "/", {
|
|
1077
|
+
origin: resolved.origin
|
|
1078
|
+
});
|
|
1079
|
+
emitter.emit("redirect", {
|
|
1080
|
+
to: safe,
|
|
1081
|
+
reason: "unauthenticated"
|
|
1082
|
+
});
|
|
1083
|
+
window.location.assign(safe);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
return off;
|
|
1087
|
+
}, [
|
|
1088
|
+
emitter,
|
|
1089
|
+
resolved
|
|
1090
|
+
]);
|
|
1091
|
+
// Init: emit event + fetch user when not prefetched from the server.
|
|
1092
|
+
react.useEffect(()=>{
|
|
1093
|
+
emitter.emit("init", {
|
|
1094
|
+
user: userRef.current
|
|
1095
|
+
});
|
|
1096
|
+
if (initialUser !== undefined || !resolved.initialRequest) return;
|
|
1097
|
+
let active = true;
|
|
1098
|
+
auth.refreshIdentity().then((next)=>{
|
|
1099
|
+
if (!active) return;
|
|
1100
|
+
setUser(next);
|
|
1101
|
+
setStatus(next ? "authenticated" : "unauthenticated");
|
|
1102
|
+
}).catch(()=>{
|
|
1103
|
+
if (active) setStatus("unauthenticated");
|
|
1104
|
+
});
|
|
1105
|
+
return ()=>{
|
|
1106
|
+
active = false;
|
|
1107
|
+
};
|
|
1108
|
+
}, []);
|
|
1109
|
+
const value = react.useMemo(()=>({
|
|
1110
|
+
config: resolved,
|
|
1111
|
+
client,
|
|
1112
|
+
emitter,
|
|
1113
|
+
user,
|
|
1114
|
+
status,
|
|
1115
|
+
isAuthenticated: status === "authenticated" && user !== null,
|
|
1116
|
+
isLoading: status === "loading",
|
|
1117
|
+
login,
|
|
1118
|
+
logout,
|
|
1119
|
+
refresh,
|
|
1120
|
+
setUser,
|
|
1121
|
+
register,
|
|
1122
|
+
forgotPassword: password.forgotPassword,
|
|
1123
|
+
resetPassword: password.resetPassword,
|
|
1124
|
+
confirmPassword: password.confirmPassword,
|
|
1125
|
+
confirmedPasswordStatus: password.confirmedPasswordStatus,
|
|
1126
|
+
updatePassword: password.updatePassword,
|
|
1127
|
+
updateProfile,
|
|
1128
|
+
resendEmailVerification: emailVerification.resendEmailVerification,
|
|
1129
|
+
verifyEmail,
|
|
1130
|
+
twoFactor,
|
|
1131
|
+
passkeys
|
|
1132
|
+
}), [
|
|
1133
|
+
resolved,
|
|
1134
|
+
client,
|
|
1135
|
+
emitter,
|
|
1136
|
+
user,
|
|
1137
|
+
status,
|
|
1138
|
+
login,
|
|
1139
|
+
logout,
|
|
1140
|
+
refresh,
|
|
1141
|
+
register,
|
|
1142
|
+
updateProfile,
|
|
1143
|
+
verifyEmail,
|
|
1144
|
+
twoFactor,
|
|
1145
|
+
passkeys,
|
|
1146
|
+
password,
|
|
1147
|
+
emailVerification
|
|
1148
|
+
]);
|
|
1149
|
+
return /*#__PURE__*/ jsxRuntime.jsx(SanctumContext.Provider, {
|
|
1150
|
+
value: value,
|
|
1151
|
+
children: children
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Authentication & account state and actions (login, register, password, profile, email verification). */ function useAuth() {
|
|
1156
|
+
const ctx = useSanctumContext();
|
|
1157
|
+
return {
|
|
1158
|
+
user: ctx.user,
|
|
1159
|
+
isAuthenticated: ctx.isAuthenticated,
|
|
1160
|
+
isLoading: ctx.isLoading,
|
|
1161
|
+
login: ctx.login,
|
|
1162
|
+
logout: ctx.logout,
|
|
1163
|
+
refresh: ctx.refresh,
|
|
1164
|
+
register: ctx.register,
|
|
1165
|
+
forgotPassword: ctx.forgotPassword,
|
|
1166
|
+
resetPassword: ctx.resetPassword,
|
|
1167
|
+
confirmPassword: ctx.confirmPassword,
|
|
1168
|
+
confirmedPasswordStatus: ctx.confirmedPasswordStatus,
|
|
1169
|
+
updatePassword: ctx.updatePassword,
|
|
1170
|
+
updateProfile: ctx.updateProfile,
|
|
1171
|
+
resendEmailVerification: ctx.resendEmailVerification,
|
|
1172
|
+
verifyEmail: ctx.verifyEmail
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** Reactive user (null when not authenticated). Cast via the generic. */ function useUser() {
|
|
1177
|
+
return useSanctumContext().user;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Authenticated fetch on the client. Minimal but sufficient for most cases;
|
|
1182
|
+
* SWR/TanStack Query adapters can be built on top of `useSanctumContext().client`.
|
|
1183
|
+
*/ function useApi(path, options = {}) {
|
|
1184
|
+
const { client } = useSanctumContext();
|
|
1185
|
+
const { enabled = true, ...init } = options;
|
|
1186
|
+
const initRef = react.useRef(init);
|
|
1187
|
+
initRef.current = init;
|
|
1188
|
+
// Serialize the request shape so option changes (method/json/body) trigger a refetch,
|
|
1189
|
+
// not just `path`. Header changes are intentionally not part of the key (Headers
|
|
1190
|
+
// aren't reliably serializable) — encode dynamic values into `path`/`json`.
|
|
1191
|
+
const requestKey = JSON.stringify({
|
|
1192
|
+
method: init.method ?? "GET",
|
|
1193
|
+
json: init.json ?? null,
|
|
1194
|
+
body: typeof init.body === "string" ? init.body : null
|
|
1195
|
+
});
|
|
1196
|
+
const [data, setData] = react.useState(undefined);
|
|
1197
|
+
const [error, setError] = react.useState(null);
|
|
1198
|
+
const [isLoading, setIsLoading] = react.useState(enabled);
|
|
1199
|
+
const refetch = react.useCallback(async ()=>{
|
|
1200
|
+
setIsLoading(true);
|
|
1201
|
+
setError(null);
|
|
1202
|
+
try {
|
|
1203
|
+
setData(await client.request(path, initRef.current));
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
setError(err);
|
|
1206
|
+
} finally{
|
|
1207
|
+
setIsLoading(false);
|
|
1208
|
+
}
|
|
1209
|
+
}, [
|
|
1210
|
+
client,
|
|
1211
|
+
path,
|
|
1212
|
+
requestKey
|
|
1213
|
+
]);
|
|
1214
|
+
react.useEffect(()=>{
|
|
1215
|
+
if (!enabled) return;
|
|
1216
|
+
let active = true;
|
|
1217
|
+
setIsLoading(true);
|
|
1218
|
+
setError(null);
|
|
1219
|
+
client.request(path, initRef.current).then((result)=>{
|
|
1220
|
+
if (active) setData(result);
|
|
1221
|
+
}).catch((err)=>{
|
|
1222
|
+
if (active) setError(err);
|
|
1223
|
+
}).finally(()=>{
|
|
1224
|
+
if (active) setIsLoading(false);
|
|
1225
|
+
});
|
|
1226
|
+
return ()=>{
|
|
1227
|
+
active = false;
|
|
1228
|
+
};
|
|
1229
|
+
}, [
|
|
1230
|
+
client,
|
|
1231
|
+
path,
|
|
1232
|
+
enabled,
|
|
1233
|
+
requestKey
|
|
1234
|
+
]);
|
|
1235
|
+
return {
|
|
1236
|
+
data,
|
|
1237
|
+
error,
|
|
1238
|
+
isLoading,
|
|
1239
|
+
refetch
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The authenticated HTTP client for imperative requests — i.e. CRUD beyond auth
|
|
1245
|
+
* (create/update/delete or on-demand reads). `client.request<T>(path, { method, json })`
|
|
1246
|
+
* returns parsed JSON; `client.raw(...)` returns the Response. It automatically attaches
|
|
1247
|
+
* CSRF (cookie mode) or Bearer (token mode), the base URL, and credentials.
|
|
1248
|
+
*/ function useClient() {
|
|
1249
|
+
return useSanctumContext().client;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* A typed REST resource over the authenticated client — convenience sugar for CRUD.
|
|
1254
|
+
* Credentials (CSRF/cookie or Bearer) are attached automatically. `TList` defaults to
|
|
1255
|
+
* `T[]`; set it (e.g. `{ data: T[]; meta: … }`) for paginated Laravel resources.
|
|
1256
|
+
*
|
|
1257
|
+
* ```ts
|
|
1258
|
+
* const posts = useResource<Post>("/api/posts")
|
|
1259
|
+
* await posts.list() // GET /api/posts
|
|
1260
|
+
* await posts.create({ title }) // POST /api/posts
|
|
1261
|
+
* await posts.update(1, { title })// PUT /api/posts/1
|
|
1262
|
+
* await posts.delete(1) // DELETE /api/posts/1
|
|
1263
|
+
* ```
|
|
1264
|
+
*/ function useResource(basePath) {
|
|
1265
|
+
const { client } = useSanctumContext();
|
|
1266
|
+
return react.useMemo(()=>{
|
|
1267
|
+
const base = basePath.replace(/\/+$/, "");
|
|
1268
|
+
const at = (id)=>`${base}/${encodeURIComponent(String(id))}`;
|
|
1269
|
+
return {
|
|
1270
|
+
list: (init)=>client.request(base, {
|
|
1271
|
+
...init,
|
|
1272
|
+
method: "GET"
|
|
1273
|
+
}),
|
|
1274
|
+
get: (id, init)=>client.request(at(id), {
|
|
1275
|
+
...init,
|
|
1276
|
+
method: "GET"
|
|
1277
|
+
}),
|
|
1278
|
+
create: (data, init)=>client.request(base, {
|
|
1279
|
+
...init,
|
|
1280
|
+
method: "POST",
|
|
1281
|
+
json: data
|
|
1282
|
+
}),
|
|
1283
|
+
update: (id, data, init)=>client.request(at(id), {
|
|
1284
|
+
...init,
|
|
1285
|
+
method: "PUT",
|
|
1286
|
+
json: data
|
|
1287
|
+
}),
|
|
1288
|
+
patch: (id, data, init)=>client.request(at(id), {
|
|
1289
|
+
...init,
|
|
1290
|
+
method: "PATCH",
|
|
1291
|
+
json: data
|
|
1292
|
+
}),
|
|
1293
|
+
delete: (id, init)=>client.request(at(id), {
|
|
1294
|
+
...init,
|
|
1295
|
+
method: "DELETE"
|
|
1296
|
+
})
|
|
1297
|
+
};
|
|
1298
|
+
}, [
|
|
1299
|
+
client,
|
|
1300
|
+
basePath
|
|
1301
|
+
]);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* A lightweight mutation hook (Inertia-style lifecycle) for imperative requests —
|
|
1306
|
+
* pair it with `useClient` / `useResource`. Manages `isPending` / `error` / `data`
|
|
1307
|
+
* and fires `onBefore` / `onSuccess` / `onError` / `onFinish`.
|
|
1308
|
+
*
|
|
1309
|
+
* ```tsx
|
|
1310
|
+
* const { request } = useClient()
|
|
1311
|
+
* const create = useMutation(
|
|
1312
|
+
* (vars: { title: string }) => request<Post>("/api/posts", { method: "POST", json: vars }),
|
|
1313
|
+
* { onSuccess: (post) => toast("Created"), onError: (e) => toast(e.message) },
|
|
1314
|
+
* )
|
|
1315
|
+
* <button disabled={create.isPending} onClick={() => create.mutate({ title })}>Save</button>
|
|
1316
|
+
* ```
|
|
1317
|
+
*/ function useMutation(mutationFn, options = {}) {
|
|
1318
|
+
const [isPending, setIsPending] = react.useState(false);
|
|
1319
|
+
const [error, setError] = react.useState(null);
|
|
1320
|
+
const [data, setData] = react.useState(undefined);
|
|
1321
|
+
const fnRef = react.useRef(mutationFn);
|
|
1322
|
+
fnRef.current = mutationFn;
|
|
1323
|
+
const optionsRef = react.useRef(options);
|
|
1324
|
+
optionsRef.current = options;
|
|
1325
|
+
const mutateAsync = react.useCallback(async (vars)=>{
|
|
1326
|
+
const opts = optionsRef.current;
|
|
1327
|
+
if (await opts.onBefore?.(vars) === false) {
|
|
1328
|
+
throw new Error("Mutation cancelled in onBefore");
|
|
1329
|
+
}
|
|
1330
|
+
setIsPending(true);
|
|
1331
|
+
setError(null);
|
|
1332
|
+
try {
|
|
1333
|
+
const result = await fnRef.current(vars);
|
|
1334
|
+
setData(result);
|
|
1335
|
+
opts.onSuccess?.(result, vars);
|
|
1336
|
+
return result;
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
const sanctumError = err;
|
|
1339
|
+
setError(sanctumError);
|
|
1340
|
+
opts.onError?.(sanctumError, vars);
|
|
1341
|
+
throw sanctumError;
|
|
1342
|
+
} finally{
|
|
1343
|
+
setIsPending(false);
|
|
1344
|
+
opts.onFinish?.(vars);
|
|
1345
|
+
}
|
|
1346
|
+
}, []);
|
|
1347
|
+
const mutate = react.useCallback((vars)=>{
|
|
1348
|
+
void mutateAsync(vars).catch(()=>{});
|
|
1349
|
+
}, [
|
|
1350
|
+
mutateAsync
|
|
1351
|
+
]);
|
|
1352
|
+
const reset = react.useCallback(()=>{
|
|
1353
|
+
setIsPending(false);
|
|
1354
|
+
setError(null);
|
|
1355
|
+
setData(undefined);
|
|
1356
|
+
}, []);
|
|
1357
|
+
return {
|
|
1358
|
+
mutate,
|
|
1359
|
+
mutateAsync,
|
|
1360
|
+
isPending,
|
|
1361
|
+
error,
|
|
1362
|
+
data,
|
|
1363
|
+
reset
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Two-factor API (Fortify): challenge during login + management (enable/confirm/disable,
|
|
1369
|
+
* QR, recovery codes). `challenge()` automatically refreshes the identity on success.
|
|
1370
|
+
*/ function useTwoFactor() {
|
|
1371
|
+
return useSanctumContext().twoFactor;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Passkeys API (interop with @laravel/passkeys). `login()` automatically refreshes
|
|
1376
|
+
* the identity on success. Requires the `@laravel/passkeys` package in the consumer.
|
|
1377
|
+
*/ function usePasskeys() {
|
|
1378
|
+
return useSanctumContext().passkeys;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
exports.ConfigError = ConfigError;
|
|
1382
|
+
exports.CookieTokenStorage = CookieTokenStorage;
|
|
1383
|
+
exports.LocalStorage = LocalStorage;
|
|
1384
|
+
exports.MemoryStorage = MemoryStorage;
|
|
1385
|
+
exports.SanctumError = SanctumError;
|
|
1386
|
+
exports.SanctumProvider = SanctumProvider;
|
|
1387
|
+
exports.ValidationError = ValidationError;
|
|
1388
|
+
exports.useApi = useApi;
|
|
1389
|
+
exports.useAuth = useAuth;
|
|
1390
|
+
exports.useClient = useClient;
|
|
1391
|
+
exports.useMutation = useMutation;
|
|
1392
|
+
exports.usePasskeys = usePasskeys;
|
|
1393
|
+
exports.useResource = useResource;
|
|
1394
|
+
exports.useTwoFactor = useTwoFactor;
|
|
1395
|
+
exports.useUser = useUser;
|