@ursalock/client 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +583 -0
- package/dist/index.js +866 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { ZKCredentials } from '@z-base/zero-knowledge-credentials';
|
|
2
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
3
|
+
|
|
4
|
+
// src/passkey.ts
|
|
5
|
+
|
|
6
|
+
// src/interfaces/http-client.ts
|
|
7
|
+
var FetchHttpClient = class {
|
|
8
|
+
async fetch(url, options) {
|
|
9
|
+
return fetch(url, options);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/passkey.ts
|
|
14
|
+
var PasskeyAuth = class _PasskeyAuth {
|
|
15
|
+
options;
|
|
16
|
+
httpClient;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = {
|
|
19
|
+
serverUrl: options.serverUrl.replace(/\/$/, ""),
|
|
20
|
+
rpName: options.rpName ?? "ursalock",
|
|
21
|
+
httpClient: options.httpClient ?? new FetchHttpClient()
|
|
22
|
+
};
|
|
23
|
+
this.httpClient = this.options.httpClient;
|
|
24
|
+
}
|
|
25
|
+
getName() {
|
|
26
|
+
return "passkey";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check if passkeys with PRF are supported in this browser
|
|
30
|
+
* Implements IAuthProvider.isSupported
|
|
31
|
+
*/
|
|
32
|
+
isSupported() {
|
|
33
|
+
return _PasskeyAuth.isSupported();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Check if passkeys with PRF are supported in this browser (static helper)
|
|
37
|
+
*/
|
|
38
|
+
static isSupported() {
|
|
39
|
+
if (typeof window === "undefined") return false;
|
|
40
|
+
if (!window.PublicKeyCredential) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Sign up - Register a new passkey
|
|
45
|
+
* Creates a passkey with PRF extension and derives encryption keys
|
|
46
|
+
* Implements IAuthProvider.signUp
|
|
47
|
+
*/
|
|
48
|
+
async signUp(options) {
|
|
49
|
+
const opts = options;
|
|
50
|
+
const displayName = opts?.displayName ?? "User";
|
|
51
|
+
if (!_PasskeyAuth.isSupported()) {
|
|
52
|
+
return { success: false, error: "Passkeys not supported in this browser" };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
await ZKCredentials.registerCredential(
|
|
56
|
+
displayName,
|
|
57
|
+
"cross-platform"
|
|
58
|
+
// Allow platform + cross-platform authenticators
|
|
59
|
+
);
|
|
60
|
+
const credential = await ZKCredentials.discoverCredential();
|
|
61
|
+
const registerRes = await this.httpClient.fetch(
|
|
62
|
+
`${this.options.serverUrl}/auth/zkc/register`,
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
opaqueId: credential.id,
|
|
68
|
+
displayName
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
if (!registerRes.ok) {
|
|
73
|
+
const err = await registerRes.json();
|
|
74
|
+
return { success: false, error: err.message ?? "Failed to register" };
|
|
75
|
+
}
|
|
76
|
+
const result = await registerRes.json();
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
user: result.user,
|
|
80
|
+
token: result.token,
|
|
81
|
+
credential
|
|
82
|
+
};
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error) {
|
|
85
|
+
if (error.name === "NotAllowedError" || error.message.includes("aborted")) {
|
|
86
|
+
return { success: false, error: "Registration cancelled" };
|
|
87
|
+
}
|
|
88
|
+
return { success: false, error: error.message };
|
|
89
|
+
}
|
|
90
|
+
return { success: false, error: String(error) };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Legacy method - kept for backward compatibility
|
|
95
|
+
* @deprecated Use signUp() instead
|
|
96
|
+
*/
|
|
97
|
+
async register(displayName) {
|
|
98
|
+
return this.signUp({ displayName });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sign in - Authenticate with an existing passkey
|
|
102
|
+
* Uses discoverCredential to authenticate and derive keys
|
|
103
|
+
* Implements IAuthProvider.signIn
|
|
104
|
+
*/
|
|
105
|
+
async signIn(_options) {
|
|
106
|
+
if (!_PasskeyAuth.isSupported()) {
|
|
107
|
+
return { success: false, error: "Passkeys not supported in this browser" };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const credential = await ZKCredentials.discoverCredential();
|
|
111
|
+
const authRes = await this.httpClient.fetch(
|
|
112
|
+
`${this.options.serverUrl}/auth/zkc/authenticate`,
|
|
113
|
+
{
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
opaqueId: credential.id
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
if (!authRes.ok) {
|
|
122
|
+
const err = await authRes.json();
|
|
123
|
+
return { success: false, error: err.message ?? "Authentication failed" };
|
|
124
|
+
}
|
|
125
|
+
const result = await authRes.json();
|
|
126
|
+
return {
|
|
127
|
+
success: true,
|
|
128
|
+
user: result.user,
|
|
129
|
+
token: result.token,
|
|
130
|
+
credential
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof Error) {
|
|
134
|
+
if (error.name === "NotAllowedError" || error.message.includes("aborted")) {
|
|
135
|
+
return { success: false, error: "Authentication cancelled" };
|
|
136
|
+
}
|
|
137
|
+
if (error.message.includes("no-credential")) {
|
|
138
|
+
return { success: false, error: "No passkey found. Please sign up first." };
|
|
139
|
+
}
|
|
140
|
+
return { success: false, error: error.message };
|
|
141
|
+
}
|
|
142
|
+
return { success: false, error: String(error) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Legacy method - kept for backward compatibility
|
|
147
|
+
* @deprecated Use signIn() instead
|
|
148
|
+
*/
|
|
149
|
+
async authenticate() {
|
|
150
|
+
return this.signIn({});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Check if user has any registered passkeys
|
|
154
|
+
*/
|
|
155
|
+
async hasPasskey(opaqueId) {
|
|
156
|
+
try {
|
|
157
|
+
const res = await this.httpClient.fetch(
|
|
158
|
+
`${this.options.serverUrl}/auth/zkc/check`,
|
|
159
|
+
{
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ opaqueId })
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
if (!res.ok) return false;
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
return data.hasPasskey === true;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/email.ts
|
|
175
|
+
var EmailAuth = class {
|
|
176
|
+
options;
|
|
177
|
+
httpClient;
|
|
178
|
+
constructor(options) {
|
|
179
|
+
this.options = {
|
|
180
|
+
serverUrl: options.serverUrl.replace(/\/$/, ""),
|
|
181
|
+
httpClient: options.httpClient ?? new FetchHttpClient()
|
|
182
|
+
};
|
|
183
|
+
this.httpClient = this.options.httpClient;
|
|
184
|
+
}
|
|
185
|
+
getName() {
|
|
186
|
+
return "email";
|
|
187
|
+
}
|
|
188
|
+
isSupported() {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Sign up - Register a new account with email/password
|
|
193
|
+
* Implements IAuthProvider.signUp
|
|
194
|
+
*/
|
|
195
|
+
async signUp(options) {
|
|
196
|
+
const { email, password } = options;
|
|
197
|
+
if (!email || !this.isValidEmail(email)) {
|
|
198
|
+
return { success: false, error: "Invalid email address" };
|
|
199
|
+
}
|
|
200
|
+
if (!password || password.length < 8) {
|
|
201
|
+
return { success: false, error: "Password must be at least 8 characters" };
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const res = await this.httpClient.fetch(
|
|
205
|
+
`${this.options.serverUrl}/auth/email/register`,
|
|
206
|
+
{
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "Content-Type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ email, password })
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
const err = await res.json();
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
error: err.message ?? "Registration failed"
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const result = await res.json();
|
|
220
|
+
return {
|
|
221
|
+
success: true,
|
|
222
|
+
user: result.user,
|
|
223
|
+
token: result.token
|
|
224
|
+
// Email auth doesn't provide encryption keys
|
|
225
|
+
};
|
|
226
|
+
} catch {
|
|
227
|
+
return { success: false, error: "Network error" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Sign in - Authenticate with email/password
|
|
232
|
+
* Implements IAuthProvider.signIn
|
|
233
|
+
*/
|
|
234
|
+
async signIn(options) {
|
|
235
|
+
const { email, password } = options;
|
|
236
|
+
if (!email || !password) {
|
|
237
|
+
return { success: false, error: "Email and password required" };
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
const res = await this.httpClient.fetch(
|
|
241
|
+
`${this.options.serverUrl}/auth/email/login`,
|
|
242
|
+
{
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ email, password })
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
const err = await res.json();
|
|
250
|
+
if (res.status === 401) {
|
|
251
|
+
return { success: false, error: "Invalid email or password" };
|
|
252
|
+
}
|
|
253
|
+
return { success: false, error: err.message ?? "Login failed" };
|
|
254
|
+
}
|
|
255
|
+
const result = await res.json();
|
|
256
|
+
return {
|
|
257
|
+
success: true,
|
|
258
|
+
user: result.user,
|
|
259
|
+
token: result.token
|
|
260
|
+
// Email auth doesn't provide encryption keys
|
|
261
|
+
};
|
|
262
|
+
} catch {
|
|
263
|
+
return { success: false, error: "Network error" };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Legacy method - kept for backward compatibility
|
|
268
|
+
* @deprecated Use signUp() instead
|
|
269
|
+
*/
|
|
270
|
+
async register(credentials) {
|
|
271
|
+
return this.signUp(credentials);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Request password reset email
|
|
275
|
+
*/
|
|
276
|
+
async forgotPassword(email) {
|
|
277
|
+
if (!this.isValidEmail(email)) {
|
|
278
|
+
return { success: false, error: "Invalid email address" };
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await this.httpClient.fetch(
|
|
282
|
+
`${this.options.serverUrl}/auth/email/forgot-password`,
|
|
283
|
+
{
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "Content-Type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ email })
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
return { success: true };
|
|
290
|
+
} catch {
|
|
291
|
+
return { success: true };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Reset password with token
|
|
296
|
+
*/
|
|
297
|
+
async resetPassword(token, newPassword) {
|
|
298
|
+
if (!newPassword || newPassword.length < 8) {
|
|
299
|
+
return { success: false, error: "Password must be at least 8 characters" };
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const res = await this.httpClient.fetch(
|
|
303
|
+
`${this.options.serverUrl}/auth/email/reset-password`,
|
|
304
|
+
{
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json" },
|
|
307
|
+
body: JSON.stringify({ token, password: newPassword })
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
const err = await res.json();
|
|
312
|
+
return { success: false, error: err.message ?? "Reset failed" };
|
|
313
|
+
}
|
|
314
|
+
return { success: true };
|
|
315
|
+
} catch {
|
|
316
|
+
return { success: false, error: "Network error" };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Change password (when logged in)
|
|
321
|
+
*/
|
|
322
|
+
async changePassword(currentPassword, newPassword, authToken) {
|
|
323
|
+
if (!newPassword || newPassword.length < 8) {
|
|
324
|
+
return { success: false, error: "New password must be at least 8 characters" };
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const res = await this.httpClient.fetch(
|
|
328
|
+
`${this.options.serverUrl}/auth/email/change-password`,
|
|
329
|
+
{
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: {
|
|
332
|
+
"Content-Type": "application/json",
|
|
333
|
+
"Authorization": `Bearer ${authToken}`
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify({ currentPassword, newPassword })
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
const err = await res.json();
|
|
340
|
+
return { success: false, error: err.message ?? "Change failed" };
|
|
341
|
+
}
|
|
342
|
+
return { success: true };
|
|
343
|
+
} catch {
|
|
344
|
+
return { success: false, error: "Network error" };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Validate email format
|
|
349
|
+
*/
|
|
350
|
+
isValidEmail(email) {
|
|
351
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// src/token.ts
|
|
356
|
+
var TokenManager = class _TokenManager {
|
|
357
|
+
token = null;
|
|
358
|
+
refreshTimer = null;
|
|
359
|
+
options;
|
|
360
|
+
listeners = /* @__PURE__ */ new Set();
|
|
361
|
+
isRefreshing = false;
|
|
362
|
+
constructor(options = {}) {
|
|
363
|
+
this.options = {
|
|
364
|
+
storageKey: options.storageKey ?? "ursalock:token",
|
|
365
|
+
serverUrl: options.serverUrl,
|
|
366
|
+
onExpire: options.onExpire ?? (() => {
|
|
367
|
+
}),
|
|
368
|
+
refreshBuffer: options.refreshBuffer ?? 5 * 60 * 1e3,
|
|
369
|
+
// 5 minutes
|
|
370
|
+
autoRefresh: options.autoRefresh ?? !!options.serverUrl
|
|
371
|
+
};
|
|
372
|
+
this.loadFromStorage();
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Set a new token
|
|
376
|
+
*/
|
|
377
|
+
setToken(token) {
|
|
378
|
+
this.token = token;
|
|
379
|
+
this.saveToStorage();
|
|
380
|
+
this.scheduleRefresh();
|
|
381
|
+
this.notifyListeners();
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Get current token
|
|
385
|
+
*/
|
|
386
|
+
getToken() {
|
|
387
|
+
if (!this.token) return null;
|
|
388
|
+
if (Date.now() >= this.token.expiresAt) {
|
|
389
|
+
this.clearToken();
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return this.token;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Get access token string (convenience method)
|
|
396
|
+
*/
|
|
397
|
+
getAccessToken() {
|
|
398
|
+
return this.getToken()?.accessToken ?? null;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Check if token is valid
|
|
402
|
+
*/
|
|
403
|
+
isValid() {
|
|
404
|
+
const token = this.getToken();
|
|
405
|
+
return token !== null && Date.now() < token.expiresAt;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Clear token
|
|
409
|
+
*/
|
|
410
|
+
clearToken() {
|
|
411
|
+
this.token = null;
|
|
412
|
+
this.clearRefreshTimer();
|
|
413
|
+
this.removeFromStorage();
|
|
414
|
+
this.notifyListeners();
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Subscribe to token changes
|
|
418
|
+
*/
|
|
419
|
+
subscribe(callback) {
|
|
420
|
+
this.listeners.add(callback);
|
|
421
|
+
return () => this.listeners.delete(callback);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Manually refresh the token
|
|
425
|
+
* Returns true if refresh succeeded
|
|
426
|
+
*/
|
|
427
|
+
async refresh() {
|
|
428
|
+
if (!this.options.serverUrl || !this.token) return false;
|
|
429
|
+
if (this.isRefreshing) return false;
|
|
430
|
+
this.isRefreshing = true;
|
|
431
|
+
try {
|
|
432
|
+
const res = await fetch(`${this.options.serverUrl}/auth/refresh`, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: {
|
|
435
|
+
"Content-Type": "application/json",
|
|
436
|
+
"Authorization": `Bearer ${this.token.accessToken}`
|
|
437
|
+
},
|
|
438
|
+
body: this.token.refreshToken ? JSON.stringify({ refreshToken: this.token.refreshToken }) : void 0
|
|
439
|
+
});
|
|
440
|
+
if (!res.ok) {
|
|
441
|
+
this.isRefreshing = false;
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const data = await res.json();
|
|
445
|
+
const payload = _TokenManager.parseToken(data.token);
|
|
446
|
+
const expiresAt = payload?.exp ? payload.exp * 1e3 : Date.now() + (data.expiresIn ?? 3600) * 1e3;
|
|
447
|
+
this.setToken({
|
|
448
|
+
accessToken: data.token,
|
|
449
|
+
expiresAt,
|
|
450
|
+
refreshToken: this.token.refreshToken
|
|
451
|
+
});
|
|
452
|
+
this.isRefreshing = false;
|
|
453
|
+
return true;
|
|
454
|
+
} catch {
|
|
455
|
+
this.isRefreshing = false;
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Parse JWT payload (without verification)
|
|
461
|
+
*/
|
|
462
|
+
static parseToken(token) {
|
|
463
|
+
try {
|
|
464
|
+
const [, payload] = token.split(".");
|
|
465
|
+
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
466
|
+
return JSON.parse(decoded);
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// Private methods
|
|
472
|
+
loadFromStorage() {
|
|
473
|
+
if (typeof window === "undefined") return;
|
|
474
|
+
try {
|
|
475
|
+
const stored = localStorage.getItem(this.options.storageKey);
|
|
476
|
+
if (stored) {
|
|
477
|
+
const token = JSON.parse(stored);
|
|
478
|
+
if (Date.now() < token.expiresAt) {
|
|
479
|
+
this.token = token;
|
|
480
|
+
this.scheduleRefresh();
|
|
481
|
+
} else {
|
|
482
|
+
this.removeFromStorage();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
saveToStorage() {
|
|
489
|
+
if (typeof window === "undefined" || !this.token) return;
|
|
490
|
+
try {
|
|
491
|
+
localStorage.setItem(this.options.storageKey, JSON.stringify(this.token));
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
removeFromStorage() {
|
|
496
|
+
if (typeof window === "undefined") return;
|
|
497
|
+
try {
|
|
498
|
+
localStorage.removeItem(this.options.storageKey);
|
|
499
|
+
} catch {
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
scheduleRefresh() {
|
|
503
|
+
this.clearRefreshTimer();
|
|
504
|
+
if (!this.token) return;
|
|
505
|
+
const timeUntilExpiry = this.token.expiresAt - Date.now();
|
|
506
|
+
const refreshIn = Math.max(0, timeUntilExpiry - this.options.refreshBuffer);
|
|
507
|
+
this.refreshTimer = setTimeout(async () => {
|
|
508
|
+
if (this.options.autoRefresh && this.options.serverUrl) {
|
|
509
|
+
const success = await this.refresh();
|
|
510
|
+
if (success) return;
|
|
511
|
+
}
|
|
512
|
+
this.options.onExpire();
|
|
513
|
+
}, refreshIn);
|
|
514
|
+
}
|
|
515
|
+
clearRefreshTimer() {
|
|
516
|
+
if (this.refreshTimer) {
|
|
517
|
+
clearTimeout(this.refreshTimer);
|
|
518
|
+
this.refreshTimer = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
notifyListeners() {
|
|
522
|
+
for (const listener of this.listeners) {
|
|
523
|
+
listener(this.token);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/client.ts
|
|
529
|
+
var VaultClient = class {
|
|
530
|
+
options;
|
|
531
|
+
passkeyAuth;
|
|
532
|
+
emailAuth;
|
|
533
|
+
tokenManager;
|
|
534
|
+
state;
|
|
535
|
+
listeners = /* @__PURE__ */ new Set();
|
|
536
|
+
constructor(options) {
|
|
537
|
+
this.options = {
|
|
538
|
+
serverUrl: options.serverUrl.replace(/\/$/, ""),
|
|
539
|
+
rpName: options.rpName ?? "ursalock",
|
|
540
|
+
preferPasskey: options.preferPasskey ?? true,
|
|
541
|
+
storageKey: options.storageKey ?? "ursalock:auth"
|
|
542
|
+
};
|
|
543
|
+
this.passkeyAuth = new PasskeyAuth({
|
|
544
|
+
serverUrl: this.options.serverUrl,
|
|
545
|
+
rpName: this.options.rpName
|
|
546
|
+
});
|
|
547
|
+
this.emailAuth = new EmailAuth({
|
|
548
|
+
serverUrl: this.options.serverUrl
|
|
549
|
+
});
|
|
550
|
+
this.tokenManager = new TokenManager({
|
|
551
|
+
storageKey: `${this.options.storageKey}:token`,
|
|
552
|
+
onExpire: () => this.handleTokenExpire()
|
|
553
|
+
});
|
|
554
|
+
this.state = {
|
|
555
|
+
isAuthenticated: false,
|
|
556
|
+
user: null,
|
|
557
|
+
isLoading: true,
|
|
558
|
+
error: null,
|
|
559
|
+
credential: null
|
|
560
|
+
};
|
|
561
|
+
this.initialize();
|
|
562
|
+
}
|
|
563
|
+
// ==================
|
|
564
|
+
// Public Auth Methods
|
|
565
|
+
// ==================
|
|
566
|
+
/**
|
|
567
|
+
* Sign up a new user
|
|
568
|
+
*/
|
|
569
|
+
async signUp(options = {}) {
|
|
570
|
+
const usePasskey = options.usePasskey ?? (this.options.preferPasskey && PasskeyAuth.isSupported() && !options.password);
|
|
571
|
+
let result;
|
|
572
|
+
if (usePasskey) {
|
|
573
|
+
result = await this.passkeyAuth.register(options.displayName ?? options.email);
|
|
574
|
+
} else {
|
|
575
|
+
if (!options.email || !options.password) {
|
|
576
|
+
return { success: false, error: "Email and password required" };
|
|
577
|
+
}
|
|
578
|
+
const emailResult = await this.emailAuth.register({
|
|
579
|
+
email: options.email,
|
|
580
|
+
password: options.password
|
|
581
|
+
});
|
|
582
|
+
result = {
|
|
583
|
+
success: emailResult.success,
|
|
584
|
+
user: emailResult.user,
|
|
585
|
+
token: emailResult.token,
|
|
586
|
+
error: emailResult.error
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (result.success && result.token) {
|
|
590
|
+
this.handleAuthSuccess(result);
|
|
591
|
+
}
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Sign in an existing user
|
|
596
|
+
*/
|
|
597
|
+
async signIn(options = {}) {
|
|
598
|
+
const usePasskey = options.usePasskey ?? (this.options.preferPasskey && PasskeyAuth.isSupported() && !options.password);
|
|
599
|
+
let result;
|
|
600
|
+
if (usePasskey) {
|
|
601
|
+
result = await this.passkeyAuth.authenticate();
|
|
602
|
+
} else {
|
|
603
|
+
if (!options.email || !options.password) {
|
|
604
|
+
return { success: false, error: "Email and password required" };
|
|
605
|
+
}
|
|
606
|
+
const emailResult = await this.emailAuth.signIn({
|
|
607
|
+
email: options.email,
|
|
608
|
+
password: options.password
|
|
609
|
+
});
|
|
610
|
+
result = {
|
|
611
|
+
success: emailResult.success,
|
|
612
|
+
user: emailResult.user,
|
|
613
|
+
token: emailResult.token,
|
|
614
|
+
error: emailResult.error
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (result.success && result.token) {
|
|
618
|
+
this.handleAuthSuccess(result);
|
|
619
|
+
}
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Sign out
|
|
624
|
+
*/
|
|
625
|
+
async signOut() {
|
|
626
|
+
const token = this.tokenManager.getAccessToken();
|
|
627
|
+
if (token) {
|
|
628
|
+
try {
|
|
629
|
+
await fetch(`${this.options.serverUrl}/auth/logout`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: { "Authorization": `Bearer ${token}` }
|
|
632
|
+
});
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
this.tokenManager.clearToken();
|
|
637
|
+
this.clearUserFromStorage();
|
|
638
|
+
this.updateState({
|
|
639
|
+
isAuthenticated: false,
|
|
640
|
+
user: null,
|
|
641
|
+
isLoading: false,
|
|
642
|
+
error: null,
|
|
643
|
+
credential: null
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Check if passkeys are supported
|
|
648
|
+
*/
|
|
649
|
+
supportsPasskey() {
|
|
650
|
+
return PasskeyAuth.isSupported();
|
|
651
|
+
}
|
|
652
|
+
// ==================
|
|
653
|
+
// State Management
|
|
654
|
+
// ==================
|
|
655
|
+
/**
|
|
656
|
+
* Get current auth state
|
|
657
|
+
* Returns the same reference unless state changes (required for useSyncExternalStore)
|
|
658
|
+
*/
|
|
659
|
+
getState() {
|
|
660
|
+
return this.state;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Get current user
|
|
664
|
+
*/
|
|
665
|
+
getUser() {
|
|
666
|
+
return this.state.user;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Get current ZK credential (with encryption keys)
|
|
670
|
+
*/
|
|
671
|
+
getCredential() {
|
|
672
|
+
return this.state.credential;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Check if authenticated
|
|
676
|
+
*/
|
|
677
|
+
isAuthenticated() {
|
|
678
|
+
return this.state.isAuthenticated;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Subscribe to auth state changes
|
|
682
|
+
*/
|
|
683
|
+
subscribe(callback) {
|
|
684
|
+
this.listeners.add(callback);
|
|
685
|
+
callback(this.state);
|
|
686
|
+
return () => this.listeners.delete(callback);
|
|
687
|
+
}
|
|
688
|
+
// ==================
|
|
689
|
+
// API Methods
|
|
690
|
+
// ==================
|
|
691
|
+
/**
|
|
692
|
+
* Get authorization header
|
|
693
|
+
*/
|
|
694
|
+
getAuthHeader() {
|
|
695
|
+
const token = this.tokenManager.getAccessToken();
|
|
696
|
+
return token ? { "Authorization": `Bearer ${token}` } : {};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Make authenticated API request
|
|
700
|
+
*/
|
|
701
|
+
async fetch(path, options = {}) {
|
|
702
|
+
const url = path.startsWith("http") ? path : `${this.options.serverUrl}${path}`;
|
|
703
|
+
return fetch(url, {
|
|
704
|
+
...options,
|
|
705
|
+
headers: {
|
|
706
|
+
...this.getAuthHeader(),
|
|
707
|
+
...options.headers
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
// ==================
|
|
712
|
+
// Private Methods
|
|
713
|
+
// ==================
|
|
714
|
+
async initialize() {
|
|
715
|
+
if (this.tokenManager.isValid()) {
|
|
716
|
+
try {
|
|
717
|
+
const res = await this.fetch("/auth/me");
|
|
718
|
+
if (res.ok) {
|
|
719
|
+
const data = await res.json();
|
|
720
|
+
this.updateState({
|
|
721
|
+
isAuthenticated: true,
|
|
722
|
+
user: data.user,
|
|
723
|
+
isLoading: false,
|
|
724
|
+
error: null,
|
|
725
|
+
credential: null
|
|
726
|
+
// Will need to re-authenticate to get credential
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
} else {
|
|
730
|
+
this.tokenManager.clearToken();
|
|
731
|
+
this.clearUserFromStorage();
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
const user = this.loadUserFromStorage();
|
|
735
|
+
if (user) {
|
|
736
|
+
this.updateState({
|
|
737
|
+
isAuthenticated: true,
|
|
738
|
+
user,
|
|
739
|
+
isLoading: false,
|
|
740
|
+
error: null,
|
|
741
|
+
credential: null
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
this.tokenManager.clearToken();
|
|
748
|
+
this.clearUserFromStorage();
|
|
749
|
+
this.updateState({
|
|
750
|
+
isAuthenticated: false,
|
|
751
|
+
user: null,
|
|
752
|
+
isLoading: false,
|
|
753
|
+
error: null,
|
|
754
|
+
credential: null
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
handleAuthSuccess(result) {
|
|
758
|
+
if (!result.token || !result.user) return;
|
|
759
|
+
const payload = TokenManager.parseToken(result.token);
|
|
760
|
+
const expiresAt = payload?.exp ? payload.exp * 1e3 : Date.now() + 7 * 24 * 60 * 60 * 1e3;
|
|
761
|
+
this.tokenManager.setToken({
|
|
762
|
+
accessToken: result.token,
|
|
763
|
+
expiresAt
|
|
764
|
+
});
|
|
765
|
+
this.saveUserToStorage(result.user);
|
|
766
|
+
this.updateState({
|
|
767
|
+
isAuthenticated: true,
|
|
768
|
+
user: result.user,
|
|
769
|
+
isLoading: false,
|
|
770
|
+
error: null,
|
|
771
|
+
credential: result.credential ?? null
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
handleTokenExpire() {
|
|
775
|
+
this.updateState({
|
|
776
|
+
isAuthenticated: false,
|
|
777
|
+
user: null,
|
|
778
|
+
isLoading: false,
|
|
779
|
+
error: new Error("Session expired"),
|
|
780
|
+
credential: null
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
updateState(newState) {
|
|
784
|
+
this.state = newState;
|
|
785
|
+
for (const listener of this.listeners) {
|
|
786
|
+
listener(this.state);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
saveUserToStorage(user) {
|
|
790
|
+
if (typeof window === "undefined") return;
|
|
791
|
+
try {
|
|
792
|
+
localStorage.setItem(`${this.options.storageKey}:user`, JSON.stringify(user));
|
|
793
|
+
} catch {
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
loadUserFromStorage() {
|
|
797
|
+
if (typeof window === "undefined") return null;
|
|
798
|
+
try {
|
|
799
|
+
const stored = localStorage.getItem(`${this.options.storageKey}:user`);
|
|
800
|
+
return stored ? JSON.parse(stored) : null;
|
|
801
|
+
} catch {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
clearUserFromStorage() {
|
|
806
|
+
if (typeof window === "undefined") return;
|
|
807
|
+
try {
|
|
808
|
+
localStorage.removeItem(`${this.options.storageKey}:user`);
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
function useAuth(client) {
|
|
814
|
+
const subscribe = useCallback(
|
|
815
|
+
(callback) => {
|
|
816
|
+
return client.subscribe(callback);
|
|
817
|
+
},
|
|
818
|
+
[client]
|
|
819
|
+
);
|
|
820
|
+
const getSnapshot = useCallback(() => client.getState(), [client]);
|
|
821
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
822
|
+
}
|
|
823
|
+
function useSignUp(client) {
|
|
824
|
+
const state = useAuth(client);
|
|
825
|
+
const signUp = useCallback(
|
|
826
|
+
async (options) => {
|
|
827
|
+
return client.signUp(options ?? {});
|
|
828
|
+
},
|
|
829
|
+
[client]
|
|
830
|
+
);
|
|
831
|
+
return {
|
|
832
|
+
signUp,
|
|
833
|
+
isLoading: state.isLoading,
|
|
834
|
+
error: state.error
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function useSignIn(client) {
|
|
838
|
+
const state = useAuth(client);
|
|
839
|
+
const signIn = useCallback(
|
|
840
|
+
async (options) => {
|
|
841
|
+
return client.signIn(options ?? {});
|
|
842
|
+
},
|
|
843
|
+
[client]
|
|
844
|
+
);
|
|
845
|
+
return {
|
|
846
|
+
signIn,
|
|
847
|
+
isLoading: state.isLoading,
|
|
848
|
+
error: state.error
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function useSignOut(client) {
|
|
852
|
+
return useCallback(() => client.signOut(), [client]);
|
|
853
|
+
}
|
|
854
|
+
function useUser(client) {
|
|
855
|
+
const state = useAuth(client);
|
|
856
|
+
return state.user;
|
|
857
|
+
}
|
|
858
|
+
function useCredential(client) {
|
|
859
|
+
const state = useAuth(client);
|
|
860
|
+
return state.credential;
|
|
861
|
+
}
|
|
862
|
+
function usePasskeySupport(client) {
|
|
863
|
+
return client.supportsPasskey();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export { EmailAuth, FetchHttpClient, PasskeyAuth, TokenManager, VaultClient, useAuth, useCredential, usePasskeySupport, useSignIn, useSignOut, useSignUp, useUser };
|