@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
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;
|
package/dist/cjs/aead.js
ADDED
|
@@ -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
|
+
}
|