@aiam/ciba 0.8.7 → 0.8.9
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/ciba.mjs +72 -93
- package/package.json +2 -1
- package/token.mjs +219 -0
package/ciba.mjs
CHANGED
|
@@ -438,83 +438,71 @@ function startDaemon(provider, deviceDoc, privateKey, serverUrl) {
|
|
|
438
438
|
dlog(`${req.command} cmd attrs=${JSON.stringify(req.attrs)}`);
|
|
439
439
|
const requests = deviceDoc.getMap('requests');
|
|
440
440
|
const resourcesMap = deviceDoc.getMap('resources');
|
|
441
|
-
|
|
442
|
-
// Pass attrs straight through — server resolves grant_type from resource URN.
|
|
443
|
-
const attrs = { ...(req.attrs || {}) };
|
|
444
441
|
const defaultResource = `urn:sap:destination:${process.env.CIBA_DEFAULT_DESTINATION || 'WEBAGENTS_BACKEND'}`;
|
|
442
|
+
const attrs = { ...(req.attrs || {}) };
|
|
445
443
|
const requestedResource = attrs.resource ?? defaultResource;
|
|
446
444
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
445
|
+
// Pure: check resources map right now, return token map or null.
|
|
446
|
+
function tryGet(resource) {
|
|
447
|
+
const key = resource
|
|
448
|
+
? resourcesMap.get(resource)
|
|
449
|
+
: [...resourcesMap.values()][0];
|
|
450
|
+
if (!key) return null;
|
|
451
|
+
const map = deviceDoc.getMap(key);
|
|
452
|
+
const expiresAt = map.get('expires_at');
|
|
453
|
+
if (expiresAt && expiresAt < Date.now()) return null; // expired
|
|
454
|
+
if (!map.get('ciphertext')) return null;
|
|
455
|
+
return map;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Pure: decrypt token map → access_token string.
|
|
459
|
+
function decrypt(map) {
|
|
460
|
+
const plain = ecdhDecrypt(map.get('ciphertext'), map.get('serverPublicKey'), privateKey);
|
|
461
|
+
try { return JSON.parse(plain).access_token ?? plain; }
|
|
453
462
|
catch { return plain; }
|
|
454
|
-
}
|
|
463
|
+
}
|
|
455
464
|
|
|
456
465
|
if (req.command === 'refresh') {
|
|
457
|
-
// Fire-and-forget —
|
|
466
|
+
// Fire-and-forget — write request, server exchanges async.
|
|
458
467
|
if (!attrs.resource) attrs.resource = defaultResource;
|
|
459
468
|
const newRid = randomBytes(8).toString('base64url');
|
|
460
|
-
dlog(`refresh; writing requests[${newRid}]
|
|
469
|
+
dlog(`refresh; writing requests[${newRid}] resource=${attrs.resource}`);
|
|
461
470
|
requests.set(newRid, { ...attrs, status: 'pending', created_at: new Date().toISOString() });
|
|
462
471
|
conn.end(JSON.stringify({ ok: true }));
|
|
463
472
|
return;
|
|
464
473
|
}
|
|
465
474
|
|
|
466
|
-
// token:
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const exp = cachedMap.get('exp');
|
|
471
|
-
const now = Math.floor(Date.now() / 1000);
|
|
472
|
-
// Return cached token if: no exp set (server manages expiry),
|
|
473
|
-
// or exp is still valid. Only skip if we know it's expired.
|
|
474
|
-
const isExpired = exp && exp < now + 60;
|
|
475
|
-
if (!isExpired) {
|
|
476
|
-
const token = decryptFromTokenMap(cachedMap);
|
|
477
|
-
if (token) { conn.end(JSON.stringify({ token })); return; }
|
|
478
|
-
}
|
|
479
|
-
}
|
|
475
|
+
// token: observe resources map, resolve when token arrives.
|
|
476
|
+
const waitForResource = (resource) => new Promise((resolve, reject) => {
|
|
477
|
+
const found = tryGet(resource);
|
|
478
|
+
if (found) return resolve(found);
|
|
480
479
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
resourcesMap
|
|
500
|
-
(key, val) => key === requestedResource && val && val !== prevTokenMapName,
|
|
501
|
-
30_000
|
|
502
|
-
).then((newMapName) => {
|
|
503
|
-
const m = deviceDoc.getMap(newMapName);
|
|
504
|
-
return firstInYMap(m, (key) => key === 'ciphertext' || key === 'error', 5_000)
|
|
505
|
-
.then(() => m).catch(() => m);
|
|
480
|
+
// Cache miss — write request to trigger server exchange.
|
|
481
|
+
const newRid = randomBytes(8).toString('base64url');
|
|
482
|
+
dlog(`cache miss; writing requests[${newRid}] resource=${resource}`);
|
|
483
|
+
requests.set(newRid, { ...attrs, resource, status: 'pending', created_at: new Date().toISOString() });
|
|
484
|
+
|
|
485
|
+
const timer = setTimeout(() => {
|
|
486
|
+
resourcesMap.unobserve(observer);
|
|
487
|
+
reject(new Error('Timeout waiting for token'));
|
|
488
|
+
}, 30_000);
|
|
489
|
+
|
|
490
|
+
const observer = () => {
|
|
491
|
+
const found = tryGet(resource);
|
|
492
|
+
if (found) {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
resourcesMap.unobserve(observer);
|
|
495
|
+
resolve(found);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
resourcesMap.observe(observer);
|
|
506
499
|
});
|
|
507
500
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
dlog(`token resolved via ${src} for ${requestedResource}`);
|
|
514
|
-
const err = map.get('error');
|
|
515
|
-
if (err) { conn.end(JSON.stringify({ error: err })); return; }
|
|
516
|
-
try { conn.end(JSON.stringify({ token: decryptFromTokenMap(map) })); }
|
|
517
|
-
catch (e) { conn.end(JSON.stringify({ error: `decrypt failed: ${e.message}` })); }
|
|
501
|
+
waitForResource(requestedResource)
|
|
502
|
+
.then((map) => {
|
|
503
|
+
dlog(`token resolved for ${requestedResource}`);
|
|
504
|
+
const access_token = decrypt(map);
|
|
505
|
+
conn.end(JSON.stringify({ token: access_token }));
|
|
518
506
|
})
|
|
519
507
|
.catch((e) => conn.end(JSON.stringify({ error: e.message || 'Timeout waiting for token' })));
|
|
520
508
|
|
|
@@ -662,51 +650,42 @@ const loginCmd = defineCommand({
|
|
|
662
650
|
});
|
|
663
651
|
|
|
664
652
|
const tokenCmd = defineCommand({
|
|
665
|
-
meta: { description: 'Return token
|
|
653
|
+
meta: { description: 'Return token (cache-first, no daemon needed)' },
|
|
666
654
|
args: {
|
|
667
|
-
resource:
|
|
668
|
-
destination: { type: 'string', description: 'Destination name (alternative to full resource URN)' },
|
|
669
|
-
'grant-type': { type: 'string', description: 'Override grant_type (inferred from resource URN if omitted)' },
|
|
655
|
+
resource: { type: 'string', description: 'Resource URN (default: urn:sap:destination:WEBAGENTS_BACKEND)' },
|
|
670
656
|
...outputArgs,
|
|
671
657
|
},
|
|
672
658
|
async run({ args }) {
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
} catch (e) {
|
|
683
|
-
process.stderr.write(`Error: ${e.message}\n`);
|
|
684
|
-
process.exit(1);
|
|
685
|
-
}
|
|
659
|
+
const tokenScript = join(_dirname(_fileURLToPath(import.meta.url)), 'token.mjs');
|
|
660
|
+
const argv = [tokenScript];
|
|
661
|
+
if (args.resource) argv.push('--resource', args.resource);
|
|
662
|
+
if (args.json) argv.push('--json');
|
|
663
|
+
if (args.env) argv.push('--env');
|
|
664
|
+
const cfg = loadConfig();
|
|
665
|
+
if (cfg.url) argv.push('--url', cfg.url);
|
|
666
|
+
const child = spawn(process.execPath, argv, { stdio: 'inherit' });
|
|
667
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
686
668
|
},
|
|
687
669
|
});
|
|
688
670
|
|
|
689
671
|
const refreshCmd = defineCommand({
|
|
690
672
|
meta: { description: 'Force a fresh token exchange (no re-approval needed)' },
|
|
691
673
|
args: {
|
|
692
|
-
resource:
|
|
693
|
-
destination: { type: 'string', description: 'Destination name (alternative to full resource URN)' },
|
|
694
|
-
'grant-type': { type: 'string', description: 'Override grant_type (inferred from resource URN if omitted)' },
|
|
674
|
+
resource: { type: 'string', description: 'Resource URN to refresh (default: WEBAGENTS_BACKEND destination)' },
|
|
695
675
|
...outputArgs,
|
|
696
676
|
},
|
|
697
677
|
async run({ args }) {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
678
|
+
// refresh writes to device-doc requests and returns immediately.
|
|
679
|
+
// token.mjs will pick up the new token when it arrives.
|
|
680
|
+
const tokenScript = join(_dirname(_fileURLToPath(import.meta.url)), 'token.mjs');
|
|
681
|
+
const argv = [tokenScript, '--refresh'];
|
|
682
|
+
if (args.resource) argv.push('--resource', args.resource);
|
|
683
|
+
if (args.json) argv.push('--json');
|
|
684
|
+
if (args.env) argv.push('--env');
|
|
685
|
+
const cfg = loadConfig();
|
|
686
|
+
if (cfg.url) argv.push('--url', cfg.url);
|
|
687
|
+
const child = spawn(process.execPath, argv, { stdio: 'inherit' });
|
|
688
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
710
689
|
},
|
|
711
690
|
});
|
|
712
691
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiam/ciba",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
4
4
|
"description": "OAuth 2.0 Device Authorization Grant CLI with cross-device push approval (Yjs sync, ECDH-encrypted token delivery, persistent device id)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"ciba.mjs",
|
|
11
|
+
"token.mjs",
|
|
11
12
|
"keychain.mjs",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
package/token.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* token.mjs — Standalone token fetcher.
|
|
4
|
+
*
|
|
5
|
+
* Connects directly to the device doc via Yjs/Hocuspocus WS,
|
|
6
|
+
* checks resources map, writes a request if needed, waits for
|
|
7
|
+
* the token to land, decrypts and prints to stdout.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node token.mjs [--resource <urn>] [--url <server>] [--json] [--env]
|
|
10
|
+
*
|
|
11
|
+
* Logs to stderr, token to stdout.
|
|
12
|
+
*/
|
|
13
|
+
import { createECDH, createHash, createDecipheriv, createSign, createPrivateKey, randomBytes } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { HocuspocusProvider } from '@hocuspocus/provider';
|
|
18
|
+
import * as Y from 'yjs';
|
|
19
|
+
import { WebSocket } from 'ws';
|
|
20
|
+
|
|
21
|
+
const CURVE = 'prime256v1';
|
|
22
|
+
const CONFIG_DIR = join(homedir(), '.config', 'ciba');
|
|
23
|
+
const KEYS_FILE = join(CONFIG_DIR, 'keys.json');
|
|
24
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
25
|
+
const DEFAULT_SERVER = 'https://mtls-device.dev-eu12.build.cloud.sap';
|
|
26
|
+
|
|
27
|
+
// ─── Args ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const get = (flag) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : undefined; };
|
|
31
|
+
const has = (flag) => args.includes(flag);
|
|
32
|
+
|
|
33
|
+
const resource = get('--resource') ?? `urn:sap:destination:${process.env.CIBA_DEFAULT_DESTINATION || 'WEBAGENTS_BACKEND'}`;
|
|
34
|
+
const jsonOut = has('--json');
|
|
35
|
+
const envOut = has('--env');
|
|
36
|
+
const isRefresh = has('--refresh');
|
|
37
|
+
|
|
38
|
+
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function loadConfig() {
|
|
41
|
+
try { if (existsSync(CONFIG_FILE)) return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch {}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cfg = loadConfig();
|
|
46
|
+
const serverUrl = get('--url') || process.env.CIBA_URL || cfg.url || DEFAULT_SERVER;
|
|
47
|
+
|
|
48
|
+
// ─── Keys ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function loadKeys() {
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(KEYS_FILE)) {
|
|
53
|
+
const k = JSON.parse(readFileSync(KEYS_FILE, 'utf8'));
|
|
54
|
+
if (k.privateKey && k.publicKey) return k;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const keys = loadKeys();
|
|
61
|
+
if (!keys) {
|
|
62
|
+
process.stderr.write('No device keys found. Run: ciba login --persist\n');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const { privateKey, publicKey } = keys;
|
|
66
|
+
|
|
67
|
+
// ─── Device JWT ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function signDeviceJwt(privBase64, pubBase64) {
|
|
70
|
+
const pubBuf = Buffer.from(pubBase64, 'base64');
|
|
71
|
+
const privBuf = Buffer.from(privBase64, 'base64');
|
|
72
|
+
const deviceId = createHash('sha256').update(pubBuf).digest('base64url');
|
|
73
|
+
const x = pubBuf.subarray(1, 33).toString('base64url');
|
|
74
|
+
const y = pubBuf.subarray(33, 65).toString('base64url');
|
|
75
|
+
const d = privBuf.toString('base64url');
|
|
76
|
+
const keyObject = createPrivateKey({ key: { kty: 'EC', crv: 'P-256', x, y, d }, format: 'jwk' });
|
|
77
|
+
const header = { alg: 'ES256', typ: 'JWT', jwk: { kty: 'EC', crv: 'P-256', x, y } };
|
|
78
|
+
const payload = { sub: deviceId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 };
|
|
79
|
+
const h = Buffer.from(JSON.stringify(header)).toString('base64url');
|
|
80
|
+
const p = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
81
|
+
const sig = createSign('SHA256').update(`${h}.${p}`).sign({ key: keyObject, dsaEncoding: 'ieee-p1363' }, 'base64url');
|
|
82
|
+
return { jwt: `${h}.${p}.${sig}`, deviceId };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Decrypt ─────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function decrypt(map) {
|
|
88
|
+
const ciphertext = map.get('ciphertext');
|
|
89
|
+
const serverPublicKey = map.get('serverPublicKey');
|
|
90
|
+
if (!ciphertext || !serverPublicKey) return null;
|
|
91
|
+
const serverPub = Buffer.from(serverPublicKey, 'base64');
|
|
92
|
+
const ecdh = createECDH(CURVE);
|
|
93
|
+
ecdh.setPrivateKey(Buffer.from(privateKey, 'base64'));
|
|
94
|
+
const shared = ecdh.computeSecret(serverPub);
|
|
95
|
+
const aesKey = createHash('sha256').update(shared).digest();
|
|
96
|
+
const buf = Buffer.from(ciphertext, 'base64');
|
|
97
|
+
const iv = buf.subarray(0, 12);
|
|
98
|
+
const tag = buf.subarray(12, 28);
|
|
99
|
+
const data = buf.subarray(28);
|
|
100
|
+
const dec = createDecipheriv('aes-256-gcm', aesKey, iv);
|
|
101
|
+
dec.setAuthTag(tag);
|
|
102
|
+
const plain = Buffer.concat([dec.update(data), dec.final()]).toString('utf8');
|
|
103
|
+
try { return JSON.parse(plain).access_token ?? plain; } catch { return plain; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── tryGet ───────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function tryGet(resourcesMap, deviceDoc, res) {
|
|
109
|
+
const key = res ? resourcesMap.get(res) : [...resourcesMap.values()][0];
|
|
110
|
+
if (!key) return null;
|
|
111
|
+
const map = deviceDoc.getMap(key);
|
|
112
|
+
const expiresAt = map.get('expires_at');
|
|
113
|
+
if (expiresAt && expiresAt < Date.now()) {
|
|
114
|
+
log(` token for ${res ?? 'default'} found but expired`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!map.get('ciphertext')) return null;
|
|
118
|
+
return map;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Output ───────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function log(msg) { process.stderr.write(msg + '\n'); }
|
|
124
|
+
|
|
125
|
+
function output(token) {
|
|
126
|
+
if (jsonOut) {
|
|
127
|
+
process.stdout.write(JSON.stringify({ access_token: token, token_type: 'Bearer' }, null, 2) + '\n');
|
|
128
|
+
} else if (envOut) {
|
|
129
|
+
process.stdout.write(`export ACCESS_TOKEN="${token}"\n`);
|
|
130
|
+
} else {
|
|
131
|
+
process.stdout.write(token + '\n');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
const { jwt: deviceJwt, deviceId } = signDeviceJwt(privateKey, publicKey);
|
|
138
|
+
log(`→ device: ${deviceId}`);
|
|
139
|
+
log(`→ server: ${serverUrl}`);
|
|
140
|
+
log(`→ resource: ${resource}`);
|
|
141
|
+
log('');
|
|
142
|
+
|
|
143
|
+
const cliUA = `ciba-cli/token (${process.platform} ${process.arch}; node ${process.version})`;
|
|
144
|
+
class TaggedWebSocket extends WebSocket {
|
|
145
|
+
constructor(url, protocols) { super(url, protocols, { headers: { 'User-Agent': cliUA } }); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const syncUrl = serverUrl.replace(/^http/, 'ws') + '/sync';
|
|
149
|
+
const deviceDoc = new Y.Doc();
|
|
150
|
+
const provider = new HocuspocusProvider({
|
|
151
|
+
url: syncUrl, name: deviceId, token: deviceJwt,
|
|
152
|
+
document: deviceDoc, WebSocketPolyfill: TaggedWebSocket,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
log('→ connecting...');
|
|
156
|
+
|
|
157
|
+
await new Promise((resolve, reject) => {
|
|
158
|
+
const t = setTimeout(() => reject(new Error('Timeout connecting')), 10000);
|
|
159
|
+
provider.on('synced', () => { clearTimeout(t); resolve(); });
|
|
160
|
+
provider.on('authenticationFailed', ({ reason }) => { clearTimeout(t); reject(new Error(reason)); });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
log('→ synced');
|
|
164
|
+
|
|
165
|
+
// Write public key
|
|
166
|
+
const meta = deviceDoc.getMap('meta');
|
|
167
|
+
if (meta.get('public_key') !== publicKey) meta.set('public_key', publicKey);
|
|
168
|
+
|
|
169
|
+
const resourcesMap = deviceDoc.getMap('resources');
|
|
170
|
+
log(`→ resources: ${JSON.stringify([...resourcesMap.entries()])}`);
|
|
171
|
+
|
|
172
|
+
// Refresh mode: write request and exit immediately.
|
|
173
|
+
if (isRefresh) {
|
|
174
|
+
const requests = deviceDoc.getMap('requests');
|
|
175
|
+
const newRid = randomBytes(8).toString('base64url');
|
|
176
|
+
log(`→ refresh — writing request ${newRid}`);
|
|
177
|
+
requests.set(newRid, { resource, status: 'pending', created_at: new Date().toISOString() });
|
|
178
|
+
provider.destroy();
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// tryGet immediately
|
|
183
|
+
const found = tryGet(resourcesMap, deviceDoc, resource);
|
|
184
|
+
if (found) {
|
|
185
|
+
log('→ cache hit');
|
|
186
|
+
const token = decrypt(found);
|
|
187
|
+
provider.destroy();
|
|
188
|
+
output(token);
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Cache miss — write request
|
|
193
|
+
const requests = deviceDoc.getMap('requests');
|
|
194
|
+
const newRid = randomBytes(8).toString('base64url');
|
|
195
|
+
log(`→ cache miss — writing request ${newRid}`);
|
|
196
|
+
requests.set(newRid, { resource, status: 'pending', created_at: new Date().toISOString() });
|
|
197
|
+
|
|
198
|
+
// Wait for resources to update
|
|
199
|
+
log('→ waiting for token...');
|
|
200
|
+
const token = await new Promise((resolve, reject) => {
|
|
201
|
+
const timer = setTimeout(() => {
|
|
202
|
+
resourcesMap.unobserve(observer);
|
|
203
|
+
reject(new Error('Timeout waiting for token'));
|
|
204
|
+
}, 30_000);
|
|
205
|
+
|
|
206
|
+
const observer = () => {
|
|
207
|
+
log(`→ resources updated: ${JSON.stringify([...resourcesMap.entries()])}`);
|
|
208
|
+
const found = tryGet(resourcesMap, deviceDoc, resource);
|
|
209
|
+
if (found) {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
resourcesMap.unobserve(observer);
|
|
212
|
+
resolve(decrypt(found));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
resourcesMap.observe(observer);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
provider.destroy();
|
|
219
|
+
output(token);
|