nebula-notebook 0.2.45 → 0.2.46
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 +18 -3
- package/dist/assets/{errorwidget-BPSbuKot.js → errorwidget-DsdJi3CP.js} +1 -1
- package/dist/assets/{index-D4OyDYHa.js → index-7dzBRqjB.js} +1 -1
- package/dist/assets/{index-i6-VDHlg.js → index-BYLhtrxB.js} +1 -1
- package/dist/assets/{index-BaABO0Mr.js → index-CGUaqGiE.js} +1 -1
- package/dist/assets/index-D7GWSc_e.js +872 -0
- package/dist/assets/index-FTP7YTVP.css +32 -0
- package/dist/assets/{services-shim-CeNGM1z_.js → services-shim-B6rclDoU.js} +1 -1
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.d.ts +8 -1
- package/node-server/dist/auth/auth-middleware.js +8 -2
- package/node-server/dist/auth/auth-service.d.ts +22 -3
- package/node-server/dist/auth/auth-service.js +47 -22
- package/node-server/dist/auth/nebula-dir.d.ts +7 -0
- package/node-server/dist/auth/nebula-dir.js +47 -0
- package/node-server/dist/auth/passkeys.d.ts +133 -0
- package/node-server/dist/auth/passkeys.js +374 -0
- package/node-server/dist/index.js +7 -10
- package/node-server/dist/routes/auth.d.ts +12 -1
- package/node-server/dist/routes/auth.js +131 -3
- package/node-server/package.json +1 -0
- package/package.json +3 -1
- package/dist/assets/index-BnQLGXqd.js +0 -857
- package/dist/assets/index-e_C_qM9S.css +0 -32
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveNebulaDir = resolveNebulaDir;
|
|
37
|
+
const os = __importStar(require("os"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
/**
|
|
40
|
+
* Directory holding Nebula's auth material (auth.json, passkeys.json).
|
|
41
|
+
* `NEBULA_AUTH_DIR` relocates it — a test seam and a way to run a throwaway
|
|
42
|
+
* server without touching the real `~/.nebula`. Resolved at call time so a
|
|
43
|
+
* process that sets the variable before first use is honored.
|
|
44
|
+
*/
|
|
45
|
+
function resolveNebulaDir() {
|
|
46
|
+
return process.env.NEBULA_AUTH_DIR || path.join(os.homedir(), '.nebula');
|
|
47
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkeys (WebAuthn) — biometric / security-key login that AUGMENTS TOTP.
|
|
3
|
+
*
|
|
4
|
+
* A verified assertion mints the same session JWT the TOTP path mints; TOTP
|
|
5
|
+
* stays as the fallback and as the way to authenticate before enrolling the
|
|
6
|
+
* first passkey. Credentials are stored per rpID (per hostname): a passkey
|
|
7
|
+
* enrolled at `localhost` (the ssh-tunnel case) works wherever the browser
|
|
8
|
+
* reaches Nebula as `localhost`, one enrolled at a tunnel domain works there.
|
|
9
|
+
*
|
|
10
|
+
* Storage: `<nebula dir>/passkeys.json`, mode 0600, next to auth.json.
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingHttpHeaders } from 'http';
|
|
13
|
+
import type { AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
|
|
14
|
+
export interface PasskeyCredential {
|
|
15
|
+
/** Credential ID (base64url) — also what the authenticator presents at login. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** COSE public key, base64url. Never leaves the server. */
|
|
18
|
+
publicKey: string;
|
|
19
|
+
counter: number;
|
|
20
|
+
transports: AuthenticatorTransportFuture[];
|
|
21
|
+
/** Hostname the credential was enrolled at; a passkey is only offered there. */
|
|
22
|
+
rpID: string;
|
|
23
|
+
label: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
lastUsedAt: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface PasskeyStore {
|
|
28
|
+
/** Stable opaque user handle (base64url) shared by every credential. */
|
|
29
|
+
userId: string | null;
|
|
30
|
+
credentials: PasskeyCredential[];
|
|
31
|
+
}
|
|
32
|
+
/** What clients may see: everything except the public key. */
|
|
33
|
+
export interface PublicPasskey {
|
|
34
|
+
id: string;
|
|
35
|
+
rpID: string;
|
|
36
|
+
label: string;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
lastUsedAt: string | null;
|
|
39
|
+
}
|
|
40
|
+
export declare const PASSKEYS_FILENAME = "passkeys.json";
|
|
41
|
+
export declare const MAX_LABEL_LENGTH = 60;
|
|
42
|
+
export declare function passkeysFilePath(): string;
|
|
43
|
+
export declare function toPublicPasskey(c: PasskeyCredential): PublicPasskey;
|
|
44
|
+
/** Load the store; a missing or unreadable file is an empty store. */
|
|
45
|
+
export declare function loadPasskeyStore(): PasskeyStore;
|
|
46
|
+
/**
|
|
47
|
+
* Persist the store, private to the user (dir 0700, file 0600) and atomically
|
|
48
|
+
* (tmp + rename) so a failed write never leaves a truncated credential list.
|
|
49
|
+
*/
|
|
50
|
+
export declare function savePasskeyStore(store: PasskeyStore): void;
|
|
51
|
+
export type ChallengeType = 'login' | 'register';
|
|
52
|
+
export declare const CHALLENGE_TTL_MS = 120000;
|
|
53
|
+
/**
|
|
54
|
+
* Outstanding WebAuthn challenges: in-memory, single-use, 120 s TTL, typed
|
|
55
|
+
* (a login challenge cannot complete a registration) and bound to the rpID
|
|
56
|
+
* they were issued for. The opaque token handed to the client is the map key.
|
|
57
|
+
*/
|
|
58
|
+
export declare class PasskeyChallenges {
|
|
59
|
+
private readonly now;
|
|
60
|
+
private readonly ttlMs;
|
|
61
|
+
private readonly entries;
|
|
62
|
+
constructor(now?: () => number, ttlMs?: number);
|
|
63
|
+
put(type: ChallengeType, rpID: string, challenge: string): string;
|
|
64
|
+
/** Consume a challenge. Returns null (and burns the token) on any mismatch. */
|
|
65
|
+
take(token: unknown, type: ChallengeType, rpID: string): string | null;
|
|
66
|
+
get size(): number;
|
|
67
|
+
private sweep;
|
|
68
|
+
}
|
|
69
|
+
export interface RpInfo {
|
|
70
|
+
/** Hostname only (no port) — what the browser scopes the credential to. */
|
|
71
|
+
rpID: string;
|
|
72
|
+
/** `${proto}://${host[:port]}` as the browser will report it in clientDataJSON. */
|
|
73
|
+
origin: string;
|
|
74
|
+
}
|
|
75
|
+
export declare class PasskeyRpError extends Error {
|
|
76
|
+
readonly code = "invalid_rp_id";
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Derive rpID and origin from the request's own Host (or X-Forwarded-Host).
|
|
80
|
+
* The rpID is the hostname with the port stripped; the origin keeps the port.
|
|
81
|
+
* Protocol honors X-Forwarded-Proto and otherwise defaults to https — except
|
|
82
|
+
* for localhost, which browsers treat as a secure context over plain http and
|
|
83
|
+
* which is the common case (ssh tunnel to localhost:3000).
|
|
84
|
+
*
|
|
85
|
+
* WebAuthn forbids IP-literal rpIDs, so `127.0.0.1` is rejected with advice
|
|
86
|
+
* to use `http://localhost:PORT` instead.
|
|
87
|
+
*/
|
|
88
|
+
export declare function deriveRpInfo(headers: IncomingHttpHeaders): RpInfo;
|
|
89
|
+
export type OptionsResult<T> = {
|
|
90
|
+
ok: true;
|
|
91
|
+
token: string;
|
|
92
|
+
options: T;
|
|
93
|
+
} | {
|
|
94
|
+
ok: false;
|
|
95
|
+
error: string;
|
|
96
|
+
};
|
|
97
|
+
export type LoginResult = {
|
|
98
|
+
ok: true;
|
|
99
|
+
credential: PasskeyCredential;
|
|
100
|
+
} | {
|
|
101
|
+
ok: false;
|
|
102
|
+
error: string;
|
|
103
|
+
};
|
|
104
|
+
export type RegisterResult = {
|
|
105
|
+
ok: true;
|
|
106
|
+
passkey: PublicPasskey;
|
|
107
|
+
} | {
|
|
108
|
+
ok: false;
|
|
109
|
+
error: string;
|
|
110
|
+
};
|
|
111
|
+
export declare class PasskeyService {
|
|
112
|
+
readonly challenges: PasskeyChallenges;
|
|
113
|
+
constructor(challenges?: PasskeyChallenges);
|
|
114
|
+
/** Credentials enrolled at this rpID. */
|
|
115
|
+
credentialsFor(rpID: string, store?: PasskeyStore): PasskeyCredential[];
|
|
116
|
+
/** Unauthenticated: a login challenge, or `ok:false` when nothing is enrolled here. */
|
|
117
|
+
loginOptions(rp: RpInfo): Promise<OptionsResult<PublicKeyCredentialRequestOptionsJSON>>;
|
|
118
|
+
/**
|
|
119
|
+
* Unauthenticated: verify an assertion against a stored credential. On
|
|
120
|
+
* success the counter and lastUsedAt are persisted. The caller mints the
|
|
121
|
+
* session and applies rate limiting.
|
|
122
|
+
*/
|
|
123
|
+
login(rp: RpInfo, body: unknown): Promise<LoginResult>;
|
|
124
|
+
/** Authenticated: a registration challenge for this rpID. */
|
|
125
|
+
registerOptions(rp: RpInfo): Promise<OptionsResult<PublicKeyCredentialCreationOptionsJSON>>;
|
|
126
|
+
/** Authenticated: verify an attestation and store the new credential. */
|
|
127
|
+
register(rp: RpInfo, body: unknown): Promise<RegisterResult>;
|
|
128
|
+
/** Authenticated: every enrolled passkey, public fields only. */
|
|
129
|
+
list(): PublicPasskey[];
|
|
130
|
+
/** Authenticated: remove a credential by id. Returns whether one was removed. */
|
|
131
|
+
delete(id: string): boolean;
|
|
132
|
+
}
|
|
133
|
+
export declare const passkeyService: PasskeyService;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Passkeys (WebAuthn) — biometric / security-key login that AUGMENTS TOTP.
|
|
4
|
+
*
|
|
5
|
+
* A verified assertion mints the same session JWT the TOTP path mints; TOTP
|
|
6
|
+
* stays as the fallback and as the way to authenticate before enrolling the
|
|
7
|
+
* first passkey. Credentials are stored per rpID (per hostname): a passkey
|
|
8
|
+
* enrolled at `localhost` (the ssh-tunnel case) works wherever the browser
|
|
9
|
+
* reaches Nebula as `localhost`, one enrolled at a tunnel domain works there.
|
|
10
|
+
*
|
|
11
|
+
* Storage: `<nebula dir>/passkeys.json`, mode 0600, next to auth.json.
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.passkeyService = exports.PasskeyService = exports.PasskeyRpError = exports.PasskeyChallenges = exports.CHALLENGE_TTL_MS = exports.MAX_LABEL_LENGTH = exports.PASSKEYS_FILENAME = void 0;
|
|
48
|
+
exports.passkeysFilePath = passkeysFilePath;
|
|
49
|
+
exports.toPublicPasskey = toPublicPasskey;
|
|
50
|
+
exports.loadPasskeyStore = loadPasskeyStore;
|
|
51
|
+
exports.savePasskeyStore = savePasskeyStore;
|
|
52
|
+
exports.deriveRpInfo = deriveRpInfo;
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
const net = __importStar(require("net"));
|
|
56
|
+
const crypto_1 = require("crypto");
|
|
57
|
+
const server_1 = require("@simplewebauthn/server");
|
|
58
|
+
const nebula_dir_1 = require("./nebula-dir");
|
|
59
|
+
exports.PASSKEYS_FILENAME = 'passkeys.json';
|
|
60
|
+
exports.MAX_LABEL_LENGTH = 60;
|
|
61
|
+
function passkeysFilePath() {
|
|
62
|
+
return path.join((0, nebula_dir_1.resolveNebulaDir)(), exports.PASSKEYS_FILENAME);
|
|
63
|
+
}
|
|
64
|
+
function toPublicPasskey(c) {
|
|
65
|
+
return { id: c.id, rpID: c.rpID, label: c.label, createdAt: c.createdAt, lastUsedAt: c.lastUsedAt ?? null };
|
|
66
|
+
}
|
|
67
|
+
function sanitizeCredential(raw) {
|
|
68
|
+
if (!raw || typeof raw !== 'object')
|
|
69
|
+
return null;
|
|
70
|
+
const c = raw;
|
|
71
|
+
if (typeof c.id !== 'string' || typeof c.publicKey !== 'string' || typeof c.rpID !== 'string')
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
id: c.id,
|
|
75
|
+
publicKey: c.publicKey,
|
|
76
|
+
counter: Number.isFinite(Number(c.counter)) ? Number(c.counter) : 0,
|
|
77
|
+
transports: Array.isArray(c.transports) ? c.transports.filter((t) => typeof t === 'string') : [],
|
|
78
|
+
rpID: c.rpID,
|
|
79
|
+
label: typeof c.label === 'string' ? c.label : `passkey · ${c.rpID}`,
|
|
80
|
+
createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date(0).toISOString(),
|
|
81
|
+
lastUsedAt: typeof c.lastUsedAt === 'string' ? c.lastUsedAt : null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Load the store; a missing or unreadable file is an empty store. */
|
|
85
|
+
function loadPasskeyStore() {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(passkeysFilePath(), 'utf8'));
|
|
88
|
+
const credentials = Array.isArray(parsed?.credentials)
|
|
89
|
+
? parsed.credentials.map(sanitizeCredential).filter(Boolean)
|
|
90
|
+
: [];
|
|
91
|
+
return { userId: typeof parsed?.userId === 'string' ? parsed.userId : null, credentials };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { userId: null, credentials: [] };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Persist the store, private to the user (dir 0700, file 0600) and atomically
|
|
99
|
+
* (tmp + rename) so a failed write never leaves a truncated credential list.
|
|
100
|
+
*/
|
|
101
|
+
function savePasskeyStore(store) {
|
|
102
|
+
const file = passkeysFilePath();
|
|
103
|
+
const dir = path.dirname(file);
|
|
104
|
+
if (!fs.existsSync(dir))
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
106
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
109
|
+
fs.chmodSync(tmp, 0o600); // mode above is subject to umask; be explicit
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
try {
|
|
114
|
+
fs.unlinkSync(tmp);
|
|
115
|
+
}
|
|
116
|
+
catch { /* nothing partial to clean */ }
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.CHALLENGE_TTL_MS = 120_000;
|
|
121
|
+
/**
|
|
122
|
+
* Outstanding WebAuthn challenges: in-memory, single-use, 120 s TTL, typed
|
|
123
|
+
* (a login challenge cannot complete a registration) and bound to the rpID
|
|
124
|
+
* they were issued for. The opaque token handed to the client is the map key.
|
|
125
|
+
*/
|
|
126
|
+
class PasskeyChallenges {
|
|
127
|
+
now;
|
|
128
|
+
ttlMs;
|
|
129
|
+
entries = new Map();
|
|
130
|
+
constructor(now = Date.now, ttlMs = exports.CHALLENGE_TTL_MS) {
|
|
131
|
+
this.now = now;
|
|
132
|
+
this.ttlMs = ttlMs;
|
|
133
|
+
}
|
|
134
|
+
put(type, rpID, challenge) {
|
|
135
|
+
this.sweep();
|
|
136
|
+
const token = (0, crypto_1.randomBytes)(16).toString('hex');
|
|
137
|
+
this.entries.set(token, { challenge, type, rpID, expiresAt: this.now() + this.ttlMs });
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
/** Consume a challenge. Returns null (and burns the token) on any mismatch. */
|
|
141
|
+
take(token, type, rpID) {
|
|
142
|
+
const key = typeof token === 'string' ? token : '';
|
|
143
|
+
const entry = this.entries.get(key);
|
|
144
|
+
this.entries.delete(key);
|
|
145
|
+
if (!entry || entry.type !== type || entry.rpID !== rpID || entry.expiresAt < this.now())
|
|
146
|
+
return null;
|
|
147
|
+
return entry.challenge;
|
|
148
|
+
}
|
|
149
|
+
get size() {
|
|
150
|
+
this.sweep();
|
|
151
|
+
return this.entries.size;
|
|
152
|
+
}
|
|
153
|
+
sweep() {
|
|
154
|
+
const now = this.now();
|
|
155
|
+
for (const [k, v] of this.entries) {
|
|
156
|
+
if (v.expiresAt < now)
|
|
157
|
+
this.entries.delete(k);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
exports.PasskeyChallenges = PasskeyChallenges;
|
|
162
|
+
class PasskeyRpError extends Error {
|
|
163
|
+
code = 'invalid_rp_id';
|
|
164
|
+
}
|
|
165
|
+
exports.PasskeyRpError = PasskeyRpError;
|
|
166
|
+
function firstHeader(value) {
|
|
167
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
168
|
+
return String(raw ?? '').split(',')[0].trim();
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Derive rpID and origin from the request's own Host (or X-Forwarded-Host).
|
|
172
|
+
* The rpID is the hostname with the port stripped; the origin keeps the port.
|
|
173
|
+
* Protocol honors X-Forwarded-Proto and otherwise defaults to https — except
|
|
174
|
+
* for localhost, which browsers treat as a secure context over plain http and
|
|
175
|
+
* which is the common case (ssh tunnel to localhost:3000).
|
|
176
|
+
*
|
|
177
|
+
* WebAuthn forbids IP-literal rpIDs, so `127.0.0.1` is rejected with advice
|
|
178
|
+
* to use `http://localhost:PORT` instead.
|
|
179
|
+
*/
|
|
180
|
+
function deriveRpInfo(headers) {
|
|
181
|
+
const hostHeader = firstHeader(headers['x-forwarded-host']) || firstHeader(headers.host);
|
|
182
|
+
let hostname = hostHeader;
|
|
183
|
+
let port = '';
|
|
184
|
+
if (hostHeader.startsWith('[')) {
|
|
185
|
+
// IPv6 literal, e.g. [::1]:3000 — rejected below, parsed only for the message.
|
|
186
|
+
const end = hostHeader.indexOf(']');
|
|
187
|
+
hostname = end > 0 ? hostHeader.slice(1, end) : hostHeader;
|
|
188
|
+
port = end > 0 ? hostHeader.slice(end + 1).replace(/^:/, '') : '';
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const m = /^([^:]*)(?::(\d+))?$/.exec(hostHeader);
|
|
192
|
+
if (m) {
|
|
193
|
+
hostname = m[1];
|
|
194
|
+
port = m[2] ?? '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
hostname = hostname.toLowerCase() || 'localhost';
|
|
198
|
+
if (net.isIP(hostname)) {
|
|
199
|
+
const suggested = `http://localhost${port ? `:${port}` : ''}`;
|
|
200
|
+
throw new PasskeyRpError(`Passkeys cannot be used at an IP address (${hostname}) — WebAuthn requires a hostname. ` +
|
|
201
|
+
`Open ${suggested} instead of ${hostname}.`);
|
|
202
|
+
}
|
|
203
|
+
const isLocal = hostname === 'localhost' || hostname.endsWith('.localhost');
|
|
204
|
+
const forwardedProto = firstHeader(headers['x-forwarded-proto']).toLowerCase();
|
|
205
|
+
const proto = forwardedProto === 'http' || forwardedProto === 'https' ? forwardedProto : (isLocal ? 'http' : 'https');
|
|
206
|
+
const defaultPort = proto === 'https' ? '443' : '80';
|
|
207
|
+
const originHost = port && port !== defaultPort ? `${hostname}:${port}` : hostname;
|
|
208
|
+
return { rpID: hostname, origin: `${proto}://${originHost}` };
|
|
209
|
+
}
|
|
210
|
+
const RP_NAME = 'Nebula Notebook';
|
|
211
|
+
const USER_NAME = 'nebula';
|
|
212
|
+
function asObject(body) {
|
|
213
|
+
return body && typeof body === 'object' ? body : {};
|
|
214
|
+
}
|
|
215
|
+
class PasskeyService {
|
|
216
|
+
challenges;
|
|
217
|
+
constructor(challenges = new PasskeyChallenges()) {
|
|
218
|
+
this.challenges = challenges;
|
|
219
|
+
}
|
|
220
|
+
/** Credentials enrolled at this rpID. */
|
|
221
|
+
credentialsFor(rpID, store = loadPasskeyStore()) {
|
|
222
|
+
return store.credentials.filter((c) => c.rpID === rpID);
|
|
223
|
+
}
|
|
224
|
+
/** Unauthenticated: a login challenge, or `ok:false` when nothing is enrolled here. */
|
|
225
|
+
async loginOptions(rp) {
|
|
226
|
+
const creds = this.credentialsFor(rp.rpID);
|
|
227
|
+
if (creds.length === 0) {
|
|
228
|
+
return { ok: false, error: `No passkeys enrolled for ${rp.rpID}` };
|
|
229
|
+
}
|
|
230
|
+
const options = await (0, server_1.generateAuthenticationOptions)({
|
|
231
|
+
rpID: rp.rpID,
|
|
232
|
+
userVerification: 'preferred',
|
|
233
|
+
allowCredentials: creds.map((c) => ({
|
|
234
|
+
id: c.id,
|
|
235
|
+
transports: c.transports.length ? c.transports : undefined,
|
|
236
|
+
})),
|
|
237
|
+
});
|
|
238
|
+
return { ok: true, token: this.challenges.put('login', rp.rpID, options.challenge), options };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Unauthenticated: verify an assertion against a stored credential. On
|
|
242
|
+
* success the counter and lastUsedAt are persisted. The caller mints the
|
|
243
|
+
* session and applies rate limiting.
|
|
244
|
+
*/
|
|
245
|
+
async login(rp, body) {
|
|
246
|
+
const { token, response } = asObject(body);
|
|
247
|
+
const challenge = this.challenges.take(token, 'login', rp.rpID);
|
|
248
|
+
const assertion = response;
|
|
249
|
+
const store = loadPasskeyStore();
|
|
250
|
+
const cred = assertion?.id
|
|
251
|
+
? store.credentials.find((c) => c.id === assertion.id && c.rpID === rp.rpID)
|
|
252
|
+
: undefined;
|
|
253
|
+
if (!challenge || !cred) {
|
|
254
|
+
return { ok: false, error: 'Passkey not recognized' };
|
|
255
|
+
}
|
|
256
|
+
let verified = false;
|
|
257
|
+
let newCounter = cred.counter;
|
|
258
|
+
try {
|
|
259
|
+
const verification = await (0, server_1.verifyAuthenticationResponse)({
|
|
260
|
+
response: assertion,
|
|
261
|
+
expectedChallenge: challenge,
|
|
262
|
+
expectedOrigin: rp.origin,
|
|
263
|
+
expectedRPID: rp.rpID,
|
|
264
|
+
credential: {
|
|
265
|
+
id: cred.id,
|
|
266
|
+
publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
|
|
267
|
+
counter: cred.counter,
|
|
268
|
+
transports: cred.transports,
|
|
269
|
+
},
|
|
270
|
+
requireUserVerification: false,
|
|
271
|
+
});
|
|
272
|
+
verified = verification.verified;
|
|
273
|
+
newCounter = verification.authenticationInfo?.newCounter ?? cred.counter;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
verified = false;
|
|
277
|
+
}
|
|
278
|
+
if (!verified) {
|
|
279
|
+
return { ok: false, error: 'Passkey verification failed' };
|
|
280
|
+
}
|
|
281
|
+
cred.counter = newCounter;
|
|
282
|
+
cred.lastUsedAt = new Date().toISOString();
|
|
283
|
+
savePasskeyStore(store);
|
|
284
|
+
return { ok: true, credential: cred };
|
|
285
|
+
}
|
|
286
|
+
/** Authenticated: a registration challenge for this rpID. */
|
|
287
|
+
async registerOptions(rp) {
|
|
288
|
+
const store = loadPasskeyStore();
|
|
289
|
+
if (!store.userId) {
|
|
290
|
+
store.userId = (0, crypto_1.randomBytes)(16).toString('base64url');
|
|
291
|
+
savePasskeyStore(store); // the user handle must survive restarts
|
|
292
|
+
}
|
|
293
|
+
const options = await (0, server_1.generateRegistrationOptions)({
|
|
294
|
+
rpName: RP_NAME,
|
|
295
|
+
rpID: rp.rpID,
|
|
296
|
+
userID: new Uint8Array(Buffer.from(store.userId, 'base64url')),
|
|
297
|
+
userName: USER_NAME,
|
|
298
|
+
userDisplayName: `Nebula @ ${rp.rpID}`,
|
|
299
|
+
attestationType: 'none',
|
|
300
|
+
excludeCredentials: this.credentialsFor(rp.rpID, store).map((c) => ({
|
|
301
|
+
id: c.id,
|
|
302
|
+
transports: c.transports.length ? c.transports : undefined,
|
|
303
|
+
})),
|
|
304
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
|
|
305
|
+
});
|
|
306
|
+
return { ok: true, token: this.challenges.put('register', rp.rpID, options.challenge), options };
|
|
307
|
+
}
|
|
308
|
+
/** Authenticated: verify an attestation and store the new credential. */
|
|
309
|
+
async register(rp, body) {
|
|
310
|
+
const { token, response, label } = asObject(body);
|
|
311
|
+
const challenge = this.challenges.take(token, 'register', rp.rpID);
|
|
312
|
+
if (!challenge) {
|
|
313
|
+
return { ok: false, error: 'Challenge expired — try again' };
|
|
314
|
+
}
|
|
315
|
+
const attestation = response;
|
|
316
|
+
if (!attestation?.id) {
|
|
317
|
+
return { ok: false, error: 'Missing attestation response' };
|
|
318
|
+
}
|
|
319
|
+
let registered;
|
|
320
|
+
try {
|
|
321
|
+
const verification = await (0, server_1.verifyRegistrationResponse)({
|
|
322
|
+
response: attestation,
|
|
323
|
+
expectedChallenge: challenge,
|
|
324
|
+
expectedOrigin: rp.origin,
|
|
325
|
+
expectedRPID: rp.rpID,
|
|
326
|
+
requireUserVerification: false,
|
|
327
|
+
});
|
|
328
|
+
registered = verification.verified ? verification.registrationInfo?.credential : undefined;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
registered = undefined;
|
|
332
|
+
}
|
|
333
|
+
if (!registered) {
|
|
334
|
+
return { ok: false, error: 'Attestation verification failed' };
|
|
335
|
+
}
|
|
336
|
+
const store = loadPasskeyStore();
|
|
337
|
+
const existing = store.credentials.find((c) => c.id === registered.id);
|
|
338
|
+
if (existing) {
|
|
339
|
+
return { ok: true, passkey: toPublicPasskey(existing) };
|
|
340
|
+
}
|
|
341
|
+
const cleanLabel = typeof label === 'string' ? label.trim().slice(0, exports.MAX_LABEL_LENGTH) : '';
|
|
342
|
+
const cred = {
|
|
343
|
+
id: registered.id,
|
|
344
|
+
publicKey: Buffer.from(registered.publicKey).toString('base64url'),
|
|
345
|
+
counter: Number(registered.counter) || 0,
|
|
346
|
+
transports: registered.transports ?? attestation.response?.transports ?? [],
|
|
347
|
+
rpID: rp.rpID,
|
|
348
|
+
label: cleanLabel || `passkey · ${rp.rpID}`,
|
|
349
|
+
createdAt: new Date().toISOString(),
|
|
350
|
+
lastUsedAt: null,
|
|
351
|
+
};
|
|
352
|
+
store.credentials.push(cred);
|
|
353
|
+
savePasskeyStore(store);
|
|
354
|
+
console.log(`[Auth] Passkey enrolled for ${rp.rpID} (${store.credentials.length} total)`);
|
|
355
|
+
return { ok: true, passkey: toPublicPasskey(cred) };
|
|
356
|
+
}
|
|
357
|
+
/** Authenticated: every enrolled passkey, public fields only. */
|
|
358
|
+
list() {
|
|
359
|
+
return loadPasskeyStore().credentials.map(toPublicPasskey);
|
|
360
|
+
}
|
|
361
|
+
/** Authenticated: remove a credential by id. Returns whether one was removed. */
|
|
362
|
+
delete(id) {
|
|
363
|
+
const store = loadPasskeyStore();
|
|
364
|
+
const before = store.credentials.length;
|
|
365
|
+
store.credentials = store.credentials.filter((c) => c.id !== id);
|
|
366
|
+
if (store.credentials.length === before)
|
|
367
|
+
return false;
|
|
368
|
+
savePasskeyStore(store);
|
|
369
|
+
console.log(`[Auth] Passkey removed (${store.credentials.length} remaining)`);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
exports.PasskeyService = PasskeyService;
|
|
374
|
+
exports.passkeyService = new PasskeyService();
|
|
@@ -81,6 +81,7 @@ const mock_scheduler_1 = require("./scheduler/mock-scheduler");
|
|
|
81
81
|
const arch_1 = require("./scheduler/arch");
|
|
82
82
|
// Import auth
|
|
83
83
|
const auth_2 = require("./auth");
|
|
84
|
+
const auth_middleware_1 = require("./auth/auth-middleware");
|
|
84
85
|
const fs_service_1 = require("./fs/fs-service");
|
|
85
86
|
const update_check_1 = require("./update-check");
|
|
86
87
|
const environment_1 = require("./environment");
|
|
@@ -357,20 +358,16 @@ async function createApp() {
|
|
|
357
358
|
}
|
|
358
359
|
return reply.send({ status: 'ready' });
|
|
359
360
|
});
|
|
360
|
-
// Auth routes
|
|
361
|
+
// Auth routes. Login endpoints are public; passkey management
|
|
362
|
+
// (enroll/list/delete) lives under the same prefix but needs a session.
|
|
361
363
|
await fastify.register(auth_1.default, { prefix: '/api' });
|
|
362
364
|
// Auth middleware - protect all other API routes
|
|
363
|
-
// Applied as an onRequest hook for /api/* routes (excluding public ones)
|
|
365
|
+
// Applied as an onRequest hook for /api/* routes (excluding public ones).
|
|
366
|
+
// The public list (health, ready, TOTP + passkey login) and the non-API
|
|
367
|
+
// static paths are decided in one place: isPublicRoute().
|
|
364
368
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
365
369
|
const pathname = request.url.split('?')[0];
|
|
366
|
-
|
|
367
|
-
if (pathname === '/api/health' ||
|
|
368
|
-
pathname === '/api/ready' ||
|
|
369
|
-
pathname.startsWith('/api/auth/')) {
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
// Skip non-API routes (static files etc)
|
|
373
|
-
if (!pathname.startsWith('/api/')) {
|
|
370
|
+
if ((0, auth_middleware_1.isPublicRoute)(pathname)) {
|
|
374
371
|
return;
|
|
375
372
|
}
|
|
376
373
|
// Apply auth middleware
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth Routes - API endpoints for 2FA
|
|
2
|
+
* Auth Routes - API endpoints for 2FA (TOTP) and passkey (WebAuthn) login
|
|
3
|
+
*
|
|
4
|
+
* Public (no session required): /auth/status, /auth/verify,
|
|
5
|
+
* /auth/passkeys/login-options, /auth/passkeys/login.
|
|
6
|
+
* Everything else under /auth/passkeys requires a valid session — the auth
|
|
7
|
+
* middleware enforces that (see PUBLIC_ROUTES in auth-middleware.ts).
|
|
3
8
|
*/
|
|
4
9
|
import { FastifyInstance } from 'fastify';
|
|
10
|
+
/**
|
|
11
|
+
* Session length flag. Absent → long (30-day) session; only an explicit
|
|
12
|
+
* `false` (or "false"/0) asks for the 24 h one. Accepts both the historical
|
|
13
|
+
* `trustBrowser` key the UI sends and the shorter `trusted`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseTrusted(body: unknown): boolean;
|
|
5
16
|
export default function authRoutes(fastify: FastifyInstance): Promise<void>;
|