@clauth/device 1.0.0 → 1.1.0

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.
Files changed (2) hide show
  1. package/device-auth.mjs +91 -58
  2. package/package.json +2 -2
package/device-auth.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import { createECDH, createHash, createDecipheriv, createSign, createPrivateKey, randomBytes } from 'node:crypto';
15
15
  import { exec, spawn } from 'node:child_process';
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
17
+ import { createServer, createConnection } from 'node:net';
17
18
  import { homedir } from 'node:os';
18
19
  import { join } from 'node:path';
19
20
 
@@ -29,6 +30,7 @@ const TIMEOUT = 120_000;
29
30
  const CURVE = 'prime256v1';
30
31
  const CONFIG_DIR = join(homedir(), '.config', 'device-auth');
31
32
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
33
+ const SOCKET_PATH = join(CONFIG_DIR, 'daemon.sock');
32
34
 
33
35
  // ─── Local config (url, domain) ──────────────────────────────────────────────
34
36
 
@@ -203,6 +205,7 @@ function clearSession() {
203
205
  if (command === 'stop') {
204
206
  const pid = keychainGet('pid');
205
207
  if (pid) { try { process.kill(parseInt(pid)); } catch {} }
208
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
206
209
  clearSession();
207
210
  process.stderr.write('Session stopped.\n');
208
211
  process.exit(0);
@@ -226,68 +229,40 @@ if (command === 'status') {
226
229
  process.exit(1);
227
230
  }
228
231
 
229
- // ─── Token command — read from Yjs via WS ────────────────────────────────────
232
+ // ─── Token command — ask daemon via unix socket ──────────────────────────────
230
233
 
231
234
  if (command === 'token') {
232
- const url = serverUrl || keychainGet('url');
233
- const { privateKey, publicKey } = getOrCreateEcdhKeys();
234
- const { jwt: deviceJwt, deviceId } = signDeviceJwt(privateKey, publicKey);
235
-
236
- if (!url) {
235
+ if (!existsSync(SOCKET_PATH)) {
237
236
  process.stderr.write('Error: No active session. Run `device-auth start --url <server>` first.\n');
238
237
  process.exit(1);
239
238
  }
240
239
 
241
- // Connect to device doc with self-signed JWT
242
- const syncUrl = url.replace(/^http/, 'ws') + '/sync/device';
243
- const doc = new Y.Doc();
244
- const provider = new HocuspocusProvider({
245
- url: syncUrl, name: 'device', token: deviceJwt,
246
- document: doc, WebSocketPolyfill: WebSocket,
247
- });
248
-
249
- await new Promise((resolve, reject) => {
250
- const t = setTimeout(() => reject(new Error('Timeout connecting')), 10000);
251
- provider.on('synced', () => { clearTimeout(t); resolve(); });
252
- provider.on('authenticationFailed', ({ reason }) => { clearTimeout(t); reject(new Error(reason)); });
253
- });
254
-
255
- // Find the LATEST completed token for this resource (most recent by created_at)
256
- const tokensMap = doc.getMap('tokens');
257
- const requests = doc.getMap('requests');
258
- let foundToken = null;
259
- let latestTime = 0;
240
+ const client = createConnection(SOCKET_PATH);
241
+ client.end(JSON.stringify({ command: 'token', resource }) + '\n');
260
242
 
261
- // Search through tokens for the most recent matching our resource
262
- for (const [rid, raw] of tokensMap.entries()) {
263
- if (!raw) continue;
243
+ let data = '';
244
+ client.on('data', (chunk) => { data += chunk.toString(); });
245
+ client.on('end', () => {
264
246
  try {
265
- const data = JSON.parse(raw);
266
- if (data.status !== 'complete') continue;
267
- const reqRaw = requests.get(rid);
268
- if (reqRaw) {
269
- const req = JSON.parse(reqRaw);
270
- if (req.resource === resource) {
271
- const time = new Date(req.created_at || 0).getTime();
272
- if (time >= latestTime) {
273
- latestTime = time;
274
- foundToken = data;
275
- }
276
- }
247
+ const res = JSON.parse(data);
248
+ if (res.error) {
249
+ process.stderr.write(`Error: ${res.error}\n`);
250
+ process.exit(1);
277
251
  }
278
- } catch {}
279
- }
280
-
281
- provider.destroy();
282
-
283
- if (!foundToken) {
284
- process.stderr.write(`Error: No token for "${resource}". Run: device-auth --url ${url} --resource "${resource}"\n`);
252
+ output(res.token);
253
+ process.exit(0);
254
+ } catch (e) {
255
+ process.stderr.write(`Error: Invalid response from daemon\n`);
256
+ process.exit(1);
257
+ }
258
+ });
259
+ client.on('error', (err) => {
260
+ process.stderr.write(`Error: Cannot connect to daemon (${err.message}). Run \`device-auth start\` first.\n`);
285
261
  process.exit(1);
286
- }
262
+ });
287
263
 
288
- const plainToken = ecdhDecrypt(foundToken.ciphertext, foundToken.serverPublicKey, privateKey);
289
- output(plainToken);
290
- process.exit(0);
264
+ // Prevent falling through to auth flow
265
+ await new Promise(() => {});
291
266
  }
292
267
 
293
268
  // ─── Start command: fork into background ─────────────────────────────────────
@@ -408,15 +383,73 @@ async function main() {
408
383
  // Step 6: Decrypt
409
384
  const plainToken = ecdhDecrypt(tokenData.ciphertext, tokenData.serverPublicKey, privateKey);
410
385
 
411
- // ─── Daemon mode: save session, keep alive ─────────────────────────────────
386
+ // ─── Daemon mode: start socket server, keep WS alive ────────────────────────
412
387
  if (isDaemon || command === 'start') {
413
388
  saveSession(serverUrl);
414
389
  saveConfig({ url: serverUrl, domain, tid });
415
- log('');
416
- log('✓ Session ready. Use `device-auth token` to get the token.');
417
- process.on('SIGTERM', () => { provider.destroy(); clearSession(); process.exit(0); });
418
- process.on('SIGINT', () => { provider.destroy(); clearSession(); process.exit(0); });
419
- return; // keep alive — WS stays connected
390
+
391
+ // Start unix socket server for `device-auth token` commands
392
+ mkdirSync(CONFIG_DIR, { recursive: true });
393
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
394
+
395
+ const socketServer = createServer((conn) => {
396
+ let data = '';
397
+ conn.on('data', (chunk) => { data += chunk.toString(); });
398
+ conn.on('end', () => {
399
+ try {
400
+ const req = JSON.parse(data);
401
+ if (req.command === 'token') {
402
+ const reqResource = req.resource || resource;
403
+ // Read from live Yjs doc
404
+ const tokensMap = deviceDoc.getMap('tokens');
405
+ const requests = deviceDoc.getMap('requests');
406
+ let found = null;
407
+ let latestTime = 0;
408
+ for (const [rid, raw] of tokensMap.entries()) {
409
+ if (!raw) continue;
410
+ try {
411
+ const td = JSON.parse(raw);
412
+ if (td.status !== 'complete') continue;
413
+ const reqRaw = requests.get(rid);
414
+ if (reqRaw) {
415
+ const r = JSON.parse(reqRaw);
416
+ if (r.resource === reqResource) {
417
+ const time = new Date(r.created_at || 0).getTime();
418
+ if (time >= latestTime) { latestTime = time; found = td; }
419
+ }
420
+ }
421
+ } catch {}
422
+ }
423
+ if (!found) {
424
+ conn.end(JSON.stringify({ error: `No token for "${reqResource}"` }));
425
+ return;
426
+ }
427
+ const token = ecdhDecrypt(found.ciphertext, found.serverPublicKey, privateKey);
428
+ conn.end(JSON.stringify({ token }));
429
+ } else {
430
+ conn.end(JSON.stringify({ error: 'Unknown command' }));
431
+ }
432
+ } catch (e) {
433
+ conn.end(JSON.stringify({ error: e.message }));
434
+ }
435
+ });
436
+ });
437
+
438
+ socketServer.listen(SOCKET_PATH, () => {
439
+ log('');
440
+ log('✓ Session ready. Use `device-auth token` to get the token.');
441
+ });
442
+
443
+ function cleanup() {
444
+ socketServer.close();
445
+ if (existsSync(SOCKET_PATH)) unlinkSync(SOCKET_PATH);
446
+ provider.destroy();
447
+ clearSession();
448
+ process.exit(0);
449
+ }
450
+ process.on('SIGTERM', cleanup);
451
+ process.on('SIGINT', cleanup);
452
+ return; // keep alive
420
453
  }
421
454
 
422
455
  // ─── One-shot: print and exit ──────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clauth/device",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "OAuth Device Authorization CLI with WebSocket token delivery and local encrypted cache",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "publishConfig": {
15
15
  "access": "public",
16
- "tag": "1.0.0"
16
+ "tag": "1.0.2"
17
17
  },
18
18
  "scripts": {
19
19
  "start": "node device-auth.mjs"