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