@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.
- package/device-auth.mjs +434 -0
- package/keychain.mjs +34 -0
- package/package.json +27 -0
package/device-auth.mjs
ADDED
|
@@ -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
|
+
}
|