@clauth/device 1.0.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.
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * device-auth — OAuth Device Authorization CLI (Yjs-first, ECDH encrypted).
4
+ *
5
+ * Tokens live in the server-side Yjs doc. CLI keeps WS alive to read them.
6
+ *
7
+ * Commands:
8
+ * device-auth --url <server> One-shot: auth, print token, exit
9
+ * device-auth start --url <server> Background: auth, keep WS alive
10
+ * device-auth token [--resource <urn>] Read token from running session
11
+ * device-auth stop Stop background, clear session
12
+ * device-auth status Check if session is active
13
+ */
14
+ import { createECDH, createHash, createDecipheriv, createSign, createPrivateKey, randomBytes } from 'node:crypto';
15
+ import { exec, spawn } from 'node:child_process';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { HocuspocusProvider } from '@hocuspocus/provider';
21
+ import * as Y from 'yjs';
22
+ import { WebSocket } from 'ws';
23
+
24
+ import { keychainGet, keychainSet, keychainDelete } from './keychain.mjs';
25
+
26
+ // ─── Config ──────────────────────────────────────────────────────────────────
27
+
28
+ const TIMEOUT = 120_000;
29
+ const CURVE = 'prime256v1';
30
+ const CONFIG_DIR = join(homedir(), '.config', 'device-auth');
31
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
32
+
33
+ // ─── Local config (url, domain) ──────────────────────────────────────────────
34
+
35
+ function loadConfig() {
36
+ try {
37
+ if (existsSync(CONFIG_FILE)) return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
38
+ } catch {}
39
+ return {};
40
+ }
41
+
42
+ function saveConfig(updates) {
43
+ mkdirSync(CONFIG_DIR, { recursive: true });
44
+ const existing = loadConfig();
45
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, ...updates }, null, 2));
46
+ }
47
+
48
+ // ─── Parse args ──────────────────────────────────────────────────────────────
49
+
50
+ const args = process.argv.slice(2);
51
+ const command = ['start', 'stop', 'token', 'status'].includes(args[0]) ? args.shift() : null;
52
+ const jsonOutput = args.includes('--json');
53
+ const envOutput = args.includes('--env');
54
+ const isDaemon = args.includes('--daemon');
55
+ const urlIdx = args.indexOf('--url');
56
+ const resourceIdx = args.indexOf('--resource');
57
+ const domainIdx = args.indexOf('--domain');
58
+ const tidIdx = args.indexOf('--tid');
59
+
60
+ const config = loadConfig();
61
+ const serverUrl = (urlIdx !== -1 && args[urlIdx + 1]) || process.env.DEVICE_AUTH_URL || config.url || '';
62
+ const resource = (resourceIdx !== -1 && args[resourceIdx + 1]) || process.env.DEVICE_AUTH_RESOURCE || 'urn:d:unified-gateway';
63
+ const domain = (domainIdx !== -1 && args[domainIdx + 1]) || process.env.IAS_DOMAIN || config.domain || '';
64
+ const tid = (tidIdx !== -1 && args[tidIdx + 1]) || process.env.IAS_TID || config.tid || '';
65
+
66
+ // ─── Help ────────────────────────────────────────────────────────────────────
67
+
68
+ if (args.includes('--help') || args.includes('-h')) {
69
+ console.log(`
70
+ device-auth — OAuth Device Authorization CLI (RFC 8628 + Yjs + ECDH)
71
+
72
+ Commands:
73
+ device-auth --url <server> [options] One-shot: authenticate, print token, exit
74
+ device-auth start --url <server> Background: authenticate, keep session alive
75
+ device-auth token [--resource <urn>] Read token from running session
76
+ device-auth stop Stop background session
77
+ device-auth status Check session status
78
+
79
+ Options:
80
+ --url <url> Auth server URL (required, or set DEVICE_AUTH_URL)
81
+ --resource <urn> Token resource (default: urn:d:unified-gateway)
82
+ --domain <name> IAS tenant subdomain (e.g. sapdasintegdev)
83
+ --tid <uuid> IAS tenant ID directly (skips tenant API)
84
+ --login Open browser for login (default: wait for external login)
85
+ --json Output as JSON
86
+ --env Output as shell export
87
+ --help Show this help
88
+
89
+ Examples:
90
+ # One-shot:
91
+ curl -H "Authorization: Bearer $(device-auth --url https://mtls-device.dev-eu12.build.cloud.sap --domain sapdasintegdev)" https://api/...
92
+
93
+ # Background session:
94
+ device-auth start --url https://mtls-device.dev-eu12.build.cloud.sap --domain sapdasintegdev
95
+ curl -H "Authorization: Bearer $(device-auth token)" https://api/...
96
+ device-auth stop
97
+ `);
98
+ process.exit(0);
99
+ }
100
+
101
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
102
+
103
+ function log(...msg) {
104
+ if (!jsonOutput && !envOutput) process.stderr.write(msg.join(' ') + '\n');
105
+ }
106
+
107
+ function openBrowser(url) {
108
+ const cmd = process.platform === 'darwin' ? 'open' :
109
+ process.platform === 'win32' ? 'start ""' : 'xdg-open';
110
+ exec(`${cmd} "${url}"`);
111
+ }
112
+
113
+ function output(token) {
114
+ if (jsonOutput) {
115
+ process.stdout.write(JSON.stringify({ access_token: token, token_type: 'Bearer' }, null, 2) + '\n');
116
+ } else if (envOutput) {
117
+ process.stdout.write(`export ACCESS_TOKEN="${token}"\n`);
118
+ } else {
119
+ process.stdout.write(token + '\n');
120
+ }
121
+ }
122
+
123
+ // ─── ECDH helpers ────────────────────────────────────────────────────────────
124
+
125
+ function getOrCreateEcdhKeys() {
126
+ let privateKey = keychainGet('ecdh_private');
127
+ let publicKey = keychainGet('ecdh_public');
128
+ if (!privateKey || !publicKey) {
129
+ const ecdh = createECDH(CURVE);
130
+ ecdh.generateKeys();
131
+ privateKey = ecdh.getPrivateKey('base64');
132
+ publicKey = ecdh.getPublicKey('base64');
133
+ keychainSet('ecdh_private', privateKey);
134
+ keychainSet('ecdh_public', publicKey);
135
+ }
136
+ return { privateKey, publicKey };
137
+ }
138
+
139
+ /**
140
+ * Sign a self-signed device JWT (ES256) using the ECDH/ECDSA private key.
141
+ * Embeds the public key in the JWT header (jwk) for server verification.
142
+ * device_id = base64url(SHA-256(raw_public_key)) — deterministic.
143
+ */
144
+ function signDeviceJwt(privateKeyBase64, publicKeyBase64) {
145
+ const pubKeyBuf = Buffer.from(publicKeyBase64, 'base64');
146
+ const privKeyBuf = Buffer.from(privateKeyBase64, 'base64');
147
+ const deviceId = createHash('sha256').update(pubKeyBuf).digest('base64url');
148
+
149
+ // P-256 uncompressed public key: 04 || x (32 bytes) || y (32 bytes)
150
+ const x = pubKeyBuf.subarray(1, 33).toString('base64url');
151
+ const y = pubKeyBuf.subarray(33, 65).toString('base64url');
152
+ const d = privKeyBuf.toString('base64url');
153
+
154
+ // Import as JWK to get a KeyObject for signing
155
+ const keyObject = createPrivateKey({ key: { kty: 'EC', crv: 'P-256', x, y, d }, format: 'jwk' });
156
+
157
+ // JWT header with embedded public key (no 'd' — only public part)
158
+ const header = { alg: 'ES256', typ: 'JWT', jwk: { kty: 'EC', crv: 'P-256', x, y } };
159
+ const payload = {
160
+ sub: deviceId,
161
+ iat: Math.floor(Date.now() / 1000),
162
+ exp: Math.floor(Date.now() / 1000) + 300,
163
+ };
164
+
165
+ const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
166
+ const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
167
+ const sigInput = `${headerB64}.${payloadB64}`;
168
+
169
+ const sig = createSign('SHA256').update(sigInput).sign({ key: keyObject, dsaEncoding: 'ieee-p1363' }, 'base64url');
170
+
171
+ return { jwt: `${headerB64}.${payloadB64}.${sig}`, deviceId };
172
+ }
173
+
174
+ function ecdhDecrypt(ciphertext, serverPublicKeyBase64, clientPrivateKeyBase64) {
175
+ const serverPublicKey = Buffer.from(serverPublicKeyBase64, 'base64');
176
+ const clientEcdh = createECDH(CURVE);
177
+ clientEcdh.setPrivateKey(Buffer.from(clientPrivateKeyBase64, 'base64'));
178
+ const sharedSecret = clientEcdh.computeSecret(serverPublicKey);
179
+ const aesKey = createHash('sha256').update(sharedSecret).digest();
180
+ const buf = Buffer.from(ciphertext, 'base64');
181
+ const iv = buf.subarray(0, 12);
182
+ const tag = buf.subarray(12, 28);
183
+ const data = buf.subarray(28);
184
+ const decipher = createDecipheriv('aes-256-gcm', aesKey, iv);
185
+ decipher.setAuthTag(tag);
186
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
187
+ }
188
+
189
+ // ─── Session helpers (Keychain-backed) ───────────────────────────────────────
190
+
191
+ function saveSession(url) {
192
+ keychainSet('url', url);
193
+ keychainSet('pid', String(process.pid));
194
+ }
195
+
196
+ function clearSession() {
197
+ keychainDelete('url');
198
+ keychainDelete('pid');
199
+ }
200
+
201
+ // ─── Stop command ────────────────────────────────────────────────────────────
202
+
203
+ if (command === 'stop') {
204
+ const pid = keychainGet('pid');
205
+ if (pid) { try { process.kill(parseInt(pid)); } catch {} }
206
+ clearSession();
207
+ process.stderr.write('Session stopped.\n');
208
+ process.exit(0);
209
+ }
210
+
211
+ // ─── Status command ──────────────────────────────────────────────────────────
212
+
213
+ if (command === 'status') {
214
+ const pid = keychainGet('pid');
215
+ const url = keychainGet('url');
216
+ if (pid && url) {
217
+ let alive = false;
218
+ try { process.kill(parseInt(pid), 0); alive = true; } catch {}
219
+ process.stderr.write(alive
220
+ ? `Active session: ${url} (pid: ${pid})\n`
221
+ : `Stale session (process ${pid} not running). Run 'device-auth stop' to clear.\n`
222
+ );
223
+ process.exit(alive ? 0 : 1);
224
+ }
225
+ process.stderr.write('No active session.\n');
226
+ process.exit(1);
227
+ }
228
+
229
+ // ─── Token command — read from Yjs via WS ────────────────────────────────────
230
+
231
+ 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) {
237
+ process.stderr.write('Error: No active session. Run `device-auth start --url <server>` first.\n');
238
+ process.exit(1);
239
+ }
240
+
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;
260
+
261
+ // Search through tokens for the most recent matching our resource
262
+ for (const [rid, raw] of tokensMap.entries()) {
263
+ if (!raw) continue;
264
+ 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
+ }
277
+ }
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`);
285
+ process.exit(1);
286
+ }
287
+
288
+ const plainToken = ecdhDecrypt(foundToken.ciphertext, foundToken.serverPublicKey, privateKey);
289
+ output(plainToken);
290
+ process.exit(0);
291
+ }
292
+
293
+ // ─── Start command: fork into background ─────────────────────────────────────
294
+
295
+ if (command === 'start' && !isDaemon) {
296
+ if (!serverUrl) {
297
+ process.stderr.write('Error: --url <server> is required.\n');
298
+ process.exit(1);
299
+ }
300
+ const child = spawn(process.execPath, [process.argv[1], '--daemon', ...args], {
301
+ detached: true, stdio: ['ignore', 'pipe', 'pipe'],
302
+ });
303
+ let output = '';
304
+ child.stderr.on('data', (d) => {
305
+ output += d.toString();
306
+ process.stderr.write(d);
307
+ if (output.includes('Session ready')) { child.unref(); process.exit(0); }
308
+ });
309
+ child.on('exit', (code) => { if (code !== 0) process.exit(code || 1); });
310
+ await new Promise(() => {});
311
+ }
312
+
313
+ // ─── Auth flow (one-shot or daemon) ──────────────────────────────────────────
314
+
315
+ if (!serverUrl) {
316
+ process.stderr.write('Error: --url <server> is required (or set DEVICE_AUTH_URL).\n');
317
+ process.exit(1);
318
+ }
319
+
320
+ async function main() {
321
+ const { privateKey, publicKey } = getOrCreateEcdhKeys();
322
+ const { jwt: deviceJwt, deviceId } = signDeviceJwt(privateKey, publicKey);
323
+
324
+ log('device-auth v2.0.0');
325
+ log(` server: ${serverUrl}`);
326
+ log(` device: ${deviceId}`);
327
+ log(` resource: ${resource}`);
328
+ log('');
329
+
330
+ // Step 1: Connect with self-signed JWT
331
+ log('→ Connecting to server...');
332
+ const syncUrl = serverUrl.replace(/^http/, 'ws') + '/sync/device';
333
+ const deviceDoc = new Y.Doc();
334
+ const provider = new HocuspocusProvider({
335
+ url: syncUrl, name: 'device', token: deviceJwt,
336
+ document: deviceDoc, WebSocketPolyfill: WebSocket,
337
+ });
338
+
339
+ await new Promise((resolve, reject) => {
340
+ const t = setTimeout(() => reject(new Error('Timeout connecting')), 10000);
341
+ provider.on('synced', () => { clearTimeout(t); resolve(); });
342
+ provider.on('authenticationFailed', ({ reason }) => { clearTimeout(t); reject(new Error(reason)); });
343
+ });
344
+
345
+ // Write public key to meta (for ECDH token encryption)
346
+ const meta = deviceDoc.getMap('meta');
347
+ if (meta.get('public_key') !== publicKey) {
348
+ deviceDoc.transact(() => { meta.set('public_key', publicKey); });
349
+ }
350
+
351
+ // Step 2: Write request
352
+ log('→ Requesting token...');
353
+ const rid = randomBytes(8).toString('base64url');
354
+ const requests = deviceDoc.getMap('requests');
355
+ deviceDoc.transact(() => {
356
+ requests.set(rid, JSON.stringify({
357
+ resource, domain, tid, base_url: serverUrl,
358
+ status: 'pending', created_at: new Date().toISOString(),
359
+ }));
360
+ });
361
+
362
+ // Step 3: Wait for response
363
+ log('→ Waiting for server response...');
364
+ const responses = deviceDoc.getMap('responses');
365
+ const response = await new Promise((resolve, reject) => {
366
+ const t = setTimeout(() => reject(new Error('Timeout')), 15000);
367
+ function check() {
368
+ const raw = responses.get(rid);
369
+ if (raw) { clearTimeout(t); resolve(JSON.parse(raw)); }
370
+ }
371
+ responses.observe(check);
372
+ check();
373
+ });
374
+
375
+ log(` user_code: ${response.user_code}`);
376
+ log(` expires: ${response.expires_in}s`);
377
+ log('');
378
+
379
+ // Step 4: Open browser only if --login specified
380
+ if (args.includes('--login')) {
381
+ log('→ Opening browser...');
382
+ log(` ${response.verification_uri_complete}`);
383
+ openBrowser(response.verification_uri_complete);
384
+ } else {
385
+ log('→ Waiting for login...');
386
+ log(` user_code: ${response.user_code}`);
387
+ log(` url: ${response.verification_uri_complete}`);
388
+ log(' (open the URL above, or use a login listener)');
389
+ }
390
+ log('');
391
+ log(' Waiting for authentication...');
392
+
393
+ // Step 5: Wait for token
394
+ const tokensMap = deviceDoc.getMap('tokens');
395
+ const tokenData = await new Promise((resolve, reject) => {
396
+ const t = setTimeout(() => { provider.destroy(); reject(new Error('Timeout')); }, TIMEOUT);
397
+ function check() {
398
+ const raw = tokensMap.get(rid);
399
+ if (!raw) return;
400
+ const data = JSON.parse(raw);
401
+ if (data.status === 'complete') { clearTimeout(t); resolve(data); }
402
+ else if (data.status === 'error') { clearTimeout(t); reject(new Error(data.error || 'Failed')); }
403
+ }
404
+ tokensMap.observe(check);
405
+ check();
406
+ });
407
+
408
+ // Step 6: Decrypt
409
+ const plainToken = ecdhDecrypt(tokenData.ciphertext, tokenData.serverPublicKey, privateKey);
410
+
411
+ // ─── Daemon mode: save session, keep alive ─────────────────────────────────
412
+ if (isDaemon || command === 'start') {
413
+ saveSession(serverUrl);
414
+ 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
420
+ }
421
+
422
+ // ─── One-shot: print and exit ──────────────────────────────────────────────
423
+ provider.destroy();
424
+ saveConfig({ url: serverUrl, domain, tid });
425
+ log('');
426
+ log('✓ Token received!');
427
+ output(plainToken);
428
+ process.exit(0);
429
+ }
430
+
431
+ main().catch((err) => {
432
+ process.stderr.write(`Error: ${err.message || err}\n`);
433
+ process.exit(1);
434
+ });
package/keychain.mjs ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * keychain.mjs — Platform keychain helpers (macOS Keychain, Linux secret-tool).
3
+ */
4
+ import { execSync } from 'node:child_process';
5
+
6
+ const SERVICE = 'device-auth-cli';
7
+
8
+ export function keychainSet(key, value) {
9
+ if (process.platform === 'darwin') {
10
+ try { execSync(`security delete-generic-password -s "${SERVICE}" -a "${key}" 2>/dev/null`); } catch {}
11
+ execSync(`security add-generic-password -s "${SERVICE}" -a "${key}" -w "${value}"`);
12
+ } else {
13
+ execSync(`echo -n "${value}" | secret-tool store --label="device-auth: ${key}" service "${SERVICE}" key "${key}"`);
14
+ }
15
+ }
16
+
17
+ export function keychainGet(key) {
18
+ try {
19
+ if (process.platform === 'darwin') {
20
+ return execSync(`security find-generic-password -s "${SERVICE}" -a "${key}" -w`, { encoding: 'utf8' }).trim();
21
+ }
22
+ return execSync(`secret-tool lookup service "${SERVICE}" key "${key}"`, { encoding: 'utf8' }).trim();
23
+ } catch { return null; }
24
+ }
25
+
26
+ export function keychainDelete(key) {
27
+ try {
28
+ if (process.platform === 'darwin') {
29
+ execSync(`security delete-generic-password -s "${SERVICE}" -a "${key}" 2>/dev/null`);
30
+ } else {
31
+ execSync(`secret-tool clear service "${SERVICE}" key "${key}"`);
32
+ }
33
+ } catch {}
34
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@clauth/device",
3
+ "version": "1.0.1",
4
+ "description": "OAuth Device Authorization CLI with WebSocket token delivery and local encrypted cache",
5
+ "type": "module",
6
+ "bin": {
7
+ "device-auth": "device-auth.mjs"
8
+ },
9
+ "files": [
10
+ "device-auth.mjs",
11
+ "keychain.mjs",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "tag": "1.0.0"
17
+ },
18
+ "scripts": {
19
+ "start": "node device-auth.mjs"
20
+ },
21
+ "dependencies": {
22
+ "@hocuspocus/provider": "^4.0.0",
23
+ "oauth4webapi": "^3.0.0",
24
+ "yjs": "^13.6.30",
25
+ "ws": "^8.18.0"
26
+ }
27
+ }