@skrr-ai/auth-core 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- package/package.json +82 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loginWithLocalhost.ts — Phase H.0 orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* @deprecated Tier-1.1 (2026-04-29). This module talks to the older
|
|
5
|
+
* `/api/auth/cli-init` + `/api/auth/cli-token-exchange` endpoints; the
|
|
6
|
+
* canonical PKCE+localhost orchestrator is now
|
|
7
|
+
* `daemon/src/cli/oauthLogin.ts:runOAuthLogin`, which uses the standard
|
|
8
|
+
* `/api/oauth/token` endpoint and additionally arms a manual-paste
|
|
9
|
+
* fallback arm. Retained until `--legacy` is removed (gated on Tier-2.2
|
|
10
|
+
* telemetry showing <1% usage). New code MUST NOT call this — the only
|
|
11
|
+
* remaining caller is the daemon's `loginInteractive` legacy fallback at
|
|
12
|
+
* `daemon/src/auth.ts:loginWithLocalhostFlow`. See
|
|
13
|
+
* `docs/daemon-auth-production-readiness-2026-04-29.md` Tier 1.1 for the
|
|
14
|
+
* removal plan.
|
|
15
|
+
*
|
|
16
|
+
* Ties PKCE primitives + local 127.0.0.1 callback server + server-side
|
|
17
|
+
* /cli-init / /cli-token-exchange endpoints into a single function the
|
|
18
|
+
* CLI/daemon can call. Returns daemon-style tokens (same shape as the
|
|
19
|
+
* device-code completion path) so the persistence layer in
|
|
20
|
+
* `daemon/src/auth.ts:saveToken()` doesn't change.
|
|
21
|
+
*
|
|
22
|
+
* Flow:
|
|
23
|
+
*
|
|
24
|
+
* 1. generate code_verifier + state
|
|
25
|
+
* 2. compute S256 code_challenge
|
|
26
|
+
* 3. start local 127.0.0.1 callback server (handle.redirectUri ready)
|
|
27
|
+
* 4. POST /api/auth/cli-init { code_challenge, redirect_uri, state,
|
|
28
|
+
* device_name, scope }
|
|
29
|
+
* → { requestId, authUrl }
|
|
30
|
+
* 5. open authUrl in user's browser (caller-injected opener)
|
|
31
|
+
* 6. user approves on app.oversky.ai/auth/cli?req=<requestId>
|
|
32
|
+
* 7. server 302s browser → http://127.0.0.1:<port>/callback?code&state
|
|
33
|
+
* 8. handle.waitForCallback() resolves with { code }
|
|
34
|
+
* 9. POST /api/auth/cli-token-exchange { code, code_verifier,
|
|
35
|
+
* redirect_uri }
|
|
36
|
+
* → { token, expiresAt, refreshToken?, refreshExpiresAt? }
|
|
37
|
+
* 10. return the token bundle (caller persists via saveToken())
|
|
38
|
+
*
|
|
39
|
+
* Design boundaries:
|
|
40
|
+
*
|
|
41
|
+
* - Browser opener is caller-injected (auth-core stays platform-
|
|
42
|
+
* agnostic; daemon passes its `execFile('open' | 'xdg-open' |
|
|
43
|
+
* 'rundll32', …)` wrapper).
|
|
44
|
+
* - fetch is caller-overridable for tests; defaults to globalThis.fetch
|
|
45
|
+
* (Node 20+).
|
|
46
|
+
* - SSH / headless / OVERSKY_FORCE_DEVICE_CODE detection is the
|
|
47
|
+
* CALLER's job — this orchestrator just runs the flow. The caller
|
|
48
|
+
* decides whether localhost callback is appropriate before calling.
|
|
49
|
+
* - No retries. The auth code is single-use and short-lived; if a
|
|
50
|
+
* network blip drops the token-exchange request, the user just
|
|
51
|
+
* reruns the login command. Adding retries here muddies error
|
|
52
|
+
* semantics for callers that want clean fallback signals.
|
|
53
|
+
*
|
|
54
|
+
* Error handling:
|
|
55
|
+
*
|
|
56
|
+
* The orchestrator throws structured errors so callers can branch:
|
|
57
|
+
*
|
|
58
|
+
* LocalCallbackBindError — port unavailable; caller may retry
|
|
59
|
+
* LocalCallbackTimeoutError — user never approved; fall back
|
|
60
|
+
* LocalCallbackStateMismatchError — likely attack; fail loudly
|
|
61
|
+
* LocalCallbackDeniedError — user clicked deny; surface clearly
|
|
62
|
+
* LocalCallbackMalformedError — server bug; surface for diagnosis
|
|
63
|
+
* LoginInitError — /cli-init HTTP failure
|
|
64
|
+
* (code='FEATURE_DISABLED' for 404 →
|
|
65
|
+
* caller falls back to device-code)
|
|
66
|
+
* LoginExchangeError — /cli-token-exchange HTTP failure
|
|
67
|
+
*
|
|
68
|
+
* Cleanup invariant: the local server is ALWAYS torn down before this
|
|
69
|
+
* function returns or throws — any failure path runs through the
|
|
70
|
+
* `try / finally(handle.close())` wrapper. A future caller cannot leak
|
|
71
|
+
* a listening socket by catching the wrong error.
|
|
72
|
+
*/
|
|
73
|
+
import { generateState, generateVerifier, challenge as pkceChallenge } from './pkce.js';
|
|
74
|
+
import { startLocalCallback } from './loginLocalhost.js';
|
|
75
|
+
import { getAuthLogger } from './runtime.js';
|
|
76
|
+
/** Default scope for new daemon installs. */
|
|
77
|
+
const DEFAULT_SCOPE = 'daemon';
|
|
78
|
+
// ---------------------------------------------------------------------
|
|
79
|
+
// Error classes
|
|
80
|
+
// ---------------------------------------------------------------------
|
|
81
|
+
/**
|
|
82
|
+
* /cli-init failed. `code` distinguishes the failure mode so the
|
|
83
|
+
* caller can branch (FEATURE_DISABLED → fall back to device-code; the
|
|
84
|
+
* rest → surface to user).
|
|
85
|
+
*/
|
|
86
|
+
export class LoginInitError extends Error {
|
|
87
|
+
code; // 200 but body shape unexpected
|
|
88
|
+
status;
|
|
89
|
+
responseBody;
|
|
90
|
+
constructor(code, message, status, body) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = 'LoginInitError';
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.status = status;
|
|
95
|
+
this.responseBody = body;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** /cli-token-exchange failed. */
|
|
99
|
+
export class LoginExchangeError extends Error {
|
|
100
|
+
code; // 200 but body shape unexpected
|
|
101
|
+
status;
|
|
102
|
+
responseBody;
|
|
103
|
+
constructor(code, message, status, body) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = 'LoginExchangeError';
|
|
106
|
+
this.code = code;
|
|
107
|
+
this.status = status;
|
|
108
|
+
this.responseBody = body;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
// Public API
|
|
113
|
+
// ---------------------------------------------------------------------
|
|
114
|
+
/**
|
|
115
|
+
* Run the full PKCE-via-localhost login flow. See file header for the
|
|
116
|
+
* step-by-step. Throws structured errors on every failure path. The
|
|
117
|
+
* local callback server is always torn down before this function
|
|
118
|
+
* returns or throws.
|
|
119
|
+
*/
|
|
120
|
+
export async function loginWithLocalhost(opts) {
|
|
121
|
+
const log = getAuthLogger();
|
|
122
|
+
const fetchFn = opts.fetchImpl ?? globalThis.fetch;
|
|
123
|
+
if (typeof fetchFn !== 'function') {
|
|
124
|
+
throw new TypeError('loginWithLocalhost: globalThis.fetch is missing — pass fetchImpl or run under Node ≥ 20');
|
|
125
|
+
}
|
|
126
|
+
const scope = opts.scope ?? DEFAULT_SCOPE;
|
|
127
|
+
const serverUrl = opts.serverUrl.replace(/\/+$/, '');
|
|
128
|
+
// Step 1+2: PKCE primitives. The verifier never leaves this
|
|
129
|
+
// function's local scope until step 9.
|
|
130
|
+
const codeVerifier = generateVerifier();
|
|
131
|
+
const codeChallenge = pkceChallenge(codeVerifier, 'S256');
|
|
132
|
+
const state = generateState();
|
|
133
|
+
// Step 3: bind the local callback server. Bind error here is fatal —
|
|
134
|
+
// caller decides whether to retry on a different port.
|
|
135
|
+
const handle = await startLocalCallback({
|
|
136
|
+
state,
|
|
137
|
+
port: opts.port,
|
|
138
|
+
host: opts.host,
|
|
139
|
+
bindAddr: opts.bindAddr,
|
|
140
|
+
timeoutMs: opts.timeoutMs,
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
// Step 4: POST /cli-init.
|
|
144
|
+
const initBody = {
|
|
145
|
+
code_challenge: codeChallenge,
|
|
146
|
+
code_challenge_method: 'S256',
|
|
147
|
+
redirect_uri: handle.redirectUri,
|
|
148
|
+
state,
|
|
149
|
+
device_name: opts.deviceName,
|
|
150
|
+
daemon_id: opts.daemonId,
|
|
151
|
+
scope,
|
|
152
|
+
};
|
|
153
|
+
let initRes;
|
|
154
|
+
try {
|
|
155
|
+
initRes = await fetchFn(`${serverUrl}/api/auth/cli-init`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json' },
|
|
158
|
+
body: JSON.stringify(initBody),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
throw new LoginInitError('NETWORK_ERROR', `POST /api/auth/cli-init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
}
|
|
164
|
+
if (initRes.status === 404) {
|
|
165
|
+
// Server doesn't expose this endpoint — feature flag off, or
|
|
166
|
+
// older server build that pre-dates Phase H.0. Caller should
|
|
167
|
+
// fall back to device-code.
|
|
168
|
+
throw new LoginInitError('FEATURE_DISABLED', 'Server does not support PKCE localhost login (feature flag off or older build). ' +
|
|
169
|
+
'Fall back to device-code.', 404);
|
|
170
|
+
}
|
|
171
|
+
if (initRes.status === 429) {
|
|
172
|
+
throw new LoginInitError('RATE_LIMITED', 'Server rate-limited the login init request. Wait a moment and retry.', 429, await safeReadBody(initRes));
|
|
173
|
+
}
|
|
174
|
+
if (initRes.status >= 500) {
|
|
175
|
+
throw new LoginInitError('SERVER_ERROR', `Server returned ${initRes.status} on /cli-init.`, initRes.status, await safeReadBody(initRes));
|
|
176
|
+
}
|
|
177
|
+
if (!initRes.ok) {
|
|
178
|
+
throw new LoginInitError('BAD_REQUEST', `Server returned ${initRes.status} on /cli-init.`, initRes.status, await safeReadBody(initRes));
|
|
179
|
+
}
|
|
180
|
+
let initJson;
|
|
181
|
+
try {
|
|
182
|
+
initJson = await initRes.json();
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new LoginInitError('MALFORMED_RESPONSE', `Could not parse /cli-init response as JSON: ${err instanceof Error ? err.message : String(err)}`, initRes.status);
|
|
186
|
+
}
|
|
187
|
+
const init = parseInitResponse(initJson);
|
|
188
|
+
if (!init) {
|
|
189
|
+
throw new LoginInitError('MALFORMED_RESPONSE', '/cli-init response missing requestId or authUrl', initRes.status);
|
|
190
|
+
}
|
|
191
|
+
// Step 5: hand the auth URL to the caller (so they can print it
|
|
192
|
+
// before / instead of opening the browser) then open it. Both are
|
|
193
|
+
// best-effort — the user can copy/paste the URL.
|
|
194
|
+
if (opts.onAuthUrl) {
|
|
195
|
+
try {
|
|
196
|
+
opts.onAuthUrl(init.authUrl);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
log.warn(`[loginWithLocalhost] onAuthUrl callback threw (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
await opts.openBrowser(init.authUrl);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
log.warn(`[loginWithLocalhost] openBrowser failed (non-fatal — user can paste URL): ${err instanceof Error ? err.message : String(err)}`);
|
|
207
|
+
}
|
|
208
|
+
// Step 6–8: wait for the callback. Errors here are the structured
|
|
209
|
+
// LocalCallback* set; they propagate as-is so the caller branches
|
|
210
|
+
// on instanceof.
|
|
211
|
+
const callback = await handle.waitForCallback();
|
|
212
|
+
// Step 9: POST /cli-token-exchange.
|
|
213
|
+
const exchangeBody = {
|
|
214
|
+
code: callback.code,
|
|
215
|
+
code_verifier: codeVerifier,
|
|
216
|
+
redirect_uri: handle.redirectUri,
|
|
217
|
+
};
|
|
218
|
+
let exchangeRes;
|
|
219
|
+
try {
|
|
220
|
+
exchangeRes = await fetchFn(`${serverUrl}/api/auth/cli-token-exchange`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify(exchangeBody),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
throw new LoginExchangeError('NETWORK_ERROR', `POST /api/auth/cli-token-exchange failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
if (exchangeRes.status === 400) {
|
|
230
|
+
throw new LoginExchangeError('INVALID_GRANT', 'Server rejected the PKCE code exchange (verifier mismatch, expired code, or already redeemed).', 400, await safeReadBody(exchangeRes));
|
|
231
|
+
}
|
|
232
|
+
if (exchangeRes.status >= 500) {
|
|
233
|
+
throw new LoginExchangeError('SERVER_ERROR', `Server returned ${exchangeRes.status} on /cli-token-exchange.`, exchangeRes.status, await safeReadBody(exchangeRes));
|
|
234
|
+
}
|
|
235
|
+
if (!exchangeRes.ok) {
|
|
236
|
+
throw new LoginExchangeError('BAD_REQUEST', `Server returned ${exchangeRes.status} on /cli-token-exchange.`, exchangeRes.status, await safeReadBody(exchangeRes));
|
|
237
|
+
}
|
|
238
|
+
let exchangeJson;
|
|
239
|
+
try {
|
|
240
|
+
exchangeJson = await exchangeRes.json();
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
throw new LoginExchangeError('MALFORMED_RESPONSE', `Could not parse /cli-token-exchange response as JSON: ${err instanceof Error ? err.message : String(err)}`, exchangeRes.status);
|
|
244
|
+
}
|
|
245
|
+
const result = parseExchangeResponse(exchangeJson);
|
|
246
|
+
if (!result) {
|
|
247
|
+
throw new LoginExchangeError('MALFORMED_RESPONSE', '/cli-token-exchange response missing token or has bad shape', exchangeRes.status);
|
|
248
|
+
}
|
|
249
|
+
log.info(`[loginWithLocalhost] login successful (scope=${result.scope ?? scope}` +
|
|
250
|
+
(result.daemonId ? ` daemonId=${result.daemonId}` : '') +
|
|
251
|
+
')');
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
// Cleanup invariant: server torn down on every exit path.
|
|
256
|
+
//
|
|
257
|
+
// Subtlety: when /cli-init fails (404, network, etc.) we throw
|
|
258
|
+
// BEFORE reaching `await handle.waitForCallback()`, so the
|
|
259
|
+
// callback Promise has no .catch attached. `handle.close()` will
|
|
260
|
+
// reject that Promise with CANCELLED → an unhandledRejection
|
|
261
|
+
// event the test runner correctly flags. Attach a no-op .catch
|
|
262
|
+
// first so the artifact-of-cleanup rejection is observed.
|
|
263
|
+
// Harmless on the happy path: the try-block's await already
|
|
264
|
+
// attached its own handler, and multiple .catches on the same
|
|
265
|
+
// Promise all observe the same outcome.
|
|
266
|
+
void handle.waitForCallback().catch(() => undefined);
|
|
267
|
+
handle.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function parseInitResponse(raw) {
|
|
271
|
+
if (!raw || typeof raw !== 'object')
|
|
272
|
+
return null;
|
|
273
|
+
const obj = raw;
|
|
274
|
+
const requestId = typeof obj.requestId === 'string' ? obj.requestId : null;
|
|
275
|
+
const authUrl = typeof obj.authUrl === 'string' ? obj.authUrl : null;
|
|
276
|
+
if (!requestId || !authUrl)
|
|
277
|
+
return null;
|
|
278
|
+
// Defense in depth: refuse to open URLs that aren't http/https.
|
|
279
|
+
// Prevents a hostile/buggy server response steering the caller's
|
|
280
|
+
// browser at javascript: / file: / data: URIs.
|
|
281
|
+
try {
|
|
282
|
+
const u = new URL(authUrl);
|
|
283
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
return { requestId, authUrl };
|
|
290
|
+
}
|
|
291
|
+
function parseExchangeResponse(raw) {
|
|
292
|
+
if (!raw || typeof raw !== 'object')
|
|
293
|
+
return null;
|
|
294
|
+
const obj = raw;
|
|
295
|
+
const token = typeof obj.token === 'string' && obj.token.length > 0 ? obj.token : undefined;
|
|
296
|
+
const expiresAt = parseEpochMs(obj.expiresAt);
|
|
297
|
+
const refreshToken = typeof obj.refreshToken === 'string' && obj.refreshToken.length > 0
|
|
298
|
+
? obj.refreshToken
|
|
299
|
+
: undefined;
|
|
300
|
+
const refreshExpiresAt = parseEpochMs(obj.refreshExpiresAt);
|
|
301
|
+
const daemonId = typeof obj.daemonId === 'string' && obj.daemonId.length > 0 ? obj.daemonId : undefined;
|
|
302
|
+
const scope = parseScope(obj.scope);
|
|
303
|
+
// Stage-E #1 — opaque `osk_dmn_*` co-issued for daemon-scope mints
|
|
304
|
+
// (server-side: routes/cliAuth.js sends it in the response payload).
|
|
305
|
+
// Treat any non-string/empty as absent — login responses don't have
|
|
306
|
+
// the "explicitly clear" null semantic that refresh responses do.
|
|
307
|
+
const opaqueAccessToken = typeof obj.opaqueAccessToken === 'string' && obj.opaqueAccessToken.length > 0
|
|
308
|
+
? obj.opaqueAccessToken
|
|
309
|
+
: undefined;
|
|
310
|
+
// Stage-E #1 step 4 — daemon-scope login responses omit `token`
|
|
311
|
+
// because the daemon-JWT substrate is retired. Accept the response
|
|
312
|
+
// as long as the server returned at least one credential class.
|
|
313
|
+
// Pre-step-4 responses (token only / token + opaque) and post-step-4
|
|
314
|
+
// responses (opaque only) both pass.
|
|
315
|
+
if (!token && !opaqueAccessToken)
|
|
316
|
+
return null;
|
|
317
|
+
// Match the device-code defensive path in daemon/src/auth.ts: if the
|
|
318
|
+
// server claimed to issue a refresh token but the expiry is
|
|
319
|
+
// unparseable, drop both rather than persist a refresh that reads
|
|
320
|
+
// back as "valid forever" via Date.now() > NaN.
|
|
321
|
+
const safeRefresh = refreshToken !== undefined && refreshExpiresAt !== undefined
|
|
322
|
+
? { refreshToken, refreshExpiresAt }
|
|
323
|
+
: undefined;
|
|
324
|
+
return {
|
|
325
|
+
...(token ? { token } : {}),
|
|
326
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
327
|
+
...(safeRefresh ?? {}),
|
|
328
|
+
...(daemonId ? { daemonId } : {}),
|
|
329
|
+
...(scope ? { scope } : {}),
|
|
330
|
+
...(opaqueAccessToken ? { opaqueAccessToken } : {}),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function parseEpochMs(v) {
|
|
334
|
+
if (typeof v !== 'string' && typeof v !== 'number')
|
|
335
|
+
return undefined;
|
|
336
|
+
const ms = new Date(v).getTime();
|
|
337
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
338
|
+
}
|
|
339
|
+
function parseScope(v) {
|
|
340
|
+
if (v === 'daemon' || v === 'cli' || v === 'web')
|
|
341
|
+
return v;
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
async function safeReadBody(res) {
|
|
345
|
+
try {
|
|
346
|
+
const txt = await res.text();
|
|
347
|
+
// Truncate so a massive 5xx HTML page doesn't bloat error objects.
|
|
348
|
+
return txt.length > 500 ? txt.slice(0, 500) + '… [truncated]' : txt;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return the stable machine-scoped daemon UUID. On first call, reads
|
|
3
|
+
* `<configDir>/daemon.id` if present; otherwise mints a UUIDv7 and
|
|
4
|
+
* persists it atomically at 0600. Cached for the process lifetime.
|
|
5
|
+
*
|
|
6
|
+
* On filesystem failure (locked home dir, read-only FS, ENOSPC) returns
|
|
7
|
+
* a deterministic `fallback-<machineUuid>` string so the caller still
|
|
8
|
+
* has something to emit. The `fallback-` prefix makes telemetry
|
|
9
|
+
* trivially bucketable and prevents the fallback from being confused
|
|
10
|
+
* with a real UUIDv7 downstream.
|
|
11
|
+
*
|
|
12
|
+
* The caller may pass `machineUuidFallback` to override the default
|
|
13
|
+
* hostname+platform fingerprint with a richer detector (e.g. the
|
|
14
|
+
* daemon's `getMachineUuid()` from `registry.ts`).
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensureMachineDaemonId(machineUuidFallback?: () => string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Scan `<configDir>/profiles/<profile>/daemon.id` for pre-unification
|
|
19
|
+
* per-profile identity files. Any daemon that shipped before the
|
|
20
|
+
* machine-scoped layout landed may have minted a UUID under the
|
|
21
|
+
* profile directory; emitting those on every register lets the server
|
|
22
|
+
* fold the stale `DaemonRefreshSession` + `Daemon` rows into the
|
|
23
|
+
* canonical machine identity without data loss.
|
|
24
|
+
*
|
|
25
|
+
* Returns deduped lowercase UUIDs only. Silent on individual read
|
|
26
|
+
* failures — unreadable / malformed / non-UUID entries are omitted.
|
|
27
|
+
* Never throws. Returns `[]` on clean installs.
|
|
28
|
+
*/
|
|
29
|
+
export declare function legacyDaemonUuids(): string[];
|
|
30
|
+
/**
|
|
31
|
+
* @internal test seam — clears the in-process cache so a test can
|
|
32
|
+
* exercise the full `ensureMachineDaemonId` flow against a fresh
|
|
33
|
+
* filesystem without rebuilding the module.
|
|
34
|
+
*/
|
|
35
|
+
export declare function __resetCachedDaemonIdForTest(): void;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* machineId.ts — stable machine-scoped daemon identity (auth-core).
|
|
3
|
+
*
|
|
4
|
+
* Lifted from `daemon/src/machineIdentity.ts` so the CLI and daemon can
|
|
5
|
+
* share a single per-machine UUIDv7 stored at `<configDir>/daemon.id`.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists alongside platform-derived machine UUIDs:
|
|
8
|
+
*
|
|
9
|
+
* - OS-derived ids (IOPlatformUUID / /etc/machine-id / MachineGuid) are
|
|
10
|
+
* stable per-machine, but they CHANGE on OS reinstall — at which
|
|
11
|
+
* point the server can't recognize "this is the same physical
|
|
12
|
+
* machine after a reinstall."
|
|
13
|
+
* - `ensureMachineDaemonId()` persists a UUID we mint ourselves in a
|
|
14
|
+
* per-user dotfile. It survives OS reinstalls (as long as the home
|
|
15
|
+
* directory survives), and a user whose machineUuid changes can
|
|
16
|
+
* still be re-anchored via the legacy-id list.
|
|
17
|
+
*
|
|
18
|
+
* Auth-core does NOT know about platform-level OS UUIDs (those still
|
|
19
|
+
* live in `daemon/src/registry.ts::getMachineUuid()`). The fallback is
|
|
20
|
+
* supplied by the consumer via the `machineUuidFallback` parameter so
|
|
21
|
+
* a daemon can pass its richer detector while a CLI uses a hostname
|
|
22
|
+
* fingerprint.
|
|
23
|
+
*/
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { getAuthMachineConfigDir, getAuthLogger } from './runtime.js';
|
|
29
|
+
/** File name for the stable machine daemon id. Never changes. */
|
|
30
|
+
const DAEMON_ID_FILENAME = 'daemon.id';
|
|
31
|
+
/** Profile subdirectory — scanned for legacy per-profile identity files. */
|
|
32
|
+
const PROFILES_DIRNAME = 'profiles';
|
|
33
|
+
/** UUID canonical form — 8-4-4-4-12 lowercase hex. */
|
|
34
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
let cachedStableDaemonId = null;
|
|
36
|
+
function machineHomeDir() {
|
|
37
|
+
// MACHINE-scoped root — deliberately NOT the profile-scoped configDir.
|
|
38
|
+
// See OSK-1478: the daemon's configDir is `~/.skrr/profiles/<profile>`,
|
|
39
|
+
// which made this "stable per-machine id" drift per-profile.
|
|
40
|
+
return getAuthMachineConfigDir();
|
|
41
|
+
}
|
|
42
|
+
function stableDaemonIdPath() {
|
|
43
|
+
return path.join(machineHomeDir(), DAEMON_ID_FILENAME);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Generate a UUIDv7 (RFC 9562 §5.7). 48 bits big-endian unix-millis
|
|
47
|
+
* timestamp, 4 bits version, 12 bits random, 2 bits variant, 62 bits
|
|
48
|
+
* random. Implemented inline because Node 20's `crypto.randomUUID()` is
|
|
49
|
+
* v4 only; pulling a dep for 15 lines is not worth the supply-chain
|
|
50
|
+
* surface area.
|
|
51
|
+
*/
|
|
52
|
+
function generateUuidV7() {
|
|
53
|
+
const bytes = crypto.randomBytes(16);
|
|
54
|
+
const ts = BigInt(Date.now());
|
|
55
|
+
bytes[0] = Number((ts >> 40n) & 0xffn);
|
|
56
|
+
bytes[1] = Number((ts >> 32n) & 0xffn);
|
|
57
|
+
bytes[2] = Number((ts >> 24n) & 0xffn);
|
|
58
|
+
bytes[3] = Number((ts >> 16n) & 0xffn);
|
|
59
|
+
bytes[4] = Number((ts >> 8n) & 0xffn);
|
|
60
|
+
bytes[5] = Number(ts & 0xffn);
|
|
61
|
+
// Version 7 → top 4 bits of byte 6.
|
|
62
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
63
|
+
// RFC 4122 variant (10xx) → top 2 bits of byte 8.
|
|
64
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
65
|
+
const hex = bytes.toString('hex');
|
|
66
|
+
return (hex.slice(0, 8) +
|
|
67
|
+
'-' +
|
|
68
|
+
hex.slice(8, 12) +
|
|
69
|
+
'-' +
|
|
70
|
+
hex.slice(12, 16) +
|
|
71
|
+
'-' +
|
|
72
|
+
hex.slice(16, 20) +
|
|
73
|
+
'-' +
|
|
74
|
+
hex.slice(20));
|
|
75
|
+
}
|
|
76
|
+
function isUuidLike(raw) {
|
|
77
|
+
return UUID_RE.test(raw.trim());
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Atomic write with 0600 mode. Tmp file in the same directory so the
|
|
81
|
+
* final rename is intra-filesystem. Concurrent processes racing the
|
|
82
|
+
* first-ever write both produce valid UUIDs; the kernel serializes
|
|
83
|
+
* the rename and the loser's file becomes unreachable — harmless.
|
|
84
|
+
*/
|
|
85
|
+
function writeDaemonIdFile(target, id) {
|
|
86
|
+
const dir = path.dirname(target);
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
88
|
+
const tmp = `${target}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
89
|
+
fs.writeFileSync(tmp, id + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
90
|
+
try {
|
|
91
|
+
fs.chmodSync(tmp, 0o600);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* best-effort — writeFileSync already set mode */
|
|
95
|
+
}
|
|
96
|
+
fs.renameSync(tmp, target);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Default fallback when a consumer doesn't supply one. Synthesizes a
|
|
100
|
+
* SHA-256 of `os.hostname()` + `os.platform()` — better than just
|
|
101
|
+
* hostname (includes platform) but worse than a true hardware UUID.
|
|
102
|
+
*
|
|
103
|
+
* The daemon overrides this with its richer detector
|
|
104
|
+
* (`registry.ts::getMachineUuid()`) when available. The CLI today has
|
|
105
|
+
* no native deps for OS UUIDs and so accepts this default.
|
|
106
|
+
*/
|
|
107
|
+
function defaultMachineUuidFallback() {
|
|
108
|
+
const host = os.hostname() || 'unknown-host';
|
|
109
|
+
const platform = os.platform() || 'unknown-platform';
|
|
110
|
+
return crypto.createHash('sha256').update(`auth-core:${platform}:${host}`).digest('hex');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Return the stable machine-scoped daemon UUID. On first call, reads
|
|
114
|
+
* `<configDir>/daemon.id` if present; otherwise mints a UUIDv7 and
|
|
115
|
+
* persists it atomically at 0600. Cached for the process lifetime.
|
|
116
|
+
*
|
|
117
|
+
* On filesystem failure (locked home dir, read-only FS, ENOSPC) returns
|
|
118
|
+
* a deterministic `fallback-<machineUuid>` string so the caller still
|
|
119
|
+
* has something to emit. The `fallback-` prefix makes telemetry
|
|
120
|
+
* trivially bucketable and prevents the fallback from being confused
|
|
121
|
+
* with a real UUIDv7 downstream.
|
|
122
|
+
*
|
|
123
|
+
* The caller may pass `machineUuidFallback` to override the default
|
|
124
|
+
* hostname+platform fingerprint with a richer detector (e.g. the
|
|
125
|
+
* daemon's `getMachineUuid()` from `registry.ts`).
|
|
126
|
+
*/
|
|
127
|
+
export function ensureMachineDaemonId(machineUuidFallback) {
|
|
128
|
+
if (cachedStableDaemonId !== null)
|
|
129
|
+
return cachedStableDaemonId;
|
|
130
|
+
const target = stableDaemonIdPath();
|
|
131
|
+
const fallbackFn = machineUuidFallback ?? defaultMachineUuidFallback;
|
|
132
|
+
const log = getAuthLogger();
|
|
133
|
+
// Fast path: the file exists and its contents parse as a UUID.
|
|
134
|
+
try {
|
|
135
|
+
const raw = fs.readFileSync(target, 'utf-8').trim();
|
|
136
|
+
if (isUuidLike(raw)) {
|
|
137
|
+
cachedStableDaemonId = raw.toLowerCase();
|
|
138
|
+
return cachedStableDaemonId;
|
|
139
|
+
}
|
|
140
|
+
// File exists but content is not a UUID. Overwrite — the file is
|
|
141
|
+
// machine-local and this binary is its only reader. Losing the
|
|
142
|
+
// garbled content is acceptable.
|
|
143
|
+
log.warn(`[machineIdentity] ${target} exists but does not contain a valid UUID — regenerating`);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
const code = err.code;
|
|
147
|
+
if (code !== 'ENOENT') {
|
|
148
|
+
log.warn(`[machineIdentity] read ${target} failed (${code ?? 'unknown'}): ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// OSK-1478 migration — before minting a brand-new id (which the server has
|
|
152
|
+
// never seen), adopt an existing pre-unification per-profile `daemon.id` if
|
|
153
|
+
// one is present. This promotes a previously-used identity to the canonical
|
|
154
|
+
// machine-root location so the daemon's `stableDaemonId` doesn't needlessly
|
|
155
|
+
// flip to a value with no server-side history. The remaining profile ids are
|
|
156
|
+
// still surfaced by `legacyDaemonUuids()` for the register-time fold, so no
|
|
157
|
+
// identity is lost either way. Deterministic pick (sorted) so racing
|
|
158
|
+
// processes converge on the same adoption.
|
|
159
|
+
const adoptable = legacyDaemonUuids().sort();
|
|
160
|
+
if (adoptable.length > 0) {
|
|
161
|
+
const adopted = adoptable[0];
|
|
162
|
+
try {
|
|
163
|
+
writeDaemonIdFile(target, adopted);
|
|
164
|
+
log.info(`[machineIdentity] adopted pre-unification per-profile id as machine-scoped canonical: ${adopted}`);
|
|
165
|
+
cachedStableDaemonId = adopted;
|
|
166
|
+
return adopted;
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
// Fall through to a fresh mint on write failure — the legacy fold still
|
|
170
|
+
// re-anchors the server row, so correctness does not depend on adoption.
|
|
171
|
+
log.warn(`[machineIdentity] adopt ${adopted} failed: ${err.message} — minting fresh`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const fresh = generateUuidV7();
|
|
175
|
+
try {
|
|
176
|
+
writeDaemonIdFile(target, fresh);
|
|
177
|
+
cachedStableDaemonId = fresh;
|
|
178
|
+
return fresh;
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
log.warn(`[machineIdentity] persist ${target} failed: ${err.message} — using fallback`);
|
|
182
|
+
const fallback = `fallback-${fallbackFn()}`;
|
|
183
|
+
cachedStableDaemonId = fallback;
|
|
184
|
+
return fallback;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Scan `<configDir>/profiles/<profile>/daemon.id` for pre-unification
|
|
189
|
+
* per-profile identity files. Any daemon that shipped before the
|
|
190
|
+
* machine-scoped layout landed may have minted a UUID under the
|
|
191
|
+
* profile directory; emitting those on every register lets the server
|
|
192
|
+
* fold the stale `DaemonRefreshSession` + `Daemon` rows into the
|
|
193
|
+
* canonical machine identity without data loss.
|
|
194
|
+
*
|
|
195
|
+
* Returns deduped lowercase UUIDs only. Silent on individual read
|
|
196
|
+
* failures — unreadable / malformed / non-UUID entries are omitted.
|
|
197
|
+
* Never throws. Returns `[]` on clean installs.
|
|
198
|
+
*/
|
|
199
|
+
export function legacyDaemonUuids() {
|
|
200
|
+
const profilesDir = path.join(machineHomeDir(), PROFILES_DIRNAME);
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = fs.readdirSync(profilesDir, { withFileTypes: true });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
if (!entry.isDirectory())
|
|
211
|
+
continue;
|
|
212
|
+
const candidate = path.join(profilesDir, entry.name, DAEMON_ID_FILENAME);
|
|
213
|
+
try {
|
|
214
|
+
const raw = fs.readFileSync(candidate, 'utf-8').trim();
|
|
215
|
+
if (isUuidLike(raw))
|
|
216
|
+
seen.add(raw.toLowerCase());
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* omit */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return [...seen];
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* @internal test seam — clears the in-process cache so a test can
|
|
226
|
+
* exercise the full `ensureMachineDaemonId` flow against a fresh
|
|
227
|
+
* filesystem without rebuilding the module.
|
|
228
|
+
*/
|
|
229
|
+
export function __resetCachedDaemonIdForTest() {
|
|
230
|
+
cachedStableDaemonId = null;
|
|
231
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
|
|
2
|
+
export declare function formatReauthMessage(reason: AuthFailureReason | string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Exit code for "permanent auth failure during initial connect" — picked to
|
|
5
|
+
* match the BSD/Unix sysexits.h convention (EX_TEMPFAIL = 75) so process
|
|
6
|
+
* supervisors (launchd, systemd, pm2) can be configured to NOT auto-restart
|
|
7
|
+
* on this code (the user must manually re-auth, retrying won't help).
|
|
8
|
+
*/
|
|
9
|
+
export declare const EXIT_NEEDS_REAUTH = 75;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing messages for auth state transitions.
|
|
3
|
+
*
|
|
4
|
+
* Centralized so every surface (daemon CLI, sky CLI, Electron tray, future
|
|
5
|
+
* mobile companion) emits consistent copy. The binary name in the suggested
|
|
6
|
+
* recovery command is parameterized via `configureAuthCore({ binaryName })`
|
|
7
|
+
* so each consumer's reauth banner matches the binary the user just typed.
|
|
8
|
+
*/
|
|
9
|
+
import { getAuthBinaryName } from './runtime.js';
|
|
10
|
+
const REASON_HUMAN = {
|
|
11
|
+
REFRESH_INVALID: 'Your saved credentials are no longer valid.',
|
|
12
|
+
REFRESH_REUSED: 'Your session was ended for security reasons (token replay detected).',
|
|
13
|
+
REFRESH_EXPIRED: 'Your session has expired.',
|
|
14
|
+
SESSION_REVOKED: 'Your session was ended (you signed out elsewhere or your password changed).',
|
|
15
|
+
TOKEN_EXPIRED: 'Your access token has expired.',
|
|
16
|
+
TOKEN_INVALID: 'Your saved credentials are no longer valid.',
|
|
17
|
+
UNKNOWN_401: 'OverSky needs you to sign in again.',
|
|
18
|
+
UNKNOWN: 'OverSky needs you to sign in again.',
|
|
19
|
+
};
|
|
20
|
+
export function formatReauthMessage(reason) {
|
|
21
|
+
const norm = REASON_HUMAN[reason] || REASON_HUMAN.UNKNOWN;
|
|
22
|
+
const isHeadless = !process.stdout.isTTY;
|
|
23
|
+
const bin = getAuthBinaryName();
|
|
24
|
+
const cmd = isHeadless ? `${bin} login --device` : `${bin} login`;
|
|
25
|
+
return [
|
|
26
|
+
'',
|
|
27
|
+
'OverSky cannot connect — re-authentication required.',
|
|
28
|
+
` Reason: ${norm}`,
|
|
29
|
+
'',
|
|
30
|
+
` To continue, run: ${cmd}`,
|
|
31
|
+
'',
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Exit code for "permanent auth failure during initial connect" — picked to
|
|
36
|
+
* match the BSD/Unix sysexits.h convention (EX_TEMPFAIL = 75) so process
|
|
37
|
+
* supervisors (launchd, systemd, pm2) can be configured to NOT auto-restart
|
|
38
|
+
* on this code (the user must manually re-auth, retrying won't help).
|
|
39
|
+
*/
|
|
40
|
+
export const EXIT_NEEDS_REAUTH = 75;
|