@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,359 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LoginExchangeError = exports.LoginInitError = void 0;
4
+ exports.loginWithLocalhost = loginWithLocalhost;
5
+ /**
6
+ * loginWithLocalhost.ts — Phase H.0 orchestrator.
7
+ *
8
+ * @deprecated Tier-1.1 (2026-04-29). This module talks to the older
9
+ * `/api/auth/cli-init` + `/api/auth/cli-token-exchange` endpoints; the
10
+ * canonical PKCE+localhost orchestrator is now
11
+ * `daemon/src/cli/oauthLogin.ts:runOAuthLogin`, which uses the standard
12
+ * `/api/oauth/token` endpoint and additionally arms a manual-paste
13
+ * fallback arm. Retained until `--legacy` is removed (gated on Tier-2.2
14
+ * telemetry showing <1% usage). New code MUST NOT call this — the only
15
+ * remaining caller is the daemon's `loginInteractive` legacy fallback at
16
+ * `daemon/src/auth.ts:loginWithLocalhostFlow`. See
17
+ * `docs/daemon-auth-production-readiness-2026-04-29.md` Tier 1.1 for the
18
+ * removal plan.
19
+ *
20
+ * Ties PKCE primitives + local 127.0.0.1 callback server + server-side
21
+ * /cli-init / /cli-token-exchange endpoints into a single function the
22
+ * CLI/daemon can call. Returns daemon-style tokens (same shape as the
23
+ * device-code completion path) so the persistence layer in
24
+ * `daemon/src/auth.ts:saveToken()` doesn't change.
25
+ *
26
+ * Flow:
27
+ *
28
+ * 1. generate code_verifier + state
29
+ * 2. compute S256 code_challenge
30
+ * 3. start local 127.0.0.1 callback server (handle.redirectUri ready)
31
+ * 4. POST /api/auth/cli-init { code_challenge, redirect_uri, state,
32
+ * device_name, scope }
33
+ * → { requestId, authUrl }
34
+ * 5. open authUrl in user's browser (caller-injected opener)
35
+ * 6. user approves on app.oversky.ai/auth/cli?req=<requestId>
36
+ * 7. server 302s browser → http://127.0.0.1:<port>/callback?code&state
37
+ * 8. handle.waitForCallback() resolves with { code }
38
+ * 9. POST /api/auth/cli-token-exchange { code, code_verifier,
39
+ * redirect_uri }
40
+ * → { token, expiresAt, refreshToken?, refreshExpiresAt? }
41
+ * 10. return the token bundle (caller persists via saveToken())
42
+ *
43
+ * Design boundaries:
44
+ *
45
+ * - Browser opener is caller-injected (auth-core stays platform-
46
+ * agnostic; daemon passes its `execFile('open' | 'xdg-open' |
47
+ * 'rundll32', …)` wrapper).
48
+ * - fetch is caller-overridable for tests; defaults to globalThis.fetch
49
+ * (Node 20+).
50
+ * - SSH / headless / OVERSKY_FORCE_DEVICE_CODE detection is the
51
+ * CALLER's job — this orchestrator just runs the flow. The caller
52
+ * decides whether localhost callback is appropriate before calling.
53
+ * - No retries. The auth code is single-use and short-lived; if a
54
+ * network blip drops the token-exchange request, the user just
55
+ * reruns the login command. Adding retries here muddies error
56
+ * semantics for callers that want clean fallback signals.
57
+ *
58
+ * Error handling:
59
+ *
60
+ * The orchestrator throws structured errors so callers can branch:
61
+ *
62
+ * LocalCallbackBindError — port unavailable; caller may retry
63
+ * LocalCallbackTimeoutError — user never approved; fall back
64
+ * LocalCallbackStateMismatchError — likely attack; fail loudly
65
+ * LocalCallbackDeniedError — user clicked deny; surface clearly
66
+ * LocalCallbackMalformedError — server bug; surface for diagnosis
67
+ * LoginInitError — /cli-init HTTP failure
68
+ * (code='FEATURE_DISABLED' for 404 →
69
+ * caller falls back to device-code)
70
+ * LoginExchangeError — /cli-token-exchange HTTP failure
71
+ *
72
+ * Cleanup invariant: the local server is ALWAYS torn down before this
73
+ * function returns or throws — any failure path runs through the
74
+ * `try / finally(handle.close())` wrapper. A future caller cannot leak
75
+ * a listening socket by catching the wrong error.
76
+ */
77
+ const pkce_js_1 = require("./pkce.js");
78
+ const loginLocalhost_js_1 = require("./loginLocalhost.js");
79
+ const runtime_js_1 = require("./runtime.js");
80
+ /** Default scope for new daemon installs. */
81
+ const DEFAULT_SCOPE = 'daemon';
82
+ // ---------------------------------------------------------------------
83
+ // Error classes
84
+ // ---------------------------------------------------------------------
85
+ /**
86
+ * /cli-init failed. `code` distinguishes the failure mode so the
87
+ * caller can branch (FEATURE_DISABLED → fall back to device-code; the
88
+ * rest → surface to user).
89
+ */
90
+ class LoginInitError extends Error {
91
+ code; // 200 but body shape unexpected
92
+ status;
93
+ responseBody;
94
+ constructor(code, message, status, body) {
95
+ super(message);
96
+ this.name = 'LoginInitError';
97
+ this.code = code;
98
+ this.status = status;
99
+ this.responseBody = body;
100
+ }
101
+ }
102
+ exports.LoginInitError = LoginInitError;
103
+ /** /cli-token-exchange failed. */
104
+ class LoginExchangeError extends Error {
105
+ code; // 200 but body shape unexpected
106
+ status;
107
+ responseBody;
108
+ constructor(code, message, status, body) {
109
+ super(message);
110
+ this.name = 'LoginExchangeError';
111
+ this.code = code;
112
+ this.status = status;
113
+ this.responseBody = body;
114
+ }
115
+ }
116
+ exports.LoginExchangeError = LoginExchangeError;
117
+ // ---------------------------------------------------------------------
118
+ // Public API
119
+ // ---------------------------------------------------------------------
120
+ /**
121
+ * Run the full PKCE-via-localhost login flow. See file header for the
122
+ * step-by-step. Throws structured errors on every failure path. The
123
+ * local callback server is always torn down before this function
124
+ * returns or throws.
125
+ */
126
+ async function loginWithLocalhost(opts) {
127
+ const log = (0, runtime_js_1.getAuthLogger)();
128
+ const fetchFn = opts.fetchImpl ?? globalThis.fetch;
129
+ if (typeof fetchFn !== 'function') {
130
+ throw new TypeError('loginWithLocalhost: globalThis.fetch is missing — pass fetchImpl or run under Node ≥ 20');
131
+ }
132
+ const scope = opts.scope ?? DEFAULT_SCOPE;
133
+ const serverUrl = opts.serverUrl.replace(/\/+$/, '');
134
+ // Step 1+2: PKCE primitives. The verifier never leaves this
135
+ // function's local scope until step 9.
136
+ const codeVerifier = (0, pkce_js_1.generateVerifier)();
137
+ const codeChallenge = (0, pkce_js_1.challenge)(codeVerifier, 'S256');
138
+ const state = (0, pkce_js_1.generateState)();
139
+ // Step 3: bind the local callback server. Bind error here is fatal —
140
+ // caller decides whether to retry on a different port.
141
+ const handle = await (0, loginLocalhost_js_1.startLocalCallback)({
142
+ state,
143
+ port: opts.port,
144
+ host: opts.host,
145
+ bindAddr: opts.bindAddr,
146
+ timeoutMs: opts.timeoutMs,
147
+ });
148
+ try {
149
+ // Step 4: POST /cli-init.
150
+ const initBody = {
151
+ code_challenge: codeChallenge,
152
+ code_challenge_method: 'S256',
153
+ redirect_uri: handle.redirectUri,
154
+ state,
155
+ device_name: opts.deviceName,
156
+ daemon_id: opts.daemonId,
157
+ scope,
158
+ };
159
+ let initRes;
160
+ try {
161
+ initRes = await fetchFn(`${serverUrl}/api/auth/cli-init`, {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(initBody),
165
+ });
166
+ }
167
+ catch (err) {
168
+ throw new LoginInitError('NETWORK_ERROR', `POST /api/auth/cli-init failed: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ if (initRes.status === 404) {
171
+ // Server doesn't expose this endpoint — feature flag off, or
172
+ // older server build that pre-dates Phase H.0. Caller should
173
+ // fall back to device-code.
174
+ throw new LoginInitError('FEATURE_DISABLED', 'Server does not support PKCE localhost login (feature flag off or older build). ' +
175
+ 'Fall back to device-code.', 404);
176
+ }
177
+ if (initRes.status === 429) {
178
+ throw new LoginInitError('RATE_LIMITED', 'Server rate-limited the login init request. Wait a moment and retry.', 429, await safeReadBody(initRes));
179
+ }
180
+ if (initRes.status >= 500) {
181
+ throw new LoginInitError('SERVER_ERROR', `Server returned ${initRes.status} on /cli-init.`, initRes.status, await safeReadBody(initRes));
182
+ }
183
+ if (!initRes.ok) {
184
+ throw new LoginInitError('BAD_REQUEST', `Server returned ${initRes.status} on /cli-init.`, initRes.status, await safeReadBody(initRes));
185
+ }
186
+ let initJson;
187
+ try {
188
+ initJson = await initRes.json();
189
+ }
190
+ catch (err) {
191
+ throw new LoginInitError('MALFORMED_RESPONSE', `Could not parse /cli-init response as JSON: ${err instanceof Error ? err.message : String(err)}`, initRes.status);
192
+ }
193
+ const init = parseInitResponse(initJson);
194
+ if (!init) {
195
+ throw new LoginInitError('MALFORMED_RESPONSE', '/cli-init response missing requestId or authUrl', initRes.status);
196
+ }
197
+ // Step 5: hand the auth URL to the caller (so they can print it
198
+ // before / instead of opening the browser) then open it. Both are
199
+ // best-effort — the user can copy/paste the URL.
200
+ if (opts.onAuthUrl) {
201
+ try {
202
+ opts.onAuthUrl(init.authUrl);
203
+ }
204
+ catch (err) {
205
+ log.warn(`[loginWithLocalhost] onAuthUrl callback threw (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
206
+ }
207
+ }
208
+ try {
209
+ await opts.openBrowser(init.authUrl);
210
+ }
211
+ catch (err) {
212
+ log.warn(`[loginWithLocalhost] openBrowser failed (non-fatal — user can paste URL): ${err instanceof Error ? err.message : String(err)}`);
213
+ }
214
+ // Step 6–8: wait for the callback. Errors here are the structured
215
+ // LocalCallback* set; they propagate as-is so the caller branches
216
+ // on instanceof.
217
+ const callback = await handle.waitForCallback();
218
+ // Step 9: POST /cli-token-exchange.
219
+ const exchangeBody = {
220
+ code: callback.code,
221
+ code_verifier: codeVerifier,
222
+ redirect_uri: handle.redirectUri,
223
+ };
224
+ let exchangeRes;
225
+ try {
226
+ exchangeRes = await fetchFn(`${serverUrl}/api/auth/cli-token-exchange`, {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify(exchangeBody),
230
+ });
231
+ }
232
+ catch (err) {
233
+ throw new LoginExchangeError('NETWORK_ERROR', `POST /api/auth/cli-token-exchange failed: ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ if (exchangeRes.status === 400) {
236
+ throw new LoginExchangeError('INVALID_GRANT', 'Server rejected the PKCE code exchange (verifier mismatch, expired code, or already redeemed).', 400, await safeReadBody(exchangeRes));
237
+ }
238
+ if (exchangeRes.status >= 500) {
239
+ throw new LoginExchangeError('SERVER_ERROR', `Server returned ${exchangeRes.status} on /cli-token-exchange.`, exchangeRes.status, await safeReadBody(exchangeRes));
240
+ }
241
+ if (!exchangeRes.ok) {
242
+ throw new LoginExchangeError('BAD_REQUEST', `Server returned ${exchangeRes.status} on /cli-token-exchange.`, exchangeRes.status, await safeReadBody(exchangeRes));
243
+ }
244
+ let exchangeJson;
245
+ try {
246
+ exchangeJson = await exchangeRes.json();
247
+ }
248
+ catch (err) {
249
+ throw new LoginExchangeError('MALFORMED_RESPONSE', `Could not parse /cli-token-exchange response as JSON: ${err instanceof Error ? err.message : String(err)}`, exchangeRes.status);
250
+ }
251
+ const result = parseExchangeResponse(exchangeJson);
252
+ if (!result) {
253
+ throw new LoginExchangeError('MALFORMED_RESPONSE', '/cli-token-exchange response missing token or has bad shape', exchangeRes.status);
254
+ }
255
+ log.info(`[loginWithLocalhost] login successful (scope=${result.scope ?? scope}` +
256
+ (result.daemonId ? ` daemonId=${result.daemonId}` : '') +
257
+ ')');
258
+ return result;
259
+ }
260
+ finally {
261
+ // Cleanup invariant: server torn down on every exit path.
262
+ //
263
+ // Subtlety: when /cli-init fails (404, network, etc.) we throw
264
+ // BEFORE reaching `await handle.waitForCallback()`, so the
265
+ // callback Promise has no .catch attached. `handle.close()` will
266
+ // reject that Promise with CANCELLED → an unhandledRejection
267
+ // event the test runner correctly flags. Attach a no-op .catch
268
+ // first so the artifact-of-cleanup rejection is observed.
269
+ // Harmless on the happy path: the try-block's await already
270
+ // attached its own handler, and multiple .catches on the same
271
+ // Promise all observe the same outcome.
272
+ void handle.waitForCallback().catch(() => undefined);
273
+ handle.close();
274
+ }
275
+ }
276
+ function parseInitResponse(raw) {
277
+ if (!raw || typeof raw !== 'object')
278
+ return null;
279
+ const obj = raw;
280
+ const requestId = typeof obj.requestId === 'string' ? obj.requestId : null;
281
+ const authUrl = typeof obj.authUrl === 'string' ? obj.authUrl : null;
282
+ if (!requestId || !authUrl)
283
+ return null;
284
+ // Defense in depth: refuse to open URLs that aren't http/https.
285
+ // Prevents a hostile/buggy server response steering the caller's
286
+ // browser at javascript: / file: / data: URIs.
287
+ try {
288
+ const u = new URL(authUrl);
289
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
290
+ return null;
291
+ }
292
+ catch {
293
+ return null;
294
+ }
295
+ return { requestId, authUrl };
296
+ }
297
+ function parseExchangeResponse(raw) {
298
+ if (!raw || typeof raw !== 'object')
299
+ return null;
300
+ const obj = raw;
301
+ const token = typeof obj.token === 'string' && obj.token.length > 0 ? obj.token : undefined;
302
+ const expiresAt = parseEpochMs(obj.expiresAt);
303
+ const refreshToken = typeof obj.refreshToken === 'string' && obj.refreshToken.length > 0
304
+ ? obj.refreshToken
305
+ : undefined;
306
+ const refreshExpiresAt = parseEpochMs(obj.refreshExpiresAt);
307
+ const daemonId = typeof obj.daemonId === 'string' && obj.daemonId.length > 0 ? obj.daemonId : undefined;
308
+ const scope = parseScope(obj.scope);
309
+ // Stage-E #1 — opaque `osk_dmn_*` co-issued for daemon-scope mints
310
+ // (server-side: routes/cliAuth.js sends it in the response payload).
311
+ // Treat any non-string/empty as absent — login responses don't have
312
+ // the "explicitly clear" null semantic that refresh responses do.
313
+ const opaqueAccessToken = typeof obj.opaqueAccessToken === 'string' && obj.opaqueAccessToken.length > 0
314
+ ? obj.opaqueAccessToken
315
+ : undefined;
316
+ // Stage-E #1 step 4 — daemon-scope login responses omit `token`
317
+ // because the daemon-JWT substrate is retired. Accept the response
318
+ // as long as the server returned at least one credential class.
319
+ // Pre-step-4 responses (token only / token + opaque) and post-step-4
320
+ // responses (opaque only) both pass.
321
+ if (!token && !opaqueAccessToken)
322
+ return null;
323
+ // Match the device-code defensive path in daemon/src/auth.ts: if the
324
+ // server claimed to issue a refresh token but the expiry is
325
+ // unparseable, drop both rather than persist a refresh that reads
326
+ // back as "valid forever" via Date.now() > NaN.
327
+ const safeRefresh = refreshToken !== undefined && refreshExpiresAt !== undefined
328
+ ? { refreshToken, refreshExpiresAt }
329
+ : undefined;
330
+ return {
331
+ ...(token ? { token } : {}),
332
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
333
+ ...(safeRefresh ?? {}),
334
+ ...(daemonId ? { daemonId } : {}),
335
+ ...(scope ? { scope } : {}),
336
+ ...(opaqueAccessToken ? { opaqueAccessToken } : {}),
337
+ };
338
+ }
339
+ function parseEpochMs(v) {
340
+ if (typeof v !== 'string' && typeof v !== 'number')
341
+ return undefined;
342
+ const ms = new Date(v).getTime();
343
+ return Number.isFinite(ms) ? ms : undefined;
344
+ }
345
+ function parseScope(v) {
346
+ if (v === 'daemon' || v === 'cli' || v === 'web')
347
+ return v;
348
+ return undefined;
349
+ }
350
+ async function safeReadBody(res) {
351
+ try {
352
+ const txt = await res.text();
353
+ // Truncate so a massive 5xx HTML page doesn't bloat error objects.
354
+ return txt.length > 500 ? txt.slice(0, 500) + '… [truncated]' : txt;
355
+ }
356
+ catch {
357
+ return undefined;
358
+ }
359
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Return the stable machine-scoped daemon UUID. On first call, reads
3
+ * `<configDir>/daemon.id` if present; otherwise mints a UUIDv7 and
4
+ * persists it atomically at 0600. Cached for the process lifetime.
5
+ *
6
+ * On filesystem failure (locked home dir, read-only FS, ENOSPC) returns
7
+ * a deterministic `fallback-<machineUuid>` string so the caller still
8
+ * has something to emit. The `fallback-` prefix makes telemetry
9
+ * trivially bucketable and prevents the fallback from being confused
10
+ * with a real UUIDv7 downstream.
11
+ *
12
+ * The caller may pass `machineUuidFallback` to override the default
13
+ * hostname+platform fingerprint with a richer detector (e.g. the
14
+ * daemon's `getMachineUuid()` from `registry.ts`).
15
+ */
16
+ export declare function ensureMachineDaemonId(machineUuidFallback?: () => string): string;
17
+ /**
18
+ * Scan `<configDir>/profiles/<profile>/daemon.id` for pre-unification
19
+ * per-profile identity files. Any daemon that shipped before the
20
+ * machine-scoped layout landed may have minted a UUID under the
21
+ * profile directory; emitting those on every register lets the server
22
+ * fold the stale `DaemonRefreshSession` + `Daemon` rows into the
23
+ * canonical machine identity without data loss.
24
+ *
25
+ * Returns deduped lowercase UUIDs only. Silent on individual read
26
+ * failures — unreadable / malformed / non-UUID entries are omitted.
27
+ * Never throws. Returns `[]` on clean installs.
28
+ */
29
+ export declare function legacyDaemonUuids(): string[];
30
+ /**
31
+ * @internal test seam — clears the in-process cache so a test can
32
+ * exercise the full `ensureMachineDaemonId` flow against a fresh
33
+ * filesystem without rebuilding the module.
34
+ */
35
+ export declare function __resetCachedDaemonIdForTest(): void;
@@ -0,0 +1,239 @@
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.ensureMachineDaemonId = ensureMachineDaemonId;
7
+ exports.legacyDaemonUuids = legacyDaemonUuids;
8
+ exports.__resetCachedDaemonIdForTest = __resetCachedDaemonIdForTest;
9
+ /**
10
+ * machineId.ts — stable machine-scoped daemon identity (auth-core).
11
+ *
12
+ * Lifted from `daemon/src/machineIdentity.ts` so the CLI and daemon can
13
+ * share a single per-machine UUIDv7 stored at `<configDir>/daemon.id`.
14
+ *
15
+ * Why this exists alongside platform-derived machine UUIDs:
16
+ *
17
+ * - OS-derived ids (IOPlatformUUID / /etc/machine-id / MachineGuid) are
18
+ * stable per-machine, but they CHANGE on OS reinstall — at which
19
+ * point the server can't recognize "this is the same physical
20
+ * machine after a reinstall."
21
+ * - `ensureMachineDaemonId()` persists a UUID we mint ourselves in a
22
+ * per-user dotfile. It survives OS reinstalls (as long as the home
23
+ * directory survives), and a user whose machineUuid changes can
24
+ * still be re-anchored via the legacy-id list.
25
+ *
26
+ * Auth-core does NOT know about platform-level OS UUIDs (those still
27
+ * live in `daemon/src/registry.ts::getMachineUuid()`). The fallback is
28
+ * supplied by the consumer via the `machineUuidFallback` parameter so
29
+ * a daemon can pass its richer detector while a CLI uses a hostname
30
+ * fingerprint.
31
+ */
32
+ const node_crypto_1 = __importDefault(require("node:crypto"));
33
+ const node_fs_1 = __importDefault(require("node:fs"));
34
+ const node_os_1 = __importDefault(require("node:os"));
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ const runtime_js_1 = require("./runtime.js");
37
+ /** File name for the stable machine daemon id. Never changes. */
38
+ const DAEMON_ID_FILENAME = 'daemon.id';
39
+ /** Profile subdirectory — scanned for legacy per-profile identity files. */
40
+ const PROFILES_DIRNAME = 'profiles';
41
+ /** UUID canonical form — 8-4-4-4-12 lowercase hex. */
42
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ let cachedStableDaemonId = null;
44
+ function machineHomeDir() {
45
+ // MACHINE-scoped root — deliberately NOT the profile-scoped configDir.
46
+ // See OSK-1478: the daemon's configDir is `~/.skrr/profiles/<profile>`,
47
+ // which made this "stable per-machine id" drift per-profile.
48
+ return (0, runtime_js_1.getAuthMachineConfigDir)();
49
+ }
50
+ function stableDaemonIdPath() {
51
+ return node_path_1.default.join(machineHomeDir(), DAEMON_ID_FILENAME);
52
+ }
53
+ /**
54
+ * Generate a UUIDv7 (RFC 9562 §5.7). 48 bits big-endian unix-millis
55
+ * timestamp, 4 bits version, 12 bits random, 2 bits variant, 62 bits
56
+ * random. Implemented inline because Node 20's `crypto.randomUUID()` is
57
+ * v4 only; pulling a dep for 15 lines is not worth the supply-chain
58
+ * surface area.
59
+ */
60
+ function generateUuidV7() {
61
+ const bytes = node_crypto_1.default.randomBytes(16);
62
+ const ts = BigInt(Date.now());
63
+ bytes[0] = Number((ts >> 40n) & 0xffn);
64
+ bytes[1] = Number((ts >> 32n) & 0xffn);
65
+ bytes[2] = Number((ts >> 24n) & 0xffn);
66
+ bytes[3] = Number((ts >> 16n) & 0xffn);
67
+ bytes[4] = Number((ts >> 8n) & 0xffn);
68
+ bytes[5] = Number(ts & 0xffn);
69
+ // Version 7 → top 4 bits of byte 6.
70
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
71
+ // RFC 4122 variant (10xx) → top 2 bits of byte 8.
72
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
73
+ const hex = bytes.toString('hex');
74
+ return (hex.slice(0, 8) +
75
+ '-' +
76
+ hex.slice(8, 12) +
77
+ '-' +
78
+ hex.slice(12, 16) +
79
+ '-' +
80
+ hex.slice(16, 20) +
81
+ '-' +
82
+ hex.slice(20));
83
+ }
84
+ function isUuidLike(raw) {
85
+ return UUID_RE.test(raw.trim());
86
+ }
87
+ /**
88
+ * Atomic write with 0600 mode. Tmp file in the same directory so the
89
+ * final rename is intra-filesystem. Concurrent processes racing the
90
+ * first-ever write both produce valid UUIDs; the kernel serializes
91
+ * the rename and the loser's file becomes unreachable — harmless.
92
+ */
93
+ function writeDaemonIdFile(target, id) {
94
+ const dir = node_path_1.default.dirname(target);
95
+ node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ const tmp = `${target}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
97
+ node_fs_1.default.writeFileSync(tmp, id + '\n', { encoding: 'utf-8', mode: 0o600 });
98
+ try {
99
+ node_fs_1.default.chmodSync(tmp, 0o600);
100
+ }
101
+ catch {
102
+ /* best-effort — writeFileSync already set mode */
103
+ }
104
+ node_fs_1.default.renameSync(tmp, target);
105
+ }
106
+ /**
107
+ * Default fallback when a consumer doesn't supply one. Synthesizes a
108
+ * SHA-256 of `os.hostname()` + `os.platform()` — better than just
109
+ * hostname (includes platform) but worse than a true hardware UUID.
110
+ *
111
+ * The daemon overrides this with its richer detector
112
+ * (`registry.ts::getMachineUuid()`) when available. The CLI today has
113
+ * no native deps for OS UUIDs and so accepts this default.
114
+ */
115
+ function defaultMachineUuidFallback() {
116
+ const host = node_os_1.default.hostname() || 'unknown-host';
117
+ const platform = node_os_1.default.platform() || 'unknown-platform';
118
+ return node_crypto_1.default.createHash('sha256').update(`auth-core:${platform}:${host}`).digest('hex');
119
+ }
120
+ /**
121
+ * Return the stable machine-scoped daemon UUID. On first call, reads
122
+ * `<configDir>/daemon.id` if present; otherwise mints a UUIDv7 and
123
+ * persists it atomically at 0600. Cached for the process lifetime.
124
+ *
125
+ * On filesystem failure (locked home dir, read-only FS, ENOSPC) returns
126
+ * a deterministic `fallback-<machineUuid>` string so the caller still
127
+ * has something to emit. The `fallback-` prefix makes telemetry
128
+ * trivially bucketable and prevents the fallback from being confused
129
+ * with a real UUIDv7 downstream.
130
+ *
131
+ * The caller may pass `machineUuidFallback` to override the default
132
+ * hostname+platform fingerprint with a richer detector (e.g. the
133
+ * daemon's `getMachineUuid()` from `registry.ts`).
134
+ */
135
+ function ensureMachineDaemonId(machineUuidFallback) {
136
+ if (cachedStableDaemonId !== null)
137
+ return cachedStableDaemonId;
138
+ const target = stableDaemonIdPath();
139
+ const fallbackFn = machineUuidFallback ?? defaultMachineUuidFallback;
140
+ const log = (0, runtime_js_1.getAuthLogger)();
141
+ // Fast path: the file exists and its contents parse as a UUID.
142
+ try {
143
+ const raw = node_fs_1.default.readFileSync(target, 'utf-8').trim();
144
+ if (isUuidLike(raw)) {
145
+ cachedStableDaemonId = raw.toLowerCase();
146
+ return cachedStableDaemonId;
147
+ }
148
+ // File exists but content is not a UUID. Overwrite — the file is
149
+ // machine-local and this binary is its only reader. Losing the
150
+ // garbled content is acceptable.
151
+ log.warn(`[machineIdentity] ${target} exists but does not contain a valid UUID — regenerating`);
152
+ }
153
+ catch (err) {
154
+ const code = err.code;
155
+ if (code !== 'ENOENT') {
156
+ log.warn(`[machineIdentity] read ${target} failed (${code ?? 'unknown'}): ${err.message}`);
157
+ }
158
+ }
159
+ // OSK-1478 migration — before minting a brand-new id (which the server has
160
+ // never seen), adopt an existing pre-unification per-profile `daemon.id` if
161
+ // one is present. This promotes a previously-used identity to the canonical
162
+ // machine-root location so the daemon's `stableDaemonId` doesn't needlessly
163
+ // flip to a value with no server-side history. The remaining profile ids are
164
+ // still surfaced by `legacyDaemonUuids()` for the register-time fold, so no
165
+ // identity is lost either way. Deterministic pick (sorted) so racing
166
+ // processes converge on the same adoption.
167
+ const adoptable = legacyDaemonUuids().sort();
168
+ if (adoptable.length > 0) {
169
+ const adopted = adoptable[0];
170
+ try {
171
+ writeDaemonIdFile(target, adopted);
172
+ log.info(`[machineIdentity] adopted pre-unification per-profile id as machine-scoped canonical: ${adopted}`);
173
+ cachedStableDaemonId = adopted;
174
+ return adopted;
175
+ }
176
+ catch (err) {
177
+ // Fall through to a fresh mint on write failure — the legacy fold still
178
+ // re-anchors the server row, so correctness does not depend on adoption.
179
+ log.warn(`[machineIdentity] adopt ${adopted} failed: ${err.message} — minting fresh`);
180
+ }
181
+ }
182
+ const fresh = generateUuidV7();
183
+ try {
184
+ writeDaemonIdFile(target, fresh);
185
+ cachedStableDaemonId = fresh;
186
+ return fresh;
187
+ }
188
+ catch (err) {
189
+ log.warn(`[machineIdentity] persist ${target} failed: ${err.message} — using fallback`);
190
+ const fallback = `fallback-${fallbackFn()}`;
191
+ cachedStableDaemonId = fallback;
192
+ return fallback;
193
+ }
194
+ }
195
+ /**
196
+ * Scan `<configDir>/profiles/<profile>/daemon.id` for pre-unification
197
+ * per-profile identity files. Any daemon that shipped before the
198
+ * machine-scoped layout landed may have minted a UUID under the
199
+ * profile directory; emitting those on every register lets the server
200
+ * fold the stale `DaemonRefreshSession` + `Daemon` rows into the
201
+ * canonical machine identity without data loss.
202
+ *
203
+ * Returns deduped lowercase UUIDs only. Silent on individual read
204
+ * failures — unreadable / malformed / non-UUID entries are omitted.
205
+ * Never throws. Returns `[]` on clean installs.
206
+ */
207
+ function legacyDaemonUuids() {
208
+ const profilesDir = node_path_1.default.join(machineHomeDir(), PROFILES_DIRNAME);
209
+ let entries;
210
+ try {
211
+ entries = node_fs_1.default.readdirSync(profilesDir, { withFileTypes: true });
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ const seen = new Set();
217
+ for (const entry of entries) {
218
+ if (!entry.isDirectory())
219
+ continue;
220
+ const candidate = node_path_1.default.join(profilesDir, entry.name, DAEMON_ID_FILENAME);
221
+ try {
222
+ const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
223
+ if (isUuidLike(raw))
224
+ seen.add(raw.toLowerCase());
225
+ }
226
+ catch {
227
+ /* omit */
228
+ }
229
+ }
230
+ return [...seen];
231
+ }
232
+ /**
233
+ * @internal test seam — clears the in-process cache so a test can
234
+ * exercise the full `ensureMachineDaemonId` flow against a fresh
235
+ * filesystem without rebuilding the module.
236
+ */
237
+ function __resetCachedDaemonIdForTest() {
238
+ cachedStableDaemonId = null;
239
+ }
@@ -0,0 +1,9 @@
1
+ export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
2
+ export declare function formatReauthMessage(reason: AuthFailureReason | string): string;
3
+ /**
4
+ * Exit code for "permanent auth failure during initial connect" — picked to
5
+ * match the BSD/Unix sysexits.h convention (EX_TEMPFAIL = 75) so process
6
+ * supervisors (launchd, systemd, pm2) can be configured to NOT auto-restart
7
+ * on this code (the user must manually re-auth, retrying won't help).
8
+ */
9
+ export declare const EXIT_NEEDS_REAUTH = 75;