isitme 0.0.1 → 0.0.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/README.md +31 -59
- package/dist/browser.d.ts +35 -3
- package/dist/browser.js +291 -29
- package/dist/express.d.ts +100 -0
- package/dist/express.js +15273 -0
- package/dist/hono.d.ts +117 -0
- package/dist/hono.js +12749 -0
- package/dist/index.d.ts +180 -75
- package/dist/index.js +763 -65
- package/dist/next.d.ts +86 -0
- package/dist/next.js +12779 -0
- package/dist/react.d.ts +123 -0
- package/dist/react.js +3471 -0
- package/dist/types.d.ts +17 -11
- package/dist/vue.d.ts +207 -0
- package/dist/vue.js +935 -0
- package/package.json +47 -3
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# isitme
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Free passkey authentication for solo builders. One owner per domain. No accounts. No passwords. No kidding.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
npm
|
|
6
|
+
npm i isitme
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
@@ -24,81 +24,53 @@ app.get("/", (req, res) => {
|
|
|
24
24
|
app.listen(3000);
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Visit `localhost:3000` — register a passkey with your fingerprint, and the app is locked to you.
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## React
|
|
30
30
|
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
secret: "your-jwt-secret", // default: random (sessions lost on restart)
|
|
34
|
-
storage: "./auth.json", // default: "./auth.json", false for in-memory
|
|
35
|
-
publicPaths: ["/", "/about"], // routes that skip auth
|
|
36
|
-
prefix: "/auth", // auth endpoint prefix
|
|
37
|
-
sessionExpiryDays: 7, // session duration
|
|
38
|
-
page: { title: "My App" }, // customize login page, or false to disable
|
|
39
|
-
}));
|
|
40
|
-
```
|
|
31
|
+
```jsx
|
|
32
|
+
import { IsItMe } from "isitme/react";
|
|
41
33
|
|
|
42
|
-
|
|
34
|
+
<IsItMe>
|
|
35
|
+
<h1>Admin dashboard</h1>
|
|
36
|
+
<p>Only you can see this.</p>
|
|
37
|
+
</IsItMe>
|
|
38
|
+
```
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
## Browser API
|
|
45
41
|
|
|
46
42
|
```js
|
|
47
|
-
|
|
43
|
+
import { signin, isItMe, logout } from "isitme/browser";
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} else {
|
|
53
|
-
res.send("Sign in to continue.");
|
|
54
|
-
}
|
|
55
|
-
});
|
|
45
|
+
await signin(); // register or login — one call
|
|
46
|
+
const session = await isItMe(); // silent check — no UI
|
|
47
|
+
await logout(); // end session
|
|
56
48
|
```
|
|
57
49
|
|
|
58
|
-
|
|
50
|
+
`signin()` checks if a passkey exists for this domain. First visit triggers registration, every visit after triggers login. No branching logic needed.
|
|
59
51
|
|
|
60
|
-
For
|
|
52
|
+
For manual control, use `login()` and `register()` directly:
|
|
61
53
|
|
|
62
54
|
```js
|
|
63
|
-
import { register,
|
|
55
|
+
import { login, register, isItMe, IsitmeError } from "isitme/browser";
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (!status.isSetup) {
|
|
68
|
-
await register();
|
|
69
|
-
} else {
|
|
57
|
+
try {
|
|
70
58
|
await login();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof IsitmeError && err.code === "NOT_SETUP") {
|
|
61
|
+
await register(); // no passkey yet — register first
|
|
62
|
+
}
|
|
71
63
|
}
|
|
72
64
|
```
|
|
73
65
|
|
|
74
|
-
##
|
|
75
|
-
|
|
76
|
-
Implement the `StorageAdapter` interface to use any backend:
|
|
77
|
-
|
|
78
|
-
```js
|
|
79
|
-
import { isitme } from "isitme";
|
|
80
|
-
|
|
81
|
-
app.use(isitme({
|
|
82
|
-
storage: {
|
|
83
|
-
isSetupComplete() { /* ... */ },
|
|
84
|
-
getUser() { /* ... */ },
|
|
85
|
-
getCredentials() { /* ... */ },
|
|
86
|
-
getCredentialById(id) { /* ... */ },
|
|
87
|
-
saveUserAndCredential(user, credential) { /* ... */ },
|
|
88
|
-
updateCredentialCounter(id, counter) { /* ... */ },
|
|
89
|
-
getAuthDataJson() { /* ... */ },
|
|
90
|
-
}
|
|
91
|
-
}));
|
|
92
|
-
```
|
|
66
|
+
## Docs
|
|
93
67
|
|
|
94
|
-
|
|
68
|
+
Full API reference, configuration options, and framework guides:
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
2. Unauthenticated visitors see a built-in login page
|
|
98
|
-
3. Registration creates a WebAuthn passkey tied to the device's biometrics
|
|
99
|
-
4. Login verifies the passkey and sets a JWT session cookie
|
|
100
|
-
5. `req.isAuthenticated` is available on every request
|
|
70
|
+
**https://isitme.dev/docs**
|
|
101
71
|
|
|
102
|
-
##
|
|
72
|
+
## Links
|
|
103
73
|
|
|
104
|
-
|
|
74
|
+
- **Docs:** https://isitme.dev/docs
|
|
75
|
+
- **GitHub:** https://github.com/isitme-dev/isitme
|
|
76
|
+
- **npm:** https://www.npmjs.com/package/isitme
|
package/dist/browser.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ interface AuthStatus {
|
|
|
2
2
|
setupComplete: boolean;
|
|
3
3
|
isAuthenticated: boolean;
|
|
4
4
|
}
|
|
5
|
+
interface Session {
|
|
6
|
+
authenticated: true;
|
|
7
|
+
registered: true;
|
|
8
|
+
}
|
|
5
9
|
interface RegisterResult {
|
|
6
10
|
verified: boolean;
|
|
7
11
|
authData?: string;
|
|
@@ -9,15 +13,43 @@ interface RegisterResult {
|
|
|
9
13
|
interface LoginResult {
|
|
10
14
|
verified: boolean;
|
|
11
15
|
}
|
|
16
|
+
interface SigninResult {
|
|
17
|
+
verified: boolean;
|
|
18
|
+
action: "register" | "login";
|
|
19
|
+
authData?: string;
|
|
20
|
+
}
|
|
21
|
+
type IsitmeErrorCode = "NOT_SETUP" | "ALREADY_SETUP" | "CREDENTIAL_NOT_FOUND" | "CHALLENGE_EXPIRED" | "VERIFICATION_FAILED" | "SITE_NOT_FOUND" | "SITE_BLOCKED" | "NETWORK_ERROR" | "PASSKEY_CANCELLED" | "UNKNOWN";
|
|
22
|
+
declare class IsitmeError extends Error {
|
|
23
|
+
code: IsitmeErrorCode;
|
|
24
|
+
cause?: Error;
|
|
25
|
+
constructor(code: IsitmeErrorCode, message: string, cause?: Error);
|
|
26
|
+
}
|
|
12
27
|
interface IsitmeBrowserOptions {
|
|
13
|
-
/** Auth API prefix. Default: `/
|
|
28
|
+
/** Auth API prefix. Default: `/_isitme` */
|
|
14
29
|
prefix?: string;
|
|
30
|
+
/** Cloud API URL (e.g. `https://api.isitme.dev`). When set, auth uses the cloud API + localStorage instead of same-origin cookies. */
|
|
31
|
+
api?: string;
|
|
15
32
|
}
|
|
16
33
|
|
|
17
|
-
declare function
|
|
34
|
+
declare function signin(opts?: IsitmeBrowserOptions): Promise<SigninResult>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Silent session check. Returns session info if authenticated, null otherwise.
|
|
38
|
+
* Never shows UI — use `login()` or `register()` for that.
|
|
39
|
+
*/
|
|
40
|
+
declare function isItMe(opts?: IsitmeBrowserOptions): Promise<Session | null>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* End the current session. Works in both server and cloud mode.
|
|
44
|
+
* Server mode: POSTs to `/_isitme/logout` to clear the session cookie.
|
|
45
|
+
* Cloud mode: clears the session from localStorage.
|
|
46
|
+
*/
|
|
47
|
+
declare function logout(opts?: IsitmeBrowserOptions): Promise<void>;
|
|
18
48
|
|
|
19
49
|
declare function login(opts?: IsitmeBrowserOptions): Promise<LoginResult>;
|
|
20
50
|
|
|
51
|
+
declare function register(opts?: IsitmeBrowserOptions): Promise<RegisterResult>;
|
|
52
|
+
|
|
21
53
|
declare function getAuthStatus(opts?: IsitmeBrowserOptions): Promise<AuthStatus>;
|
|
22
54
|
|
|
23
|
-
export { type AuthStatus, type IsitmeBrowserOptions, type LoginResult, type RegisterResult, getAuthStatus, login, register };
|
|
55
|
+
export { type AuthStatus, type IsitmeBrowserOptions, IsitmeError, type IsitmeErrorCode, type LoginResult, type RegisterResult, type Session, type SigninResult, getAuthStatus, isItMe, login, logout, register, signin };
|
package/dist/browser.js
CHANGED
|
@@ -355,46 +355,308 @@ async function startAuthentication(options) {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
// ../browser/dist/index.js
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
358
|
+
var IsitmeError = class extends Error {
|
|
359
|
+
code;
|
|
360
|
+
cause;
|
|
361
|
+
constructor(code, message, cause) {
|
|
362
|
+
super(message);
|
|
363
|
+
this.name = "IsitmeError";
|
|
364
|
+
this.code = code;
|
|
365
|
+
this.cause = cause;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
var DEFAULT_PREFIX = "/_isitme";
|
|
369
|
+
var STORAGE_KEY = "isitme_session";
|
|
370
|
+
var Errors = {
|
|
371
|
+
SETUP_COMPLETE: "Setup already complete",
|
|
372
|
+
NO_CREDENTIALS: "No credentials registered",
|
|
373
|
+
CHALLENGE_EXPIRED: "Challenge expired",
|
|
374
|
+
VERIFICATION_FAILED: "Verification failed",
|
|
375
|
+
CREDENTIAL_NOT_FOUND: "Credential not found"
|
|
376
|
+
};
|
|
377
|
+
function getDomain() {
|
|
378
|
+
return location.hostname;
|
|
379
|
+
}
|
|
380
|
+
function getCloudSession() {
|
|
381
|
+
try {
|
|
382
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
383
|
+
if (!raw) return null;
|
|
384
|
+
return JSON.parse(raw);
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function saveSession(data) {
|
|
390
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
391
|
+
}
|
|
392
|
+
function clearCloudSession() {
|
|
393
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
394
|
+
}
|
|
395
|
+
async function apiPost(api, path, body) {
|
|
396
|
+
const res = await fetch(api + path, {
|
|
365
397
|
method: "POST",
|
|
366
398
|
headers: { "Content-Type": "application/json" },
|
|
367
|
-
body: JSON.stringify(
|
|
399
|
+
body: JSON.stringify(body)
|
|
368
400
|
});
|
|
369
|
-
const
|
|
370
|
-
if (!
|
|
371
|
-
return
|
|
401
|
+
const data = await res.json();
|
|
402
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
403
|
+
return data;
|
|
372
404
|
}
|
|
373
|
-
async function
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
405
|
+
async function cloudRegister(api) {
|
|
406
|
+
const domain = getDomain();
|
|
407
|
+
const ch = await apiPost(api, "/v1/challenge", {
|
|
408
|
+
domain,
|
|
409
|
+
type: "registration"
|
|
410
|
+
});
|
|
411
|
+
const regResp = await startRegistration({
|
|
412
|
+
optionsJSON: {
|
|
413
|
+
challenge: ch.challenge,
|
|
414
|
+
rp: { name: ch.rpName, id: ch.rpId },
|
|
415
|
+
user: {
|
|
416
|
+
id: crypto.randomUUID(),
|
|
417
|
+
name: domain + "-owner",
|
|
418
|
+
displayName: domain + " owner"
|
|
419
|
+
},
|
|
420
|
+
pubKeyCredParams: [
|
|
421
|
+
{ alg: -7, type: "public-key" },
|
|
422
|
+
{ alg: -257, type: "public-key" }
|
|
423
|
+
],
|
|
424
|
+
authenticatorSelection: {
|
|
425
|
+
residentKey: "preferred",
|
|
426
|
+
userVerification: "preferred"
|
|
427
|
+
},
|
|
428
|
+
timeout: 6e4,
|
|
429
|
+
attestation: "none"
|
|
430
|
+
}
|
|
383
431
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
432
|
+
const result = await apiPost(api, "/v1/register", {
|
|
433
|
+
challengeId: ch.challengeId,
|
|
434
|
+
credentialId: regResp.id,
|
|
435
|
+
publicKey: regResp.response.publicKey,
|
|
436
|
+
signCount: 0,
|
|
437
|
+
transports: regResp.response.transports || []
|
|
438
|
+
});
|
|
439
|
+
saveSession({
|
|
440
|
+
userId: result.userId,
|
|
441
|
+
siteId: result.siteId,
|
|
442
|
+
credentialId: regResp.id,
|
|
443
|
+
transports: regResp.response.transports || [],
|
|
444
|
+
authenticatedAt: Date.now(),
|
|
445
|
+
domain
|
|
446
|
+
});
|
|
447
|
+
return { verified: true };
|
|
448
|
+
}
|
|
449
|
+
async function cloudLogin(api) {
|
|
450
|
+
const domain = getDomain();
|
|
451
|
+
const existing = getCloudSession();
|
|
452
|
+
const ch = await apiPost(api, "/v1/challenge", {
|
|
453
|
+
domain,
|
|
454
|
+
type: "authentication"
|
|
455
|
+
});
|
|
456
|
+
const allowCredentials = ch.credentialIds.map((id) => ({
|
|
457
|
+
id,
|
|
458
|
+
type: "public-key",
|
|
459
|
+
transports: existing?.transports || []
|
|
460
|
+
}));
|
|
461
|
+
const authResp = await startAuthentication({
|
|
462
|
+
optionsJSON: {
|
|
463
|
+
challenge: ch.challenge,
|
|
464
|
+
rpId: ch.rpId,
|
|
465
|
+
allowCredentials,
|
|
466
|
+
timeout: 6e4,
|
|
467
|
+
userVerification: "preferred"
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
const result = await apiPost(api, "/v1/authenticate", {
|
|
471
|
+
challengeId: ch.challengeId,
|
|
472
|
+
credentialId: authResp.id,
|
|
473
|
+
signCount: 0
|
|
474
|
+
});
|
|
475
|
+
saveSession({
|
|
476
|
+
userId: result.userId,
|
|
477
|
+
siteId: result.siteId,
|
|
478
|
+
credentialId: authResp.id,
|
|
479
|
+
transports: existing?.transports || [],
|
|
480
|
+
authenticatedAt: Date.now(),
|
|
481
|
+
domain
|
|
482
|
+
});
|
|
483
|
+
return { verified: true };
|
|
484
|
+
}
|
|
485
|
+
async function cloudStatus(api) {
|
|
486
|
+
const domain = getDomain();
|
|
487
|
+
const session = getCloudSession();
|
|
488
|
+
let setupComplete = false;
|
|
489
|
+
try {
|
|
490
|
+
const res = await fetch(api + "/v1/site/" + encodeURIComponent(domain));
|
|
491
|
+
if (res.ok) {
|
|
492
|
+
const data = await res.json();
|
|
493
|
+
setupComplete = data.setupComplete === true;
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
setupComplete = !!session;
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
setupComplete,
|
|
500
|
+
isAuthenticated: !!session
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function cloudIsItMe() {
|
|
504
|
+
const session = getCloudSession();
|
|
505
|
+
if (session) {
|
|
506
|
+
return { authenticated: true, registered: true };
|
|
387
507
|
}
|
|
388
|
-
return
|
|
508
|
+
return null;
|
|
389
509
|
}
|
|
390
510
|
async function getAuthStatus(opts = {}) {
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
511
|
+
if (opts.api) return cloudStatus(opts.api);
|
|
512
|
+
const prefix = opts.prefix || DEFAULT_PREFIX;
|
|
513
|
+
const url = `${prefix}/status`;
|
|
514
|
+
const res = await fetch(url, { credentials: "include" });
|
|
515
|
+
if (!res.ok) throw new Error(`Auth status check failed: ${res.status} from ${url}`);
|
|
394
516
|
return res.json();
|
|
395
517
|
}
|
|
518
|
+
function classifyRegisterError(err) {
|
|
519
|
+
if (err instanceof IsitmeError) return err;
|
|
520
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
+
if (msg.includes(Errors.SETUP_COMPLETE) || msg.includes("already has an owner"))
|
|
522
|
+
return new IsitmeError("ALREADY_SETUP", "This domain already has an owner. Use login() or signin() instead.", err instanceof Error ? err : void 0);
|
|
523
|
+
if (msg.includes(Errors.CHALLENGE_EXPIRED))
|
|
524
|
+
return new IsitmeError("CHALLENGE_EXPIRED", "Registration challenge expired. Please try again.", err instanceof Error ? err : void 0);
|
|
525
|
+
if (msg.includes(Errors.VERIFICATION_FAILED))
|
|
526
|
+
return new IsitmeError("VERIFICATION_FAILED", "Passkey verification failed during registration.", err instanceof Error ? err : void 0);
|
|
527
|
+
if (msg.includes("Site not found"))
|
|
528
|
+
return new IsitmeError("SITE_NOT_FOUND", "Domain not found. The cloud API may be unreachable.", err instanceof Error ? err : void 0);
|
|
529
|
+
if (msg.includes("blocked"))
|
|
530
|
+
return new IsitmeError("SITE_BLOCKED", "This domain has been blocked.", err instanceof Error ? err : void 0);
|
|
531
|
+
if (msg.includes("The operation either timed out") || msg.includes("NotAllowedError") || msg.includes("cancelled") || msg.includes("canceled") || msg.includes("AbortError"))
|
|
532
|
+
return new IsitmeError("PASSKEY_CANCELLED", "Passkey prompt was cancelled or timed out.", err instanceof Error ? err : void 0);
|
|
533
|
+
return new IsitmeError("UNKNOWN", `Registration failed: ${msg}`, err instanceof Error ? err : void 0);
|
|
534
|
+
}
|
|
535
|
+
async function register(opts = {}) {
|
|
536
|
+
try {
|
|
537
|
+
if (opts.api) return await cloudRegister(opts.api);
|
|
538
|
+
const prefix = opts.prefix || DEFAULT_PREFIX;
|
|
539
|
+
const optUrl = `${prefix}/register/start`;
|
|
540
|
+
const optRes = await fetch(optUrl, { method: "POST", credentials: "include" });
|
|
541
|
+
if (!optRes.ok) {
|
|
542
|
+
const body = await optRes.json().catch(() => null);
|
|
543
|
+
throw new Error(body?.error || `Registration options failed: ${optRes.status} from ${optUrl}`);
|
|
544
|
+
}
|
|
545
|
+
const { options, nonce, userId } = await optRes.json();
|
|
546
|
+
const registration = await startRegistration({ optionsJSON: options });
|
|
547
|
+
const verifyUrl = `${prefix}/register/finish`;
|
|
548
|
+
const verifyRes = await fetch(verifyUrl, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
credentials: "include",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({ nonce, userId, response: registration })
|
|
553
|
+
});
|
|
554
|
+
const result = await verifyRes.json();
|
|
555
|
+
if (!verifyRes.ok) throw new Error(result.error || `Registration verify failed: ${verifyRes.status} from ${verifyUrl}`);
|
|
556
|
+
return result;
|
|
557
|
+
} catch (err) {
|
|
558
|
+
throw classifyRegisterError(err);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function classifyLoginError(err) {
|
|
562
|
+
if (err instanceof IsitmeError) return err;
|
|
563
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
564
|
+
if (msg.includes(Errors.NO_CREDENTIALS) || msg.includes("not complete"))
|
|
565
|
+
return new IsitmeError("NOT_SETUP", "No passkey registered for this domain. Use register() or signin() first.", err instanceof Error ? err : void 0);
|
|
566
|
+
if (msg.includes(Errors.CREDENTIAL_NOT_FOUND))
|
|
567
|
+
return new IsitmeError("CREDENTIAL_NOT_FOUND", "Passkey not recognized. It may have been removed or registered on a different domain.", err instanceof Error ? err : void 0);
|
|
568
|
+
if (msg.includes(Errors.CHALLENGE_EXPIRED))
|
|
569
|
+
return new IsitmeError("CHALLENGE_EXPIRED", "Authentication challenge expired. Please try again.", err instanceof Error ? err : void 0);
|
|
570
|
+
if (msg.includes(Errors.VERIFICATION_FAILED))
|
|
571
|
+
return new IsitmeError("VERIFICATION_FAILED", "Passkey verification failed. Try again or re-register.", err instanceof Error ? err : void 0);
|
|
572
|
+
if (msg.includes("The operation either timed out") || msg.includes("NotAllowedError") || msg.includes("cancelled") || msg.includes("canceled") || msg.includes("AbortError"))
|
|
573
|
+
return new IsitmeError("PASSKEY_CANCELLED", "Passkey prompt was cancelled or timed out.", err instanceof Error ? err : void 0);
|
|
574
|
+
return new IsitmeError("UNKNOWN", `Login failed: ${msg}`, err instanceof Error ? err : void 0);
|
|
575
|
+
}
|
|
576
|
+
async function login(opts = {}) {
|
|
577
|
+
try {
|
|
578
|
+
if (opts.api) return await cloudLogin(opts.api);
|
|
579
|
+
const prefix = opts.prefix || DEFAULT_PREFIX;
|
|
580
|
+
const optUrl = `${prefix}/login/start`;
|
|
581
|
+
const optRes = await fetch(optUrl, { method: "POST", credentials: "include" });
|
|
582
|
+
if (!optRes.ok) {
|
|
583
|
+
const body = await optRes.json().catch(() => null);
|
|
584
|
+
throw new Error(body?.error || `Login options failed: ${optRes.status} from ${optUrl}`);
|
|
585
|
+
}
|
|
586
|
+
const { options, nonce } = await optRes.json();
|
|
587
|
+
const authentication = await startAuthentication({ optionsJSON: options });
|
|
588
|
+
const verifyUrl = `${prefix}/login/finish`;
|
|
589
|
+
const verifyRes = await fetch(verifyUrl, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
credentials: "include",
|
|
592
|
+
headers: { "Content-Type": "application/json" },
|
|
593
|
+
body: JSON.stringify({ nonce, response: authentication })
|
|
594
|
+
});
|
|
595
|
+
if (!verifyRes.ok) {
|
|
596
|
+
const data = await verifyRes.json().catch(() => null);
|
|
597
|
+
throw new Error(data?.error || `Login verify failed: ${verifyRes.status} from ${verifyUrl}`);
|
|
598
|
+
}
|
|
599
|
+
return verifyRes.json();
|
|
600
|
+
} catch (err) {
|
|
601
|
+
throw classifyLoginError(err);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function signin(opts = {}) {
|
|
605
|
+
let status;
|
|
606
|
+
try {
|
|
607
|
+
status = await getAuthStatus(opts);
|
|
608
|
+
} catch (err) {
|
|
609
|
+
throw new IsitmeError(
|
|
610
|
+
"NETWORK_ERROR",
|
|
611
|
+
"Could not check auth status. Make sure isitme middleware is running or the cloud API is reachable.",
|
|
612
|
+
err instanceof Error ? err : void 0
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (status.setupComplete) {
|
|
616
|
+
const result = await login(opts);
|
|
617
|
+
return { verified: result.verified, action: "login" };
|
|
618
|
+
} else {
|
|
619
|
+
const result = await register(opts);
|
|
620
|
+
return { verified: result.verified, action: "register", authData: result.authData };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function isItMe(opts = {}) {
|
|
624
|
+
if (opts.api) return cloudIsItMe();
|
|
625
|
+
const prefix = opts.prefix || DEFAULT_PREFIX;
|
|
626
|
+
const res = await fetch(`${prefix}/status`, { credentials: "include" });
|
|
627
|
+
if (!res.ok) return null;
|
|
628
|
+
const status = await res.json();
|
|
629
|
+
if (status.isAuthenticated) {
|
|
630
|
+
return { authenticated: true, registered: true };
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
async function logout(opts = {}) {
|
|
635
|
+
if (opts.api) {
|
|
636
|
+
clearCloudSession();
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const prefix = opts.prefix || DEFAULT_PREFIX;
|
|
640
|
+
const url = `${prefix}/logout`;
|
|
641
|
+
try {
|
|
642
|
+
const res = await fetch(url, { method: "POST", credentials: "include" });
|
|
643
|
+
if (!res.ok) {
|
|
644
|
+
throw new Error(`Logout failed: ${res.status} from ${url}`);
|
|
645
|
+
}
|
|
646
|
+
} catch (err) {
|
|
647
|
+
throw new IsitmeError(
|
|
648
|
+
"NETWORK_ERROR",
|
|
649
|
+
"Could not reach the auth server to log out.",
|
|
650
|
+
err instanceof Error ? err : void 0
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
396
654
|
export {
|
|
655
|
+
IsitmeError,
|
|
397
656
|
getAuthStatus,
|
|
657
|
+
isItMe,
|
|
398
658
|
login,
|
|
399
|
-
|
|
659
|
+
logout,
|
|
660
|
+
register,
|
|
661
|
+
signin
|
|
400
662
|
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Response, Request, Router } from 'express';
|
|
2
|
+
|
|
3
|
+
interface StoredCredential {
|
|
4
|
+
id: string;
|
|
5
|
+
publicKey: string;
|
|
6
|
+
counter: number;
|
|
7
|
+
transports?: AuthenticatorTransport[];
|
|
8
|
+
}
|
|
9
|
+
interface StoredUser {
|
|
10
|
+
id: string;
|
|
11
|
+
username: string;
|
|
12
|
+
}
|
|
13
|
+
interface StorageAdapter {
|
|
14
|
+
isSetupComplete(): boolean | Promise<boolean>;
|
|
15
|
+
getUser(): StoredUser | null | Promise<StoredUser | null>;
|
|
16
|
+
getCredentials(): StoredCredential[] | Promise<StoredCredential[]>;
|
|
17
|
+
getCredentialById(id: string): StoredCredential | undefined | Promise<StoredCredential | undefined>;
|
|
18
|
+
saveUserAndCredential(user: StoredUser, credential: StoredCredential): void | Promise<void>;
|
|
19
|
+
updateCredentialCounter(id: string, counter: number): void | Promise<void>;
|
|
20
|
+
getAuthDataJson(): string | Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
interface PageOptions {
|
|
23
|
+
title?: string;
|
|
24
|
+
heading?: string;
|
|
25
|
+
brandColor?: string;
|
|
26
|
+
logoUrl?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
brandName?: string;
|
|
29
|
+
}
|
|
30
|
+
interface IsitmeOptions {
|
|
31
|
+
/** JWT signing secret. If omitted, a random secret is generated (sessions won't survive restarts). */
|
|
32
|
+
sessionSecret?: string;
|
|
33
|
+
/** File path for JSON storage, `false` for in-memory, or a custom StorageAdapter. Default: `"./auth.json"` */
|
|
34
|
+
storage?: string | false | StorageAdapter;
|
|
35
|
+
/** Route prefix for auth endpoints. Default: `"/_isitme"` */
|
|
36
|
+
prefix?: string;
|
|
37
|
+
/** Session cookie name. Default: `"isitme_session"` */
|
|
38
|
+
cookieName?: string;
|
|
39
|
+
/** Session max age in seconds. Default: `86400` (1 day) */
|
|
40
|
+
sessionMaxAge?: number;
|
|
41
|
+
/** WebAuthn Relying Party ID. Default: auto from hostname */
|
|
42
|
+
rpId?: string;
|
|
43
|
+
/** WebAuthn RP display name. Default: `"isitme"` */
|
|
44
|
+
rpName?: string;
|
|
45
|
+
/** WebAuthn origin. Default: auto from request */
|
|
46
|
+
origin?: string;
|
|
47
|
+
/** Extra paths to skip auth for (in addition to auth routes and static files). */
|
|
48
|
+
publicPaths?: string[];
|
|
49
|
+
/** Login page config. Set to `false` to disable the built-in page. */
|
|
50
|
+
loginPage?: false | PageOptions;
|
|
51
|
+
/** Callback after first passkey registration. Receives the auth data JSON string. */
|
|
52
|
+
onRegister?: (authData: string) => void;
|
|
53
|
+
/** Set cookie Secure flag. Default: `true`. Express adapter passes `process.env.NODE_ENV === "production"`. */
|
|
54
|
+
secure?: boolean;
|
|
55
|
+
/** Skip auth entirely on localhost (127.0.0.1 / localhost). Only for development. Default: `false` */
|
|
56
|
+
bypassAuthOnLocalhost?: boolean;
|
|
57
|
+
}
|
|
58
|
+
interface RequestInfo {
|
|
59
|
+
hostname: string;
|
|
60
|
+
protocol: string;
|
|
61
|
+
host: string;
|
|
62
|
+
}
|
|
63
|
+
interface CookieConfig {
|
|
64
|
+
name: string;
|
|
65
|
+
maxAge: number;
|
|
66
|
+
httpOnly: boolean;
|
|
67
|
+
secure: boolean;
|
|
68
|
+
sameSite: "strict" | "lax" | "none";
|
|
69
|
+
path: string;
|
|
70
|
+
}
|
|
71
|
+
interface SetCookieAction {
|
|
72
|
+
name: string;
|
|
73
|
+
value: string;
|
|
74
|
+
options: CookieConfig;
|
|
75
|
+
}
|
|
76
|
+
interface ClearCookieAction {
|
|
77
|
+
name: string;
|
|
78
|
+
options: CookieConfig;
|
|
79
|
+
}
|
|
80
|
+
interface HandlerResult {
|
|
81
|
+
status: number;
|
|
82
|
+
body?: unknown;
|
|
83
|
+
html?: string;
|
|
84
|
+
redirect?: string;
|
|
85
|
+
setCookie?: SetCookieAction;
|
|
86
|
+
clearCookie?: ClearCookieAction;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
declare global {
|
|
90
|
+
namespace Express {
|
|
91
|
+
interface Request {
|
|
92
|
+
isAuthenticated?: boolean;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
declare function getRequestInfo(req: Request): RequestInfo;
|
|
97
|
+
declare function applyResult(res: Response, result: HandlerResult): void;
|
|
98
|
+
declare function isitme(options?: IsitmeOptions): Router;
|
|
99
|
+
|
|
100
|
+
export { applyResult, getRequestInfo, isitme };
|