@aiam/ciba 0.8.8 → 0.9.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 (3) hide show
  1. package/ciba.mjs +23 -32
  2. package/package.json +2 -1
  3. package/token.mjs +219 -0
package/ciba.mjs CHANGED
@@ -650,51 +650,42 @@ const loginCmd = defineCommand({
650
650
  });
651
651
 
652
652
  const tokenCmd = defineCommand({
653
- meta: { description: 'Return token from running session (cache-first)' },
653
+ meta: { description: 'Return token (cache-first, no daemon needed)' },
654
654
  args: {
655
- resource: { type: 'string', description: 'Resource URN (default: urn:sap:destination:WEBAGENTS_BACKEND)' },
656
- destination: { type: 'string', description: 'Destination name (alternative to full resource URN)' },
657
- '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)' },
658
656
  ...outputArgs,
659
657
  },
660
658
  async run({ args }) {
661
- const attrs = {
662
- ...(args.resource ? { resource: args.resource } : {}),
663
- ...(args.destination ? { destination: args.destination } : {}),
664
- ...(args['grant-type'] ? { grant_type: args['grant-type'] } : {}),
665
- };
666
- try {
667
- const token = await socketCommand('token', attrs);
668
- makeOutput(args).print(token);
669
- process.exit(0);
670
- } catch (e) {
671
- process.stderr.write(`Error: ${e.message}\n`);
672
- process.exit(1);
673
- }
659
+ const tokenScript = join(_pkgDir, "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));
674
668
  },
675
669
  });
676
670
 
677
671
  const refreshCmd = defineCommand({
678
672
  meta: { description: 'Force a fresh token exchange (no re-approval needed)' },
679
673
  args: {
680
- resource: { type: 'string', description: 'Resource URN to refresh (default: WEBAGENTS_BACKEND destination)' },
681
- destination: { type: 'string', description: 'Destination name (alternative to full resource URN)' },
682
- '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)' },
683
675
  ...outputArgs,
684
676
  },
685
677
  async run({ args }) {
686
- const attrs = {
687
- ...(args.resource ? { resource: args.resource } : {}),
688
- ...(args.destination ? { destination: args.destination } : {}),
689
- ...(args['grant-type'] ? { grant_type: args['grant-type'] } : {}),
690
- };
691
- try {
692
- await socketCommand('refresh', attrs);
693
- process.exit(0);
694
- } catch (e) {
695
- process.stderr.write(`Error: ${e.message}\n`);
696
- process.exit(1);
697
- }
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(_pkgDir, "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));
698
689
  },
699
690
  });
700
691
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiam/ciba",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
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);