@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,863 @@
1
+ /**
2
+ * refresh.ts — refresh-token client for OverSky daemon-style sessions.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Single-flight per daemonId: concurrent callers share one in-flight HTTP
6
+ * request so we don't fire 6 refreshes for 6 parallel API calls (which
7
+ * would race-rotate and trip the server's family-reuse detector).
8
+ * 2. File-locked read-modify-write on `<configDir>/auth.lock` so the daemon,
9
+ * Electron main, and CLI can't corrupt the stored token pair.
10
+ * 3. Structured error classification — callers get either
11
+ * PermanentAuthFailure (stop reconnecting, show re-auth UX) or
12
+ * TransientAuthFailure (network glitch, retry later).
13
+ * 4. Persists a needs-reauth flag so the next start / Electron read can
14
+ * surface the banner without another round-trip.
15
+ *
16
+ * Lock strategy: `proper-lockfile` is battle-tested on Linux/macOS/Windows.
17
+ * Every read-modify-write is wrapped in `withAuthLock()`. The lock is
18
+ * released in `finally` — crash between acquire and release leaves a stale
19
+ * `.lock` file that `proper-lockfile` invalidates after `LOCK_STALE_MS`
20
+ * (30 s, longer than any reasonable refresh RTT).
21
+ */
22
+ import { AsyncLocalStorage } from 'node:async_hooks';
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import lockfile from 'proper-lockfile';
27
+ import { getAuthLogger, getAuthConfigDir, emitAuthTelemetry, notifyResponseObserver, } from './runtime.js';
28
+ import { PermanentAuthFailure, TransientAuthFailure, } from './types.js';
29
+ /**
30
+ * P1-6: Reduced from 30 s to 5 s.
31
+ *
32
+ * The original 30 s matched the worst-case refresh RTT — meaning a
33
+ * crashed process holding `auth.lock` could block all siblings for up
34
+ * to 30 s, risking a deadlock on degraded networks. 5 s is well above
35
+ * any realistic refresh RTT (p99 ≈ 50 ms) but low enough that a stale
36
+ * lock from a crashed daemon clears before the outer reconnect backoff
37
+ * times out.
38
+ */
39
+ const LOCK_STALE_MS = 5_000;
40
+ const LOCK_RETRY_MS = 100;
41
+ const LOCK_MAX_RETRIES = 50; // ~5 s total wait before giving up
42
+ /**
43
+ * P1-6: Module-level guard so registering process-exit handlers is
44
+ * idempotent. A second import (or a unit test that re-requires the
45
+ * module) must not double-register the same handlers.
46
+ */
47
+ let _exitHandlersInstalled = false;
48
+ /**
49
+ * D-4 — Re-entrancy tracking for `withAuthLock`.
50
+ *
51
+ * `proper-lockfile` is NOT re-entrant within a process: a second
52
+ * `lockfile.lock()` call on the same file from the same process will
53
+ * block until `LOCK_STALE_MS` (5 s) elapses and treat itself as stale —
54
+ * effectively a 5 s deadlock. Several call sites legitimately nest:
55
+ *
56
+ * performRefresh → withAuthLock → saveToken → withAuthLock (new)
57
+ * consumeFdAuth → saveToken → withAuthLock (new, no outer lock)
58
+ *
59
+ * `AsyncLocalStorage` propagates across `await` boundaries within the
60
+ * same async call chain, so a nested `withAuthLock` invocation in the
61
+ * same chain can detect "outer lock already held" and skip the
62
+ * `proper-lockfile` acquire. Cross-process callers each have their own
63
+ * ALS store and always go through the real file lock.
64
+ *
65
+ * The store carries a boolean: `true` = the current async context
66
+ * already holds the auth lock.
67
+ */
68
+ const _authLockHeld = new AsyncLocalStorage();
69
+ function authLockPath() {
70
+ return path.join(getAuthConfigDir(), 'auth.lock');
71
+ }
72
+ function reauthFlagPath() {
73
+ return path.join(getAuthConfigDir(), 'needs-reauth.json');
74
+ }
75
+ /**
76
+ * P1-6 — Register best-effort lock-release handlers on process exit
77
+ * signals so a crash or SIGTERM during a refresh leaves the lock file
78
+ * in a released state. Without this, a sibling process must wait up to
79
+ * `LOCK_STALE_MS` (5 s) for `proper-lockfile` to declare the lock stale.
80
+ *
81
+ * Guards:
82
+ * - `_exitHandlersInstalled` (module-level): safe to call multiple
83
+ * times (e.g., from unit-test re-imports) — handlers are registered
84
+ * exactly once per process.
85
+ * - Every handler swallows errors: `lockfile.unlockSync` throws when
86
+ * the lock file doesn't exist or was never acquired; we must not
87
+ * crash the process during shutdown.
88
+ *
89
+ * `lockfile.unlockSync` is available in `proper-lockfile` ≥ 4.1.0 and
90
+ * removes the `.lock` companion directory that `proper-lockfile` creates
91
+ * next to `auth.lock`. It is synchronous so it runs safely in POSIX
92
+ * signal handlers and `process.on('exit')`.
93
+ */
94
+ export function installLockExitHandlers() {
95
+ if (_exitHandlersInstalled)
96
+ return;
97
+ _exitHandlersInstalled = true;
98
+ function tryRelease() {
99
+ try {
100
+ lockfile.unlockSync(authLockPath());
101
+ }
102
+ catch {
103
+ /* lock not held or already released — not an error */
104
+ }
105
+ }
106
+ process.on('exit', tryRelease);
107
+ process.on('SIGTERM', () => {
108
+ tryRelease();
109
+ process.exit(0);
110
+ });
111
+ process.on('SIGINT', () => {
112
+ tryRelease();
113
+ process.exit(130);
114
+ });
115
+ process.on('SIGHUP', () => {
116
+ tryRelease();
117
+ process.exit(1);
118
+ });
119
+ }
120
+ /**
121
+ * Acquire the cross-process auth lock, run the body, release on `finally`.
122
+ * Throws if the lock can't be acquired within
123
+ * `LOCK_MAX_RETRIES × LOCK_RETRY_MS`.
124
+ *
125
+ * We lock a sentinel file (auth.lock) that we create/touch if missing —
126
+ * locking the real auth.json would race with atomic-rename writes.
127
+ *
128
+ * @param daemonId When supplied, lock-lifecycle telemetry events are emitted:
129
+ * refresh_lock_acquiring, refresh_lock_acquired, refresh_lock_released.
130
+ */
131
+ export async function withAuthLock(body, daemonId) {
132
+ // D-4 — Re-entrancy guard. If the current async context already holds
133
+ // the auth lock (e.g. performRefresh → withAuthLock → saveToken →
134
+ // withAuthLock), skip the proper-lockfile acquire to avoid the 5 s
135
+ // deadlock that would occur because proper-lockfile is not re-entrant
136
+ // within a process. Cross-process callers each have their own ALS store
137
+ // and always go through the real file lock.
138
+ if (_authLockHeld.getStore() === true) {
139
+ return body();
140
+ }
141
+ const lockPath = authLockPath();
142
+ const dir = path.dirname(lockPath);
143
+ if (!fs.existsSync(dir))
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ if (!fs.existsSync(lockPath)) {
146
+ fs.writeFileSync(lockPath, '', { mode: 0o600 });
147
+ }
148
+ const acquireStart = Date.now();
149
+ emitAuthTelemetry({
150
+ timestamp: new Date().toISOString(),
151
+ eventType: 'auth',
152
+ success: true,
153
+ metadata: { event: 'refresh_lock_acquiring', ...(daemonId ? { daemonId } : {}) },
154
+ });
155
+ const release = await lockfile.lock(lockPath, {
156
+ stale: LOCK_STALE_MS,
157
+ retries: {
158
+ retries: LOCK_MAX_RETRIES,
159
+ minTimeout: LOCK_RETRY_MS,
160
+ maxTimeout: LOCK_RETRY_MS * 4,
161
+ factor: 1.5,
162
+ },
163
+ });
164
+ const acquiredAt = Date.now();
165
+ emitAuthTelemetry({
166
+ timestamp: new Date().toISOString(),
167
+ eventType: 'auth',
168
+ success: true,
169
+ metadata: {
170
+ event: 'refresh_lock_acquired',
171
+ waitedMs: acquiredAt - acquireStart,
172
+ ...(daemonId ? { daemonId } : {}),
173
+ },
174
+ });
175
+ // Run body inside ALS context marking the lock as held for this async chain.
176
+ return _authLockHeld.run(true, async () => {
177
+ try {
178
+ return await body();
179
+ }
180
+ finally {
181
+ const heldForMs = Date.now() - acquiredAt;
182
+ emitAuthTelemetry({
183
+ timestamp: new Date().toISOString(),
184
+ eventType: 'auth',
185
+ success: true,
186
+ metadata: {
187
+ event: 'refresh_lock_released',
188
+ heldForMs,
189
+ ...(daemonId ? { daemonId } : {}),
190
+ },
191
+ });
192
+ try {
193
+ await release();
194
+ }
195
+ catch (err) {
196
+ getAuthLogger().debug(`[auth/refresh] lock release failed: ${err?.message}`);
197
+ }
198
+ }
199
+ });
200
+ }
201
+ /**
202
+ * In-flight refresh promises keyed by daemonId. Coalesces concurrent callers.
203
+ *
204
+ * Keying matters: an unkeyed map silently returned the wrong pair if two
205
+ * different daemons (or a future Electron host running multiple embedded
206
+ * daemons) refreshed simultaneously in the same process. Today most
207
+ * processes are single-instance, but the invariant is cheaper to preserve
208
+ * than to debug later.
209
+ *
210
+ * Defense-in-depth: the `.finally(() => delete)` is the primary cleanup,
211
+ * but if a future caller throws inside that callback the entry would leak
212
+ * forever and silently mask every subsequent refresh attempt for the same
213
+ * daemonId. `INFLIGHT_REAP_MS` provides a forced cleanup ceiling well above
214
+ * any reasonable refresh RTT (default 60 s ≫ p99 ~50 ms).
215
+ */
216
+ const inflightRefreshes = new Map();
217
+ const INFLIGHT_REAP_MS = 60_000;
218
+ /**
219
+ * Consecutive unknown-shape-401 budget per daemonId.
220
+ *
221
+ * A 401 from `/token/refresh` whose body `code` is NOT one of the four
222
+ * credential-death codes (REFRESH_INVALID / REFRESH_REUSED /
223
+ * REFRESH_EXPIRED / SESSION_REVOKED) — an unknown code, a missing code,
224
+ * or an unparseable body — is most likely an artifact of infra OverSky
225
+ * does not fully control: a proxy/middleware regression, a clock-skew
226
+ * replay rejection (DEVICE_PROOF_REPLAYED), or a half-rolled-out server
227
+ * route. By construction it is NOT a daemon's actual refresh-family burn
228
+ * (those arrive as one of the four known codes).
229
+ *
230
+ * Pre-fix every unknown-shape 401 latched `PermanentAuthFailure('UNKNOWN_401')`
231
+ * + `persistReauthState` on the FIRST occurrence — forcing a manual
232
+ * reconnect even when the next refresh would have succeeded. Now the
233
+ * first `UNKNOWN_401_BUDGET` unknown-401s accumulated on a given daemonId
234
+ * (within `UNKNOWN_401_WINDOW_MS`, see the reset/window rules below) throw
235
+ * `TransientAuthFailure` instead, so the daemon's
236
+ * `performRefresh → return false` path lets the refreshScheduler /
237
+ * reactive retry+backoff ride out a transient regression WITHOUT latching
238
+ * needs-reauth. Only after the budget is exhausted do we fall back to the
239
+ * original permanent latch — preserving the iron rule that the interactive
240
+ * re-auth modal remains the final fallback.
241
+ *
242
+ * The four KNOWN codes are genuinely permanent and are NEVER routed
243
+ * through this budget — they latch immediately as before.
244
+ *
245
+ * Counter semantics:
246
+ * - Incremented on each consecutive unknown-shape 401 for a daemonId.
247
+ * - Reset to zero ONLY on a DEFINITIVE outcome that tells us about the
248
+ * credential's true state: a 200 success (credential alive) OR a
249
+ * KNOWN-permanent 401 (credential dead, recognized). It is DELIBERATELY
250
+ * NOT reset on a transient non-401 outcome (network error, 429, 5xx).
251
+ *
252
+ * RESET SEMANTICS (Finding 4 follow-up — deliberate). Originally the
253
+ * streak reset on ANY non-unknown-401 outcome, including transient ones.
254
+ * That created a retry-forever hole: a dead-but-unknown-code credential
255
+ * whose endpoint INTERMITTENTLY returns transient errors (a flaky proxy
256
+ * that alternates 503 ↔ unknown-401, say) could never accumulate
257
+ * `UNKNOWN_401_BUDGET` consecutive unknown-401s — every transient blip
258
+ * wiped the streak — so it would never latch and the daemon would retry
259
+ * forever, never surfacing the re-auth affordance. By treating only
260
+ * success / known-permanent-401 as streak-resetting, a flaky-but-dead
261
+ * unknown credential still converges to the permanent latch: the
262
+ * unknown-401s accumulate ACROSS interleaved transient noise (subject to
263
+ * the window below) and eventually exhaust the budget.
264
+ * - Bounded window: if the previous unknown-401 for this daemonId is
265
+ * older than `UNKNOWN_401_WINDOW_MS`, the run is considered stale and
266
+ * the counter restarts at 1. This bounds how far apart interleaved
267
+ * unknown-401s can be and still count toward the same latch, so two
268
+ * genuinely unrelated incidents hours apart don't sum into a premature
269
+ * latch — while a flaky-but-dead credential, which keeps producing
270
+ * unknown-401s well within the window, still converges.
271
+ */
272
+ /**
273
+ * BOOT-BUDGET COUPLING (Finding 4 follow-up). Exported so the daemon's
274
+ * startup-recovery loop can statically assert
275
+ * `STARTUP_RECOVERY_MAX_ATTEMPTS >= UNKNOWN_401_BUDGET`.
276
+ *
277
+ * Why the coupling is load-bearing: the boot path
278
+ * (`recoverAccessTokenAtStartup` in `daemon/src/auth.ts`) retries
279
+ * `transient_error` results up to `STARTUP_RECOVERY_MAX_ATTEMPTS` times,
280
+ * then exits `EX_TEMPFAIL` so the supervisor restarts the process. Each
281
+ * in-budget unknown-shape 401 is classified `transient_error`. If the
282
+ * boot loop's max attempts were LESS than this budget, a
283
+ * genuinely-dead-but-unknown-code credential would make the boot loop
284
+ * give up (EX_TEMPFAIL → supervisor restart) BEFORE the in-process
285
+ * budget ever exhausts and latches `PermanentAuthFailure('UNKNOWN_401')`
286
+ * + persists needs-reauth. The supervisor would then restart-loop
287
+ * forever and the user would NEVER see the re-auth affordance — a
288
+ * strand. Keeping `STARTUP_RECOVERY_MAX_ATTEMPTS >= UNKNOWN_401_BUDGET`
289
+ * guarantees the in-process budget exhausts and latches within a single
290
+ * boot, on the final boot attempt, before control returns to the
291
+ * supervisor. The daemon enforces this with a module-load static
292
+ * assertion (`assertStartupRecoveryCoversUnknown401Budget`) plus a unit
293
+ * test; see `daemon/src/auth.ts`.
294
+ */
295
+ export const UNKNOWN_401_BUDGET = 3;
296
+ const UNKNOWN_401_WINDOW_MS = 5 * 60_000;
297
+ const unknown401Streaks = new Map();
298
+ /**
299
+ * Record one unknown-shape 401 for `daemonId` and report whether the
300
+ * consecutive budget is now exhausted (i.e. this occurrence should latch
301
+ * permanent) or still within budget (i.e. this occurrence should be
302
+ * treated as transient and retried).
303
+ *
304
+ * Returns `{ exhausted, count }` where `count` is the post-increment
305
+ * consecutive streak length. When `exhausted` is true the streak is
306
+ * cleared so a subsequent successful re-auth + future regression starts
307
+ * a fresh budget rather than latching on its first unknown-401.
308
+ */
309
+ function recordUnknown401(daemonId, now) {
310
+ const prev = unknown401Streaks.get(daemonId);
311
+ // A gap longer than the window means the prior unknown-401 belongs to a
312
+ // different, already-resolved incident — restart the streak at 1.
313
+ const count = prev && now - prev.lastAt <= UNKNOWN_401_WINDOW_MS ? prev.count + 1 : 1;
314
+ if (count >= UNKNOWN_401_BUDGET) {
315
+ // Budget exhausted — clear so the next regression (after a successful
316
+ // re-auth) gets its own fresh budget instead of latching immediately.
317
+ unknown401Streaks.delete(daemonId);
318
+ return { exhausted: true, count };
319
+ }
320
+ unknown401Streaks.set(daemonId, { count, lastAt: now });
321
+ return { exhausted: false, count };
322
+ }
323
+ /**
324
+ * Reset the unknown-shape-401 streak for `daemonId`. Called ONLY on the
325
+ * two DEFINITIVE outcomes — a 200 success or a KNOWN-permanent 401 — that
326
+ * tell us the credential's true state. Transient non-401 outcomes
327
+ * (network error, 429, 5xx) intentionally do NOT call this, so a
328
+ * flaky-but-dead unknown credential still converges to the permanent
329
+ * latch instead of retrying forever. See the `UNKNOWN_401_BUDGET` /
330
+ * `recordUnknown401` block above for the full rationale.
331
+ */
332
+ function resetUnknown401Streak(daemonId) {
333
+ unknown401Streaks.delete(daemonId);
334
+ }
335
+ /** Test-only: clear all per-daemonId unknown-401 streak state. */
336
+ export function __resetUnknown401StreaksForTest() {
337
+ unknown401Streaks.clear();
338
+ }
339
+ /**
340
+ * H1 — Test-only diagnostic counters that prove every outgoing refresh
341
+ * request to `/refresh` is paired with a freshly-built proof.
342
+ *
343
+ * - `requestAttemptCount`: incremented each time `buildRequestInit()`
344
+ * prepares an outgoing fetch.
345
+ * - `proofAttemptCount`: incremented each time `signRequestProof` is
346
+ * invoked (success, null return, OR thrown — they all count as
347
+ * "an attempt was made").
348
+ *
349
+ * Invariant under test: when `signRequestProof` is supplied,
350
+ * `proofAttemptCount === requestAttemptCount` after each call. A future
351
+ * retry that forgets to re-call `buildRequestInit()` (or that hoists
352
+ * the proof outside the loop) breaks this invariant immediately.
353
+ *
354
+ * Exposed only for tests — no production code reads it. Callers can
355
+ * snapshot + reset between asserts.
356
+ */
357
+ export const __refreshDiagnostics = {
358
+ proofAttemptCount: 0,
359
+ requestAttemptCount: 0,
360
+ reset() {
361
+ this.proofAttemptCount = 0;
362
+ this.requestAttemptCount = 0;
363
+ },
364
+ };
365
+ export async function refreshDaemonToken(serverUrl, currentRefreshToken, daemonId, options = {}) {
366
+ const existing = inflightRefreshes.get(daemonId);
367
+ if (existing) {
368
+ // A concurrent caller is coalescing onto the in-flight promise.
369
+ // refresh_race_resolved fires when it resolves (the caller adopted
370
+ // the sibling's token rather than firing a duplicate HTTP request).
371
+ return existing.then((pair) => {
372
+ emitAuthTelemetry({
373
+ timestamp: new Date().toISOString(),
374
+ eventType: 'auth',
375
+ success: true,
376
+ metadata: { event: 'refresh_race_resolved', daemonId, sourceTokenAdopted: true },
377
+ });
378
+ return pair;
379
+ });
380
+ }
381
+ const p = doRefresh(serverUrl, currentRefreshToken, daemonId, options).finally(() => {
382
+ // Only delete our own entry — a racing caller could have replaced it,
383
+ // but with keying by daemonId that would be a different key.
384
+ if (inflightRefreshes.get(daemonId) === p) {
385
+ inflightRefreshes.delete(daemonId);
386
+ }
387
+ });
388
+ inflightRefreshes.set(daemonId, p);
389
+ // Belt-and-suspenders reap if the `finally` ever throws (logger crash etc.).
390
+ const reaper = setTimeout(() => {
391
+ if (inflightRefreshes.get(daemonId) === p) {
392
+ try {
393
+ getAuthLogger().warn(`[auth/refresh] inflight reap fired for ${daemonId} — finally cleanup did not complete within ${INFLIGHT_REAP_MS}ms`);
394
+ }
395
+ catch {
396
+ /* logger unavailable */
397
+ }
398
+ inflightRefreshes.delete(daemonId);
399
+ }
400
+ }, INFLIGHT_REAP_MS);
401
+ if (typeof reaper.unref === 'function')
402
+ reaper.unref();
403
+ return p;
404
+ }
405
+ /**
406
+ * Best-effort server-side revocation of a refresh session. Idempotent on
407
+ * the server — never reveals whether the presented token existed. Returns
408
+ * silently on transport failure; callers still proceed with local cleanup.
409
+ *
410
+ * Kept tight: 5-second timeout so a hung server doesn't stall logout.
411
+ *
412
+ * H1 (HIGH) — wire contract: the body carries BOTH `refreshTokenHash`
413
+ * (preferred — SHA-256 hex digest of the plaintext refresh token, the
414
+ * exact value the server stores in `refreshHash`) AND the legacy
415
+ * `refreshToken` plaintext during a migration window. Newer servers
416
+ * short-circuit on `refreshTokenHash` and never read the plaintext;
417
+ * older servers ignore the unknown field and fall through to the
418
+ * legacy `refreshToken` path. Once the next API release ships the
419
+ * hash-aware revoke route to the entire fleet, the legacy plaintext
420
+ * field can be dropped from this body.
421
+ *
422
+ * Why both during migration: any intermediary that logs request
423
+ * bodies (CDN, WAF, API gateway, corporate proxy) would otherwise
424
+ * capture a usable refresh token in plaintext. The refresh path
425
+ * already only sends what the server hashes server-side; the revoke
426
+ * path's contract previously leaked the token in transit. Sending
427
+ * the hash makes the body ineligible as a credential for any future
428
+ * replay attempt while keeping back-compat with un-upgraded servers.
429
+ */
430
+ export async function revokeDaemonRefreshSession(serverUrl, refreshToken) {
431
+ if (!refreshToken)
432
+ return;
433
+ const log = getAuthLogger();
434
+ const url = `${serverUrl.replace(/\/+$/, '')}/api/daemons/token/revoke`;
435
+ const controller = new AbortController();
436
+ const timer = setTimeout(() => controller.abort(), 5_000);
437
+ // H1 — SHA-256 hex digest matches the server's `hashRefreshToken`
438
+ // helper byte-for-byte: `crypto.createHash('sha256').update(token).digest('hex')`.
439
+ // 64-char lowercase hex string, the exact lookup key in the
440
+ // `refreshHash` index.
441
+ const refreshTokenHash = crypto.createHash('sha256').update(refreshToken, 'utf8').digest('hex');
442
+ try {
443
+ const res = await fetch(url, {
444
+ method: 'POST',
445
+ headers: { 'Content-Type': 'application/json' },
446
+ body: JSON.stringify({ refreshTokenHash, refreshToken }),
447
+ signal: controller.signal,
448
+ });
449
+ notifyResponseObserver(res);
450
+ if (!res.ok) {
451
+ log.debug(`[auth/refresh] revoke returned ${res.status}`);
452
+ }
453
+ }
454
+ catch (err) {
455
+ log.debug(`[auth/refresh] revoke network error: ${err?.message}`);
456
+ }
457
+ finally {
458
+ clearTimeout(timer);
459
+ }
460
+ }
461
+ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {}) {
462
+ const log = getAuthLogger();
463
+ const startMs = Date.now();
464
+ emitAuthTelemetry({
465
+ timestamp: new Date().toISOString(),
466
+ eventType: 'auth',
467
+ success: true,
468
+ metadata: { event: 'refresh_start', daemonId },
469
+ });
470
+ const url = `${serverUrl.replace(/\/+$/, '')}/api/daemons/token/refresh`;
471
+ // Build body with machineUuid only when supplied — preserves backward
472
+ // compat with older servers that ignore unknown fields. The server's
473
+ // soft-binding check (H7) tolerates either side missing the field.
474
+ const body = {
475
+ refreshToken: currentRefreshToken,
476
+ daemonId,
477
+ };
478
+ if (options.machineUuid) {
479
+ body.machineUuid = options.machineUuid;
480
+ }
481
+ // H1 — Per-attempt request builder. The proof MUST be minted once
482
+ // per outgoing HTTP request: every fetch carries a fresh `iat` +
483
+ // `jti` so the server-side replay cache (`seen-jti:{thumbprint}:{jti}`,
484
+ // see `api/server/services/Auth/jtiReplayCache.js`) never rejects a
485
+ // legitimate retry as a duplicate. If the proof were minted once and
486
+ // reused across retries, every retry would deterministically lose
487
+ // to the cache.
488
+ //
489
+ // Today the function only makes one HTTP attempt — but the contract
490
+ // is "every outgoing request to /refresh uses a freshly-built proof"
491
+ // and we want the structure to enforce it before any future retry
492
+ // logic re-introduces the bug. The attempt counter is exposed via
493
+ // a test-only diagnostic (`__refreshDiagnostics`) so we can assert
494
+ // the invariant without exporting a private control surface.
495
+ //
496
+ // FOOTGUN — do NOT hoist `buildRequestInit` outside this function or
497
+ // outside any future retry loop. The structural test
498
+ // "proof-per-attempt invariant: a retry loop must call buildRequestInit()
499
+ // per attempt" in `refresh.deviceProof.test.ts` asserts the function
500
+ // is defined inside `doRefresh` and will fail if it is moved.
501
+ function buildRequestInit() {
502
+ // Phase 3-R5: the stable `X-Oversky-Origin` header is the server's
503
+ // preferred identity signal on the benign-grace rotation match. Newer
504
+ // servers prefer it over the raw User-Agent; older servers ignore
505
+ // unknown headers. Safe one-sided change either way.
506
+ const headers = { 'Content-Type': 'application/json' };
507
+ if (options.origin) {
508
+ headers['X-Oversky-Origin'] = options.origin;
509
+ }
510
+ // L11 Phase 3 — attach a device-identity proof when the daemon-side
511
+ // bridge can mint one. A null/undefined return or a thrown callback
512
+ // is non-fatal: the request goes out without the header and the
513
+ // server's lenient middleware (`off` / `warn` mode default) accepts
514
+ // it. Once the operator flips `OVERSKY_DEVICE_PROOF_ENFORCE=enforce`
515
+ // on the server, a missing-proof request from an enrolled daemon
516
+ // becomes a 401 — but daemons that reach this code path always have
517
+ // device identity initialized via `prefetchAuth()`, so the only
518
+ // legitimate "missing proof" case is a daemon binary older than
519
+ // Phase 1.
520
+ __refreshDiagnostics.requestAttemptCount += 1;
521
+ if (options.signRequestProof) {
522
+ try {
523
+ const proof = options.signRequestProof({
524
+ method: 'POST',
525
+ url,
526
+ refreshToken: currentRefreshToken,
527
+ });
528
+ // Count every callback invocation, including ones that
529
+ // returned null/empty/threw. The invariant we care about is
530
+ // "one mint per attempt" — whether the mint produced a usable
531
+ // header is a separate concern (legacy daemons, key not yet
532
+ // initialized, etc.).
533
+ __refreshDiagnostics.proofAttemptCount += 1;
534
+ if (typeof proof === 'string' && proof.length > 0) {
535
+ headers['OverSky-DPoP'] = proof;
536
+ }
537
+ }
538
+ catch (err) {
539
+ // Even a thrown callback counts as an attempt — the daemon
540
+ // bridge tried to mint, the mint failed, and we proceed
541
+ // without the header. Without this count the invariant
542
+ // would silently drift if a future regression made the
543
+ // callback throw on every-other call.
544
+ __refreshDiagnostics.proofAttemptCount += 1;
545
+ log.debug(`[auth/refresh] signRequestProof threw — proceeding without device-identity header: ${err?.message ?? 'unknown'}`);
546
+ }
547
+ }
548
+ return {
549
+ method: 'POST',
550
+ headers,
551
+ body: JSON.stringify(body),
552
+ };
553
+ }
554
+ let response;
555
+ try {
556
+ // Single attempt today. The builder structure is the load-bearing
557
+ // bit: any future retry must call `buildRequestInit()` again so
558
+ // the per-attempt invariant — fresh jti per request — holds.
559
+ response = await fetch(url, buildRequestInit());
560
+ notifyResponseObserver(response);
561
+ }
562
+ catch (err) {
563
+ log.warn(`[auth/refresh] network error: ${err?.message}`);
564
+ // RESET SEMANTICS (Finding 4 follow-up): a transient (network) outcome
565
+ // does NOT reset the unknown-401 streak. Resetting here would let a
566
+ // dead-but-unknown-code credential whose endpoint INTERMITTENTLY drops
567
+ // the connection never accumulate to the latch — it would retry forever
568
+ // because every other network blip wipes the streak. The streak resets
569
+ // ONLY on a 200 success or a KNOWN-permanent 401 (see those branches).
570
+ emitAuthTelemetry({
571
+ timestamp: new Date().toISOString(),
572
+ eventType: 'auth',
573
+ success: false,
574
+ metadata: {
575
+ event: 'refresh_failure',
576
+ daemonId,
577
+ durationMs: Date.now() - startMs,
578
+ errorCode: 'NETWORK_ERROR',
579
+ },
580
+ });
581
+ throw new TransientAuthFailure('Network error during refresh', err);
582
+ }
583
+ // TODO: refresh.ts could delegate to classifyRefreshResponse(response.status, body)
584
+ // from ./refreshClassification.ts once mobile parity is validated and device tests
585
+ // cover the 401/429/5xx branches. Today the inline classification is kept to avoid
586
+ // the risk of a behavioural regression in the daemon's S1/S2/S3 paths without full
587
+ // test coverage. See packages/auth-core/src/refreshClassification.ts for the
588
+ // extracted pure helper that mobile uses.
589
+ if (response.status === 200) {
590
+ const data = (await response.json());
591
+ // Soft backoff hint (production-hardening 2026-05-06). Server-side
592
+ // sets `X-Refresh-Backoff: <seconds>` when fleet refresh-QPS is
593
+ // high; the daemon's scheduler should defer its NEXT proactive
594
+ // refresh by that amount + jitter. Distinct from 429 Retry-After
595
+ // which signals "this request was rejected" — soft backoff fires
596
+ // on 200 responses, the rotation succeeded, and ignoring the hint
597
+ // is safe but causes the herd to reconverge.
598
+ //
599
+ // Parse defensively: a malformed header (negative, NaN, absurdly
600
+ // large) produces `undefined` so consumers fall through to their
601
+ // own scheduler defaults.
602
+ // Cap at 10 min so a misconfigured / compromised server cannot pin
603
+ // daemons to a multi-hour interval and starve them of refresh.
604
+ const SOFT_BACKOFF_MAX_SECONDS = 600;
605
+ let softBackoffSeconds;
606
+ const backoffHeader = response.headers.get('X-Refresh-Backoff');
607
+ if (typeof backoffHeader === 'string') {
608
+ const parsed = parseInt(backoffHeader, 10);
609
+ if (Number.isFinite(parsed) && parsed > 0) {
610
+ if (parsed > SOFT_BACKOFF_MAX_SECONDS) {
611
+ getAuthLogger().warn(`[auth/refresh] X-Refresh-Backoff=${parsed}s exceeds ${SOFT_BACKOFF_MAX_SECONDS}s cap — clamping`);
612
+ }
613
+ softBackoffSeconds = Math.min(parsed, SOFT_BACKOFF_MAX_SECONDS);
614
+ }
615
+ }
616
+ const pair = {
617
+ // Coerce empty string / undefined to null so consumers branch
618
+ // on a single canonical shape (the wire may legitimately ship
619
+ // any of three things for daemon scope: `null` literal, field
620
+ // absent, or empty string). Non-daemon scopes always supply a
621
+ // non-empty JWT here.
622
+ accessToken: typeof data.accessToken === 'string' && data.accessToken.length > 0
623
+ ? data.accessToken
624
+ : null,
625
+ accessExpiresAt: new Date(data.accessExpiresAt).getTime(),
626
+ refreshToken: data.refreshToken,
627
+ refreshExpiresAt: new Date(data.refreshExpiresAt).getTime(),
628
+ // Distinguish "field absent" (older server, leave caller's
629
+ // existing value alone) from "field present but null" (server
630
+ // explicitly cleared, callers MUST overwrite their cache).
631
+ // Three-valued semantic mirrors the daemon-side saveToken
632
+ // options bag — see daemon/src/auth.ts JSDoc.
633
+ opaqueAccessToken: Object.prototype.hasOwnProperty.call(data, 'opaqueAccessToken')
634
+ ? typeof data.opaqueAccessToken === 'string'
635
+ ? data.opaqueAccessToken
636
+ : null
637
+ : undefined,
638
+ ...(softBackoffSeconds !== undefined ? { softBackoffSeconds } : {}),
639
+ };
640
+ log.info(`[auth/refresh] rotated — access expires in ${Math.round((pair.accessExpiresAt - Date.now()) / 1000)}s`);
641
+ emitAuthTelemetry({
642
+ timestamp: new Date().toISOString(),
643
+ eventType: 'auth',
644
+ success: true,
645
+ metadata: {
646
+ event: 'refresh_success',
647
+ daemonId,
648
+ durationMs: Date.now() - startMs,
649
+ tokensRotated: true,
650
+ },
651
+ });
652
+ // RESET SEMANTICS (Finding 4 follow-up): a 200 success is one of only
653
+ // TWO outcomes that reset the unknown-401 streak (the other is a
654
+ // KNOWN-permanent 401). A success PROVES the credential is alive and the
655
+ // endpoint healthy, so any prior unknown-401 run was genuinely transient
656
+ // — the next regression starts with a fresh budget. Transient non-401
657
+ // outcomes (network/429/5xx) deliberately do NOT reset, so a flaky-but-
658
+ // dead unknown credential still converges to the permanent latch.
659
+ resetUnknown401Streak(daemonId);
660
+ try {
661
+ clearReauthState();
662
+ }
663
+ catch {
664
+ /* ignore */
665
+ }
666
+ return pair;
667
+ }
668
+ if (response.status === 429) {
669
+ // RESET SEMANTICS (Finding 4 follow-up): a 429 is transient and does
670
+ // NOT reset the unknown-401 streak. A dead-but-unknown-code credential
671
+ // whose endpoint also intermittently rate-limits must still converge to
672
+ // the permanent latch; resetting here would let it retry forever.
673
+ emitAuthTelemetry({
674
+ timestamp: new Date().toISOString(),
675
+ eventType: 'auth',
676
+ success: false,
677
+ metadata: {
678
+ event: 'refresh_failure',
679
+ daemonId,
680
+ durationMs: Date.now() - startMs,
681
+ errorCode: 'RATE_LIMITED',
682
+ statusCode: 429,
683
+ },
684
+ });
685
+ throw new TransientAuthFailure('Refresh rate-limited by server');
686
+ }
687
+ if (response.status === 401) {
688
+ let parsedBody = {};
689
+ try {
690
+ parsedBody = (await response.json());
691
+ }
692
+ catch {
693
+ /* ignore */
694
+ }
695
+ const code = parsedBody.code;
696
+ const permanent = [
697
+ 'REFRESH_INVALID',
698
+ 'REFRESH_REUSED',
699
+ 'REFRESH_EXPIRED',
700
+ 'SESSION_REVOKED',
701
+ ];
702
+ if (code && permanent.includes(code)) {
703
+ // A KNOWN credential-death code is genuinely permanent — latch
704
+ // immediately, exactly as before. It is NOT routed through the
705
+ // unknown-401 budget.
706
+ //
707
+ // RESET SEMANTICS (Finding 4 follow-up): this is one of only TWO
708
+ // outcomes that reset the unknown-401 streak (the other is a 200
709
+ // success). We reset here because the server just spoke a definitive,
710
+ // recognized verdict about the credential — whatever it says next
711
+ // about an unknown-shape 401 starts a clean run, and we also prevent a
712
+ // prior unknown-401 run from compounding with a real family-burn.
713
+ resetUnknown401Streak(daemonId);
714
+ log.warn(`[auth/refresh] permanent failure: ${code} — ${parsedBody.message ?? ''}`);
715
+ emitAuthTelemetry({
716
+ timestamp: new Date().toISOString(),
717
+ eventType: 'auth',
718
+ success: false,
719
+ metadata: {
720
+ event: 'refresh_failure',
721
+ daemonId,
722
+ durationMs: Date.now() - startMs,
723
+ errorCode: code,
724
+ statusCode: 401,
725
+ },
726
+ });
727
+ persistReauthState({ code, message: parsedBody.message, at: Date.now() });
728
+ throw new PermanentAuthFailure(code, parsedBody.message || code);
729
+ }
730
+ // Unknown-shape 401 — unknown code, missing code, or unparseable body.
731
+ // By construction this is NOT a daemon's actual refresh-family burn
732
+ // (those arrive as one of the four KNOWN codes above); it most likely
733
+ // came from infra OverSky does not fully control — a proxy/middleware
734
+ // regression, a clock-skew replay rejection (DEVICE_PROOF_REPLAYED),
735
+ // or a half-rolled-out server route.
736
+ //
737
+ // Pre-fix every such 401 latched `PermanentAuthFailure('UNKNOWN_401')`
738
+ // + `persistReauthState` on the FIRST occurrence, forcing a manual
739
+ // reconnect even when the next refresh would have succeeded. Now we
740
+ // give a bounded retry budget: the first `UNKNOWN_401_BUDGET`
741
+ // consecutive unknown-401s on this daemonId throw `TransientAuthFailure`
742
+ // (NO persistReauthState), so the daemon's `performRefresh → return false`
743
+ // path lets the scheduler / reactive retry+backoff ride out a transient
744
+ // regression. Only once the budget is exhausted do we fall back to the
745
+ // original permanent latch — keeping the interactive re-auth modal as
746
+ // the FINAL fallback. The distinct `UNKNOWN_401` telemetry tag is kept
747
+ // on BOTH branches so monitoring can still see a server-side bug
748
+ // separately from a real expiration (and watch the retry-vs-latch split).
749
+ const message = parsedBody.message || 'Unknown auth failure';
750
+ const { exhausted, count } = recordUnknown401(daemonId, Date.now());
751
+ if (!exhausted) {
752
+ log.warn(`[auth/refresh] unknown-shape 401 (${count}/${UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}`);
753
+ emitAuthTelemetry({
754
+ timestamp: new Date().toISOString(),
755
+ eventType: 'auth',
756
+ success: false,
757
+ metadata: {
758
+ event: 'refresh_failure',
759
+ daemonId,
760
+ durationMs: Date.now() - startMs,
761
+ errorCode: 'UNKNOWN_401',
762
+ statusCode: 401,
763
+ // Distinguish a transient (retrying) unknown-401 from the
764
+ // budget-exhausted latch in the telemetry stream.
765
+ transient: true,
766
+ unknown401Streak: count,
767
+ },
768
+ });
769
+ throw new TransientAuthFailure(`Unknown auth failure (transient, retrying): ${message}`);
770
+ }
771
+ // Budget exhausted within the window — fall back to the original
772
+ // permanent behavior: latch + persist the reauth flag so the daemon
773
+ // surfaces the banner and the interactive modal becomes the final
774
+ // recovery path.
775
+ log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${UNKNOWN_401_BUDGET}) — latching permanent: ${message}`);
776
+ emitAuthTelemetry({
777
+ timestamp: new Date().toISOString(),
778
+ eventType: 'auth',
779
+ success: false,
780
+ metadata: {
781
+ event: 'refresh_failure',
782
+ daemonId,
783
+ durationMs: Date.now() - startMs,
784
+ errorCode: 'UNKNOWN_401',
785
+ statusCode: 401,
786
+ transient: false,
787
+ unknown401Streak: count,
788
+ },
789
+ });
790
+ persistReauthState({
791
+ code: 'UNKNOWN_401',
792
+ message,
793
+ at: Date.now(),
794
+ });
795
+ throw new PermanentAuthFailure('UNKNOWN_401', message);
796
+ }
797
+ // 5xx / unexpected — transient. Caller will back off.
798
+ // RESET SEMANTICS (Finding 4 follow-up): a 5xx is transient and does NOT
799
+ // reset the unknown-401 streak. A dead-but-unknown-code credential whose
800
+ // endpoint intermittently 5xxs must still converge to the permanent latch;
801
+ // resetting here would let it retry forever.
802
+ let bodyText = '';
803
+ try {
804
+ bodyText = await response.text();
805
+ }
806
+ catch {
807
+ /* ignore */
808
+ }
809
+ emitAuthTelemetry({
810
+ timestamp: new Date().toISOString(),
811
+ eventType: 'auth',
812
+ success: false,
813
+ metadata: {
814
+ event: 'refresh_failure',
815
+ daemonId,
816
+ durationMs: Date.now() - startMs,
817
+ errorCode: 'SERVER_ERROR',
818
+ statusCode: response.status,
819
+ },
820
+ });
821
+ throw new TransientAuthFailure(`Refresh failed (${response.status}): ${bodyText.slice(0, 200)}`);
822
+ }
823
+ export function persistReauthState(state) {
824
+ const log = getAuthLogger();
825
+ const flagPath = reauthFlagPath();
826
+ try {
827
+ const dir = path.dirname(flagPath);
828
+ if (!fs.existsSync(dir))
829
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
830
+ fs.writeFileSync(flagPath, JSON.stringify(state, null, 2) + '\n', {
831
+ mode: 0o600,
832
+ });
833
+ }
834
+ catch (err) {
835
+ log.debug(`[auth/refresh] failed to persist reauth flag: ${err?.message}`);
836
+ }
837
+ }
838
+ export function needsReauthState() {
839
+ try {
840
+ const flagPath = reauthFlagPath();
841
+ if (!fs.existsSync(flagPath))
842
+ return { needed: false };
843
+ const raw = fs.readFileSync(flagPath, 'utf-8');
844
+ const state = JSON.parse(raw);
845
+ return { needed: true, reason: state.code, since: state.at };
846
+ }
847
+ catch {
848
+ return { needed: false };
849
+ }
850
+ }
851
+ export function clearReauthState() {
852
+ const log = getAuthLogger();
853
+ try {
854
+ const flagPath = reauthFlagPath();
855
+ if (fs.existsSync(flagPath))
856
+ fs.unlinkSync(flagPath);
857
+ }
858
+ catch (err) {
859
+ log.debug(`[auth/refresh] clearReauthState: ${err?.message}`);
860
+ }
861
+ }
862
+ /** Path getters exposed for tests + the daemon's connection.ts handler. */
863
+ export { authLockPath as AUTH_LOCK_PATH_FN, reauthFlagPath as REAUTH_FLAG_PATH_FN };