@skrr-ai/auth-core 0.1.2

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 (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LocalCallbackMalformedError = exports.LocalCallbackDeniedError = exports.LocalCallbackStateMismatchError = exports.LocalCallbackTimeoutError = exports.LocalCallbackBindError = exports.LocalCallbackError = exports.DEFAULT_LOGIN_TIMEOUT_MS = void 0;
7
+ exports.startLocalCallback = startLocalCallback;
8
+ /**
9
+ * loginLocalhost.ts — local 127.0.0.1 callback server for the PKCE
10
+ * authorization-code login flow (Phase H.0,
11
+ * docs/daemon-auth-roadmap.md).
12
+ *
13
+ * The CLI starts this server on an ephemeral port BEFORE opening the
14
+ * browser to the consent page. After the user approves on
15
+ * `app.oversky.ai/auth/cli`, the OverSky server 302s the browser to
16
+ * `http://127.0.0.1:<port>/callback?code=...&state=...`. We accept
17
+ * exactly that one redirect (single-use, validated state, reject every
18
+ * other path/method/payload), hand the auth code back to the caller,
19
+ * close the server, and the caller proceeds to PKCE token exchange.
20
+ *
21
+ * API shape — handle pattern:
22
+ *
23
+ * const handle = await startLocalCallback({ state });
24
+ * // handle.redirectUri is bound and known synchronously; pass it to
25
+ * // /cli-init so the server can validate + embed it in the consent
26
+ * // page's redirect.
27
+ * await postCliInit({ redirect_uri: handle.redirectUri, state, ... });
28
+ * openBrowser(authUrl);
29
+ * const { code } = await handle.waitForCallback();
30
+ *
31
+ * The split avoids the awkward "we need redirectUri before the
32
+ * callback arrives but the same Promise resolves with the code" tangle
33
+ * — the redirectUri is hot the moment listen() succeeds, the callback
34
+ * promise resolves much later when the user approves.
35
+ *
36
+ * Security model:
37
+ * - 127.0.0.1 only — never 0.0.0.0. A daemon binding 0.0.0.0 lets
38
+ * any device on the LAN snag the auth code by hitting the callback
39
+ * URL on the developer's machine before the legitimate browser
40
+ * does. Numeric host (not "localhost") to avoid a DNS resolver
41
+ * surprise routing the lookup off-loopback.
42
+ * - State validation with timingSafeEqual via `pkce.safeEqual`. The
43
+ * state is generated by the CLI and bound into both the consent
44
+ * page request and the callback URL. A redirect that does not
45
+ * echo the exact state is rejected with no detail leaked to the
46
+ * page — attacker cannot tell "missing" from "wrong".
47
+ * - Single-use: the first valid callback resolves the promise and
48
+ * immediately closes the server. Any further requests during
49
+ * teardown receive a generic 503 from the closing socket.
50
+ * - Reject non-GET (405) and unknown paths (404) so a probing
51
+ * request can't deduce the server's purpose from differential
52
+ * responses.
53
+ * - No user-controlled data in any HTML response — static templates
54
+ * only. Defense vs reflected-XSS via a malicious
55
+ * `error_description` value an attacker might inject.
56
+ * - Structured error classes so the caller branches correctly:
57
+ * BindError → fall back to a different port or device-code
58
+ * TimeoutError → fall back to device-code with explanation
59
+ * StateMismatchError → likely attack; fail loudly, do not retry
60
+ * DeniedError → user declined; surface clearly, do not retry
61
+ * MalformedCallbackError → server bug or rogue extension;
62
+ * surface for diagnosis
63
+ * (CANCELLED) → caller invoked handle.close() before the
64
+ * callback arrived
65
+ *
66
+ * The server and timeout DO keep the event loop alive — that's the
67
+ * whole point: this module is invoked from a top-level CLI command
68
+ * that needs to block on the user clicking approve. Ctrl-C in the
69
+ * CLI sends SIGINT, which interrupts the caller's await and triggers
70
+ * normal shutdown logic (which should call handle.close()). Setting
71
+ * server.unref() here would let isolated CLI invocations exit before
72
+ * the callback arrives.
73
+ */
74
+ const node_http_1 = __importDefault(require("node:http"));
75
+ const pkce_js_1 = require("./pkce.js");
76
+ const runtime_js_1 = require("./runtime.js");
77
+ /** Callback path — hardcoded; caller MUST construct redirectUri to match. */
78
+ const CALLBACK_PATH = '/callback';
79
+ /** Default total wait (ms) for the callback. Matches consent-page UX. */
80
+ exports.DEFAULT_LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
81
+ // ---------------------------------------------------------------------
82
+ // Structured errors — caller branches on these
83
+ // ---------------------------------------------------------------------
84
+ class LocalCallbackError extends Error {
85
+ code;
86
+ constructor(code, message) {
87
+ super(message);
88
+ this.name = 'LocalCallbackError';
89
+ this.code = code;
90
+ }
91
+ }
92
+ exports.LocalCallbackError = LocalCallbackError;
93
+ class LocalCallbackBindError extends LocalCallbackError {
94
+ port;
95
+ cause;
96
+ constructor(port, cause) {
97
+ super('BIND_FAILED', `Failed to bind localhost callback on port ${port}: ${cause?.message ?? 'unknown error'}`);
98
+ this.name = 'LocalCallbackBindError';
99
+ this.port = port;
100
+ this.cause = cause;
101
+ }
102
+ }
103
+ exports.LocalCallbackBindError = LocalCallbackBindError;
104
+ class LocalCallbackTimeoutError extends LocalCallbackError {
105
+ timeoutMs;
106
+ constructor(timeoutMs) {
107
+ super('TIMEOUT', `No callback received within ${timeoutMs} ms`);
108
+ this.name = 'LocalCallbackTimeoutError';
109
+ this.timeoutMs = timeoutMs;
110
+ }
111
+ }
112
+ exports.LocalCallbackTimeoutError = LocalCallbackTimeoutError;
113
+ class LocalCallbackStateMismatchError extends LocalCallbackError {
114
+ constructor() {
115
+ super('STATE_MISMATCH', 'Callback state did not match the expected value — rejecting (possible attack)');
116
+ this.name = 'LocalCallbackStateMismatchError';
117
+ }
118
+ }
119
+ exports.LocalCallbackStateMismatchError = LocalCallbackStateMismatchError;
120
+ class LocalCallbackDeniedError extends LocalCallbackError {
121
+ serverError;
122
+ constructor(serverError, description) {
123
+ super('DENIED', description ? `${serverError}: ${description}` : serverError);
124
+ this.name = 'LocalCallbackDeniedError';
125
+ this.serverError = serverError;
126
+ }
127
+ }
128
+ exports.LocalCallbackDeniedError = LocalCallbackDeniedError;
129
+ class LocalCallbackMalformedError extends LocalCallbackError {
130
+ constructor(reason) {
131
+ super('MALFORMED', `Malformed callback: ${reason}`);
132
+ this.name = 'LocalCallbackMalformedError';
133
+ }
134
+ }
135
+ exports.LocalCallbackMalformedError = LocalCallbackMalformedError;
136
+ // ---------------------------------------------------------------------
137
+ // Public API
138
+ // ---------------------------------------------------------------------
139
+ /**
140
+ * Start the local 127.0.0.1 callback server. Binds synchronously
141
+ * (await'd via the listen handshake) and returns a handle whose
142
+ * `redirectUri` is immediately usable. The caller drives the rest of
143
+ * the flow and awaits `waitForCallback()` when ready.
144
+ *
145
+ * Throws `LocalCallbackBindError` if the bind fails (caller decides
146
+ * whether to retry on a different port or fall back to device-code).
147
+ */
148
+ async function startLocalCallback(opts) {
149
+ const log = (0, runtime_js_1.getAuthLogger)();
150
+ const port = opts.port ?? 0;
151
+ const timeoutMs = opts.timeoutMs ?? exports.DEFAULT_LOGIN_TIMEOUT_MS;
152
+ const expectedState = opts.state;
153
+ // P1 #5 (multica-convergence) — host/bindAddr split. Defaults preserve the
154
+ // pre-change security posture (127.0.0.1 for both). Any deviation is
155
+ // logged prominently because it widens the callback blast radius.
156
+ const urlHost = typeof opts.host === 'string' && opts.host.length > 0 ? opts.host : '127.0.0.1';
157
+ const bindAddr = typeof opts.bindAddr === 'string' && opts.bindAddr.length > 0 ? opts.bindAddr : urlHost;
158
+ const isLoopback = (h) => h === '127.0.0.1' || h === '::1' || h === 'localhost';
159
+ if (!isLoopback(bindAddr)) {
160
+ log.warn(`[loginLocalhost] non-loopback bind ${bindAddr} — any device reaching this interface ` +
161
+ 'can race the callback. Only use on trusted networks.');
162
+ }
163
+ if (typeof expectedState !== 'string' || expectedState.length === 0) {
164
+ throw new LocalCallbackError('BAD_OPTIONS', 'startLocalCallback: state is required');
165
+ }
166
+ if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
167
+ throw new LocalCallbackError('BAD_OPTIONS', `startLocalCallback: invalid timeoutMs=${timeoutMs}`);
168
+ }
169
+ // Wire the callback promise up front so the request handler can
170
+ // settle it without referencing the outer Promise constructor.
171
+ let settled = false;
172
+ let resolveCallback;
173
+ let rejectCallback;
174
+ const callbackPromise = new Promise((resolve, reject) => {
175
+ resolveCallback = resolve;
176
+ rejectCallback = reject;
177
+ });
178
+ let timer = null;
179
+ // Populated after listen() succeeds — every code path that reads
180
+ // `redirectUri` runs strictly after that resolution.
181
+ let redirectUri = '';
182
+ const tearDown = () => {
183
+ if (timer) {
184
+ clearTimeout(timer);
185
+ timer = null;
186
+ }
187
+ try {
188
+ // Drop any keep-alive sockets a misbehaving extension may have
189
+ // opened — without this, server.close() waits indefinitely for
190
+ // them to idle out. Available since Node 18.2; we require >=20.
191
+ server.closeAllConnections?.();
192
+ }
193
+ catch {
194
+ /* ignore */
195
+ }
196
+ try {
197
+ server.close();
198
+ }
199
+ catch {
200
+ /* ignore */
201
+ }
202
+ };
203
+ const settle = (err, result) => {
204
+ if (settled)
205
+ return;
206
+ settled = true;
207
+ tearDown();
208
+ if (err)
209
+ rejectCallback(err);
210
+ else if (result)
211
+ resolveCallback(result);
212
+ };
213
+ const server = node_http_1.default.createServer((req, res) => {
214
+ // Reject anything except GET /callback. Bodies are deliberately
215
+ // generic — never reveal which check failed (purpose-disclosure
216
+ // resistance); attacker probing the port shouldn't learn it's a
217
+ // PKCE callback server vs. some other localhost service.
218
+ if (req.method !== 'GET') {
219
+ res.writeHead(405, { 'Content-Type': 'text/plain', Allow: 'GET' });
220
+ res.end('Method not allowed');
221
+ return;
222
+ }
223
+ // Parse with an explicit base — never trust the Host header.
224
+ const reqUrl = new URL(req.url || '/', 'http://127.0.0.1');
225
+ if (reqUrl.pathname !== CALLBACK_PATH) {
226
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
227
+ res.end('Not found');
228
+ return;
229
+ }
230
+ // Authorization-server error path: ?error=access_denied[&error_description=...]
231
+ // The error_description is attacker-controllable in principle, so
232
+ // never echo it into HTML — we serve a static page and surface
233
+ // the structured detail only via the rejected promise + log.
234
+ const oauthError = reqUrl.searchParams.get('error');
235
+ if (oauthError) {
236
+ const desc = reqUrl.searchParams.get('error_description') ?? undefined;
237
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
238
+ res.end(declinedHtml());
239
+ log.warn(`[loginLocalhost] authorization server returned error=${oauthError} ` +
240
+ (desc ? `description=${desc}` : ''));
241
+ settle(new LocalCallbackDeniedError(oauthError, desc));
242
+ return;
243
+ }
244
+ const code = reqUrl.searchParams.get('code');
245
+ const returnedState = reqUrl.searchParams.get('state');
246
+ if (!code || !returnedState) {
247
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
248
+ res.end(genericFailureHtml());
249
+ settle(new LocalCallbackMalformedError('missing code or state'));
250
+ return;
251
+ }
252
+ if (!(0, pkce_js_1.safeEqual)(returnedState, expectedState)) {
253
+ // Generic 400 body — do NOT distinguish "missing" from "wrong"
254
+ // on the page. Logged separately for ops triage.
255
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
256
+ res.end(genericFailureHtml());
257
+ log.warn('[loginLocalhost] state mismatch on callback — rejecting');
258
+ settle(new LocalCallbackStateMismatchError());
259
+ return;
260
+ }
261
+ // Happy path.
262
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
263
+ res.end(successHtml());
264
+ settle(null, { code, state: returnedState, redirectUri });
265
+ });
266
+ // Bind. Use a one-shot 'error' listener that's removed on success so
267
+ // post-bind socket errors flow through the server-level handler set
268
+ // up below.
269
+ await new Promise((resolve, reject) => {
270
+ const onListenError = (err) => {
271
+ reject(new LocalCallbackBindError(port, err));
272
+ };
273
+ server.once('error', onListenError);
274
+ server.listen(port, bindAddr, () => {
275
+ server.removeListener('error', onListenError);
276
+ resolve();
277
+ });
278
+ });
279
+ const addr = server.address();
280
+ if (!addr || typeof addr === 'string') {
281
+ try {
282
+ server.close();
283
+ }
284
+ catch {
285
+ /* ignore */
286
+ }
287
+ throw new LocalCallbackBindError(port, new Error('server.address() returned non-AddressInfo after listen'));
288
+ }
289
+ // IPv6 literal hosts need brackets in the URL form.
290
+ const hostInUrl = urlHost.includes(':') && !urlHost.startsWith('[') ? `[${urlHost}]` : urlHost;
291
+ redirectUri = `http://${hostInUrl}:${addr.port}${CALLBACK_PATH}`;
292
+ log.debug?.(`[loginLocalhost] listening on ${redirectUri}`);
293
+ // Post-bind error handler — socket errors etc. Once we've settled the
294
+ // happy path, swallow further errors quietly so a delayed
295
+ // ECONNRESET from a closed-tab keep-alive can't blow up the process.
296
+ server.on('error', (err) => {
297
+ if (settled)
298
+ return;
299
+ log.warn(`[loginLocalhost] post-bind server error: ${err.message}`);
300
+ settle(new LocalCallbackError('SERVER_ERROR', err.message));
301
+ });
302
+ timer = setTimeout(() => {
303
+ settle(new LocalCallbackTimeoutError(timeoutMs));
304
+ }, timeoutMs);
305
+ // Deliberately NOT calling server.unref() / timer.unref(). The
306
+ // whole point of this server is to keep the parent process alive
307
+ // while waiting for the user to click approve in the browser; if
308
+ // nothing else in the CLI is ref'd (we may be deep in an isolated
309
+ // login command), an unref'd server would let the event loop exit
310
+ // before the callback ever arrives. Ctrl-C handles teardown via
311
+ // SIGINT, which interrupts the await in the caller and lets normal
312
+ // shutdown logic invoke handle.close().
313
+ return {
314
+ redirectUri,
315
+ waitForCallback: () => callbackPromise,
316
+ close: () => {
317
+ settle(new LocalCallbackError('CANCELLED', 'callback wait cancelled by caller'));
318
+ },
319
+ };
320
+ }
321
+ // ---------------------------------------------------------------------
322
+ // Static HTML responses — no user-controlled fields anywhere.
323
+ // ---------------------------------------------------------------------
324
+ function htmlShell(title, accent, headline, body) {
325
+ // Single inline template — keeps the binary self-contained (no
326
+ // bundled assets) and survives offline/firewalled environments.
327
+ return `<!doctype html>
328
+ <html lang="en">
329
+ <head>
330
+ <meta charset="utf-8">
331
+ <title>${title}</title>
332
+ <meta name="viewport" content="width=device-width,initial-scale=1">
333
+ <style>
334
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
335
+ background: #0b0d12; color: #f4f6fb; margin: 0; min-height: 100vh;
336
+ display: grid; place-items: center; }
337
+ .card { max-width: 24rem; padding: 2rem; text-align: center; }
338
+ h1 { font-size: 1.4rem; margin: 0 0 0.5rem 0; font-weight: 600; }
339
+ p { margin: 0; color: #94a3b8; line-height: 1.5; font-size: 0.95rem; }
340
+ .badge { width: 56px; height: 56px; margin: 0 auto 1.25rem; border-radius: 50%;
341
+ background: ${accent}; display: grid; place-items: center; }
342
+ .badge svg { width: 28px; height: 28px; stroke: white; stroke-width: 3; fill: none;
343
+ stroke-linecap: round; stroke-linejoin: round; }
344
+ </style>
345
+ </head>
346
+ <body>
347
+ <div class="card">
348
+ ${headline}
349
+ <p>${body}</p>
350
+ </div>
351
+ </body>
352
+ </html>`;
353
+ }
354
+ function successHtml() {
355
+ const headline = `<div class="badge"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div>
356
+ <h1>Authorized</h1>`;
357
+ return htmlShell('OverSky — Authorized', '#10b981', headline, 'You can close this tab and return to your terminal.');
358
+ }
359
+ function declinedHtml() {
360
+ const headline = `<div class="badge" style="background:#64748b"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></div>
361
+ <h1>Authorization declined</h1>`;
362
+ return htmlShell('OverSky — Authorization declined', '#64748b', headline, 'You can close this tab. Run the CLI command again to retry.');
363
+ }
364
+ function genericFailureHtml() {
365
+ const headline = `<div class="badge" style="background:#ef4444"><svg viewBox="0 0 24 24"><path d="M12 8v4M12 16h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
366
+ <h1>Authorization error</h1>`;
367
+ return htmlShell('OverSky — Authorization error', '#ef4444', headline, 'Something went wrong. You can close this tab and retry from the terminal.');
368
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Caller-injected browser opener. Daemon side wraps platform-specific
3
+ * commands (`open`, `xdg-open`, `rundll32 url.dll,FileProtocolHandler`)
4
+ * in this signature. Failure to open the browser is non-fatal — we
5
+ * always print the URL to stdout as well, so a user with a broken
6
+ * default browser association can copy/paste.
7
+ */
8
+ export type BrowserOpener = (url: string) => void | Promise<void>;
9
+ export interface LoginWithLocalhostOptions {
10
+ /** Server API base URL — e.g. https://api.oversky.ai or http://localhost:3080. */
11
+ serverUrl: string;
12
+ /**
13
+ * Scope to request. Maps directly to DaemonRefreshSession.scope on
14
+ * the issued refresh token (Phase F). 'daemon' for the long-running
15
+ * tool-executor; 'cli' for short-lived REST-only operations.
16
+ */
17
+ scope?: 'daemon' | 'cli';
18
+ /**
19
+ * User-facing label for the device. Surfaced on the consent page so
20
+ * the user can confirm they're authorizing the right machine.
21
+ * Defaults to a generic label if omitted.
22
+ */
23
+ deviceName?: string;
24
+ /**
25
+ * Daemon ID to bind the issued refresh token to. Same role as the
26
+ * `daemonId` parameter on the device-code init endpoint. Without it,
27
+ * the server falls back to a synthesized ID and the first refresh
28
+ * rotation may fail (the C1 incident pattern from device-code).
29
+ */
30
+ daemonId?: string;
31
+ /**
32
+ * Port for the local callback. 0 = ephemeral. Pin via
33
+ * `OVERSKY_LOGIN_PORT` for firewall-allowlisted environments.
34
+ */
35
+ port?: number;
36
+ /**
37
+ * P1 #5 (multica-convergence) — override the host that appears in
38
+ * `redirectUri`. Defaults to `'127.0.0.1'`. Use only when the
39
+ * browser is on a different machine than the CLI (LAN scenario).
40
+ * A warning is logged when non-loopback.
41
+ */
42
+ host?: string;
43
+ /**
44
+ * Bind-address override (paired with `host`). Defaults to `host`
45
+ * value. Use `0.0.0.0` to listen on all interfaces when pairing with
46
+ * a LAN-reachable `host` IP.
47
+ */
48
+ bindAddr?: string;
49
+ /** Total timeout (ms) the local server waits for the callback. */
50
+ timeoutMs?: number;
51
+ /** Caller-injected browser opener. */
52
+ openBrowser: BrowserOpener;
53
+ /**
54
+ * fetch override for tests. Defaults to `globalThis.fetch` (Node 20+).
55
+ * Tests can swap in a mock that drives the local callback directly
56
+ * instead of needing a real server.
57
+ */
58
+ fetchImpl?: typeof fetch;
59
+ /**
60
+ * If provided, called once with the authUrl AFTER the local server
61
+ * is bound but BEFORE the browser is opened. Lets the CLI print the
62
+ * URL up front (so a user whose browser fails to open can still
63
+ * paste it). Non-fatal — exceptions are caught and logged.
64
+ */
65
+ onAuthUrl?: (authUrl: string) => void;
66
+ }
67
+ export interface LoginWithLocalhostResult {
68
+ /**
69
+ * Access token (JWT). Optional after Stage-E #1 step 4: daemon-scope
70
+ * logins ship `opaqueAccessToken` only and omit `token` because the
71
+ * daemon-JWT substrate has been retired. CLI / web logins still
72
+ * receive a JWT here.
73
+ */
74
+ token?: string;
75
+ /** Access token expiry (ms epoch), if the server returned one. */
76
+ expiresAt?: number;
77
+ /** Rotating refresh token, if issued (Phase E). */
78
+ refreshToken?: string;
79
+ /** Refresh token expiry (ms epoch), if issued. */
80
+ refreshExpiresAt?: number;
81
+ /** Daemon ID the refresh token is bound to (server may have synthesized). */
82
+ daemonId?: string;
83
+ /** Echo of the scope of the issued tokens. */
84
+ scope?: 'daemon' | 'cli' | 'web';
85
+ /**
86
+ * Stage-E #1 — opaque `osk_dmn_*` daemon access token co-issued with
87
+ * the JWT for daemon-scope login. Optional during the dark-ship
88
+ * migration:
89
+ * - undefined → server didn't co-issue (older API, non-daemon
90
+ * scope, or kill switch on). Caller stores JWT only.
91
+ * - string → caller MUST persist via
92
+ * `saveToken(..., { opaqueAccessToken: result.opaqueAccessToken })`
93
+ * so the next handshake prefers it over the JWT.
94
+ *
95
+ * Closes the "fresh login pre-first-refresh" 1h blind spot. Without
96
+ * this, a freshly-logged-in daemon would auth via JWT until its
97
+ * first refresh response (~1h) co-issued an opaque, then switch
98
+ * over. With this, the daemon presents opaque from the very first
99
+ * post-login WS handshake.
100
+ *
101
+ * NEVER null on this surface — login responses don't carry the
102
+ * "explicitly clear" semantic that refresh responses do. A null
103
+ * field on the wire is treated as undefined (field absent).
104
+ */
105
+ opaqueAccessToken?: string;
106
+ }
107
+ /**
108
+ * /cli-init failed. `code` distinguishes the failure mode so the
109
+ * caller can branch (FEATURE_DISABLED → fall back to device-code; the
110
+ * rest → surface to user).
111
+ */
112
+ export declare class LoginInitError extends Error {
113
+ readonly code: 'FEATURE_DISABLED' | 'BAD_REQUEST' | 'RATE_LIMITED' | 'SERVER_ERROR' | 'NETWORK_ERROR' | 'MALFORMED_RESPONSE';
114
+ readonly status?: number;
115
+ readonly responseBody?: string;
116
+ constructor(code: LoginInitError['code'], message: string, status?: number, body?: string);
117
+ }
118
+ /** /cli-token-exchange failed. */
119
+ export declare class LoginExchangeError extends Error {
120
+ readonly code: 'INVALID_GRANT' | 'BAD_REQUEST' | 'SERVER_ERROR' | 'NETWORK_ERROR' | 'MALFORMED_RESPONSE';
121
+ readonly status?: number;
122
+ readonly responseBody?: string;
123
+ constructor(code: LoginExchangeError['code'], message: string, status?: number, body?: string);
124
+ }
125
+ /**
126
+ * Run the full PKCE-via-localhost login flow. See file header for the
127
+ * step-by-step. Throws structured errors on every failure path. The
128
+ * local callback server is always torn down before this function
129
+ * returns or throws.
130
+ */
131
+ export declare function loginWithLocalhost(opts: LoginWithLocalhostOptions): Promise<LoginWithLocalhostResult>;