next-token-auth 1.0.14 → 1.0.16
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/README.md +596 -285
- package/dist/{index-CejL5heu.d.mts → index-Csz5lfEv.d.mts} +30 -2
- package/dist/{index-CejL5heu.d.ts → index-Csz5lfEv.d.ts} +30 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +58 -31
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +59 -32
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.d.mts +4 -4
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +53 -538
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +54 -539
- package/dist/react/index.mjs.map +1 -1
- package/dist/server/index.d.mts +45 -7
- package/dist/server/index.d.ts +45 -7
- package/dist/server/index.js +178 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +178 -1
- package/dist/server/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/react/index.js
CHANGED
|
@@ -4,523 +4,8 @@ var react = require('react');
|
|
|
4
4
|
var jsxRuntime = require('react/jsx-runtime');
|
|
5
5
|
|
|
6
6
|
// src/react/AuthProvider.tsx
|
|
7
|
-
|
|
8
|
-
// src/utils/expiry.ts
|
|
9
|
-
var UNIT_MAP = {
|
|
10
|
-
s: 1,
|
|
11
|
-
m: 60,
|
|
12
|
-
h: 3600,
|
|
13
|
-
d: 86400,
|
|
14
|
-
w: 604800
|
|
15
|
-
};
|
|
16
|
-
function parseExpiry(input) {
|
|
17
|
-
if (input === void 0 || input === null) {
|
|
18
|
-
throw new Error("parseExpiry: no expiry value provided");
|
|
19
|
-
}
|
|
20
|
-
if (typeof input === "number") {
|
|
21
|
-
if (input <= 0) throw new Error("parseExpiry: value must be positive");
|
|
22
|
-
return input;
|
|
23
|
-
}
|
|
24
|
-
const trimmed = input.trim();
|
|
25
|
-
if (/^\d+$/.test(trimmed)) {
|
|
26
|
-
return parseInt(trimmed, 10);
|
|
27
|
-
}
|
|
28
|
-
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([smhdw])$/i);
|
|
29
|
-
if (!match) {
|
|
30
|
-
throw new Error(
|
|
31
|
-
`parseExpiry: unrecognised format "${input}". Expected a number or a string like "15m", "2h", "2d", "7d", "1w".`
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
const value = parseFloat(match[1]);
|
|
35
|
-
const unit = match[2].toLowerCase();
|
|
36
|
-
return Math.floor(value * UNIT_MAP[unit]);
|
|
37
|
-
}
|
|
38
|
-
function safeParseExpiry(input, fallbackSeconds = 900) {
|
|
39
|
-
try {
|
|
40
|
-
return parseExpiry(input);
|
|
41
|
-
} catch {
|
|
42
|
-
return fallbackSeconds;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
function resolveAccessTokenExpiry(response, configExpiry, strategy = "hybrid") {
|
|
46
|
-
const now = Date.now();
|
|
47
|
-
const fromBackend = response.accessTokenExpiresIn ?? response.expiresIn ?? void 0;
|
|
48
|
-
if (strategy === "backend") {
|
|
49
|
-
if (fromBackend === void 0) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
'resolveAccessTokenExpiry: strategy is "backend" but API returned no expiry'
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
return now + parseExpiry(fromBackend) * 1e3;
|
|
55
|
-
}
|
|
56
|
-
if (strategy === "config") {
|
|
57
|
-
if (configExpiry === void 0) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
'resolveAccessTokenExpiry: strategy is "config" but no expiry configured'
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
return now + parseExpiry(configExpiry) * 1e3;
|
|
63
|
-
}
|
|
64
|
-
if (fromBackend !== void 0) {
|
|
65
|
-
return now + safeParseExpiry(fromBackend) * 1e3;
|
|
66
|
-
}
|
|
67
|
-
if (configExpiry !== void 0) {
|
|
68
|
-
return now + safeParseExpiry(configExpiry) * 1e3;
|
|
69
|
-
}
|
|
70
|
-
return now + 900 * 1e3;
|
|
71
|
-
}
|
|
72
|
-
function resolveRefreshTokenExpiry(response, configExpiry, strategy = "hybrid") {
|
|
73
|
-
const now = Date.now();
|
|
74
|
-
const fromBackend = response.refreshTokenExpiresIn;
|
|
75
|
-
if (strategy === "backend") {
|
|
76
|
-
return fromBackend !== void 0 ? now + parseExpiry(fromBackend) * 1e3 : void 0;
|
|
77
|
-
}
|
|
78
|
-
if (strategy === "config") {
|
|
79
|
-
return configExpiry !== void 0 ? now + parseExpiry(configExpiry) * 1e3 : void 0;
|
|
80
|
-
}
|
|
81
|
-
if (fromBackend !== void 0) {
|
|
82
|
-
return now + safeParseExpiry(fromBackend) * 1e3;
|
|
83
|
-
}
|
|
84
|
-
if (configExpiry !== void 0) {
|
|
85
|
-
return now + safeParseExpiry(configExpiry) * 1e3;
|
|
86
|
-
}
|
|
87
|
-
return void 0;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// src/core/HttpClient.ts
|
|
91
|
-
var HttpClient = class {
|
|
92
|
-
constructor(config, tokenManager) {
|
|
93
|
-
this.refreshFn = null;
|
|
94
|
-
this.refreshPromise = null;
|
|
95
|
-
this.config = config;
|
|
96
|
-
this.tokenManager = tokenManager;
|
|
97
|
-
}
|
|
98
|
-
/** Register the refresh callback (set by AuthClient to avoid circular deps) */
|
|
99
|
-
setRefreshFn(fn) {
|
|
100
|
-
this.refreshFn = fn;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Authenticated fetch wrapper.
|
|
104
|
-
* Automatically injects the Bearer token and handles 401 → refresh → retry.
|
|
105
|
-
*/
|
|
106
|
-
async fetch(input, init = {}) {
|
|
107
|
-
const tokens = this.tokenManager.getTokens();
|
|
108
|
-
const headers = new Headers(init.headers);
|
|
109
|
-
if (tokens?.accessToken) {
|
|
110
|
-
headers.set("Authorization", `Bearer ${tokens.accessToken}`);
|
|
111
|
-
}
|
|
112
|
-
const response = await this.doFetch(input, { ...init, headers });
|
|
113
|
-
if (response.status === 401 && this.refreshFn) {
|
|
114
|
-
const refreshed = await this.deduplicatedRefresh();
|
|
115
|
-
if (refreshed) {
|
|
116
|
-
const newTokens = this.tokenManager.getTokens();
|
|
117
|
-
if (newTokens?.accessToken) {
|
|
118
|
-
headers.set("Authorization", `Bearer ${newTokens.accessToken}`);
|
|
119
|
-
}
|
|
120
|
-
return this.doFetch(input, { ...init, headers });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return response;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Raw fetch using the configured fetchFn or global fetch.
|
|
127
|
-
*/
|
|
128
|
-
async doFetch(input, init) {
|
|
129
|
-
const fetchFn = this.config.fetchFn ?? fetch;
|
|
130
|
-
return fetchFn(input, init);
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Builds a full URL from a path relative to baseUrl.
|
|
134
|
-
*/
|
|
135
|
-
url(path) {
|
|
136
|
-
return `${this.config.baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
137
|
-
}
|
|
138
|
-
// ─── Private ────────────────────────────────────────────────────────────────
|
|
139
|
-
/**
|
|
140
|
-
* Ensures only one refresh request is in-flight at a time.
|
|
141
|
-
*/
|
|
142
|
-
async deduplicatedRefresh() {
|
|
143
|
-
if (this.refreshPromise) return this.refreshPromise;
|
|
144
|
-
this.refreshPromise = this.refreshFn().finally(() => {
|
|
145
|
-
this.refreshPromise = null;
|
|
146
|
-
});
|
|
147
|
-
return this.refreshPromise;
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// src/core/SessionManager.ts
|
|
152
|
-
var SessionManager = class {
|
|
153
|
-
constructor(config, tokenManager, httpClient) {
|
|
154
|
-
this.session = {
|
|
155
|
-
user: null,
|
|
156
|
-
tokens: null,
|
|
157
|
-
isAuthenticated: false
|
|
158
|
-
};
|
|
159
|
-
this.config = config;
|
|
160
|
-
this.tokenManager = tokenManager;
|
|
161
|
-
this.httpClient = httpClient;
|
|
162
|
-
}
|
|
163
|
-
getSession() {
|
|
164
|
-
return this.session;
|
|
165
|
-
}
|
|
166
|
-
setSession(session) {
|
|
167
|
-
this.session = session;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Builds a session from stored tokens, optionally fetching the user profile.
|
|
171
|
-
*/
|
|
172
|
-
async loadSession() {
|
|
173
|
-
const tokens = this.tokenManager.getTokens();
|
|
174
|
-
if (!tokens) {
|
|
175
|
-
this.session = { user: null, tokens: null, isAuthenticated: false };
|
|
176
|
-
return this.session;
|
|
177
|
-
}
|
|
178
|
-
if (this.tokenManager.isAccessExpired(tokens) && this.tokenManager.isRefreshExpired(tokens)) {
|
|
179
|
-
this.tokenManager.clearTokens();
|
|
180
|
-
this.session = { user: null, tokens: null, isAuthenticated: false };
|
|
181
|
-
return this.session;
|
|
182
|
-
}
|
|
183
|
-
const user = await this.fetchUser(tokens);
|
|
184
|
-
this.session = {
|
|
185
|
-
user,
|
|
186
|
-
tokens,
|
|
187
|
-
isAuthenticated: true
|
|
188
|
-
};
|
|
189
|
-
return this.session;
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Updates the session after a successful token refresh.
|
|
193
|
-
*/
|
|
194
|
-
async refreshSession(tokens) {
|
|
195
|
-
const user = this.session.user ?? await this.fetchUser(tokens);
|
|
196
|
-
this.session = { user, tokens, isAuthenticated: true };
|
|
197
|
-
}
|
|
198
|
-
clearSession() {
|
|
199
|
-
this.session = { user: null, tokens: null, isAuthenticated: false };
|
|
200
|
-
}
|
|
201
|
-
// ─── Private ────────────────────────────────────────────────────────────────
|
|
202
|
-
async fetchUser(tokens) {
|
|
203
|
-
const meEndpoint = this.config.endpoints.me;
|
|
204
|
-
if (!meEndpoint) return null;
|
|
205
|
-
try {
|
|
206
|
-
const res = await this.httpClient.fetch(this.httpClient.url(meEndpoint));
|
|
207
|
-
if (!res.ok) return null;
|
|
208
|
-
return await res.json();
|
|
209
|
-
} catch {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// src/utils/crypto.ts
|
|
216
|
-
var ALGO = "AES-GCM";
|
|
217
|
-
var IV_LENGTH = 12;
|
|
218
|
-
function getTextEncoder() {
|
|
219
|
-
return new TextEncoder();
|
|
220
|
-
}
|
|
221
|
-
function getTextDecoder() {
|
|
222
|
-
return new TextDecoder();
|
|
223
|
-
}
|
|
224
|
-
async function deriveKey(secret) {
|
|
225
|
-
const raw = getTextEncoder().encode(secret.padEnd(32, "0").slice(0, 32));
|
|
226
|
-
return crypto.subtle.importKey("raw", raw, { name: ALGO }, false, [
|
|
227
|
-
"encrypt",
|
|
228
|
-
"decrypt"
|
|
229
|
-
]);
|
|
230
|
-
}
|
|
231
|
-
async function encrypt(data, secret) {
|
|
232
|
-
const key = await deriveKey(secret);
|
|
233
|
-
const ivArray = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
234
|
-
const iv = ivArray.buffer.slice(0, IV_LENGTH);
|
|
235
|
-
const encoded = getTextEncoder().encode(data);
|
|
236
|
-
const cipherBuffer = await crypto.subtle.encrypt({ name: ALGO, iv }, key, encoded);
|
|
237
|
-
const ivB64 = bufferToBase64(new Uint8Array(iv));
|
|
238
|
-
const cipherB64 = bufferToBase64(new Uint8Array(cipherBuffer));
|
|
239
|
-
return `${ivB64}.${cipherB64}`;
|
|
240
|
-
}
|
|
241
|
-
async function decrypt(data, secret) {
|
|
242
|
-
const [ivB64, cipherB64] = data.split(".");
|
|
243
|
-
if (!ivB64 || !cipherB64) {
|
|
244
|
-
throw new Error("decrypt: invalid ciphertext format");
|
|
245
|
-
}
|
|
246
|
-
const key = await deriveKey(secret);
|
|
247
|
-
const ivBytes = base64ToBuffer(ivB64);
|
|
248
|
-
const iv = ivBytes.buffer.slice(
|
|
249
|
-
ivBytes.byteOffset,
|
|
250
|
-
ivBytes.byteOffset + ivBytes.byteLength
|
|
251
|
-
);
|
|
252
|
-
const cipherBytes = base64ToBuffer(cipherB64);
|
|
253
|
-
const cipherBuffer = cipherBytes.buffer.slice(
|
|
254
|
-
cipherBytes.byteOffset,
|
|
255
|
-
cipherBytes.byteOffset + cipherBytes.byteLength
|
|
256
|
-
);
|
|
257
|
-
const plainBuffer = await crypto.subtle.decrypt({ name: ALGO, iv }, key, cipherBuffer);
|
|
258
|
-
return getTextDecoder().decode(plainBuffer);
|
|
259
|
-
}
|
|
260
|
-
function bufferToBase64(buffer) {
|
|
261
|
-
return btoa(String.fromCharCode(...buffer)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
262
|
-
}
|
|
263
|
-
function base64ToBuffer(b64) {
|
|
264
|
-
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
265
|
-
const binary = atob(padded);
|
|
266
|
-
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// src/core/TokenManager.ts
|
|
270
|
-
var TokenManager = class {
|
|
271
|
-
constructor(config) {
|
|
272
|
-
this.memoryStore = null;
|
|
273
|
-
/** In-memory cache of the last successfully decrypted cookie value. */
|
|
274
|
-
this.decryptedCache = null;
|
|
275
|
-
this.config = config;
|
|
276
|
-
}
|
|
277
|
-
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
278
|
-
getTokens() {
|
|
279
|
-
if (this.config.token.storage === "memory") {
|
|
280
|
-
return this.memoryStore;
|
|
281
|
-
}
|
|
282
|
-
return this.decryptedCache;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Must be called once on startup (before getTokens) when storage is "cookie".
|
|
286
|
-
* Reads and decrypts the cookie, populating the in-memory cache.
|
|
287
|
-
*/
|
|
288
|
-
async initFromCookie() {
|
|
289
|
-
if (typeof document === "undefined") return;
|
|
290
|
-
if (this.config.token.storage !== "cookie") return;
|
|
291
|
-
const raw = getCookieValue(this.cookieName());
|
|
292
|
-
if (!raw) return;
|
|
293
|
-
try {
|
|
294
|
-
const json = await decrypt(decodeURIComponent(raw), this.config.secret);
|
|
295
|
-
this.decryptedCache = JSON.parse(json);
|
|
296
|
-
} catch {
|
|
297
|
-
this.decryptedCache = null;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
async setTokens(tokens) {
|
|
301
|
-
if (this.config.token.storage === "memory") {
|
|
302
|
-
this.memoryStore = tokens;
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
await this.writeToCookie(tokens);
|
|
306
|
-
}
|
|
307
|
-
clearTokens() {
|
|
308
|
-
this.memoryStore = null;
|
|
309
|
-
this.decryptedCache = null;
|
|
310
|
-
if (this.config.token.storage === "cookie") {
|
|
311
|
-
this.deleteCookie();
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
isAccessExpired(tokens) {
|
|
315
|
-
const threshold = (this.config.refreshThreshold ?? 60) * 1e3;
|
|
316
|
-
return Date.now() >= tokens.accessTokenExpiresAt - threshold;
|
|
317
|
-
}
|
|
318
|
-
isRefreshExpired(tokens) {
|
|
319
|
-
if (!tokens.refreshTokenExpiresAt) return false;
|
|
320
|
-
return Date.now() >= tokens.refreshTokenExpiresAt;
|
|
321
|
-
}
|
|
322
|
-
// ─── Cookie helpers (client-side only) ──────────────────────────────────────
|
|
323
|
-
cookieName() {
|
|
324
|
-
return this.config.token.cookieName ?? "next-token-auth.session";
|
|
325
|
-
}
|
|
326
|
-
async writeToCookie(tokens) {
|
|
327
|
-
if (typeof document === "undefined") return;
|
|
328
|
-
const value = await encrypt(JSON.stringify(tokens), this.config.secret);
|
|
329
|
-
const secure = this.config.token.secure !== false ? "; Secure" : "";
|
|
330
|
-
const sameSite = this.config.token.sameSite ?? "lax";
|
|
331
|
-
const maxAge = tokens.refreshTokenExpiresAt ? Math.floor((tokens.refreshTokenExpiresAt - Date.now()) / 1e3) : 604800;
|
|
332
|
-
document.cookie = [
|
|
333
|
-
`${this.cookieName()}=${encodeURIComponent(value)}`,
|
|
334
|
-
`Max-Age=${maxAge}`,
|
|
335
|
-
`Path=/`,
|
|
336
|
-
`SameSite=${sameSite}`,
|
|
337
|
-
secure
|
|
338
|
-
].filter(Boolean).join("; ");
|
|
339
|
-
this.decryptedCache = tokens;
|
|
340
|
-
}
|
|
341
|
-
deleteCookie() {
|
|
342
|
-
if (typeof document === "undefined") return;
|
|
343
|
-
document.cookie = `${this.cookieName()}=; Max-Age=0; Path=/`;
|
|
344
|
-
}
|
|
345
|
-
// ─── Server-side helpers ─────────────────────────────────────────────────────
|
|
346
|
-
/**
|
|
347
|
-
* Encrypts tokens — used internally by writeToCookie and available for
|
|
348
|
-
* advanced server-side use cases.
|
|
349
|
-
*/
|
|
350
|
-
async encryptTokens(tokens) {
|
|
351
|
-
return encrypt(JSON.stringify(tokens), this.config.secret);
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Decrypts tokens from a server-side cookie value.
|
|
355
|
-
*/
|
|
356
|
-
async decryptTokens(ciphertext) {
|
|
357
|
-
try {
|
|
358
|
-
const json = await decrypt(ciphertext, this.config.secret);
|
|
359
|
-
return JSON.parse(json);
|
|
360
|
-
} catch {
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
function getCookieValue(name) {
|
|
366
|
-
if (typeof document === "undefined") return null;
|
|
367
|
-
const match = document.cookie.match(
|
|
368
|
-
new RegExp(`(?:^|;\\s*)${escapeRegex(name)}=([^;]*)`)
|
|
369
|
-
);
|
|
370
|
-
return match ? match[1] : null;
|
|
371
|
-
}
|
|
372
|
-
function escapeRegex(str) {
|
|
373
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// src/core/AuthClient.ts
|
|
377
|
-
var AuthClient = class {
|
|
378
|
-
constructor(config) {
|
|
379
|
-
this.sessionListeners = [];
|
|
380
|
-
this.config = config;
|
|
381
|
-
this.tokenManager = new TokenManager(config);
|
|
382
|
-
this.httpClient = new HttpClient(config, this.tokenManager);
|
|
383
|
-
this.sessionManager = new SessionManager(
|
|
384
|
-
config,
|
|
385
|
-
this.tokenManager,
|
|
386
|
-
this.httpClient
|
|
387
|
-
);
|
|
388
|
-
this.httpClient.setRefreshFn(() => this.refresh());
|
|
389
|
-
}
|
|
390
|
-
// ─── Auth Operations ────────────────────────────────────────────────────────
|
|
391
|
-
/**
|
|
392
|
-
* Authenticates the user and stores tokens.
|
|
393
|
-
*/
|
|
394
|
-
async login(input) {
|
|
395
|
-
const res = await this.httpClient.doFetch(
|
|
396
|
-
this.httpClient.url(this.config.endpoints.login),
|
|
397
|
-
{
|
|
398
|
-
method: "POST",
|
|
399
|
-
headers: { "Content-Type": "application/json" },
|
|
400
|
-
body: JSON.stringify(input)
|
|
401
|
-
}
|
|
402
|
-
);
|
|
403
|
-
if (!res.ok) {
|
|
404
|
-
const error = await res.text();
|
|
405
|
-
throw new Error(`Login failed (${res.status}): ${error}`);
|
|
406
|
-
}
|
|
407
|
-
const data = await res.json();
|
|
408
|
-
const tokens = this.buildTokens(data);
|
|
409
|
-
await this.tokenManager.setTokens(tokens);
|
|
410
|
-
await this.sessionManager.loadSession();
|
|
411
|
-
const session = this.sessionManager.getSession();
|
|
412
|
-
this.config.onLogin?.(session);
|
|
413
|
-
this.notifyListeners(session);
|
|
414
|
-
return session;
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Logs out the user, clears tokens, and optionally calls the backend.
|
|
418
|
-
*/
|
|
419
|
-
async logout() {
|
|
420
|
-
const logoutEndpoint = this.config.endpoints.logout;
|
|
421
|
-
if (logoutEndpoint) {
|
|
422
|
-
try {
|
|
423
|
-
await this.httpClient.fetch(this.httpClient.url(logoutEndpoint), {
|
|
424
|
-
method: "POST"
|
|
425
|
-
});
|
|
426
|
-
} catch {
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
this.tokenManager.clearTokens();
|
|
430
|
-
this.sessionManager.clearSession();
|
|
431
|
-
this.config.onLogout?.();
|
|
432
|
-
this.notifyListeners(this.sessionManager.getSession());
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Refreshes the access token using the stored refresh token.
|
|
436
|
-
* Returns true on success, false on failure.
|
|
437
|
-
*/
|
|
438
|
-
async refresh() {
|
|
439
|
-
const tokens = this.tokenManager.getTokens();
|
|
440
|
-
if (!tokens) return false;
|
|
441
|
-
if (this.tokenManager.isRefreshExpired(tokens)) {
|
|
442
|
-
await this.logout();
|
|
443
|
-
return false;
|
|
444
|
-
}
|
|
445
|
-
try {
|
|
446
|
-
const res = await this.httpClient.doFetch(
|
|
447
|
-
this.httpClient.url(this.config.endpoints.refresh),
|
|
448
|
-
{
|
|
449
|
-
method: "POST",
|
|
450
|
-
headers: { "Content-Type": "application/json" },
|
|
451
|
-
body: JSON.stringify({ refreshToken: tokens.refreshToken })
|
|
452
|
-
}
|
|
453
|
-
);
|
|
454
|
-
if (!res.ok) {
|
|
455
|
-
await this.logout();
|
|
456
|
-
return false;
|
|
457
|
-
}
|
|
458
|
-
const data = await res.json();
|
|
459
|
-
const newTokens = this.buildTokens(data);
|
|
460
|
-
await this.tokenManager.setTokens(newTokens);
|
|
461
|
-
await this.sessionManager.refreshSession(newTokens);
|
|
462
|
-
this.notifyListeners(this.sessionManager.getSession());
|
|
463
|
-
return true;
|
|
464
|
-
} catch (err) {
|
|
465
|
-
this.config.onRefreshError?.(err);
|
|
466
|
-
return false;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
/**
|
|
470
|
-
* Loads the session from stored tokens (call on app mount).
|
|
471
|
-
*/
|
|
472
|
-
async initialize() {
|
|
473
|
-
await this.tokenManager.initFromCookie();
|
|
474
|
-
const session = await this.sessionManager.loadSession();
|
|
475
|
-
if (session.isAuthenticated && session.tokens && this.tokenManager.isAccessExpired(session.tokens) && !this.tokenManager.isRefreshExpired(session.tokens)) {
|
|
476
|
-
await this.refresh();
|
|
477
|
-
}
|
|
478
|
-
return this.sessionManager.getSession();
|
|
479
|
-
}
|
|
480
|
-
getSession() {
|
|
481
|
-
return this.sessionManager.getSession();
|
|
482
|
-
}
|
|
483
|
-
/**
|
|
484
|
-
* Returns the authenticated fetch wrapper.
|
|
485
|
-
*/
|
|
486
|
-
get fetch() {
|
|
487
|
-
return this.httpClient.fetch.bind(this.httpClient);
|
|
488
|
-
}
|
|
489
|
-
// ─── Subscription ────────────────────────────────────────────────────────────
|
|
490
|
-
subscribe(listener) {
|
|
491
|
-
this.sessionListeners.push(listener);
|
|
492
|
-
return () => {
|
|
493
|
-
this.sessionListeners = this.sessionListeners.filter((l) => l !== listener);
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
// ─── Private ────────────────────────────────────────────────────────────────
|
|
497
|
-
buildTokens(data) {
|
|
498
|
-
const strategy = this.config.expiry?.strategy ?? "hybrid";
|
|
499
|
-
const configAccess = this.config.expiry?.accessTokenExpiresIn;
|
|
500
|
-
const configRefresh = this.config.expiry?.refreshTokenExpiresIn;
|
|
501
|
-
return {
|
|
502
|
-
accessToken: data.accessToken,
|
|
503
|
-
refreshToken: data.refreshToken,
|
|
504
|
-
accessTokenExpiresAt: resolveAccessTokenExpiry(data, configAccess, strategy),
|
|
505
|
-
refreshTokenExpiresAt: resolveRefreshTokenExpiry(data, configRefresh, strategy)
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
notifyListeners(session) {
|
|
509
|
-
for (const listener of this.sessionListeners) {
|
|
510
|
-
listener(session);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
};
|
|
514
7
|
var AuthContext = react.createContext(null);
|
|
515
|
-
function AuthProvider({
|
|
516
|
-
config,
|
|
517
|
-
children
|
|
518
|
-
}) {
|
|
519
|
-
const clientRef = react.useRef(null);
|
|
520
|
-
if (!clientRef.current) {
|
|
521
|
-
clientRef.current = new AuthClient(config);
|
|
522
|
-
}
|
|
523
|
-
const client = clientRef.current;
|
|
8
|
+
function AuthProvider({ config, children }) {
|
|
524
9
|
const [session, setSession] = react.useState({
|
|
525
10
|
user: null,
|
|
526
11
|
tokens: null,
|
|
@@ -529,57 +14,87 @@ function AuthProvider({
|
|
|
529
14
|
const [isLoading, setIsLoading] = react.useState(true);
|
|
530
15
|
react.useEffect(() => {
|
|
531
16
|
let cancelled = false;
|
|
532
|
-
|
|
17
|
+
fetch("/api/auth/session").then((res) => res.json()).then((data) => {
|
|
18
|
+
if (!cancelled) {
|
|
19
|
+
setSession({
|
|
20
|
+
user: data.user ?? null,
|
|
21
|
+
tokens: null,
|
|
22
|
+
// tokens are HttpOnly, never exposed to client
|
|
23
|
+
isAuthenticated: data.isAuthenticated ?? false
|
|
24
|
+
});
|
|
25
|
+
setIsLoading(false);
|
|
26
|
+
}
|
|
27
|
+
}).catch(() => {
|
|
533
28
|
if (!cancelled) {
|
|
534
|
-
setSession(
|
|
29
|
+
setSession({ user: null, tokens: null, isAuthenticated: false });
|
|
535
30
|
setIsLoading(false);
|
|
536
31
|
}
|
|
537
32
|
});
|
|
538
|
-
const unsubscribe = client.subscribe((s) => {
|
|
539
|
-
if (!cancelled) setSession(s);
|
|
540
|
-
});
|
|
541
33
|
return () => {
|
|
542
34
|
cancelled = true;
|
|
543
|
-
unsubscribe();
|
|
544
35
|
};
|
|
545
|
-
}, [
|
|
36
|
+
}, []);
|
|
546
37
|
react.useEffect(() => {
|
|
547
38
|
if (!config.autoRefresh) return;
|
|
548
39
|
const interval = setInterval(async () => {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
40
|
+
if (session.isAuthenticated) {
|
|
41
|
+
try {
|
|
42
|
+
await fetch("/api/auth/refresh", { method: "POST" });
|
|
43
|
+
const updated = await fetch("/api/auth/session").then((r) => r.json());
|
|
44
|
+
setSession({
|
|
45
|
+
user: updated.user ?? null,
|
|
46
|
+
tokens: null,
|
|
47
|
+
isAuthenticated: updated.isAuthenticated ?? false
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
552
51
|
}
|
|
553
|
-
},
|
|
52
|
+
}, (config.refreshThreshold ?? 60) * 1e3);
|
|
554
53
|
return () => clearInterval(interval);
|
|
555
|
-
}, [
|
|
54
|
+
}, [config.autoRefresh, config.refreshThreshold, session.isAuthenticated]);
|
|
556
55
|
const login = react.useCallback(
|
|
557
56
|
async (input) => {
|
|
558
57
|
setIsLoading(true);
|
|
559
58
|
try {
|
|
560
|
-
const
|
|
561
|
-
|
|
59
|
+
const res = await fetch("/api/auth/login", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify(input)
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const { error } = await res.json();
|
|
66
|
+
throw new Error(error ?? "Login failed");
|
|
67
|
+
}
|
|
68
|
+
const { user } = await res.json();
|
|
69
|
+
const newSession = { user, tokens: null, isAuthenticated: true };
|
|
70
|
+
setSession(newSession);
|
|
71
|
+
config.onLogin?.(newSession);
|
|
562
72
|
} finally {
|
|
563
73
|
setIsLoading(false);
|
|
564
74
|
}
|
|
565
75
|
},
|
|
566
|
-
[
|
|
76
|
+
[config]
|
|
567
77
|
);
|
|
568
78
|
const logout = react.useCallback(async () => {
|
|
569
|
-
await
|
|
79
|
+
await fetch("/api/auth/logout", { method: "POST" });
|
|
570
80
|
setSession({ user: null, tokens: null, isAuthenticated: false });
|
|
571
|
-
|
|
81
|
+
config.onLogout?.();
|
|
82
|
+
}, [config]);
|
|
572
83
|
const refresh = react.useCallback(async () => {
|
|
573
|
-
await
|
|
574
|
-
|
|
575
|
-
|
|
84
|
+
await fetch("/api/auth/refresh", { method: "POST" });
|
|
85
|
+
const updated = await fetch("/api/auth/session").then((r) => r.json());
|
|
86
|
+
setSession({
|
|
87
|
+
user: updated.user ?? null,
|
|
88
|
+
tokens: null,
|
|
89
|
+
isAuthenticated: updated.isAuthenticated ?? false
|
|
90
|
+
});
|
|
91
|
+
}, []);
|
|
576
92
|
const value = {
|
|
577
93
|
session,
|
|
578
94
|
isLoading,
|
|
579
95
|
login,
|
|
580
96
|
logout,
|
|
581
|
-
refresh
|
|
582
|
-
client
|
|
97
|
+
refresh
|
|
583
98
|
};
|
|
584
99
|
return /* @__PURE__ */ jsxRuntime.jsx(AuthContext.Provider, { value, children });
|
|
585
100
|
}
|