@skrr-ai/auth-core 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. package/package.json +82 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * credentialResolver.ts — documented credential precedence for the
3
+ * OverSky daemon and CLI.
4
+ *
5
+ * Roadmap: docs/daemon-auth-roadmap.md, Phase H.
6
+ *
7
+ * The resolver walks a fixed, ordered list of credential sources and
8
+ * returns the first non-null token along with its source name. Source
9
+ * names are part of the public surface — they show up in
10
+ * `oversky doctor`, `oversky status`, and `sky whoami`, and operators
11
+ * use them to triage "where did this token come from?" tickets.
12
+ *
13
+ * Precedence (high → low):
14
+ *
15
+ * 1. flag — `--token <jwt>` CLI flag (debugging)
16
+ * 2. env-token — `OVERSKY_TOKEN` env (CI / static; Phase I adds
17
+ * server-side scope=ci validation)
18
+ * 3. auth-helper — `OVERSKY_AUTH_HELPER` script output (rotating
19
+ * creds — Vault, AWS SM, internal SSO proxy)
20
+ * 4. fd-handoff — `OVERSKY_AUTH_FD` parent→child IPC (Electron
21
+ * supervisor → daemon child). Internal; never
22
+ * user-configured.
23
+ * 5. keychain — macOS Keychain (`ai.skrr.daemon`)
24
+ * 6. file — `~/.skrr/auth.json` (0600)
25
+ * 7. interactive — Browser/device-code login (TTY only, blocked
26
+ * under launchd via `isHeadless()`)
27
+ *
28
+ * Skip rules (Phase H.2 + H.4):
29
+ * - `bareMode: true` skips slots 4–7 (no platform storage, no IPC,
30
+ * no interactive). Bare mode demands an explicit token from env or
31
+ * flag — designed for reproducible CI runs and "is the daemon
32
+ * broken or is my config corrupted?" triage.
33
+ * - `storeMode: 'env-only'` skips slots 5 + 6 (and the daemon
34
+ * wrapper additionally disables the auth-generation.json sidecar).
35
+ * - `storeMode: 'keychain'` skips slot 6 (file backend).
36
+ * - `storeMode: 'file'` skips slot 5 (keychain backend).
37
+ *
38
+ * Backend access uses dependency-injected callbacks
39
+ * (`readKeychainToken`, `readFileToken`, `runInteractiveLogin`) so this
40
+ * module stays free of OS-specific imports and the Sky CLI can wire its
41
+ * own backends without dragging the daemon's `keychain.ts` into
42
+ * non-daemon binaries.
43
+ */
44
+ import { type AuthHelperFailureReason } from './authHelper.js';
45
+ export type CredentialSource = 'flag' | 'env-token' | 'auth-helper' | 'fd-handoff' | 'keychain' | 'file' | 'interactive' | 'none';
46
+ /**
47
+ * Ordered slots tried by `CredentialResolver.resolve()`. Excludes
48
+ * `'none'` (only emitted when no slot produced a token). Treat this as
49
+ * the canonical published precedence — flipping the order is a public
50
+ * API change and breaks operator runbooks.
51
+ */
52
+ export declare const CREDENTIAL_PRECEDENCE: readonly ["flag", "env-token", "auth-helper", "fd-handoff", "keychain", "file", "interactive"];
53
+ export type CredentialStoreMode = 'auto' | 'keychain' | 'file' | 'env-only';
54
+ export interface CredentialResolverOptions {
55
+ /** `--token <jwt>` flag value, when present on the CLI. */
56
+ flagToken?: string;
57
+ /** `--bare` mode (or `OVERSKY_BARE_MODE=1`): skips slots 4–7. */
58
+ bareMode?: boolean;
59
+ /** `config.credentials.store` value; defaults to 'auto'. */
60
+ storeMode?: CredentialStoreMode;
61
+ /**
62
+ * Test/explicit override for `OVERSKY_TOKEN`. Pass `undefined` to read
63
+ * from `process.env`; pass `null` to force-skip the slot; pass a
64
+ * string to use that value directly.
65
+ */
66
+ envTokenOverride?: string | null;
67
+ /** Test/explicit override for `OVERSKY_AUTH_HELPER` env. Same semantics. */
68
+ authHelperPathOverride?: string | null;
69
+ /**
70
+ * Predicate consulted before invoking the interactive slot. The daemon
71
+ * wires `isHeadlessDaemon()`; the CLI may wire its own check.
72
+ */
73
+ isHeadless?: () => boolean;
74
+ /**
75
+ * Read the keychain backend. Returns the token string or null. The
76
+ * daemon wraps `keychain.readToken()` (and handles `resetAvailability`
77
+ * on misses) inside this callback.
78
+ */
79
+ readKeychainToken?: () => string | null;
80
+ /** Read the file backend (`~/.skrr/auth.json`). */
81
+ readFileToken?: () => string | null;
82
+ /** Trigger interactive login; returns the new token or null. */
83
+ runInteractiveLogin?: () => Promise<string | null>;
84
+ /**
85
+ * L6.1 — workspace-trust gate for `OVERSKY_AUTH_HELPER`.
86
+ *
87
+ * Called BEFORE the helper is invoked. The daemon wires this to the
88
+ * `daemon/src/permissions/workspace-trust.ts` checker; tests can pass a
89
+ * stub directly. When this returns `{trusted: false, ...}` for a
90
+ * project- or local-scope helper, the resolver:
91
+ * 1. Logs an `auth_helper_blocked_untrusted` event via
92
+ * `onAuthHelperBlocked` if wired.
93
+ * 2. Fails CLOSED — returns a `{token: null, source: 'auth-helper'}`
94
+ * record with `failClosed: true` so the chain does NOT fall
95
+ * through to OAuth. (See L6.1 spec point 4.)
96
+ *
97
+ * Absent → no trust check is performed (backward-compat for the CLI
98
+ * and tests that don't care about workspace-trust). Production daemon
99
+ * MUST wire this.
100
+ */
101
+ helperTrustGate?: (helperPath: string, fromEnvVar: boolean) => HelperTrustDecision;
102
+ /**
103
+ * Audit hook fired when `helperTrustGate` blocks the helper. Daemon
104
+ * wires this to the event-log so security-sensitive denials are
105
+ * persisted alongside other auth events.
106
+ */
107
+ onAuthHelperBlocked?: (info: AuthHelperBlockedInfo) => void;
108
+ /**
109
+ * Audit hook fired when L6.3 returns the cold-cache failure sentinel.
110
+ * Useful for surfacing "your helper is broken" without spamming the
111
+ * event-log on every read within the TTL.
112
+ */
113
+ onAuthHelperSentinelHit?: (info: AuthHelperSentinelInfo) => void;
114
+ }
115
+ /**
116
+ * L6.1 — return value of the workspace-trust callback.
117
+ */
118
+ export interface HelperTrustDecision {
119
+ trusted: boolean;
120
+ origin: 'user' | 'project' | 'local';
121
+ reason?: 'no-record' | 'helper-changed';
122
+ }
123
+ export interface AuthHelperBlockedInfo {
124
+ helperPath: string;
125
+ origin: 'user' | 'project' | 'local';
126
+ reason: 'no-record' | 'helper-changed';
127
+ cwd: string;
128
+ }
129
+ export interface AuthHelperSentinelInfo {
130
+ helperPath: string;
131
+ reason: AuthHelperFailureReason;
132
+ cachedAt: number;
133
+ ttlMs: number;
134
+ }
135
+ /**
136
+ * Token kind discriminator (Phase I).
137
+ *
138
+ * - 'standard' — anything that should be treated as a JWT (the default
139
+ * daemon device-code-issued access token, refresh-derived
140
+ * rotations, the user's CLI session token, etc.).
141
+ * - 'ci' — long-lived CI token with the `osk_ci_` prefix. The
142
+ * daemon uses this for the bootstrap-to-daemon-JWT flow
143
+ * (POST /api/daemons/token), MUST NOT schedule refresh,
144
+ * and on auth failure exits 75 with a CI-specific error
145
+ * rather than writing needs-reauth.json.
146
+ * - 'install' — short-lived web-minted JWT with `scope=daemon-install`.
147
+ * The daemon exchanges it through the same /api/daemons/token
148
+ * bootstrap path, persists only the returned daemon
149
+ * credential, and never treats the install JWT as a normal
150
+ * session token.
151
+ *
152
+ * Detection is local-only; no server round-trip. The JWT payload read is
153
+ * intentionally signature-less and is only used to select the client
154
+ * bootstrap path. The server remains the authority when the token is
155
+ * exchanged at /api/daemons/token.
156
+ *
157
+ * The kill switch `OVERSKY_DISABLE_CI_TOKEN_RESOLVER=1` forces every token to
158
+ * `'standard'` for triage purposes.
159
+ */
160
+ export type TokenKind = 'standard' | 'ci' | 'install';
161
+ /** CI-token plaintext prefix; mirrors api/server/services/Auth/daemonCiTokens.js. */
162
+ export declare const CI_TOKEN_PREFIX = "osk_ci_";
163
+ /**
164
+ * Classify a token by local shape. Pure function; safe to call on any string
165
+ * (including `null`-coerced empty). Returns `'standard'` for empty input
166
+ * so callers don't need to null-check before classification.
167
+ *
168
+ * Honors `OVERSKY_DISABLE_CI_TOKEN_RESOLVER=1` kill switch — when set,
169
+ * everything classifies as `'standard'` so the resolver can be used as
170
+ * a triage tool ("is the CI-token path the problem?").
171
+ */
172
+ /**
173
+ * SECURITY NOTE — this reads `scope` out of an UNVERIFIED JWT payload.
174
+ *
175
+ * That is safe here, and stays safe only under one condition: the return value
176
+ * selects a local code path and nothing else. `'install'` means "refresh this
177
+ * the install-token way"; it grants no capability, skips no check, and asserts
178
+ * no identity. The server still validates the token on every request, so a
179
+ * forged `scope: daemon-install` buys an attacker a different local branch and
180
+ * the same 401.
181
+ *
182
+ * The forgery is trivial — no signature is checked, so anyone who can write the
183
+ * credential file or set the env var can put any claim in it. That is fine
184
+ * while the only consequence is routing. It stops being fine the moment a
185
+ * caller treats `TokenKind` as a permission. Do not let this value gate a
186
+ * capability, and do not add a branch here that returns a kind meaning "more
187
+ * privileged".
188
+ *
189
+ * See `jwtUtils.ts` for the general rule.
190
+ */
191
+ export declare function classifyTokenKind(token: string | null | undefined): TokenKind;
192
+ export interface ResolvedCredential {
193
+ token: string | null;
194
+ source: CredentialSource;
195
+ /**
196
+ * Phase I — discriminator the daemon uses to branch on refresh
197
+ * scheduling, needs-reauth handling, and the bootstrap-to-daemon-JWT
198
+ * flow. Always `'standard'` when `token` is null.
199
+ */
200
+ kind: TokenKind;
201
+ }
202
+ /** @internal test-only — drop the helper cache between tests. */
203
+ export declare function __resetCredentialResolverForTest(): void;
204
+ /**
205
+ * L6.3 — typed error thrown when the cold-cache failure sentinel is in
206
+ * effect. Callers (daemon refresh path, CLI) can catch this specifically
207
+ * to render "auth helper unavailable: <reason>" without falling through
208
+ * to OAuth or device-code retry.
209
+ */
210
+ export declare class AuthHelperUnavailableError extends Error {
211
+ readonly reason: AuthHelperFailureReason;
212
+ readonly cachedAt: number;
213
+ constructor(reason: AuthHelperFailureReason, cachedAt: number);
214
+ }
215
+ /**
216
+ * L6.1 — typed error thrown when the workspace-trust gate blocks a
217
+ * project- or local-scope helper. Surfaces the actionable next step
218
+ * (`oversky trust accept`) to the user; the daemon catches and presents
219
+ * this without falling through to OAuth.
220
+ */
221
+ export declare class AuthHelperUntrustedError extends Error {
222
+ readonly origin: 'user' | 'project' | 'local';
223
+ readonly reason: 'no-record' | 'helper-changed';
224
+ readonly helperPath: string;
225
+ constructor(helperPath: string, origin: 'user' | 'project' | 'local', reason: 'no-record' | 'helper-changed');
226
+ }
227
+ /**
228
+ * H3-6 — public invalidation seam for the `OVERSKY_AUTH_HELPER` result
229
+ * cache. 401 handlers call this right after receiving a 401 from the
230
+ * server so the NEXT credential resolve re-invokes the helper to mint
231
+ * a fresh token instead of serving the stale (cached) one for up to
232
+ * `OVERSKY_AUTH_HELPER_TTL_MS`.
233
+ *
234
+ * Without this seam, a rotated secret at the helper's source (Vault,
235
+ * AWS Secrets Manager, etc.) could take up to 5 minutes to propagate
236
+ * to OverSky even when the server is actively rejecting the old
237
+ * value. The helper is the only slot in the precedence chain that
238
+ * naturally caches; keychain/file reads are already one-shot per
239
+ * resolve.
240
+ *
241
+ * L6.3 — also clears the cold-cache failure sentinel. A 401 means the
242
+ * server is rejecting whatever we sent, which usually means the helper
243
+ * needs to retry from scratch — even when the LAST helper attempt
244
+ * failed and seeded the sentinel. Without this, a transient helper
245
+ * failure during the same TTL window as a server-issued 401 would lock
246
+ * the daemon out for the full TTL.
247
+ *
248
+ * Idempotent: calling this on an already-empty cache is a no-op.
249
+ */
250
+ export declare function invalidateAuthHelperCache(): void;
251
+ export declare class CredentialResolver {
252
+ private readonly opts;
253
+ constructor(opts?: CredentialResolverOptions);
254
+ /**
255
+ * Walk the precedence chain and return the first non-null token.
256
+ * Returns `{ token: null, source: 'none' }` when nothing matches.
257
+ *
258
+ * Fail-closed exceptions from the auth-helper slot
259
+ * (`AuthHelperUntrustedError`, `AuthHelperUnavailableError`) propagate
260
+ * out — they intentionally PREVENT fall-through to OAuth. Callers
261
+ * that want soft-fall-through behavior must catch explicitly. See
262
+ * L6.1 spec point 4 and L6.3.
263
+ */
264
+ resolve(): Promise<ResolvedCredential>;
265
+ /**
266
+ * Test seam: invoke a single slot in isolation. Production code should
267
+ * always go through `resolve()` so the chain ordering is enforced.
268
+ */
269
+ tryResolveSlot(source: Exclude<CredentialSource, 'none'>): Promise<string | null>;
270
+ private shouldSkip;
271
+ private tryResolveFlag;
272
+ private tryResolveEnvToken;
273
+ private tryResolveAuthHelper;
274
+ private tryResolveFdHandoff;
275
+ private tryResolveKeychain;
276
+ private tryResolveFile;
277
+ private tryResolveInteractive;
278
+ }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * credentialResolver.ts — documented credential precedence for the
3
+ * OverSky daemon and CLI.
4
+ *
5
+ * Roadmap: docs/daemon-auth-roadmap.md, Phase H.
6
+ *
7
+ * The resolver walks a fixed, ordered list of credential sources and
8
+ * returns the first non-null token along with its source name. Source
9
+ * names are part of the public surface — they show up in
10
+ * `oversky doctor`, `oversky status`, and `sky whoami`, and operators
11
+ * use them to triage "where did this token come from?" tickets.
12
+ *
13
+ * Precedence (high → low):
14
+ *
15
+ * 1. flag — `--token <jwt>` CLI flag (debugging)
16
+ * 2. env-token — `OVERSKY_TOKEN` env (CI / static; Phase I adds
17
+ * server-side scope=ci validation)
18
+ * 3. auth-helper — `OVERSKY_AUTH_HELPER` script output (rotating
19
+ * creds — Vault, AWS SM, internal SSO proxy)
20
+ * 4. fd-handoff — `OVERSKY_AUTH_FD` parent→child IPC (Electron
21
+ * supervisor → daemon child). Internal; never
22
+ * user-configured.
23
+ * 5. keychain — macOS Keychain (`ai.skrr.daemon`)
24
+ * 6. file — `~/.skrr/auth.json` (0600)
25
+ * 7. interactive — Browser/device-code login (TTY only, blocked
26
+ * under launchd via `isHeadless()`)
27
+ *
28
+ * Skip rules (Phase H.2 + H.4):
29
+ * - `bareMode: true` skips slots 4–7 (no platform storage, no IPC,
30
+ * no interactive). Bare mode demands an explicit token from env or
31
+ * flag — designed for reproducible CI runs and "is the daemon
32
+ * broken or is my config corrupted?" triage.
33
+ * - `storeMode: 'env-only'` skips slots 5 + 6 (and the daemon
34
+ * wrapper additionally disables the auth-generation.json sidecar).
35
+ * - `storeMode: 'keychain'` skips slot 6 (file backend).
36
+ * - `storeMode: 'file'` skips slot 5 (keychain backend).
37
+ *
38
+ * Backend access uses dependency-injected callbacks
39
+ * (`readKeychainToken`, `readFileToken`, `runInteractiveLogin`) so this
40
+ * module stays free of OS-specific imports and the Sky CLI can wire its
41
+ * own backends without dragging the daemon's `keychain.ts` into
42
+ * non-daemon binaries.
43
+ */
44
+ import { invokeAuthHelper, getDefaultHelperTtlMs, getLastAuthHelperFailureReason, SENTINEL_TRIGGERING_FAILURES, } from './authHelper.js';
45
+ import { readAuthFromFd } from './fdToken.js';
46
+ import { decodeJwtPayload } from './jwtUtils.js';
47
+ import { getAuthLogger } from './runtime.js';
48
+ /**
49
+ * Ordered slots tried by `CredentialResolver.resolve()`. Excludes
50
+ * `'none'` (only emitted when no slot produced a token). Treat this as
51
+ * the canonical published precedence — flipping the order is a public
52
+ * API change and breaks operator runbooks.
53
+ */
54
+ export const CREDENTIAL_PRECEDENCE = [
55
+ 'flag',
56
+ 'env-token',
57
+ 'auth-helper',
58
+ 'fd-handoff',
59
+ 'keychain',
60
+ 'file',
61
+ 'interactive',
62
+ ];
63
+ /** CI-token plaintext prefix; mirrors api/server/services/Auth/daemonCiTokens.js. */
64
+ export const CI_TOKEN_PREFIX = 'osk_ci_';
65
+ /**
66
+ * Classify a token by local shape. Pure function; safe to call on any string
67
+ * (including `null`-coerced empty). Returns `'standard'` for empty input
68
+ * so callers don't need to null-check before classification.
69
+ *
70
+ * Honors `OVERSKY_DISABLE_CI_TOKEN_RESOLVER=1` kill switch — when set,
71
+ * everything classifies as `'standard'` so the resolver can be used as
72
+ * a triage tool ("is the CI-token path the problem?").
73
+ */
74
+ /**
75
+ * SECURITY NOTE — this reads `scope` out of an UNVERIFIED JWT payload.
76
+ *
77
+ * That is safe here, and stays safe only under one condition: the return value
78
+ * selects a local code path and nothing else. `'install'` means "refresh this
79
+ * the install-token way"; it grants no capability, skips no check, and asserts
80
+ * no identity. The server still validates the token on every request, so a
81
+ * forged `scope: daemon-install` buys an attacker a different local branch and
82
+ * the same 401.
83
+ *
84
+ * The forgery is trivial — no signature is checked, so anyone who can write the
85
+ * credential file or set the env var can put any claim in it. That is fine
86
+ * while the only consequence is routing. It stops being fine the moment a
87
+ * caller treats `TokenKind` as a permission. Do not let this value gate a
88
+ * capability, and do not add a branch here that returns a kind meaning "more
89
+ * privileged".
90
+ *
91
+ * See `jwtUtils.ts` for the general rule.
92
+ */
93
+ export function classifyTokenKind(token) {
94
+ if (process.env.OVERSKY_DISABLE_CI_TOKEN_RESOLVER === '1')
95
+ return 'standard';
96
+ if (typeof token !== 'string' || token.length === 0)
97
+ return 'standard';
98
+ if (token.startsWith(CI_TOKEN_PREFIX))
99
+ return 'ci';
100
+ const payload = decodeJwtPayload(token);
101
+ if (payload &&
102
+ typeof payload === 'object' &&
103
+ !Array.isArray(payload) &&
104
+ payload.scope === 'daemon-install') {
105
+ return 'install';
106
+ }
107
+ return 'standard';
108
+ }
109
+ let _helperCache = null;
110
+ /** @internal test-only — drop the helper cache between tests. */
111
+ export function __resetCredentialResolverForTest() {
112
+ _helperCache = null;
113
+ }
114
+ /**
115
+ * L6.3 — typed error thrown when the cold-cache failure sentinel is in
116
+ * effect. Callers (daemon refresh path, CLI) can catch this specifically
117
+ * to render "auth helper unavailable: <reason>" without falling through
118
+ * to OAuth or device-code retry.
119
+ */
120
+ export class AuthHelperUnavailableError extends Error {
121
+ reason;
122
+ cachedAt;
123
+ constructor(reason, cachedAt) {
124
+ super(`auth helper unavailable: ${reason}`);
125
+ this.name = 'AuthHelperUnavailableError';
126
+ this.reason = reason;
127
+ this.cachedAt = cachedAt;
128
+ }
129
+ }
130
+ /**
131
+ * L6.1 — typed error thrown when the workspace-trust gate blocks a
132
+ * project- or local-scope helper. Surfaces the actionable next step
133
+ * (`oversky trust accept`) to the user; the daemon catches and presents
134
+ * this without falling through to OAuth.
135
+ */
136
+ export class AuthHelperUntrustedError extends Error {
137
+ origin;
138
+ reason;
139
+ helperPath;
140
+ constructor(helperPath, origin, reason) {
141
+ super(`OVERSKY_AUTH_HELPER (${origin}-scope) is not trusted for this workspace ` +
142
+ `(reason: ${reason}). Run \`oversky trust accept\` to allow it.`);
143
+ this.name = 'AuthHelperUntrustedError';
144
+ this.origin = origin;
145
+ this.reason = reason;
146
+ this.helperPath = helperPath;
147
+ }
148
+ }
149
+ /**
150
+ * H3-6 — public invalidation seam for the `OVERSKY_AUTH_HELPER` result
151
+ * cache. 401 handlers call this right after receiving a 401 from the
152
+ * server so the NEXT credential resolve re-invokes the helper to mint
153
+ * a fresh token instead of serving the stale (cached) one for up to
154
+ * `OVERSKY_AUTH_HELPER_TTL_MS`.
155
+ *
156
+ * Without this seam, a rotated secret at the helper's source (Vault,
157
+ * AWS Secrets Manager, etc.) could take up to 5 minutes to propagate
158
+ * to OverSky even when the server is actively rejecting the old
159
+ * value. The helper is the only slot in the precedence chain that
160
+ * naturally caches; keychain/file reads are already one-shot per
161
+ * resolve.
162
+ *
163
+ * L6.3 — also clears the cold-cache failure sentinel. A 401 means the
164
+ * server is rejecting whatever we sent, which usually means the helper
165
+ * needs to retry from scratch — even when the LAST helper attempt
166
+ * failed and seeded the sentinel. Without this, a transient helper
167
+ * failure during the same TTL window as a server-issued 401 would lock
168
+ * the daemon out for the full TTL.
169
+ *
170
+ * Idempotent: calling this on an already-empty cache is a no-op.
171
+ */
172
+ export function invalidateAuthHelperCache() {
173
+ _helperCache = null;
174
+ }
175
+ export class CredentialResolver {
176
+ opts;
177
+ constructor(opts = {}) {
178
+ this.opts = opts;
179
+ }
180
+ /**
181
+ * Walk the precedence chain and return the first non-null token.
182
+ * Returns `{ token: null, source: 'none' }` when nothing matches.
183
+ *
184
+ * Fail-closed exceptions from the auth-helper slot
185
+ * (`AuthHelperUntrustedError`, `AuthHelperUnavailableError`) propagate
186
+ * out — they intentionally PREVENT fall-through to OAuth. Callers
187
+ * that want soft-fall-through behavior must catch explicitly. See
188
+ * L6.1 spec point 4 and L6.3.
189
+ */
190
+ async resolve() {
191
+ for (const source of CREDENTIAL_PRECEDENCE) {
192
+ if (this.shouldSkip(source))
193
+ continue;
194
+ const token = await this.tryResolveSlot(source);
195
+ if (token) {
196
+ return { token, source, kind: classifyTokenKind(token) };
197
+ }
198
+ }
199
+ return { token: null, source: 'none', kind: 'standard' };
200
+ }
201
+ /**
202
+ * Test seam: invoke a single slot in isolation. Production code should
203
+ * always go through `resolve()` so the chain ordering is enforced.
204
+ */
205
+ async tryResolveSlot(source) {
206
+ switch (source) {
207
+ case 'flag':
208
+ return this.tryResolveFlag();
209
+ case 'env-token':
210
+ return this.tryResolveEnvToken();
211
+ case 'auth-helper':
212
+ return this.tryResolveAuthHelper();
213
+ case 'fd-handoff':
214
+ return this.tryResolveFdHandoff();
215
+ case 'keychain':
216
+ return this.tryResolveKeychain();
217
+ case 'file':
218
+ return this.tryResolveFile();
219
+ case 'interactive':
220
+ return this.tryResolveInteractive();
221
+ }
222
+ }
223
+ shouldSkip(source) {
224
+ const { bareMode } = this.opts;
225
+ const storeMode = this.opts.storeMode ?? 'auto';
226
+ if (bareMode &&
227
+ (source === 'fd-handoff' ||
228
+ source === 'keychain' ||
229
+ source === 'file' ||
230
+ source === 'interactive')) {
231
+ return true;
232
+ }
233
+ if (storeMode === 'env-only' && (source === 'keychain' || source === 'file')) {
234
+ return true;
235
+ }
236
+ if (storeMode === 'keychain' && source === 'file')
237
+ return true;
238
+ if (storeMode === 'file' && source === 'keychain')
239
+ return true;
240
+ return false;
241
+ }
242
+ // ------------------------------------------------------------------
243
+ // Slot implementations
244
+ // ------------------------------------------------------------------
245
+ tryResolveFlag() {
246
+ const v = this.opts.flagToken;
247
+ return typeof v === 'string' && v.length > 0 ? v : null;
248
+ }
249
+ tryResolveEnvToken() {
250
+ if (this.opts.envTokenOverride === null)
251
+ return null; // explicit force-skip
252
+ if (typeof this.opts.envTokenOverride === 'string') {
253
+ return this.opts.envTokenOverride.length > 0 ? this.opts.envTokenOverride : null;
254
+ }
255
+ const v = process.env.OVERSKY_TOKEN;
256
+ return typeof v === 'string' && v.length > 0 ? v : null;
257
+ }
258
+ async tryResolveAuthHelper() {
259
+ let helperPath;
260
+ let fromEnvVar = false;
261
+ if (this.opts.authHelperPathOverride === null)
262
+ return null;
263
+ if (typeof this.opts.authHelperPathOverride === 'string') {
264
+ helperPath =
265
+ this.opts.authHelperPathOverride.length > 0 ? this.opts.authHelperPathOverride : null;
266
+ }
267
+ else {
268
+ helperPath = process.env.OVERSKY_AUTH_HELPER ?? null;
269
+ fromEnvVar = helperPath !== null;
270
+ }
271
+ if (!helperPath)
272
+ return null;
273
+ // ---------------------------------------------------------------
274
+ // L6.3 — sentinel served BEFORE re-spawning. Failure cache prevents
275
+ // silent fall-through to OAuth within the TTL window.
276
+ // ---------------------------------------------------------------
277
+ if (_helperCache && Date.now() < _helperCache.expiresAt) {
278
+ if (_helperCache.kind === 'success') {
279
+ return _helperCache.token;
280
+ }
281
+ // Failed sentinel still warm — fail closed. The slot returns
282
+ // a fail-closed signal via thrown error so the chain doesn't
283
+ // continue to keychain/file/OAuth.
284
+ this.opts.onAuthHelperSentinelHit?.({
285
+ helperPath,
286
+ reason: _helperCache.reason,
287
+ cachedAt: _helperCache.cachedAt,
288
+ ttlMs: _helperCache.expiresAt - _helperCache.cachedAt,
289
+ });
290
+ throw new AuthHelperUnavailableError(_helperCache.reason, _helperCache.cachedAt);
291
+ }
292
+ // ---------------------------------------------------------------
293
+ // L6.1 — workspace-trust gate. Project- and local-scope helpers
294
+ // must have an accepted trust record for the cwd. User-scope
295
+ // helpers (env var, ~/.skrr/) bypass the check.
296
+ //
297
+ // Trust check runs BEFORE invocation so we never even spawn an
298
+ // untrusted script. Failed trust = fail-closed throw, never
299
+ // falls through to OAuth.
300
+ // ---------------------------------------------------------------
301
+ if (this.opts.helperTrustGate) {
302
+ const decision = this.opts.helperTrustGate(helperPath, fromEnvVar);
303
+ if (!decision.trusted) {
304
+ const reason = decision.reason ?? 'no-record';
305
+ this.opts.onAuthHelperBlocked?.({
306
+ helperPath,
307
+ origin: decision.origin,
308
+ reason,
309
+ cwd: process.cwd(),
310
+ });
311
+ throw new AuthHelperUntrustedError(helperPath, decision.origin, reason);
312
+ }
313
+ }
314
+ const result = await invokeAuthHelper(helperPath);
315
+ if (result) {
316
+ const ttlMs = getDefaultHelperTtlMs();
317
+ const expiresAt = result.expiresAt ?? Date.now() + ttlMs;
318
+ _helperCache = { kind: 'success', token: result.token, expiresAt };
319
+ return result.token;
320
+ }
321
+ // ---------------------------------------------------------------
322
+ // L6.3 — invocation failed. Distinguish two failure classes:
323
+ //
324
+ // - Validation failures ('disabled', 'invalid-path',
325
+ // 'not-executable', null) — helper isn't really wired up.
326
+ // Fall through to next slot. NO sentinel.
327
+ //
328
+ // - Execution failures ('timeout', 'non-zero-exit', 'oversized',
329
+ // 'empty-output', 'parse-error', 'spawn-error') — the helper
330
+ // was wired up but couldn't deliver a token. Cache the
331
+ // sentinel and fail closed for the TTL window. This matches
332
+ // CC's `' '` literal sentinel at CC-A-34.
333
+ // ---------------------------------------------------------------
334
+ const reason = getLastAuthHelperFailureReason();
335
+ if (reason && SENTINEL_TRIGGERING_FAILURES.has(reason)) {
336
+ const ttlMs = getDefaultHelperTtlMs();
337
+ const cachedAt = Date.now();
338
+ _helperCache = {
339
+ kind: 'failed',
340
+ reason,
341
+ cachedAt,
342
+ expiresAt: cachedAt + ttlMs,
343
+ };
344
+ this.opts.onAuthHelperSentinelHit?.({
345
+ helperPath,
346
+ reason,
347
+ cachedAt,
348
+ ttlMs,
349
+ });
350
+ throw new AuthHelperUnavailableError(reason, cachedAt);
351
+ }
352
+ // Validation failure — no sentinel; fall through.
353
+ return null;
354
+ }
355
+ async tryResolveFdHandoff() {
356
+ // FD handoff is daemon-only; CLI never has OVERSKY_AUTH_FD set, so
357
+ // the env check below makes this a near-zero-cost slot for CLI
358
+ // processes. The fdToken module does its own kill-switch check
359
+ // (`fdTokenHandoff` killSwitch) and bounded read.
360
+ if (!process.env.OVERSKY_AUTH_FD)
361
+ return null;
362
+ try {
363
+ const payload = await readAuthFromFd();
364
+ if (!payload)
365
+ return null;
366
+ if (payload.accessExpiresAt && Date.now() > payload.accessExpiresAt) {
367
+ return null;
368
+ }
369
+ return payload.accessToken || null;
370
+ }
371
+ catch (err) {
372
+ getAuthLogger().warn(`[credentialResolver] FD handoff read failed: ${formatErr(err)}`);
373
+ return null;
374
+ }
375
+ }
376
+ tryResolveKeychain() {
377
+ if (!this.opts.readKeychainToken)
378
+ return null;
379
+ try {
380
+ return this.opts.readKeychainToken();
381
+ }
382
+ catch (err) {
383
+ getAuthLogger().warn(`[credentialResolver] keychain read failed: ${formatErr(err)}`);
384
+ return null;
385
+ }
386
+ }
387
+ tryResolveFile() {
388
+ if (!this.opts.readFileToken)
389
+ return null;
390
+ try {
391
+ return this.opts.readFileToken();
392
+ }
393
+ catch (err) {
394
+ getAuthLogger().warn(`[credentialResolver] file read failed: ${formatErr(err)}`);
395
+ return null;
396
+ }
397
+ }
398
+ async tryResolveInteractive() {
399
+ if (!this.opts.runInteractiveLogin)
400
+ return null;
401
+ if (this.opts.isHeadless?.())
402
+ return null;
403
+ try {
404
+ return await this.opts.runInteractiveLogin();
405
+ }
406
+ catch (err) {
407
+ getAuthLogger().warn(`[credentialResolver] interactive login failed: ${formatErr(err)}`);
408
+ return null;
409
+ }
410
+ }
411
+ }
412
+ function formatErr(err) {
413
+ return err instanceof Error ? err.message : String(err);
414
+ }