nebula-notebook 0.2.45 → 0.2.47

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.
@@ -1,11 +1,43 @@
1
1
  "use strict";
2
2
  /**
3
- * Auth Routes - API endpoints for 2FA authentication
3
+ * Auth Routes - API endpoints for 2FA (TOTP) and passkey (WebAuthn) login
4
+ *
5
+ * Public (no session required): /auth/status, /auth/verify,
6
+ * /auth/passkeys/login-options, /auth/passkeys/login.
7
+ * Everything else under /auth/passkeys requires a valid session — the auth
8
+ * middleware enforces that (see PUBLIC_ROUTES in auth-middleware.ts).
4
9
  */
5
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.parseTrusted = parseTrusted;
6
12
  exports.default = authRoutes;
7
13
  const auth_service_1 = require("../auth/auth-service");
8
14
  const auth_middleware_1 = require("../auth/auth-middleware");
15
+ const passkeys_1 = require("../auth/passkeys");
16
+ /**
17
+ * Session length flag. Absent → long (30-day) session; only an explicit
18
+ * `false` (or "false"/0) asks for the 24 h one. Accepts both the historical
19
+ * `trustBrowser` key the UI sends and the shorter `trusted`.
20
+ */
21
+ function parseTrusted(body) {
22
+ const b = body && typeof body === 'object' ? body : {};
23
+ const flag = b.trusted !== undefined ? b.trusted : b.trustBrowser;
24
+ if (flag === undefined || flag === null)
25
+ return true;
26
+ return !(flag === false || flag === 'false' || flag === 0 || flag === '0');
27
+ }
28
+ /** rpID/origin for this request, or a 400 reply (IP-literal host). */
29
+ function rpInfoOrReply(request, reply) {
30
+ try {
31
+ return (0, passkeys_1.deriveRpInfo)(request.headers);
32
+ }
33
+ catch (err) {
34
+ if (err instanceof passkeys_1.PasskeyRpError) {
35
+ reply.code(400).send({ ok: false, error: err.message, code: err.code });
36
+ return null;
37
+ }
38
+ throw err;
39
+ }
40
+ }
9
41
  async function authRoutes(fastify) {
10
42
  /**
11
43
  * GET /auth/status
@@ -26,7 +58,7 @@ async function authRoutes(fastify) {
26
58
  * Verify a TOTP code and issue a session token
27
59
  */
28
60
  fastify.post('/auth/verify', async (request, reply) => {
29
- const { code, trustBrowser } = request.body;
61
+ const { code } = request.body ?? {};
30
62
  if (!code || typeof code !== 'string') {
31
63
  return reply.code(400).send({
32
64
  error: 'invalid_request',
@@ -41,7 +73,7 @@ async function authRoutes(fastify) {
41
73
  message: 'Code must be 6 digits',
42
74
  });
43
75
  }
44
- const result = auth_service_1.authService.verifyCode(cleanCode, !!trustBrowser);
76
+ const result = auth_service_1.authService.verifyCode(cleanCode, parseTrusted(request.body));
45
77
  if (result.success) {
46
78
  // Persist token so MCP servers and CLI tools can auto-authenticate
47
79
  if (result.token)
@@ -58,4 +90,100 @@ async function authRoutes(fastify) {
58
90
  });
59
91
  }
60
92
  });
93
+ // ── Passkeys (WebAuthn) ───────────────────────────────────────────────────
94
+ /**
95
+ * POST /auth/passkeys/login-options (public)
96
+ * A login challenge for the passkeys enrolled at this hostname, or
97
+ * `{ ok:false, error }` when there are none (the UI shows a hint).
98
+ */
99
+ fastify.post('/auth/passkeys/login-options', async (request, reply) => {
100
+ const rp = rpInfoOrReply(request, reply);
101
+ if (!rp)
102
+ return;
103
+ return reply.send(await passkeys_1.passkeyService.loginOptions(rp));
104
+ });
105
+ /**
106
+ * POST /auth/passkeys/login (public, rate-limited like TOTP)
107
+ * Verify the assertion and issue the SAME session token TOTP issues, in the
108
+ * same response shape, so the client's login path is shared. Passkey login
109
+ * always gets the long (30-day) session — the device already proved itself.
110
+ */
111
+ fastify.post('/auth/passkeys/login', async (request, reply) => {
112
+ if (auth_service_1.authService.isAuthDisabled()) {
113
+ return reply.code(400).send({ success: false, error: 'Authentication is disabled on this server' });
114
+ }
115
+ const rp = rpInfoOrReply(request, reply);
116
+ if (!rp)
117
+ return;
118
+ const limit = auth_service_1.authService.checkRateLimit();
119
+ if (!limit.allowed) {
120
+ return reply.code(429).send({ success: false, error: `Too many attempts. Try again in ${limit.waitSeconds}s` });
121
+ }
122
+ const result = await passkeys_1.passkeyService.login(rp, request.body);
123
+ // `=== false` (not `!`): the root tsconfig has no strictNullChecks, where
124
+ // truthiness does not narrow a boolean discriminant.
125
+ if (result.ok === false) {
126
+ auth_service_1.authService.recordFailedAttempt();
127
+ return reply.code(401).send({ success: false, error: result.error });
128
+ }
129
+ auth_service_1.authService.clearFailedAttempts();
130
+ const token = auth_service_1.authService.issueToken(true);
131
+ (0, auth_middleware_1.persistSessionToken)(token);
132
+ return reply.send({ success: true, token });
133
+ });
134
+ /**
135
+ * POST /auth/passkeys/register-options (authenticated)
136
+ */
137
+ fastify.post('/auth/passkeys/register-options', async (request, reply) => {
138
+ const rp = rpInfoOrReply(request, reply);
139
+ if (!rp)
140
+ return;
141
+ return reply.send(await passkeys_1.passkeyService.registerOptions(rp));
142
+ });
143
+ /**
144
+ * POST /auth/passkeys/register (authenticated)
145
+ * Body: { token, response, label? }
146
+ */
147
+ fastify.post('/auth/passkeys/register', async (request, reply) => {
148
+ const rp = rpInfoOrReply(request, reply);
149
+ if (!rp)
150
+ return;
151
+ const result = await passkeys_1.passkeyService.register(rp, request.body);
152
+ if (result.ok === false) {
153
+ return reply.code(400).send(result);
154
+ }
155
+ return reply.send(result);
156
+ });
157
+ /**
158
+ * GET /auth/passkeys (authenticated)
159
+ * Every enrolled passkey (never the public keys) plus this request's rpID so
160
+ * the UI can tell which ones apply to the address it is open at.
161
+ */
162
+ fastify.get('/auth/passkeys', async (request, reply) => {
163
+ let rpID = null;
164
+ let rpError = null;
165
+ try {
166
+ rpID = (0, passkeys_1.deriveRpInfo)(request.headers).rpID;
167
+ }
168
+ catch (err) {
169
+ if (!(err instanceof passkeys_1.PasskeyRpError))
170
+ throw err;
171
+ rpError = err.message; // listing still works; enrolling here will not
172
+ }
173
+ return reply.send({ ok: true, rpID, rpError, passkeys: passkeys_1.passkeyService.list() });
174
+ });
175
+ /**
176
+ * DELETE /auth/passkeys/:id (authenticated)
177
+ */
178
+ fastify.delete('/auth/passkeys/:id', async (request, reply) => {
179
+ const { id } = request.params;
180
+ if (!id) {
181
+ return reply.code(400).send({ ok: false, error: 'Passkey id is required' });
182
+ }
183
+ const removed = passkeys_1.passkeyService.delete(id);
184
+ if (!removed) {
185
+ return reply.code(404).send({ ok: false, error: 'Passkey not found' });
186
+ }
187
+ return reply.send({ ok: true, removed: 1 });
188
+ });
61
189
  }
@@ -17,6 +17,7 @@
17
17
  "@fastify/static": "^9.0.0",
18
18
  "@fastify/websocket": "^11.2.0",
19
19
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.0",
20
+ "@simplewebauthn/server": "^13.3.3",
20
21
  "better-sqlite3": "^11.0.0",
21
22
  "fastify": "^5.8.2",
22
23
  "jsonwebtoken": "^9.0.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nebula-notebook",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "description": "AI-native notebook computing environment — real Jupyter kernels, real filesystem, built to be driven by agents (Claude Code / Codex) via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,6 +61,7 @@
61
61
  "@fastify/static": "^9.0.0",
62
62
  "@fastify/websocket": "^11.2.0",
63
63
  "@homebridge/node-pty-prebuilt-multiarch": "^0.14.0",
64
+ "@simplewebauthn/server": "^13.3.3",
64
65
  "@xterm/addon-clipboard": "^0.2.0",
65
66
  "@xterm/addon-webgl": "^0.19.0",
66
67
  "better-sqlite3": "^11.0.0",
@@ -79,6 +80,7 @@
79
80
  "@codemirror/lang-python": "^6.2.1",
80
81
  "@jupyter-widgets/html-manager": "^1.0.14",
81
82
  "@playwright/test": "^1.57.0",
83
+ "@simplewebauthn/browser": "^13.3.0",
82
84
  "@tailwindcss/vite": "^4.2.1",
83
85
  "@testing-library/jest-dom": "^6.9.1",
84
86
  "@testing-library/react": "^16.3.1",