@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
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @skrr-ai/auth-core
2
+
3
+ Shared authentication substrate for OverSky packages.
4
+
5
+ This package contains the token store, refresh helpers, localhost login
6
+ callback, device-key helpers, TTL parsing, credential envelope helpers, and auth
7
+ failure classification used by the OverSky daemon and Sky CLI.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @skrr-ai/auth-core
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { parseTtlDays } from '@skrr-ai/auth-core';
19
+ ```
20
+
21
+ The npm package ships compiled JavaScript and TypeScript declarations only.
@@ -0,0 +1,24 @@
1
+ /** Key length in bytes (256 bits). */
2
+ export declare const AEAD_KEY_LEN = 32;
3
+ /** Nonce length in bytes (96-bit GCM standard). */
4
+ export declare const AEAD_NONCE_LEN = 12;
5
+ /** GCM authentication tag length in bytes (128 bits). */
6
+ export declare const AEAD_TAG_LEN = 16;
7
+ /** Result of an AEAD seal operation. */
8
+ export interface AeadSealed {
9
+ readonly nonce: Buffer;
10
+ readonly ct: Buffer;
11
+ readonly tag: Buffer;
12
+ }
13
+ /**
14
+ * Seal `plaintext` under `key` with optional `aad`. A fresh 96-bit nonce is
15
+ * generated per call — callers must never reuse nonces for the same key.
16
+ *
17
+ * Throws if `key` is not exactly 32 bytes.
18
+ */
19
+ export declare function aeadSeal(plaintext: Buffer, key: Buffer, aad?: Buffer): AeadSealed;
20
+ /**
21
+ * Open a sealed AEAD blob. Throws on any authentication failure (wrong key,
22
+ * wrong AAD, truncated ciphertext). Never silently returns garbage.
23
+ */
24
+ export declare function aeadOpen(sealed: AeadSealed, key: Buffer, aad?: Buffer): Buffer;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AEAD_TAG_LEN = exports.AEAD_NONCE_LEN = exports.AEAD_KEY_LEN = void 0;
7
+ exports.aeadSeal = aeadSeal;
8
+ exports.aeadOpen = aeadOpen;
9
+ /**
10
+ * aead.ts — shared AES-256-GCM primitive for auth-core envelope formats.
11
+ *
12
+ * CredentialEnvelope (L12 at-rest) uses AES-256-GCM. This module owns the
13
+ * bare cipher operation; framing, versioning, and serialization live in its
14
+ * envelope module.
15
+ */
16
+ const node_crypto_1 = __importDefault(require("node:crypto"));
17
+ /** AEAD algorithm. */
18
+ const ALG = 'aes-256-gcm';
19
+ /** Key length in bytes (256 bits). */
20
+ exports.AEAD_KEY_LEN = 32;
21
+ /** Nonce length in bytes (96-bit GCM standard). */
22
+ exports.AEAD_NONCE_LEN = 12;
23
+ /** GCM authentication tag length in bytes (128 bits). */
24
+ exports.AEAD_TAG_LEN = 16;
25
+ /**
26
+ * Seal `plaintext` under `key` with optional `aad`. A fresh 96-bit nonce is
27
+ * generated per call — callers must never reuse nonces for the same key.
28
+ *
29
+ * Throws if `key` is not exactly 32 bytes.
30
+ */
31
+ function aeadSeal(plaintext, key, aad) {
32
+ if (!Buffer.isBuffer(key) || key.length !== exports.AEAD_KEY_LEN) {
33
+ throw new TypeError(`aeadSeal: key must be a ${exports.AEAD_KEY_LEN}-byte Buffer`);
34
+ }
35
+ const nonce = node_crypto_1.default.randomBytes(exports.AEAD_NONCE_LEN);
36
+ const cipher = node_crypto_1.default.createCipheriv(ALG, key, nonce);
37
+ if (aad && aad.length > 0)
38
+ cipher.setAAD(aad);
39
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
40
+ const tag = cipher.getAuthTag();
41
+ return { nonce, ct, tag };
42
+ }
43
+ /**
44
+ * Open a sealed AEAD blob. Throws on any authentication failure (wrong key,
45
+ * wrong AAD, truncated ciphertext). Never silently returns garbage.
46
+ */
47
+ function aeadOpen(sealed, key, aad) {
48
+ if (!Buffer.isBuffer(key) || key.length !== exports.AEAD_KEY_LEN) {
49
+ throw new TypeError(`aeadOpen: key must be a ${exports.AEAD_KEY_LEN}-byte Buffer`);
50
+ }
51
+ if (!Buffer.isBuffer(sealed.nonce) || sealed.nonce.length !== exports.AEAD_NONCE_LEN) {
52
+ throw new Error('aeadOpen: invalid nonce length');
53
+ }
54
+ if (!Buffer.isBuffer(sealed.tag) || sealed.tag.length !== exports.AEAD_TAG_LEN) {
55
+ throw new Error('aeadOpen: invalid auth tag length');
56
+ }
57
+ if (!Buffer.isBuffer(sealed.ct)) {
58
+ throw new Error('aeadOpen: ciphertext must be a Buffer');
59
+ }
60
+ const decipher = node_crypto_1.default.createDecipheriv(ALG, key, sealed.nonce);
61
+ if (aad && aad.length > 0)
62
+ decipher.setAAD(aad);
63
+ decipher.setAuthTag(sealed.tag);
64
+ return Buffer.concat([decipher.update(sealed.ct), decipher.final()]);
65
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * authExpiredDetector.ts — mid-stream auth-expired SSE detector.
3
+ *
4
+ * HTTP auth is checked BEFORE the body stream starts — a 401 mid-stream
5
+ * is technically impossible. But long-running SSE / Socket.IO sessions
6
+ * (chat with tool calls, agent steps, voice) can see the access token
7
+ * expire mid-body, because the server-side auth check fires per-event
8
+ * on every internal step. The server's contract: emit a terminal event
9
+ * with `type: 'error'` + `error.code === 'auth_expired'` (or the legacy
10
+ * forms `event: 'auth:expired'`, `event: 'auth_expired'`, `code:
11
+ * 'auth_expired'`) signalling the client to refresh and restart.
12
+ *
13
+ * This module recognises every documented shape and exposes a small
14
+ * orchestration helper that runs the caller-supplied refresher, then
15
+ * fires a user-visible message via the callbacks bag.
16
+ *
17
+ * Pure — no React, no fetch, no platform code. Suitable for daemon,
18
+ * desktop, web, and mobile clients. The mobile React Native client
19
+ * keeps a byte-equal mirror at
20
+ * `mobile/react-native/src/chat/streaming/authExpiredDetector.ts`
21
+ * (separate repo, can't import this package today). Treat that file
22
+ * as auth-tier canonical: any change here MUST be ported there.
23
+ */
24
+ /** Minimal shape of a parsed SSE event this module recognises. */
25
+ export interface ParsedSseLike {
26
+ type?: unknown;
27
+ event?: unknown;
28
+ error?: unknown;
29
+ code?: unknown;
30
+ }
31
+ /**
32
+ * Return true when `parsed` looks like the server's mid-stream auth
33
+ * expiry signal. The check is broad on purpose — multiple server
34
+ * generations use slightly different shapes and we want to catch them
35
+ * all rather than drop into the generic-error branch and confuse the
36
+ * user.
37
+ *
38
+ * Accepted shapes (any one is enough):
39
+ * - `{ type: 'error', error: { code: 'auth_expired' } }`
40
+ * - `{ event: 'auth:expired' }`
41
+ * - `{ event: 'auth_expired' }`
42
+ * - `{ code: 'auth_expired' }`
43
+ * - `{ type: 'error', error: 'auth_expired' }`
44
+ */
45
+ export declare function isAuthExpiredSseEvent(parsed: unknown): boolean;
46
+ /**
47
+ * Integration helper — call from the SSE parser when `isAuthExpiredSseEvent`
48
+ * returns true. Calls the supplied refresher, fires callbacks.onError
49
+ * with a user-visible message, and returns the new token (or null on
50
+ * failure) so the caller can decide whether to auto-retry.
51
+ *
52
+ * Pure in the sense that it does NOT drive a fetch / socket itself —
53
+ * that is the caller's responsibility, because retry semantics differ
54
+ * between SSE (re-POST) and Socket.IO (disconnect+reconnect).
55
+ */
56
+ export interface AuthExpiredCallbacks {
57
+ onError?: (message: string) => void;
58
+ }
59
+ export interface HandleMidStreamAuthExpiryResult {
60
+ /** True when the refresher returned a fresh token. */
61
+ refreshed: boolean;
62
+ /** The fresh token when refreshed===true, else null. */
63
+ token: string | null;
64
+ }
65
+ export declare function handleMidStreamAuthExpiry(callbacks: AuthExpiredCallbacks, refresher: () => Promise<string | null>): Promise<HandleMidStreamAuthExpiryResult>;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * authExpiredDetector.ts — mid-stream auth-expired SSE detector.
4
+ *
5
+ * HTTP auth is checked BEFORE the body stream starts — a 401 mid-stream
6
+ * is technically impossible. But long-running SSE / Socket.IO sessions
7
+ * (chat with tool calls, agent steps, voice) can see the access token
8
+ * expire mid-body, because the server-side auth check fires per-event
9
+ * on every internal step. The server's contract: emit a terminal event
10
+ * with `type: 'error'` + `error.code === 'auth_expired'` (or the legacy
11
+ * forms `event: 'auth:expired'`, `event: 'auth_expired'`, `code:
12
+ * 'auth_expired'`) signalling the client to refresh and restart.
13
+ *
14
+ * This module recognises every documented shape and exposes a small
15
+ * orchestration helper that runs the caller-supplied refresher, then
16
+ * fires a user-visible message via the callbacks bag.
17
+ *
18
+ * Pure — no React, no fetch, no platform code. Suitable for daemon,
19
+ * desktop, web, and mobile clients. The mobile React Native client
20
+ * keeps a byte-equal mirror at
21
+ * `mobile/react-native/src/chat/streaming/authExpiredDetector.ts`
22
+ * (separate repo, can't import this package today). Treat that file
23
+ * as auth-tier canonical: any change here MUST be ported there.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.isAuthExpiredSseEvent = isAuthExpiredSseEvent;
27
+ exports.handleMidStreamAuthExpiry = handleMidStreamAuthExpiry;
28
+ /**
29
+ * Return true when `parsed` looks like the server's mid-stream auth
30
+ * expiry signal. The check is broad on purpose — multiple server
31
+ * generations use slightly different shapes and we want to catch them
32
+ * all rather than drop into the generic-error branch and confuse the
33
+ * user.
34
+ *
35
+ * Accepted shapes (any one is enough):
36
+ * - `{ type: 'error', error: { code: 'auth_expired' } }`
37
+ * - `{ event: 'auth:expired' }`
38
+ * - `{ event: 'auth_expired' }`
39
+ * - `{ code: 'auth_expired' }`
40
+ * - `{ type: 'error', error: 'auth_expired' }`
41
+ */
42
+ function isAuthExpiredSseEvent(parsed) {
43
+ if (!parsed || typeof parsed !== 'object')
44
+ return false;
45
+ const p = parsed;
46
+ const eventStr = typeof p.event === 'string' ? p.event.toLowerCase() : '';
47
+ if (eventStr === 'auth:expired' || eventStr === 'auth_expired')
48
+ return true;
49
+ const codeStr = typeof p.code === 'string' ? p.code.toLowerCase() : '';
50
+ if (codeStr === 'auth_expired')
51
+ return true;
52
+ const typeStr = typeof p.type === 'string' ? p.type.toLowerCase() : '';
53
+ if (typeStr === 'error' && isAuthExpiredError(p.error)) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+ function isAuthExpiredError(err) {
59
+ if (!err)
60
+ return false;
61
+ if (typeof err === 'string')
62
+ return err.toLowerCase() === 'auth_expired';
63
+ if (typeof err !== 'object')
64
+ return false;
65
+ const maybe = err.code;
66
+ if (typeof maybe === 'string' && maybe.toLowerCase() === 'auth_expired') {
67
+ return true;
68
+ }
69
+ const message = err.message;
70
+ if (typeof message === 'string' && message.toLowerCase().includes('auth_expired')) {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ async function handleMidStreamAuthExpiry(callbacks, refresher) {
76
+ let token = null;
77
+ try {
78
+ token = await refresher();
79
+ }
80
+ catch {
81
+ token = null;
82
+ }
83
+ if (token) {
84
+ callbacks.onError?.('Your session was refreshed. Tap retry to resume the response.');
85
+ return { refreshed: true, token };
86
+ }
87
+ callbacks.onError?.('Session expired. Please sign in again.');
88
+ return { refreshed: false, token: null };
89
+ }
@@ -0,0 +1,51 @@
1
+ export interface HelperResult {
2
+ token: string;
3
+ /** ms-epoch absolute time. When omitted, callers default to TTL window. */
4
+ expiresAt?: number;
5
+ }
6
+ /**
7
+ * L6.3 — diagnostic taxonomy for the cold-cache failure sentinel.
8
+ *
9
+ * - 'disabled' — kill switch / config ruled out the helper before
10
+ * any I/O. Resolver: fall through (no sentinel).
11
+ * - 'invalid-path' — relative path, not-a-file, non-existent. The
12
+ * helper isn't really "configured" so the resolver
13
+ * falls through to the next slot.
14
+ * - 'not-executable'— exec-bit unset. Same fall-through semantics.
15
+ * - 'spawn-error' — Node failed to spawn (ENOMEM, EACCES at exec
16
+ * time, etc.). Counts as "helper attempted and
17
+ * failed" → sentinel.
18
+ * - 'timeout' — helper exceeded `OVERSKY_AUTH_HELPER_TIMEOUT_MS`
19
+ * and we SIGKILL'd it → sentinel.
20
+ * - 'non-zero-exit' — script exited with non-zero code → sentinel.
21
+ * - 'oversized' — stdout exceeded 64 KiB cap → sentinel.
22
+ * - 'empty-output' — script exited 0 with no stdout → sentinel.
23
+ * - 'parse-error' — JSON-shaped stdout but malformed, OR bare-token
24
+ * output had embedded whitespace → sentinel.
25
+ */
26
+ export type AuthHelperFailureReason = 'disabled' | 'invalid-path' | 'not-executable' | 'spawn-error' | 'timeout' | 'non-zero-exit' | 'oversized' | 'empty-output' | 'parse-error';
27
+ /**
28
+ * L6.3 — failure outcomes that should TRIGGER the cold-cache sentinel.
29
+ * Validation failures (`disabled`, `invalid-path`, `not-executable`) are
30
+ * deliberately excluded: those mean "helper isn't really wired up" and
31
+ * the resolver chain should fall through to the next slot, not fail
32
+ * closed.
33
+ *
34
+ * Exported as a `const` set so the resolver can do
35
+ * `SENTINEL_TRIGGERING_FAILURES.has(reason)` without re-encoding the
36
+ * policy in two places.
37
+ */
38
+ export declare const SENTINEL_TRIGGERING_FAILURES: ReadonlySet<AuthHelperFailureReason>;
39
+ export interface InvokeAuthHelperOptions {
40
+ /** Override the timeout (defaults to env or 10 s). */
41
+ timeoutMs?: number;
42
+ }
43
+ export declare function getLastAuthHelperFailureReason(): AuthHelperFailureReason | null;
44
+ /** @internal test-only — clear the failure-reason latch between cases. */
45
+ export declare function __resetAuthHelperFailureReasonForTest(): void;
46
+ /**
47
+ * Invoke the helper script and return its parsed token, or null on any
48
+ * failure. Never throws.
49
+ */
50
+ export declare function invokeAuthHelper(scriptPath: string, options?: InvokeAuthHelperOptions): Promise<HelperResult | null>;
51
+ export declare function getDefaultHelperTtlMs(): number;
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SENTINEL_TRIGGERING_FAILURES = void 0;
7
+ exports.getLastAuthHelperFailureReason = getLastAuthHelperFailureReason;
8
+ exports.__resetAuthHelperFailureReasonForTest = __resetAuthHelperFailureReasonForTest;
9
+ exports.invokeAuthHelper = invokeAuthHelper;
10
+ exports.getDefaultHelperTtlMs = getDefaultHelperTtlMs;
11
+ /**
12
+ * authHelper.ts — invocation contract for `OVERSKY_AUTH_HELPER`.
13
+ *
14
+ * The helper is a user-supplied executable that prints a fresh token to
15
+ * stdout. It exists so operators with rotating credentials (Vault, AWS
16
+ * Secrets Manager, internal SSO proxy) can plug in without OverSky needing
17
+ * to know how their secret store works. The contract mirrors Claude Code's
18
+ * `apiKeyHelper`.
19
+ *
20
+ * Wire protocol:
21
+ * - `OVERSKY_AUTH_HELPER` env points at an absolute, executable path.
22
+ * - We `spawn(path, [], { shell: false })` — never via shell, so no
23
+ * argument injection regardless of what's in the env value.
24
+ * - The helper writes EITHER a bare JWT to stdout, OR a JSON object
25
+ * `{ "token": "...", "expiresAt"?: <ms epoch> }`.
26
+ * - Stderr is captured for diagnostic logging only — never returned as
27
+ * token material.
28
+ * - Stdout cap: 64 KiB (`MAX_OUTPUT_BYTES`). Real JWTs are < 4 KiB.
29
+ * - Default timeout: 10 s (`OVERSKY_AUTH_HELPER_TIMEOUT_MS` overrides).
30
+ *
31
+ * Safety invariants:
32
+ * - Relative paths rejected — explicit absolute path required.
33
+ * - Non-executable file rejected (any of u/g/o exec bits must be set).
34
+ * - Non-zero exit, timeout, oversized output, malformed JSON → null.
35
+ * - On any failure the resolver chain falls through to the next slot;
36
+ * we never throw from a helper failure.
37
+ * - L6.3 (cold-cache failure sentinel) lives one layer up in
38
+ * `credentialResolver.ts` — when the SPAWN ATTEMPT itself fails on a
39
+ * cold cache the resolver caches a typed `{kind:'failed'}` sentinel
40
+ * to prevent silent fall-through to OAuth.
41
+ *
42
+ * Kill switch: `OVERSKY_DISABLE_AUTH_HELPER=1` short-circuits to null.
43
+ */
44
+ const node_child_process_1 = require("node:child_process");
45
+ const node_fs_1 = __importDefault(require("node:fs"));
46
+ const node_path_1 = __importDefault(require("node:path"));
47
+ const runtime_js_1 = require("./runtime.js");
48
+ const MAX_OUTPUT_BYTES = 64 * 1024;
49
+ const DEFAULT_TIMEOUT_MS = 10_000;
50
+ const DEFAULT_TTL_MS = 5 * 60_000;
51
+ /**
52
+ * L6.3 — failure outcomes that should TRIGGER the cold-cache sentinel.
53
+ * Validation failures (`disabled`, `invalid-path`, `not-executable`) are
54
+ * deliberately excluded: those mean "helper isn't really wired up" and
55
+ * the resolver chain should fall through to the next slot, not fail
56
+ * closed.
57
+ *
58
+ * Exported as a `const` set so the resolver can do
59
+ * `SENTINEL_TRIGGERING_FAILURES.has(reason)` without re-encoding the
60
+ * policy in two places.
61
+ */
62
+ exports.SENTINEL_TRIGGERING_FAILURES = new Set([
63
+ 'spawn-error',
64
+ 'timeout',
65
+ 'non-zero-exit',
66
+ 'oversized',
67
+ 'empty-output',
68
+ 'parse-error',
69
+ ]);
70
+ /**
71
+ * Diagnostic outcome of the LAST `invokeAuthHelper()` call. The resolver
72
+ * reads this immediately after the call to decide whether to cache a
73
+ * cold-cache failure sentinel.
74
+ *
75
+ * Module-level (not part of the function return) because changing
76
+ * `invokeAuthHelper`'s return type would ripple through every existing
77
+ * caller and test. Reading it BEFORE another invocation is racy in
78
+ * theory but safe in practice — the resolver awaits the same promise it
79
+ * read this from.
80
+ */
81
+ let _lastFailureReason = null;
82
+ function getLastAuthHelperFailureReason() {
83
+ return _lastFailureReason;
84
+ }
85
+ /** @internal test-only — clear the failure-reason latch between cases. */
86
+ function __resetAuthHelperFailureReasonForTest() {
87
+ _lastFailureReason = null;
88
+ }
89
+ /**
90
+ * Invoke the helper script and return its parsed token, or null on any
91
+ * failure. Never throws.
92
+ */
93
+ async function invokeAuthHelper(scriptPath, options = {}) {
94
+ const log = (0, runtime_js_1.getAuthLogger)();
95
+ // Reset on entry so a successful call clears any prior failure latch.
96
+ _lastFailureReason = null;
97
+ if (process.env.OVERSKY_DISABLE_AUTH_HELPER === '1') {
98
+ log.debug?.('[authHelper] OVERSKY_DISABLE_AUTH_HELPER=1 — skipping helper');
99
+ _lastFailureReason = 'disabled';
100
+ return null;
101
+ }
102
+ if (!scriptPath || !node_path_1.default.isAbsolute(scriptPath)) {
103
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER must be an absolute path; got "${scriptPath}"`);
104
+ _lastFailureReason = 'invalid-path';
105
+ return null;
106
+ }
107
+ let stat;
108
+ try {
109
+ stat = node_fs_1.default.statSync(scriptPath);
110
+ }
111
+ catch (err) {
112
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER not found at "${scriptPath}": ${formatErr(err)}`);
113
+ _lastFailureReason = 'invalid-path';
114
+ return null;
115
+ }
116
+ if (!stat.isFile()) {
117
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not a regular file`);
118
+ _lastFailureReason = 'invalid-path';
119
+ return null;
120
+ }
121
+ if ((stat.mode & 0o111) === 0) {
122
+ log.error(`[authHelper] OVERSKY_AUTH_HELPER "${scriptPath}" is not executable`);
123
+ _lastFailureReason = 'not-executable';
124
+ return null;
125
+ }
126
+ const timeoutMs = options.timeoutMs ?? parseTimeoutMs();
127
+ return new Promise((resolve) => {
128
+ const stdoutChunks = [];
129
+ const stderrChunks = [];
130
+ let stdoutBytes = 0;
131
+ let stderrBytes = 0;
132
+ let truncated = false;
133
+ let killedForTimeout = false;
134
+ let resolved = false;
135
+ const settle = (result) => {
136
+ if (resolved)
137
+ return;
138
+ resolved = true;
139
+ clearTimeout(timer);
140
+ resolve(result);
141
+ };
142
+ let child;
143
+ try {
144
+ child = (0, node_child_process_1.spawn)(scriptPath, [], {
145
+ shell: false,
146
+ // detached: true makes the child its own process-group leader so
147
+ // we can SIGKILL the whole group on timeout. Without this, killing
148
+ // the script's shell leaves grandchildren (sleep, curl, vault) as
149
+ // orphans holding stdio open — `close` would never fire and the
150
+ // daemon would hang past the timeout window.
151
+ detached: true,
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ });
154
+ }
155
+ catch (err) {
156
+ log.error(`[authHelper] spawn failed: ${formatErr(err)}`);
157
+ _lastFailureReason = 'spawn-error';
158
+ settle(null);
159
+ return;
160
+ }
161
+ const timer = setTimeout(() => {
162
+ killedForTimeout = true;
163
+ log.warn(`[authHelper] helper timed out after ${timeoutMs}ms — killing`);
164
+ // Kill the entire process group so subprocess trees (a helper that
165
+ // shells out to vault/aws/curl) don't outlive the timeout. The
166
+ // negative pid form targets the process group; falls back to a
167
+ // single-process kill on platforms or shapes where that fails.
168
+ try {
169
+ if (typeof child.pid === 'number') {
170
+ process.kill(-child.pid, 'SIGKILL');
171
+ }
172
+ else {
173
+ child.kill('SIGKILL');
174
+ }
175
+ }
176
+ catch {
177
+ try {
178
+ child.kill('SIGKILL');
179
+ }
180
+ catch {
181
+ /* ignore */
182
+ }
183
+ }
184
+ // Tear down stdio so any lingering pipe holders don't keep us hung.
185
+ try {
186
+ child.stdout?.destroy();
187
+ }
188
+ catch {
189
+ /* ignore */
190
+ }
191
+ try {
192
+ child.stderr?.destroy();
193
+ }
194
+ catch {
195
+ /* ignore */
196
+ }
197
+ _lastFailureReason = 'timeout';
198
+ // Settle immediately — do not wait for `close`. Orphan grandchildren
199
+ // may keep the close event pending indefinitely; the resolver chain
200
+ // must move on within the configured timeout.
201
+ settle(null);
202
+ }, timeoutMs);
203
+ if (typeof timer.unref === 'function')
204
+ timer.unref();
205
+ child.stdout.on('data', (chunk) => {
206
+ stdoutBytes += chunk.length;
207
+ if (stdoutBytes > MAX_OUTPUT_BYTES) {
208
+ truncated = true;
209
+ try {
210
+ child.kill('SIGKILL');
211
+ }
212
+ catch {
213
+ /* ignore */
214
+ }
215
+ return;
216
+ }
217
+ stdoutChunks.push(chunk);
218
+ });
219
+ child.stderr.on('data', (chunk) => {
220
+ // Defensive cap on stderr too (a chatty helper shouldn't OOM the daemon).
221
+ if (stderrBytes < MAX_OUTPUT_BYTES) {
222
+ stderrChunks.push(chunk);
223
+ stderrBytes += chunk.length;
224
+ }
225
+ });
226
+ child.on('error', (err) => {
227
+ log.error(`[authHelper] runtime error: ${formatErr(err)}`);
228
+ _lastFailureReason = 'spawn-error';
229
+ settle(null);
230
+ });
231
+ child.on('close', (code) => {
232
+ if (truncated) {
233
+ log.error(`[authHelper] stdout exceeded ${MAX_OUTPUT_BYTES} bytes — refusing to parse`);
234
+ _lastFailureReason = 'oversized';
235
+ settle(null);
236
+ return;
237
+ }
238
+ if (killedForTimeout) {
239
+ // _lastFailureReason already set to 'timeout' in the timer.
240
+ settle(null);
241
+ return;
242
+ }
243
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim();
244
+ if (stderr)
245
+ log.debug?.(`[authHelper] stderr: ${stderr}`);
246
+ if (code !== 0) {
247
+ log.error(`[authHelper] helper exited with code ${code}`);
248
+ _lastFailureReason = 'non-zero-exit';
249
+ settle(null);
250
+ return;
251
+ }
252
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim();
253
+ if (!stdout) {
254
+ log.error('[authHelper] helper produced empty stdout');
255
+ _lastFailureReason = 'empty-output';
256
+ settle(null);
257
+ return;
258
+ }
259
+ const parsed = parseHelperOutput(stdout, log);
260
+ if (parsed === null) {
261
+ _lastFailureReason = 'parse-error';
262
+ }
263
+ settle(parsed);
264
+ });
265
+ });
266
+ }
267
+ function parseHelperOutput(stdout, log) {
268
+ // JSON path first; fall back to bare-string token.
269
+ const trimmed = stdout.trim();
270
+ const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('"');
271
+ if (looksLikeJson) {
272
+ try {
273
+ const obj = JSON.parse(trimmed);
274
+ if (typeof obj === 'string') {
275
+ return obj.length > 0 ? { token: obj } : null;
276
+ }
277
+ if (obj && typeof obj === 'object') {
278
+ const rec = obj;
279
+ const token = typeof rec.token === 'string' ? rec.token : null;
280
+ if (!token || token.length === 0) {
281
+ log.error('[authHelper] JSON output missing "token" field');
282
+ return null;
283
+ }
284
+ const expiresAt = typeof rec.expiresAt === 'number' && Number.isFinite(rec.expiresAt)
285
+ ? rec.expiresAt
286
+ : undefined;
287
+ return expiresAt !== undefined ? { token, expiresAt } : { token };
288
+ }
289
+ log.error('[authHelper] JSON output was not an object or string');
290
+ return null;
291
+ }
292
+ catch (err) {
293
+ log.error(`[authHelper] JSON parse failed: ${formatErr(err)}`);
294
+ return null;
295
+ }
296
+ }
297
+ // Bare token — single line, no JSON. Reject if it contains whitespace
298
+ // beyond surrounding trim (defensive — helper might have leaked logs).
299
+ if (/\s/.test(trimmed)) {
300
+ log.error('[authHelper] bare-token output contains whitespace — refusing (use JSON if helper logs to stdout)');
301
+ return null;
302
+ }
303
+ return { token: trimmed };
304
+ }
305
+ function parseTimeoutMs() {
306
+ const raw = process.env.OVERSKY_AUTH_HELPER_TIMEOUT_MS;
307
+ if (!raw)
308
+ return DEFAULT_TIMEOUT_MS;
309
+ const n = Number.parseInt(raw, 10);
310
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
311
+ }
312
+ function getDefaultHelperTtlMs() {
313
+ const raw = process.env.OVERSKY_AUTH_HELPER_TTL_MS;
314
+ if (!raw)
315
+ return DEFAULT_TTL_MS;
316
+ const n = Number.parseInt(raw, 10);
317
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
318
+ }
319
+ function formatErr(err) {
320
+ return err instanceof Error ? err.message : String(err);
321
+ }