@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.
- package/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- 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 };
|