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/actions.cjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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
|
+
* Shared Set-Cookie parsing helpers (pure — no `next`/`server-only` imports, so this
|
|
43
|
+
* stays isomorphic and tree-shakes out of the client graph). Used by `server.ts` and
|
|
44
|
+
* `actions.ts` to mirror Laravel's Set-Cookie into Next's writable cookie store.
|
|
45
|
+
*/ /**
|
|
46
|
+
* Read all Set-Cookie headers as an array. Prefers `Headers.getSetCookie()` (Node ≥18.14,
|
|
47
|
+
* all supported Next runtimes). The single-header fallback only handles one cookie — we do
|
|
48
|
+
* NOT attempt to comma-split, which is unreliable (Expires dates contain commas).
|
|
49
|
+
*/ function getSetCookies(headers) {
|
|
50
|
+
const anyHeaders = headers;
|
|
51
|
+
if (typeof anyHeaders.getSetCookie === "function") return anyHeaders.getSetCookie();
|
|
52
|
+
const value = headers.get("set-cookie");
|
|
53
|
+
return value ? [
|
|
54
|
+
value
|
|
55
|
+
] : [];
|
|
56
|
+
}
|
|
57
|
+
/** Parse a single Set-Cookie header. Validates attributes; returns null when unusable. */ function parseSetCookie(header) {
|
|
58
|
+
const segments = header.split(";");
|
|
59
|
+
const first = segments.shift();
|
|
60
|
+
if (!first) return null;
|
|
61
|
+
const eq = first.indexOf("=");
|
|
62
|
+
if (eq === -1) return null;
|
|
63
|
+
const name = first.slice(0, eq).trim();
|
|
64
|
+
if (name === "") return null;
|
|
65
|
+
let value = first.slice(eq + 1).trim();
|
|
66
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
67
|
+
value = value.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
const options = {};
|
|
70
|
+
for (const segment of segments){
|
|
71
|
+
const idx = segment.indexOf("=");
|
|
72
|
+
const key = (idx === -1 ? segment : segment.slice(0, idx)).trim().toLowerCase();
|
|
73
|
+
const val = idx === -1 ? "" : segment.slice(idx + 1).trim();
|
|
74
|
+
switch(key){
|
|
75
|
+
case "path":
|
|
76
|
+
options.path = val;
|
|
77
|
+
break;
|
|
78
|
+
case "domain":
|
|
79
|
+
options.domain = val;
|
|
80
|
+
break;
|
|
81
|
+
case "max-age":
|
|
82
|
+
{
|
|
83
|
+
const n = Number(val);
|
|
84
|
+
if (val !== "" && Number.isFinite(n)) options.maxAge = n;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case "expires":
|
|
88
|
+
{
|
|
89
|
+
const d = new Date(val);
|
|
90
|
+
if (!Number.isNaN(d.getTime())) options.expires = d;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "samesite":
|
|
94
|
+
{
|
|
95
|
+
const lower = val.toLowerCase();
|
|
96
|
+
if (lower === "lax" || lower === "strict" || lower === "none") {
|
|
97
|
+
options.sameSite = lower;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "secure":
|
|
102
|
+
options.secure = true;
|
|
103
|
+
break;
|
|
104
|
+
case "httponly":
|
|
105
|
+
options.httpOnly = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
value,
|
|
112
|
+
options
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Mirror a response's Set-Cookie headers into a writable cookie store. */ function applySetCookies(store, response) {
|
|
116
|
+
for (const raw of getSetCookies(response.headers)){
|
|
117
|
+
const parsed = parseSetCookie(raw);
|
|
118
|
+
if (!parsed) continue;
|
|
119
|
+
try {
|
|
120
|
+
store.set(parsed.name, parsed.value, parsed.options);
|
|
121
|
+
} catch {
|
|
122
|
+
// store is read-only (e.g. called from a Server Component) — ignore.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DEFAULTS = {
|
|
128
|
+
csrf: "/sanctum/csrf-cookie",
|
|
129
|
+
login: "/login",
|
|
130
|
+
logout: "/logout",
|
|
131
|
+
register: "/register",
|
|
132
|
+
forgotPassword: "/forgot-password",
|
|
133
|
+
resetPassword: "/reset-password",
|
|
134
|
+
confirmPassword: "/user/confirm-password",
|
|
135
|
+
twoFactorChallenge: "/two-factor-challenge"
|
|
136
|
+
};
|
|
137
|
+
async function readErrors(response) {
|
|
138
|
+
try {
|
|
139
|
+
const data = await response.json();
|
|
140
|
+
return data?.errors;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function statefulPost(path, json, config) {
|
|
146
|
+
const store = await headers.cookies();
|
|
147
|
+
const base = resolveServerBaseUrl(config?.baseUrl, "actions");
|
|
148
|
+
const csrfCookie = config?.csrf?.cookie ?? "XSRF-TOKEN";
|
|
149
|
+
const csrfHeader = config?.csrf?.header ?? "X-XSRF-TOKEN";
|
|
150
|
+
const csrfPath = config?.endpoints?.csrf ?? DEFAULTS.csrf;
|
|
151
|
+
let token = store.get(csrfCookie)?.value;
|
|
152
|
+
if (!token) {
|
|
153
|
+
const csrfResponse = await fetch(`${base}${csrfPath}`, {
|
|
154
|
+
headers: {
|
|
155
|
+
cookie: store.toString(),
|
|
156
|
+
accept: "application/json"
|
|
157
|
+
},
|
|
158
|
+
cache: "no-store"
|
|
159
|
+
});
|
|
160
|
+
applySetCookies(store, csrfResponse);
|
|
161
|
+
token = store.get(csrfCookie)?.value;
|
|
162
|
+
}
|
|
163
|
+
const headers$1 = {
|
|
164
|
+
accept: "application/json",
|
|
165
|
+
"content-type": "application/json",
|
|
166
|
+
cookie: store.toString()
|
|
167
|
+
};
|
|
168
|
+
if (token) headers$1[csrfHeader] = decodeURIComponent(token);
|
|
169
|
+
const response = await fetch(`${base}${path}`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: headers$1,
|
|
172
|
+
body: JSON.stringify(json ?? {}),
|
|
173
|
+
cache: "no-store"
|
|
174
|
+
});
|
|
175
|
+
applySetCookies(store, response);
|
|
176
|
+
return {
|
|
177
|
+
ok: response.ok,
|
|
178
|
+
status: response.status,
|
|
179
|
+
raw: response
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function toResult(result) {
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
status: result.status,
|
|
187
|
+
errors: result.status === 422 ? await readErrors(result.raw) : undefined
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
status: result.status
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Login (CSRF → POST /login). Check `twoFactor` on the result before assuming success. */ async function login(credentials, config) {
|
|
196
|
+
const result = await statefulPost(config?.endpoints?.login ?? DEFAULTS.login, credentials, config);
|
|
197
|
+
if (!result.ok) return toResult(result);
|
|
198
|
+
let twoFactor = false;
|
|
199
|
+
try {
|
|
200
|
+
const data = await result.raw.json();
|
|
201
|
+
twoFactor = Boolean(data?.two_factor);
|
|
202
|
+
} catch {
|
|
203
|
+
// empty body → not 2FA
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
status: result.status,
|
|
208
|
+
twoFactor
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async function logout(config) {
|
|
212
|
+
return toResult(await statefulPost(config?.endpoints?.logout ?? DEFAULTS.logout, {}, config));
|
|
213
|
+
}
|
|
214
|
+
async function register(payload, config) {
|
|
215
|
+
return toResult(await statefulPost(config?.endpoints?.register ?? DEFAULTS.register, payload, config));
|
|
216
|
+
}
|
|
217
|
+
async function twoFactorChallenge(payload, config) {
|
|
218
|
+
return toResult(await statefulPost(config?.endpoints?.twoFactorChallenge ?? DEFAULTS.twoFactorChallenge, payload, config));
|
|
219
|
+
}
|
|
220
|
+
async function forgotPassword(payload, config) {
|
|
221
|
+
return toResult(await statefulPost(config?.endpoints?.forgotPassword ?? DEFAULTS.forgotPassword, payload, config));
|
|
222
|
+
}
|
|
223
|
+
async function resetPassword(payload, config) {
|
|
224
|
+
return toResult(await statefulPost(config?.endpoints?.resetPassword ?? DEFAULTS.resetPassword, payload, config));
|
|
225
|
+
}
|
|
226
|
+
async function confirmPassword(payload, config) {
|
|
227
|
+
return toResult(await statefulPost(config?.endpoints?.confirmPassword ?? DEFAULTS.confirmPassword, payload, config));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
exports.confirmPassword = confirmPassword;
|
|
231
|
+
exports.forgotPassword = forgotPassword;
|
|
232
|
+
exports.login = login;
|
|
233
|
+
exports.logout = logout;
|
|
234
|
+
exports.register = register;
|
|
235
|
+
exports.resetPassword = resetPassword;
|
|
236
|
+
exports.twoFactorChallenge = twoFactorChallenge;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public & internal type surface of next-sanctum.
|
|
3
|
+
* TypeScript-first, generic User model, no public `any`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface LoginCredentials {
|
|
7
|
+
email?: string;
|
|
8
|
+
/** Supports backends that use `username` (config/fortify.php). */
|
|
9
|
+
username?: string;
|
|
10
|
+
password: string;
|
|
11
|
+
remember?: boolean;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
interface TwoFactorChallengePayload {
|
|
15
|
+
code?: string;
|
|
16
|
+
recovery_code?: string;
|
|
17
|
+
}
|
|
18
|
+
interface RegisterPayload {
|
|
19
|
+
name?: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
password?: string;
|
|
23
|
+
password_confirmation?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface ForgotPasswordPayload {
|
|
27
|
+
email: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface ResetPasswordPayload {
|
|
31
|
+
token: string;
|
|
32
|
+
email: string;
|
|
33
|
+
password: string;
|
|
34
|
+
password_confirmation: string;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
interface ConfirmPasswordPayload {
|
|
38
|
+
password: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Server-action helpers. WRAPPED by the consumer's own Server Action (`'use server'`)
|
|
43
|
+
* — not literally `'use server'` (see §6 of the plan). Runs the CSRF→POST flow and
|
|
44
|
+
* writes Laravel's Set-Cookie into `cookies()` so the session is persisted.
|
|
45
|
+
*/
|
|
46
|
+
interface ActionConfig {
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
endpoints?: {
|
|
49
|
+
csrf?: string;
|
|
50
|
+
login?: string;
|
|
51
|
+
logout?: string;
|
|
52
|
+
register?: string;
|
|
53
|
+
forgotPassword?: string;
|
|
54
|
+
resetPassword?: string;
|
|
55
|
+
confirmPassword?: string;
|
|
56
|
+
twoFactorChallenge?: string;
|
|
57
|
+
};
|
|
58
|
+
csrf?: {
|
|
59
|
+
cookie?: string;
|
|
60
|
+
header?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
interface ActionResult {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
status: number;
|
|
66
|
+
/** True when login requires a 2FA challenge (Fortify `two_factor`). */
|
|
67
|
+
twoFactor?: boolean;
|
|
68
|
+
/** Laravel validation errors (422). */
|
|
69
|
+
errors?: Record<string, string[]>;
|
|
70
|
+
}
|
|
71
|
+
/** Login (CSRF → POST /login). Check `twoFactor` on the result before assuming success. */
|
|
72
|
+
declare function login(credentials: LoginCredentials, config?: ActionConfig): Promise<ActionResult>;
|
|
73
|
+
declare function logout(config?: ActionConfig): Promise<ActionResult>;
|
|
74
|
+
declare function register(payload: RegisterPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
75
|
+
declare function twoFactorChallenge(payload: TwoFactorChallengePayload, config?: ActionConfig): Promise<ActionResult>;
|
|
76
|
+
declare function forgotPassword(payload: ForgotPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
77
|
+
declare function resetPassword(payload: ResetPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
78
|
+
declare function confirmPassword(payload: ConfirmPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
79
|
+
|
|
80
|
+
export { confirmPassword, forgotPassword, login, logout, register, resetPassword, twoFactorChallenge };
|
|
81
|
+
export type { ActionConfig, ActionResult };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public & internal type surface of next-sanctum.
|
|
3
|
+
* TypeScript-first, generic User model, no public `any`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface LoginCredentials {
|
|
7
|
+
email?: string;
|
|
8
|
+
/** Supports backends that use `username` (config/fortify.php). */
|
|
9
|
+
username?: string;
|
|
10
|
+
password: string;
|
|
11
|
+
remember?: boolean;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
interface TwoFactorChallengePayload {
|
|
15
|
+
code?: string;
|
|
16
|
+
recovery_code?: string;
|
|
17
|
+
}
|
|
18
|
+
interface RegisterPayload {
|
|
19
|
+
name?: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
password?: string;
|
|
23
|
+
password_confirmation?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
interface ForgotPasswordPayload {
|
|
27
|
+
email: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface ResetPasswordPayload {
|
|
31
|
+
token: string;
|
|
32
|
+
email: string;
|
|
33
|
+
password: string;
|
|
34
|
+
password_confirmation: string;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
interface ConfirmPasswordPayload {
|
|
38
|
+
password: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Server-action helpers. WRAPPED by the consumer's own Server Action (`'use server'`)
|
|
43
|
+
* — not literally `'use server'` (see §6 of the plan). Runs the CSRF→POST flow and
|
|
44
|
+
* writes Laravel's Set-Cookie into `cookies()` so the session is persisted.
|
|
45
|
+
*/
|
|
46
|
+
interface ActionConfig {
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
endpoints?: {
|
|
49
|
+
csrf?: string;
|
|
50
|
+
login?: string;
|
|
51
|
+
logout?: string;
|
|
52
|
+
register?: string;
|
|
53
|
+
forgotPassword?: string;
|
|
54
|
+
resetPassword?: string;
|
|
55
|
+
confirmPassword?: string;
|
|
56
|
+
twoFactorChallenge?: string;
|
|
57
|
+
};
|
|
58
|
+
csrf?: {
|
|
59
|
+
cookie?: string;
|
|
60
|
+
header?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
interface ActionResult {
|
|
64
|
+
ok: boolean;
|
|
65
|
+
status: number;
|
|
66
|
+
/** True when login requires a 2FA challenge (Fortify `two_factor`). */
|
|
67
|
+
twoFactor?: boolean;
|
|
68
|
+
/** Laravel validation errors (422). */
|
|
69
|
+
errors?: Record<string, string[]>;
|
|
70
|
+
}
|
|
71
|
+
/** Login (CSRF → POST /login). Check `twoFactor` on the result before assuming success. */
|
|
72
|
+
declare function login(credentials: LoginCredentials, config?: ActionConfig): Promise<ActionResult>;
|
|
73
|
+
declare function logout(config?: ActionConfig): Promise<ActionResult>;
|
|
74
|
+
declare function register(payload: RegisterPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
75
|
+
declare function twoFactorChallenge(payload: TwoFactorChallengePayload, config?: ActionConfig): Promise<ActionResult>;
|
|
76
|
+
declare function forgotPassword(payload: ForgotPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
77
|
+
declare function resetPassword(payload: ResetPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
78
|
+
declare function confirmPassword(payload: ConfirmPasswordPayload, config?: ActionConfig): Promise<ActionResult>;
|
|
79
|
+
|
|
80
|
+
export { confirmPassword, forgotPassword, login, logout, register, resetPassword, twoFactorChallenge };
|
|
81
|
+
export type { ActionConfig, ActionResult };
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
* Shared Set-Cookie parsing helpers (pure — no `next`/`server-only` imports, so this
|
|
41
|
+
* stays isomorphic and tree-shakes out of the client graph). Used by `server.ts` and
|
|
42
|
+
* `actions.ts` to mirror Laravel's Set-Cookie into Next's writable cookie store.
|
|
43
|
+
*/ /**
|
|
44
|
+
* Read all Set-Cookie headers as an array. Prefers `Headers.getSetCookie()` (Node ≥18.14,
|
|
45
|
+
* all supported Next runtimes). The single-header fallback only handles one cookie — we do
|
|
46
|
+
* NOT attempt to comma-split, which is unreliable (Expires dates contain commas).
|
|
47
|
+
*/ function getSetCookies(headers) {
|
|
48
|
+
const anyHeaders = headers;
|
|
49
|
+
if (typeof anyHeaders.getSetCookie === "function") return anyHeaders.getSetCookie();
|
|
50
|
+
const value = headers.get("set-cookie");
|
|
51
|
+
return value ? [
|
|
52
|
+
value
|
|
53
|
+
] : [];
|
|
54
|
+
}
|
|
55
|
+
/** Parse a single Set-Cookie header. Validates attributes; returns null when unusable. */ function parseSetCookie(header) {
|
|
56
|
+
const segments = header.split(";");
|
|
57
|
+
const first = segments.shift();
|
|
58
|
+
if (!first) return null;
|
|
59
|
+
const eq = first.indexOf("=");
|
|
60
|
+
if (eq === -1) return null;
|
|
61
|
+
const name = first.slice(0, eq).trim();
|
|
62
|
+
if (name === "") return null;
|
|
63
|
+
let value = first.slice(eq + 1).trim();
|
|
64
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
65
|
+
value = value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
const options = {};
|
|
68
|
+
for (const segment of segments){
|
|
69
|
+
const idx = segment.indexOf("=");
|
|
70
|
+
const key = (idx === -1 ? segment : segment.slice(0, idx)).trim().toLowerCase();
|
|
71
|
+
const val = idx === -1 ? "" : segment.slice(idx + 1).trim();
|
|
72
|
+
switch(key){
|
|
73
|
+
case "path":
|
|
74
|
+
options.path = val;
|
|
75
|
+
break;
|
|
76
|
+
case "domain":
|
|
77
|
+
options.domain = val;
|
|
78
|
+
break;
|
|
79
|
+
case "max-age":
|
|
80
|
+
{
|
|
81
|
+
const n = Number(val);
|
|
82
|
+
if (val !== "" && Number.isFinite(n)) options.maxAge = n;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "expires":
|
|
86
|
+
{
|
|
87
|
+
const d = new Date(val);
|
|
88
|
+
if (!Number.isNaN(d.getTime())) options.expires = d;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "samesite":
|
|
92
|
+
{
|
|
93
|
+
const lower = val.toLowerCase();
|
|
94
|
+
if (lower === "lax" || lower === "strict" || lower === "none") {
|
|
95
|
+
options.sameSite = lower;
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "secure":
|
|
100
|
+
options.secure = true;
|
|
101
|
+
break;
|
|
102
|
+
case "httponly":
|
|
103
|
+
options.httpOnly = true;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
value,
|
|
110
|
+
options
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Mirror a response's Set-Cookie headers into a writable cookie store. */ function applySetCookies(store, response) {
|
|
114
|
+
for (const raw of getSetCookies(response.headers)){
|
|
115
|
+
const parsed = parseSetCookie(raw);
|
|
116
|
+
if (!parsed) continue;
|
|
117
|
+
try {
|
|
118
|
+
store.set(parsed.name, parsed.value, parsed.options);
|
|
119
|
+
} catch {
|
|
120
|
+
// store is read-only (e.g. called from a Server Component) — ignore.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const DEFAULTS = {
|
|
126
|
+
csrf: "/sanctum/csrf-cookie",
|
|
127
|
+
login: "/login",
|
|
128
|
+
logout: "/logout",
|
|
129
|
+
register: "/register",
|
|
130
|
+
forgotPassword: "/forgot-password",
|
|
131
|
+
resetPassword: "/reset-password",
|
|
132
|
+
confirmPassword: "/user/confirm-password",
|
|
133
|
+
twoFactorChallenge: "/two-factor-challenge"
|
|
134
|
+
};
|
|
135
|
+
async function readErrors(response) {
|
|
136
|
+
try {
|
|
137
|
+
const data = await response.json();
|
|
138
|
+
return data?.errors;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function statefulPost(path, json, config) {
|
|
144
|
+
const store = await cookies();
|
|
145
|
+
const base = resolveServerBaseUrl(config?.baseUrl, "actions");
|
|
146
|
+
const csrfCookie = config?.csrf?.cookie ?? "XSRF-TOKEN";
|
|
147
|
+
const csrfHeader = config?.csrf?.header ?? "X-XSRF-TOKEN";
|
|
148
|
+
const csrfPath = config?.endpoints?.csrf ?? DEFAULTS.csrf;
|
|
149
|
+
let token = store.get(csrfCookie)?.value;
|
|
150
|
+
if (!token) {
|
|
151
|
+
const csrfResponse = await fetch(`${base}${csrfPath}`, {
|
|
152
|
+
headers: {
|
|
153
|
+
cookie: store.toString(),
|
|
154
|
+
accept: "application/json"
|
|
155
|
+
},
|
|
156
|
+
cache: "no-store"
|
|
157
|
+
});
|
|
158
|
+
applySetCookies(store, csrfResponse);
|
|
159
|
+
token = store.get(csrfCookie)?.value;
|
|
160
|
+
}
|
|
161
|
+
const headers = {
|
|
162
|
+
accept: "application/json",
|
|
163
|
+
"content-type": "application/json",
|
|
164
|
+
cookie: store.toString()
|
|
165
|
+
};
|
|
166
|
+
if (token) headers[csrfHeader] = decodeURIComponent(token);
|
|
167
|
+
const response = await fetch(`${base}${path}`, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(json ?? {}),
|
|
171
|
+
cache: "no-store"
|
|
172
|
+
});
|
|
173
|
+
applySetCookies(store, response);
|
|
174
|
+
return {
|
|
175
|
+
ok: response.ok,
|
|
176
|
+
status: response.status,
|
|
177
|
+
raw: response
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async function toResult(result) {
|
|
181
|
+
if (!result.ok) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
status: result.status,
|
|
185
|
+
errors: result.status === 422 ? await readErrors(result.raw) : undefined
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
status: result.status
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Login (CSRF → POST /login). Check `twoFactor` on the result before assuming success. */ async function login(credentials, config) {
|
|
194
|
+
const result = await statefulPost(config?.endpoints?.login ?? DEFAULTS.login, credentials, config);
|
|
195
|
+
if (!result.ok) return toResult(result);
|
|
196
|
+
let twoFactor = false;
|
|
197
|
+
try {
|
|
198
|
+
const data = await result.raw.json();
|
|
199
|
+
twoFactor = Boolean(data?.two_factor);
|
|
200
|
+
} catch {
|
|
201
|
+
// empty body → not 2FA
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
status: result.status,
|
|
206
|
+
twoFactor
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async function logout(config) {
|
|
210
|
+
return toResult(await statefulPost(config?.endpoints?.logout ?? DEFAULTS.logout, {}, config));
|
|
211
|
+
}
|
|
212
|
+
async function register(payload, config) {
|
|
213
|
+
return toResult(await statefulPost(config?.endpoints?.register ?? DEFAULTS.register, payload, config));
|
|
214
|
+
}
|
|
215
|
+
async function twoFactorChallenge(payload, config) {
|
|
216
|
+
return toResult(await statefulPost(config?.endpoints?.twoFactorChallenge ?? DEFAULTS.twoFactorChallenge, payload, config));
|
|
217
|
+
}
|
|
218
|
+
async function forgotPassword(payload, config) {
|
|
219
|
+
return toResult(await statefulPost(config?.endpoints?.forgotPassword ?? DEFAULTS.forgotPassword, payload, config));
|
|
220
|
+
}
|
|
221
|
+
async function resetPassword(payload, config) {
|
|
222
|
+
return toResult(await statefulPost(config?.endpoints?.resetPassword ?? DEFAULTS.resetPassword, payload, config));
|
|
223
|
+
}
|
|
224
|
+
async function confirmPassword(payload, config) {
|
|
225
|
+
return toResult(await statefulPost(config?.endpoints?.confirmPassword ?? DEFAULTS.confirmPassword, payload, config));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export { confirmPassword, forgotPassword, login, logout, register, resetPassword, twoFactorChallenge };
|