@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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* refreshClassification.ts — pure HTTP→{Permanent, Transient, Success} mapping.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from refresh.ts so RN-side clients (which can't pull the full
|
|
5
|
+
* Node-bound refresh module) can apply the same response classification
|
|
6
|
+
* the daemon does. The byte-equal mirror in mobile is at
|
|
7
|
+
* `mobile/react-native/src/api/authProtocol.ts` — keep both files in sync.
|
|
8
|
+
* (2026-08 — the codeless/unknown-401 → `UNKNOWN_401` change below still needs
|
|
9
|
+
* to be ported to that mirror in the mobile repo, which is a separate git root
|
|
10
|
+
* gitignored from this one; until then mobile keeps the old immediate-latch
|
|
11
|
+
* behaviour, which is safe — it just re-auths a touch more eagerly.)
|
|
12
|
+
*
|
|
13
|
+
* Pure: no fetch, no fs, no lock. Caller passes in the parsed response and
|
|
14
|
+
* the http status; we return a discriminated union.
|
|
15
|
+
*/
|
|
16
|
+
import { PermanentAuthFailure, TransientAuthFailure, type PermanentAuthReason } from './types.js';
|
|
17
|
+
export { PermanentAuthFailure, TransientAuthFailure };
|
|
18
|
+
export type { PermanentAuthReason };
|
|
19
|
+
export interface RefreshResponseShape {
|
|
20
|
+
/** Optional server-supplied error code, e.g. 'REFRESH_REUSED'. */
|
|
21
|
+
code?: string;
|
|
22
|
+
/** Optional human-readable error message. */
|
|
23
|
+
message?: string;
|
|
24
|
+
/** When 200, the new access token. */
|
|
25
|
+
token?: string;
|
|
26
|
+
/** When 200, the (possibly rotated) refresh token. */
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ClassifiedRefreshSuccess {
|
|
30
|
+
kind: 'success';
|
|
31
|
+
token: string;
|
|
32
|
+
refreshToken?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ClassifiedRefreshFailure {
|
|
35
|
+
kind: 'failure';
|
|
36
|
+
failure: PermanentAuthFailure | TransientAuthFailure;
|
|
37
|
+
}
|
|
38
|
+
export type ClassifiedRefreshResult = ClassifiedRefreshSuccess | ClassifiedRefreshFailure;
|
|
39
|
+
/**
|
|
40
|
+
* Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
|
|
41
|
+
* into a discriminated union of success or failure.
|
|
42
|
+
*
|
|
43
|
+
* Rules match the canonical doRefresh() logic in refresh.ts:
|
|
44
|
+
* - 200 + body.token → success
|
|
45
|
+
* - 429 → TransientAuthFailure (rate-limited, caller should back off)
|
|
46
|
+
* - 401 + known permanent code → PermanentAuthFailure with that reason
|
|
47
|
+
* - 401 + unknown/missing code → PermanentAuthFailure('UNKNOWN_401', ...) — AMBIGUOUS,
|
|
48
|
+
* a caller with a retry budget may ride it out (see the WEB interceptor and the
|
|
49
|
+
* daemon's UNKNOWN_401_BUDGET in refresh.ts); a caller without one fails closed.
|
|
50
|
+
* - 4xx (not 429) → PermanentAuthFailure('REFRESH_INVALID', ...) — server explicitly rejected
|
|
51
|
+
* - 5xx / null body / anything else → TransientAuthFailure (transient, retry OK)
|
|
52
|
+
*/
|
|
53
|
+
export declare function classifyRefreshResponse(status: number, body: RefreshResponseShape | null): ClassifiedRefreshResult;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* refreshClassification.ts — pure HTTP→{Permanent, Transient, Success} mapping.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from refresh.ts so RN-side clients (which can't pull the full
|
|
6
|
+
* Node-bound refresh module) can apply the same response classification
|
|
7
|
+
* the daemon does. The byte-equal mirror in mobile is at
|
|
8
|
+
* `mobile/react-native/src/api/authProtocol.ts` — keep both files in sync.
|
|
9
|
+
* (2026-08 — the codeless/unknown-401 → `UNKNOWN_401` change below still needs
|
|
10
|
+
* to be ported to that mirror in the mobile repo, which is a separate git root
|
|
11
|
+
* gitignored from this one; until then mobile keeps the old immediate-latch
|
|
12
|
+
* behaviour, which is safe — it just re-auths a touch more eagerly.)
|
|
13
|
+
*
|
|
14
|
+
* Pure: no fetch, no fs, no lock. Caller passes in the parsed response and
|
|
15
|
+
* the http status; we return a discriminated union.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.TransientAuthFailure = exports.PermanentAuthFailure = void 0;
|
|
19
|
+
exports.classifyRefreshResponse = classifyRefreshResponse;
|
|
20
|
+
const types_js_1 = require("./types.js");
|
|
21
|
+
Object.defineProperty(exports, "PermanentAuthFailure", { enumerable: true, get: function () { return types_js_1.PermanentAuthFailure; } });
|
|
22
|
+
Object.defineProperty(exports, "TransientAuthFailure", { enumerable: true, get: function () { return types_js_1.TransientAuthFailure; } });
|
|
23
|
+
/**
|
|
24
|
+
* Maps server-supplied error codes to the canonical PermanentAuthReason values
|
|
25
|
+
* exported from types.ts. Only the four values present in PermanentAuthReason
|
|
26
|
+
* are listed; anything else falls through to 'REFRESH_INVALID'.
|
|
27
|
+
*/
|
|
28
|
+
const PERMANENT_CODE_SET = new Set([
|
|
29
|
+
'REFRESH_INVALID',
|
|
30
|
+
'REFRESH_REUSED',
|
|
31
|
+
'REFRESH_EXPIRED',
|
|
32
|
+
'SESSION_REVOKED',
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* Classify an HTTP response from /api/auth/refresh (or /api/daemons/token/refresh)
|
|
36
|
+
* into a discriminated union of success or failure.
|
|
37
|
+
*
|
|
38
|
+
* Rules match the canonical doRefresh() logic in refresh.ts:
|
|
39
|
+
* - 200 + body.token → success
|
|
40
|
+
* - 429 → TransientAuthFailure (rate-limited, caller should back off)
|
|
41
|
+
* - 401 + known permanent code → PermanentAuthFailure with that reason
|
|
42
|
+
* - 401 + unknown/missing code → PermanentAuthFailure('UNKNOWN_401', ...) — AMBIGUOUS,
|
|
43
|
+
* a caller with a retry budget may ride it out (see the WEB interceptor and the
|
|
44
|
+
* daemon's UNKNOWN_401_BUDGET in refresh.ts); a caller without one fails closed.
|
|
45
|
+
* - 4xx (not 429) → PermanentAuthFailure('REFRESH_INVALID', ...) — server explicitly rejected
|
|
46
|
+
* - 5xx / null body / anything else → TransientAuthFailure (transient, retry OK)
|
|
47
|
+
*/
|
|
48
|
+
function classifyRefreshResponse(status, body) {
|
|
49
|
+
// Success
|
|
50
|
+
if (status === 200 && body?.token) {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'success',
|
|
53
|
+
token: body.token,
|
|
54
|
+
refreshToken: body.refreshToken,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// 429 — rate limited, transient
|
|
58
|
+
if (status === 429) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'failure',
|
|
61
|
+
failure: new types_js_1.TransientAuthFailure(body?.message ?? 'Refresh rate-limited by server'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// 401 — auth failure; check for known permanent code
|
|
65
|
+
if (status === 401) {
|
|
66
|
+
const code = body?.code;
|
|
67
|
+
if (code && PERMANENT_CODE_SET.has(code)) {
|
|
68
|
+
return {
|
|
69
|
+
kind: 'failure',
|
|
70
|
+
failure: new types_js_1.PermanentAuthFailure(code, body?.message ?? code),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Unknown-shape 401 (no code, or a code we don't recognise). This is NOT a
|
|
74
|
+
// proven credential death: the refresh endpoint returns a codeless 401 for
|
|
75
|
+
// benign, self-healing conditions too — the refresh cookie simply not riding
|
|
76
|
+
// along on THIS request (SameSite suppression on a cross-site entry / a
|
|
77
|
+
// restored tab), or a token another concurrent refresh already rotated (the
|
|
78
|
+
// loser presents a now-rotated token; its next same-origin retry carries the
|
|
79
|
+
// successor and succeeds). Labelling those permanent makes a single transient
|
|
80
|
+
// 401 sign the user out of every tab while the server-side session is still
|
|
81
|
+
// alive and unexpired. So classify as `UNKNOWN_401`: still a
|
|
82
|
+
// PermanentAuthFailure so a caller with no retry budget stays safe (fails
|
|
83
|
+
// closed), but a distinct code the WEB interceptor recognises and rides out
|
|
84
|
+
// under a bounded budget before latching (mirrors the daemon's
|
|
85
|
+
// UNKNOWN_401_BUDGET in refresh.ts). A KNOWN death code above still latches
|
|
86
|
+
// immediately.
|
|
87
|
+
return {
|
|
88
|
+
kind: 'failure',
|
|
89
|
+
failure: new types_js_1.PermanentAuthFailure('UNKNOWN_401', body?.message ?? 'Unknown auth failure'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Other 4xx (not 429, not 401) — server explicitly rejected, not retryable
|
|
93
|
+
if (status >= 400 && status < 500) {
|
|
94
|
+
return {
|
|
95
|
+
kind: 'failure',
|
|
96
|
+
failure: new types_js_1.PermanentAuthFailure('REFRESH_INVALID', body?.message ?? `HTTP ${status}`),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// 200 without a token (malformed success), 5xx, network-level errors, or any
|
|
100
|
+
// other status — treat as transient so the caller's retry loop can recover.
|
|
101
|
+
return {
|
|
102
|
+
kind: 'failure',
|
|
103
|
+
failure: new types_js_1.TransientAuthFailure(body?.message ?? `HTTP ${status}`),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface RefreshSchedulerOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Invoked when the scheduler decides to refresh. Must return the fresh
|
|
4
|
+
* access token object on success (so the scheduler can chain-schedule
|
|
5
|
+
* from it) or null on any failure. On null the failure counter is bumped.
|
|
6
|
+
*/
|
|
7
|
+
onRefresh: () => Promise<{
|
|
8
|
+
accessToken: string;
|
|
9
|
+
} | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Current access token getter. Used on wake to reschedule from the
|
|
12
|
+
* canonical token when the in-memory one is stale (e.g. another process
|
|
13
|
+
* rotated while we were asleep).
|
|
14
|
+
*/
|
|
15
|
+
getAccessToken: () => string | undefined | null;
|
|
16
|
+
/** Log prefix label. Default "refresh". */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** How long before `exp` to fire refresh. Default 5 min. */
|
|
19
|
+
refreshBufferMs?: number;
|
|
20
|
+
/** Fallback interval if `exp` cannot be decoded. Default 30 min. */
|
|
21
|
+
fallbackIntervalMs?: number;
|
|
22
|
+
/** Max consecutive onRefresh failures before giving up. Default 3. */
|
|
23
|
+
maxFailures?: number;
|
|
24
|
+
/** Test hooks. */
|
|
25
|
+
setTimeoutFn?: (cb: () => void, ms: number) => NodeJS.Timeout;
|
|
26
|
+
clearTimeoutFn?: (timer: NodeJS.Timeout) => void;
|
|
27
|
+
nowFn?: () => number;
|
|
28
|
+
/** Backoff cap — controls onWake drift threshold (2×). Default 120_000. */
|
|
29
|
+
backoffCapMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Override the kill-switch check. When undefined (default) the package's
|
|
32
|
+
* runtime-injected kill switches are consulted. Useful for tests.
|
|
33
|
+
*/
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface RefreshScheduler {
|
|
37
|
+
/** Schedule refresh for a token by decoding its exp. Cancels any existing timer. */
|
|
38
|
+
schedule(token: string): void;
|
|
39
|
+
/** Schedule using an explicit TTL in seconds (for opaque tokens). */
|
|
40
|
+
scheduleFromExpiresIn(expiresInSeconds: number): void;
|
|
41
|
+
/** Cancel any pending refresh. */
|
|
42
|
+
cancel(): void;
|
|
43
|
+
/** Call on wake/visibility change to detect dropped fires. */
|
|
44
|
+
onWake(): void;
|
|
45
|
+
/** Test-only: expose the current timer state. */
|
|
46
|
+
readonly _debugState: {
|
|
47
|
+
scheduledFor: number | null;
|
|
48
|
+
generation: number;
|
|
49
|
+
consecutiveFailures: number;
|
|
50
|
+
longTailAttempt: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export declare function createRefreshScheduler(opts: RefreshSchedulerOptions): RefreshScheduler;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRefreshScheduler = createRefreshScheduler;
|
|
4
|
+
/**
|
|
5
|
+
* refreshScheduler.ts — proactive, sleep-aware JWT refresh scheduler.
|
|
6
|
+
*
|
|
7
|
+
* Why: reactive refresh (wait for 4401, then rotate) loses minutes of
|
|
8
|
+
* latency and surfaces as user-visible reconnect flicker. Scheduling a
|
|
9
|
+
* refresh BEFORE exp means the socket never trips the auth floor in
|
|
10
|
+
* steady state. Reactive refresh stays as the correctness floor — this
|
|
11
|
+
* scheduler is the comfort layer.
|
|
12
|
+
*
|
|
13
|
+
* Core invariants:
|
|
14
|
+
*
|
|
15
|
+
* 1. Generation counter per scheduler. Every schedule() / cancel() bumps
|
|
16
|
+
* it. In-flight `doRefresh()` callbacks check generation before setting
|
|
17
|
+
* any follow-up timer — so a late callback from a stale schedule
|
|
18
|
+
* never hijacks the live timer chain.
|
|
19
|
+
*
|
|
20
|
+
* 2. Follow-up timer after every successful refresh. A one-shot timer
|
|
21
|
+
* is insufficient for long-lived processes: once the first proactive
|
|
22
|
+
* fire rotates the token, we re-derive `exp` from the new token and
|
|
23
|
+
* schedule the next fire. Fallback to fallbackIntervalMs (default 30 m)
|
|
24
|
+
* when the new token is opaque or undecodable.
|
|
25
|
+
*
|
|
26
|
+
* 3. Failure cap. If onRefresh returns null more than maxFailures times
|
|
27
|
+
* in a row, stop rescheduling until schedule() is called externally
|
|
28
|
+
* again. Prevents spinning forever against a broken backend.
|
|
29
|
+
*
|
|
30
|
+
* 4. .unref() on every timer. The scheduler must never be the reason the
|
|
31
|
+
* process stays alive.
|
|
32
|
+
*
|
|
33
|
+
* 5. Sleep detection. onWake() compares Date.now() against the recorded
|
|
34
|
+
* fire target. If wallclock has jumped past fire-target + 2×backoffCap,
|
|
35
|
+
* the timer fired while the CPU was frozen (laptop lid) — we lost the
|
|
36
|
+
* fire and must reschedule from the current token. Cheap when no drift.
|
|
37
|
+
*/
|
|
38
|
+
const runtime_js_1 = require("./runtime.js");
|
|
39
|
+
const jwtUtils_js_1 = require("./jwtUtils.js");
|
|
40
|
+
/** Refresh buffer: fire this long BEFORE exp. Default 5 min. */
|
|
41
|
+
const DEFAULT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
42
|
+
/** Fallback refresh interval when the new token's expiry is unknown. */
|
|
43
|
+
const DEFAULT_FALLBACK_INTERVAL_MS = 30 * 60 * 1000;
|
|
44
|
+
/** Max consecutive onRefresh failures before giving up. */
|
|
45
|
+
const DEFAULT_MAX_FAILURES = 3;
|
|
46
|
+
/** Retry spacing between onRefresh failures. */
|
|
47
|
+
const REFRESH_RETRY_DELAY_MS = 60_000;
|
|
48
|
+
/**
|
|
49
|
+
* ±60 s jitter applied to the computed delay. Spreading the fleet prevents
|
|
50
|
+
* a 1M-daemon thundering herd after an outage recovery.
|
|
51
|
+
*/
|
|
52
|
+
const REFRESH_JITTER_MS = 60 * 1000;
|
|
53
|
+
/** Floor on scheduled delay — never fire sooner than this. Guards against
|
|
54
|
+
* clock skew producing negative delays with jitter. */
|
|
55
|
+
const MIN_DELAY_FLOOR_MS = 30_000;
|
|
56
|
+
/** Minimum effective refresh buffer — preserves SOME lead time even on
|
|
57
|
+
* pathologically short-TTL tokens. */
|
|
58
|
+
const MIN_REFRESH_BUFFER_FLOOR_MS = 5_000;
|
|
59
|
+
/** Sleep/wake detection threshold: 2 × backoff cap (default 4 min). */
|
|
60
|
+
const DEFAULT_BACKOFF_CAP_MS = 120_000;
|
|
61
|
+
/**
|
|
62
|
+
* Long-tail recovery — see S2 fix. After `maxFailures` consecutive
|
|
63
|
+
* `onRefresh` returns of null, instead of stopping forever the scheduler
|
|
64
|
+
* arms a slow exponential-backoff timer that keeps probing in the
|
|
65
|
+
* background. Reset on the next successful refresh. Numbers chosen so the
|
|
66
|
+
* worst-case dead-comfort-layer window is bounded at 30 min instead of an
|
|
67
|
+
* unbounded "until the socket disconnects" forever.
|
|
68
|
+
*/
|
|
69
|
+
const LONG_TAIL_INITIAL_DELAY_MS = 5 * 60 * 1000;
|
|
70
|
+
const LONG_TAIL_MAX_DELAY_MS = 30 * 60 * 1000;
|
|
71
|
+
const LONG_TAIL_BACKOFF_FACTOR = 2;
|
|
72
|
+
function formatDuration(ms) {
|
|
73
|
+
if (ms < 60_000)
|
|
74
|
+
return `${Math.round(ms / 1000)}s`;
|
|
75
|
+
const m = Math.floor(ms / 60_000);
|
|
76
|
+
const s = Math.round((ms % 60_000) / 1000);
|
|
77
|
+
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Long-tail delay for attempt N (1-indexed). N=0 returns the initial delay
|
|
81
|
+
* — used the first time we transition into long-tail. Capped at
|
|
82
|
+
* LONG_TAIL_MAX_DELAY_MS.
|
|
83
|
+
*/
|
|
84
|
+
function computeLongTailDelay(currentAttempt) {
|
|
85
|
+
const exponent = Math.max(0, currentAttempt);
|
|
86
|
+
const delay = LONG_TAIL_INITIAL_DELAY_MS * Math.pow(LONG_TAIL_BACKOFF_FACTOR, exponent);
|
|
87
|
+
return Math.min(delay, LONG_TAIL_MAX_DELAY_MS);
|
|
88
|
+
}
|
|
89
|
+
function createRefreshScheduler(opts) {
|
|
90
|
+
const log = (0, runtime_js_1.getAuthLogger)();
|
|
91
|
+
const label = opts.label ?? 'refresh';
|
|
92
|
+
// Kill-switch resolution: explicit `opts.disabled` wins, else runtime-
|
|
93
|
+
// injected `killSwitches.refreshScheduler`. Defaults to enabled.
|
|
94
|
+
const disabled = opts.disabled ?? (0, runtime_js_1.getAuthKillSwitches)().refreshScheduler ?? false;
|
|
95
|
+
if (disabled) {
|
|
96
|
+
let warnedOnce = false;
|
|
97
|
+
const warnDisabled = (method) => {
|
|
98
|
+
if (!warnedOnce) {
|
|
99
|
+
log.warn(`[${label}] refresh scheduler is disabled via kill switch — ${method}() is a no-op; falling back to pre-scheduler refresh path`);
|
|
100
|
+
warnedOnce = true;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
schedule(_token) {
|
|
105
|
+
warnDisabled('schedule');
|
|
106
|
+
},
|
|
107
|
+
scheduleFromExpiresIn(_expiresInSeconds) {
|
|
108
|
+
warnDisabled('scheduleFromExpiresIn');
|
|
109
|
+
},
|
|
110
|
+
cancel() {
|
|
111
|
+
/* no-op */
|
|
112
|
+
},
|
|
113
|
+
onWake() {
|
|
114
|
+
/* no-op */
|
|
115
|
+
},
|
|
116
|
+
get _debugState() {
|
|
117
|
+
return {
|
|
118
|
+
scheduledFor: null,
|
|
119
|
+
generation: 0,
|
|
120
|
+
consecutiveFailures: 0,
|
|
121
|
+
longTailAttempt: 0,
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const refreshBufferMs = opts.refreshBufferMs ?? DEFAULT_REFRESH_BUFFER_MS;
|
|
127
|
+
const fallbackIntervalMs = opts.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL_MS;
|
|
128
|
+
const maxFailures = opts.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
129
|
+
const backoffCapMs = opts.backoffCapMs ?? DEFAULT_BACKOFF_CAP_MS;
|
|
130
|
+
const setTimeoutFn = opts.setTimeoutFn ??
|
|
131
|
+
global.setTimeout;
|
|
132
|
+
const clearTimeoutFn = opts.clearTimeoutFn ?? global.clearTimeout;
|
|
133
|
+
const now = opts.nowFn ?? (() => Date.now());
|
|
134
|
+
let timer = null;
|
|
135
|
+
let generation = 0;
|
|
136
|
+
let consecutiveFailures = 0;
|
|
137
|
+
let scheduledFor = null;
|
|
138
|
+
/**
|
|
139
|
+
* S2 fix: when `consecutiveFailures` hits `maxFailures` we don't stop
|
|
140
|
+
* forever — instead we enter long-tail recovery and probe with
|
|
141
|
+
* exponential backoff. `longTailAttempt` is 0 outside long-tail and the
|
|
142
|
+
* 1-indexed attempt number while in it. Reused for delay computation:
|
|
143
|
+
* delay = min(INITIAL × FACTOR^(attempt-1), MAX).
|
|
144
|
+
*/
|
|
145
|
+
let longTailAttempt = 0;
|
|
146
|
+
function bumpGeneration() {
|
|
147
|
+
generation += 1;
|
|
148
|
+
return generation;
|
|
149
|
+
}
|
|
150
|
+
function clearTimer() {
|
|
151
|
+
if (timer) {
|
|
152
|
+
try {
|
|
153
|
+
clearTimeoutFn(timer);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* ignore */
|
|
157
|
+
}
|
|
158
|
+
timer = null;
|
|
159
|
+
}
|
|
160
|
+
scheduledFor = null;
|
|
161
|
+
}
|
|
162
|
+
function setDelayTimer(delayMs, gen) {
|
|
163
|
+
const effective = Math.max(MIN_DELAY_FLOOR_MS, delayMs);
|
|
164
|
+
scheduledFor = now() + effective;
|
|
165
|
+
const t = setTimeoutFn(() => {
|
|
166
|
+
void doRefresh(gen);
|
|
167
|
+
}, effective);
|
|
168
|
+
if (t && typeof t.unref === 'function') {
|
|
169
|
+
try {
|
|
170
|
+
t.unref();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* ignore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
timer = t;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* H8 fix: a flat 5-minute buffer breaks for tokens with TTL < 10 min — the
|
|
180
|
+
* buffer meets-or-exceeds TTL, producing a <=0 baseDelay and an immediate-
|
|
181
|
+
* refresh loop. Clamp effective buffer to ttlMs/2 when configured buffer
|
|
182
|
+
* exceeds half the TTL. Never below MIN_REFRESH_BUFFER_FLOOR_MS so we
|
|
183
|
+
* preserve some lead time. When TTL/2 is itself below the floor (sub-10s
|
|
184
|
+
* tokens) the token is so short-lived we accept an immediate refresh.
|
|
185
|
+
*/
|
|
186
|
+
function effectiveBufferForTtl(ttlMs) {
|
|
187
|
+
if (ttlMs <= 0)
|
|
188
|
+
return refreshBufferMs;
|
|
189
|
+
const half = ttlMs / 2;
|
|
190
|
+
if (refreshBufferMs > half) {
|
|
191
|
+
const clamped = Math.max(half, MIN_REFRESH_BUFFER_FLOOR_MS);
|
|
192
|
+
log.debug(`[${label}] refresh buffer clamped: ${formatDuration(refreshBufferMs)} → ${formatDuration(clamped)} (ttl=${formatDuration(ttlMs)})`);
|
|
193
|
+
return clamped;
|
|
194
|
+
}
|
|
195
|
+
return refreshBufferMs;
|
|
196
|
+
}
|
|
197
|
+
function schedule(token) {
|
|
198
|
+
const exp = (0, jwtUtils_js_1.decodeJwtExpiry)(token);
|
|
199
|
+
clearTimer();
|
|
200
|
+
const gen = bumpGeneration();
|
|
201
|
+
// External re-arm exits long-tail mode. The generation bump already
|
|
202
|
+
// invalidates any in-flight long-tail callback; reset the counter here
|
|
203
|
+
// so the next failure run starts fresh.
|
|
204
|
+
longTailAttempt = 0;
|
|
205
|
+
consecutiveFailures = 0;
|
|
206
|
+
if (!exp) {
|
|
207
|
+
log.debug(`[${label}] token has no decodable exp; using fallback interval ${formatDuration(fallbackIntervalMs)}`);
|
|
208
|
+
setDelayTimer(fallbackIntervalMs, gen);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const expMs = exp * 1000;
|
|
212
|
+
const ttlMs = expMs - now();
|
|
213
|
+
const effectiveBuffer = effectiveBufferForTtl(ttlMs);
|
|
214
|
+
const baseDelay = ttlMs - effectiveBuffer;
|
|
215
|
+
const jitter = (Math.random() * 2 - 1) * REFRESH_JITTER_MS;
|
|
216
|
+
const delayMs = baseDelay + jitter;
|
|
217
|
+
if (delayMs <= 0) {
|
|
218
|
+
log.debug(`[${label}] token exp=${new Date(expMs).toISOString()} is past or inside buffer — refreshing immediately`);
|
|
219
|
+
scheduledFor = now();
|
|
220
|
+
setDelayTimer(0, gen);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
log.debug(`[${label}] scheduling refresh in ${formatDuration(delayMs)} (exp=${new Date(expMs).toISOString()})`);
|
|
224
|
+
setDelayTimer(delayMs, gen);
|
|
225
|
+
}
|
|
226
|
+
function scheduleFromExpiresIn(expiresInSeconds) {
|
|
227
|
+
clearTimer();
|
|
228
|
+
const gen = bumpGeneration();
|
|
229
|
+
const ttlMs = expiresInSeconds * 1000;
|
|
230
|
+
const effectiveBuffer = effectiveBufferForTtl(ttlMs);
|
|
231
|
+
const delayMs = Math.max(ttlMs - effectiveBuffer, MIN_DELAY_FLOOR_MS);
|
|
232
|
+
log.debug(`[${label}] scheduling refresh in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${formatDuration(effectiveBuffer)})`);
|
|
233
|
+
setDelayTimer(delayMs, gen);
|
|
234
|
+
}
|
|
235
|
+
function cancel() {
|
|
236
|
+
bumpGeneration();
|
|
237
|
+
clearTimer();
|
|
238
|
+
consecutiveFailures = 0;
|
|
239
|
+
longTailAttempt = 0;
|
|
240
|
+
}
|
|
241
|
+
async function doRefresh(gen) {
|
|
242
|
+
if (gen !== generation) {
|
|
243
|
+
log.debug(`[${label}] stale doRefresh (gen ${gen} vs ${generation}) — skipping`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
let result = null;
|
|
247
|
+
try {
|
|
248
|
+
result = await opts.onRefresh();
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
log.warn(`[${label}] onRefresh threw: ${err?.message}`);
|
|
252
|
+
result = null;
|
|
253
|
+
}
|
|
254
|
+
if (gen !== generation) {
|
|
255
|
+
log.debug(`[${label}] post-refresh stale (gen ${gen} vs ${generation}) — skipping follow-up`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!result) {
|
|
259
|
+
consecutiveFailures += 1;
|
|
260
|
+
if (consecutiveFailures >= maxFailures) {
|
|
261
|
+
// S2 fix: instead of stopping forever after maxFailures, enter
|
|
262
|
+
// long-tail recovery. The first time we hit the cap we log
|
|
263
|
+
// `daemon_token_refresh_give_up` for backwards-compatible alerting,
|
|
264
|
+
// then arm a slow exponential-backoff probe. On every long-tail
|
|
265
|
+
// attempt we emit a structured log so operators can alert on
|
|
266
|
+
// sustained degradation. Reset on success.
|
|
267
|
+
const longTailDelay = computeLongTailDelay(longTailAttempt);
|
|
268
|
+
const isFirstEntry = longTailAttempt === 0;
|
|
269
|
+
if (isFirstEntry) {
|
|
270
|
+
log.error('[auth/refresh] daemon_token_refresh_give_up', {
|
|
271
|
+
label,
|
|
272
|
+
failures: consecutiveFailures,
|
|
273
|
+
reason: 'max_consecutive_failures',
|
|
274
|
+
});
|
|
275
|
+
longTailAttempt = 1;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
longTailAttempt += 1;
|
|
279
|
+
}
|
|
280
|
+
log.warn('[auth/refresh] daemon_token_refresh_long_tail_attempt', {
|
|
281
|
+
label,
|
|
282
|
+
attempt: longTailAttempt,
|
|
283
|
+
nextDelayMs: longTailDelay,
|
|
284
|
+
});
|
|
285
|
+
setDelayTimer(longTailDelay, gen);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
log.warn(`[${label}] refresh failed (${consecutiveFailures}/${maxFailures}) — retrying in ${formatDuration(REFRESH_RETRY_DELAY_MS)}`);
|
|
289
|
+
setDelayTimer(REFRESH_RETRY_DELAY_MS, gen);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Successful refresh — fully recover from long-tail (if active) and
|
|
293
|
+
// chain-schedule from the new token's exp.
|
|
294
|
+
consecutiveFailures = 0;
|
|
295
|
+
longTailAttempt = 0;
|
|
296
|
+
const newExp = (0, jwtUtils_js_1.decodeJwtExpiry)(result.accessToken);
|
|
297
|
+
if (newExp) {
|
|
298
|
+
schedule(result.accessToken);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
log.debug(`[${label}] refreshed token has no decodable exp — using fallback interval`);
|
|
302
|
+
clearTimer();
|
|
303
|
+
const nextGen = bumpGeneration();
|
|
304
|
+
setDelayTimer(fallbackIntervalMs, nextGen);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function onWake() {
|
|
308
|
+
if (scheduledFor === null)
|
|
309
|
+
return;
|
|
310
|
+
const drift = now() - scheduledFor;
|
|
311
|
+
const threshold = 2 * backoffCapMs;
|
|
312
|
+
if (drift <= threshold)
|
|
313
|
+
return;
|
|
314
|
+
log.info(`[${label}] wake detected drift ${formatDuration(drift)} past scheduled fire — rescheduling`);
|
|
315
|
+
const currentToken = opts.getAccessToken();
|
|
316
|
+
if (!currentToken) {
|
|
317
|
+
log.warn(`[${label}] onWake: no access token available; cancelling timer`);
|
|
318
|
+
cancel();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
schedule(currentToken);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
schedule,
|
|
325
|
+
scheduleFromExpiresIn,
|
|
326
|
+
cancel,
|
|
327
|
+
onWake,
|
|
328
|
+
get _debugState() {
|
|
329
|
+
return { scheduledFor, generation, consecutiveFailures, longTailAttempt };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|