@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,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loginLocalhost.ts — local 127.0.0.1 callback server for the PKCE
|
|
3
|
+
* authorization-code login flow (Phase H.0,
|
|
4
|
+
* docs/daemon-auth-roadmap.md).
|
|
5
|
+
*
|
|
6
|
+
* The CLI starts this server on an ephemeral port BEFORE opening the
|
|
7
|
+
* browser to the consent page. After the user approves on
|
|
8
|
+
* `app.oversky.ai/auth/cli`, the OverSky server 302s the browser to
|
|
9
|
+
* `http://127.0.0.1:<port>/callback?code=...&state=...`. We accept
|
|
10
|
+
* exactly that one redirect (single-use, validated state, reject every
|
|
11
|
+
* other path/method/payload), hand the auth code back to the caller,
|
|
12
|
+
* close the server, and the caller proceeds to PKCE token exchange.
|
|
13
|
+
*
|
|
14
|
+
* API shape — handle pattern:
|
|
15
|
+
*
|
|
16
|
+
* const handle = await startLocalCallback({ state });
|
|
17
|
+
* // handle.redirectUri is bound and known synchronously; pass it to
|
|
18
|
+
* // /cli-init so the server can validate + embed it in the consent
|
|
19
|
+
* // page's redirect.
|
|
20
|
+
* await postCliInit({ redirect_uri: handle.redirectUri, state, ... });
|
|
21
|
+
* openBrowser(authUrl);
|
|
22
|
+
* const { code } = await handle.waitForCallback();
|
|
23
|
+
*
|
|
24
|
+
* The split avoids the awkward "we need redirectUri before the
|
|
25
|
+
* callback arrives but the same Promise resolves with the code" tangle
|
|
26
|
+
* — the redirectUri is hot the moment listen() succeeds, the callback
|
|
27
|
+
* promise resolves much later when the user approves.
|
|
28
|
+
*
|
|
29
|
+
* Security model:
|
|
30
|
+
* - 127.0.0.1 only — never 0.0.0.0. A daemon binding 0.0.0.0 lets
|
|
31
|
+
* any device on the LAN snag the auth code by hitting the callback
|
|
32
|
+
* URL on the developer's machine before the legitimate browser
|
|
33
|
+
* does. Numeric host (not "localhost") to avoid a DNS resolver
|
|
34
|
+
* surprise routing the lookup off-loopback.
|
|
35
|
+
* - State validation with timingSafeEqual via `pkce.safeEqual`. The
|
|
36
|
+
* state is generated by the CLI and bound into both the consent
|
|
37
|
+
* page request and the callback URL. A redirect that does not
|
|
38
|
+
* echo the exact state is rejected with no detail leaked to the
|
|
39
|
+
* page — attacker cannot tell "missing" from "wrong".
|
|
40
|
+
* - Single-use: the first valid callback resolves the promise and
|
|
41
|
+
* immediately closes the server. Any further requests during
|
|
42
|
+
* teardown receive a generic 503 from the closing socket.
|
|
43
|
+
* - Reject non-GET (405) and unknown paths (404) so a probing
|
|
44
|
+
* request can't deduce the server's purpose from differential
|
|
45
|
+
* responses.
|
|
46
|
+
* - No user-controlled data in any HTML response — static templates
|
|
47
|
+
* only. Defense vs reflected-XSS via a malicious
|
|
48
|
+
* `error_description` value an attacker might inject.
|
|
49
|
+
* - Structured error classes so the caller branches correctly:
|
|
50
|
+
* BindError → fall back to a different port or device-code
|
|
51
|
+
* TimeoutError → fall back to device-code with explanation
|
|
52
|
+
* StateMismatchError → likely attack; fail loudly, do not retry
|
|
53
|
+
* DeniedError → user declined; surface clearly, do not retry
|
|
54
|
+
* MalformedCallbackError → server bug or rogue extension;
|
|
55
|
+
* surface for diagnosis
|
|
56
|
+
* (CANCELLED) → caller invoked handle.close() before the
|
|
57
|
+
* callback arrived
|
|
58
|
+
*
|
|
59
|
+
* The server and timeout DO keep the event loop alive — that's the
|
|
60
|
+
* whole point: this module is invoked from a top-level CLI command
|
|
61
|
+
* that needs to block on the user clicking approve. Ctrl-C in the
|
|
62
|
+
* CLI sends SIGINT, which interrupts the caller's await and triggers
|
|
63
|
+
* normal shutdown logic (which should call handle.close()). Setting
|
|
64
|
+
* server.unref() here would let isolated CLI invocations exit before
|
|
65
|
+
* the callback arrives.
|
|
66
|
+
*/
|
|
67
|
+
import http from 'node:http';
|
|
68
|
+
import { safeEqual } from './pkce.js';
|
|
69
|
+
import { getAuthLogger } from './runtime.js';
|
|
70
|
+
/** Callback path — hardcoded; caller MUST construct redirectUri to match. */
|
|
71
|
+
const CALLBACK_PATH = '/callback';
|
|
72
|
+
/** Default total wait (ms) for the callback. Matches consent-page UX. */
|
|
73
|
+
export const DEFAULT_LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
74
|
+
// ---------------------------------------------------------------------
|
|
75
|
+
// Structured errors — caller branches on these
|
|
76
|
+
// ---------------------------------------------------------------------
|
|
77
|
+
export class LocalCallbackError extends Error {
|
|
78
|
+
code;
|
|
79
|
+
constructor(code, message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = 'LocalCallbackError';
|
|
82
|
+
this.code = code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export class LocalCallbackBindError extends LocalCallbackError {
|
|
86
|
+
port;
|
|
87
|
+
cause;
|
|
88
|
+
constructor(port, cause) {
|
|
89
|
+
super('BIND_FAILED', `Failed to bind localhost callback on port ${port}: ${cause?.message ?? 'unknown error'}`);
|
|
90
|
+
this.name = 'LocalCallbackBindError';
|
|
91
|
+
this.port = port;
|
|
92
|
+
this.cause = cause;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export class LocalCallbackTimeoutError extends LocalCallbackError {
|
|
96
|
+
timeoutMs;
|
|
97
|
+
constructor(timeoutMs) {
|
|
98
|
+
super('TIMEOUT', `No callback received within ${timeoutMs} ms`);
|
|
99
|
+
this.name = 'LocalCallbackTimeoutError';
|
|
100
|
+
this.timeoutMs = timeoutMs;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export class LocalCallbackStateMismatchError extends LocalCallbackError {
|
|
104
|
+
constructor() {
|
|
105
|
+
super('STATE_MISMATCH', 'Callback state did not match the expected value — rejecting (possible attack)');
|
|
106
|
+
this.name = 'LocalCallbackStateMismatchError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export class LocalCallbackDeniedError extends LocalCallbackError {
|
|
110
|
+
serverError;
|
|
111
|
+
constructor(serverError, description) {
|
|
112
|
+
super('DENIED', description ? `${serverError}: ${description}` : serverError);
|
|
113
|
+
this.name = 'LocalCallbackDeniedError';
|
|
114
|
+
this.serverError = serverError;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export class LocalCallbackMalformedError extends LocalCallbackError {
|
|
118
|
+
constructor(reason) {
|
|
119
|
+
super('MALFORMED', `Malformed callback: ${reason}`);
|
|
120
|
+
this.name = 'LocalCallbackMalformedError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------
|
|
124
|
+
// Public API
|
|
125
|
+
// ---------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Start the local 127.0.0.1 callback server. Binds synchronously
|
|
128
|
+
* (await'd via the listen handshake) and returns a handle whose
|
|
129
|
+
* `redirectUri` is immediately usable. The caller drives the rest of
|
|
130
|
+
* the flow and awaits `waitForCallback()` when ready.
|
|
131
|
+
*
|
|
132
|
+
* Throws `LocalCallbackBindError` if the bind fails (caller decides
|
|
133
|
+
* whether to retry on a different port or fall back to device-code).
|
|
134
|
+
*/
|
|
135
|
+
export async function startLocalCallback(opts) {
|
|
136
|
+
const log = getAuthLogger();
|
|
137
|
+
const port = opts.port ?? 0;
|
|
138
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
|
|
139
|
+
const expectedState = opts.state;
|
|
140
|
+
// P1 #5 (multica-convergence) — host/bindAddr split. Defaults preserve the
|
|
141
|
+
// pre-change security posture (127.0.0.1 for both). Any deviation is
|
|
142
|
+
// logged prominently because it widens the callback blast radius.
|
|
143
|
+
const urlHost = typeof opts.host === 'string' && opts.host.length > 0 ? opts.host : '127.0.0.1';
|
|
144
|
+
const bindAddr = typeof opts.bindAddr === 'string' && opts.bindAddr.length > 0 ? opts.bindAddr : urlHost;
|
|
145
|
+
const isLoopback = (h) => h === '127.0.0.1' || h === '::1' || h === 'localhost';
|
|
146
|
+
if (!isLoopback(bindAddr)) {
|
|
147
|
+
log.warn(`[loginLocalhost] non-loopback bind ${bindAddr} — any device reaching this interface ` +
|
|
148
|
+
'can race the callback. Only use on trusted networks.');
|
|
149
|
+
}
|
|
150
|
+
if (typeof expectedState !== 'string' || expectedState.length === 0) {
|
|
151
|
+
throw new LocalCallbackError('BAD_OPTIONS', 'startLocalCallback: state is required');
|
|
152
|
+
}
|
|
153
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
154
|
+
throw new LocalCallbackError('BAD_OPTIONS', `startLocalCallback: invalid timeoutMs=${timeoutMs}`);
|
|
155
|
+
}
|
|
156
|
+
// Wire the callback promise up front so the request handler can
|
|
157
|
+
// settle it without referencing the outer Promise constructor.
|
|
158
|
+
let settled = false;
|
|
159
|
+
let resolveCallback;
|
|
160
|
+
let rejectCallback;
|
|
161
|
+
const callbackPromise = new Promise((resolve, reject) => {
|
|
162
|
+
resolveCallback = resolve;
|
|
163
|
+
rejectCallback = reject;
|
|
164
|
+
});
|
|
165
|
+
let timer = null;
|
|
166
|
+
// Populated after listen() succeeds — every code path that reads
|
|
167
|
+
// `redirectUri` runs strictly after that resolution.
|
|
168
|
+
let redirectUri = '';
|
|
169
|
+
const tearDown = () => {
|
|
170
|
+
if (timer) {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
timer = null;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// Drop any keep-alive sockets a misbehaving extension may have
|
|
176
|
+
// opened — without this, server.close() waits indefinitely for
|
|
177
|
+
// them to idle out. Available since Node 18.2; we require >=20.
|
|
178
|
+
server.closeAllConnections?.();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* ignore */
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
server.close();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* ignore */
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const settle = (err, result) => {
|
|
191
|
+
if (settled)
|
|
192
|
+
return;
|
|
193
|
+
settled = true;
|
|
194
|
+
tearDown();
|
|
195
|
+
if (err)
|
|
196
|
+
rejectCallback(err);
|
|
197
|
+
else if (result)
|
|
198
|
+
resolveCallback(result);
|
|
199
|
+
};
|
|
200
|
+
const server = http.createServer((req, res) => {
|
|
201
|
+
// Reject anything except GET /callback. Bodies are deliberately
|
|
202
|
+
// generic — never reveal which check failed (purpose-disclosure
|
|
203
|
+
// resistance); attacker probing the port shouldn't learn it's a
|
|
204
|
+
// PKCE callback server vs. some other localhost service.
|
|
205
|
+
if (req.method !== 'GET') {
|
|
206
|
+
res.writeHead(405, { 'Content-Type': 'text/plain', Allow: 'GET' });
|
|
207
|
+
res.end('Method not allowed');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Parse with an explicit base — never trust the Host header.
|
|
211
|
+
const reqUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
|
212
|
+
if (reqUrl.pathname !== CALLBACK_PATH) {
|
|
213
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
214
|
+
res.end('Not found');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Authorization-server error path: ?error=access_denied[&error_description=...]
|
|
218
|
+
// The error_description is attacker-controllable in principle, so
|
|
219
|
+
// never echo it into HTML — we serve a static page and surface
|
|
220
|
+
// the structured detail only via the rejected promise + log.
|
|
221
|
+
const oauthError = reqUrl.searchParams.get('error');
|
|
222
|
+
if (oauthError) {
|
|
223
|
+
const desc = reqUrl.searchParams.get('error_description') ?? undefined;
|
|
224
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
225
|
+
res.end(declinedHtml());
|
|
226
|
+
log.warn(`[loginLocalhost] authorization server returned error=${oauthError} ` +
|
|
227
|
+
(desc ? `description=${desc}` : ''));
|
|
228
|
+
settle(new LocalCallbackDeniedError(oauthError, desc));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const code = reqUrl.searchParams.get('code');
|
|
232
|
+
const returnedState = reqUrl.searchParams.get('state');
|
|
233
|
+
if (!code || !returnedState) {
|
|
234
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
235
|
+
res.end(genericFailureHtml());
|
|
236
|
+
settle(new LocalCallbackMalformedError('missing code or state'));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!safeEqual(returnedState, expectedState)) {
|
|
240
|
+
// Generic 400 body — do NOT distinguish "missing" from "wrong"
|
|
241
|
+
// on the page. Logged separately for ops triage.
|
|
242
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
243
|
+
res.end(genericFailureHtml());
|
|
244
|
+
log.warn('[loginLocalhost] state mismatch on callback — rejecting');
|
|
245
|
+
settle(new LocalCallbackStateMismatchError());
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Happy path.
|
|
249
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
250
|
+
res.end(successHtml());
|
|
251
|
+
settle(null, { code, state: returnedState, redirectUri });
|
|
252
|
+
});
|
|
253
|
+
// Bind. Use a one-shot 'error' listener that's removed on success so
|
|
254
|
+
// post-bind socket errors flow through the server-level handler set
|
|
255
|
+
// up below.
|
|
256
|
+
await new Promise((resolve, reject) => {
|
|
257
|
+
const onListenError = (err) => {
|
|
258
|
+
reject(new LocalCallbackBindError(port, err));
|
|
259
|
+
};
|
|
260
|
+
server.once('error', onListenError);
|
|
261
|
+
server.listen(port, bindAddr, () => {
|
|
262
|
+
server.removeListener('error', onListenError);
|
|
263
|
+
resolve();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
const addr = server.address();
|
|
267
|
+
if (!addr || typeof addr === 'string') {
|
|
268
|
+
try {
|
|
269
|
+
server.close();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* ignore */
|
|
273
|
+
}
|
|
274
|
+
throw new LocalCallbackBindError(port, new Error('server.address() returned non-AddressInfo after listen'));
|
|
275
|
+
}
|
|
276
|
+
// IPv6 literal hosts need brackets in the URL form.
|
|
277
|
+
const hostInUrl = urlHost.includes(':') && !urlHost.startsWith('[') ? `[${urlHost}]` : urlHost;
|
|
278
|
+
redirectUri = `http://${hostInUrl}:${addr.port}${CALLBACK_PATH}`;
|
|
279
|
+
log.debug?.(`[loginLocalhost] listening on ${redirectUri}`);
|
|
280
|
+
// Post-bind error handler — socket errors etc. Once we've settled the
|
|
281
|
+
// happy path, swallow further errors quietly so a delayed
|
|
282
|
+
// ECONNRESET from a closed-tab keep-alive can't blow up the process.
|
|
283
|
+
server.on('error', (err) => {
|
|
284
|
+
if (settled)
|
|
285
|
+
return;
|
|
286
|
+
log.warn(`[loginLocalhost] post-bind server error: ${err.message}`);
|
|
287
|
+
settle(new LocalCallbackError('SERVER_ERROR', err.message));
|
|
288
|
+
});
|
|
289
|
+
timer = setTimeout(() => {
|
|
290
|
+
settle(new LocalCallbackTimeoutError(timeoutMs));
|
|
291
|
+
}, timeoutMs);
|
|
292
|
+
// Deliberately NOT calling server.unref() / timer.unref(). The
|
|
293
|
+
// whole point of this server is to keep the parent process alive
|
|
294
|
+
// while waiting for the user to click approve in the browser; if
|
|
295
|
+
// nothing else in the CLI is ref'd (we may be deep in an isolated
|
|
296
|
+
// login command), an unref'd server would let the event loop exit
|
|
297
|
+
// before the callback ever arrives. Ctrl-C handles teardown via
|
|
298
|
+
// SIGINT, which interrupts the await in the caller and lets normal
|
|
299
|
+
// shutdown logic invoke handle.close().
|
|
300
|
+
return {
|
|
301
|
+
redirectUri,
|
|
302
|
+
waitForCallback: () => callbackPromise,
|
|
303
|
+
close: () => {
|
|
304
|
+
settle(new LocalCallbackError('CANCELLED', 'callback wait cancelled by caller'));
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// ---------------------------------------------------------------------
|
|
309
|
+
// Static HTML responses — no user-controlled fields anywhere.
|
|
310
|
+
// ---------------------------------------------------------------------
|
|
311
|
+
function htmlShell(title, accent, headline, body) {
|
|
312
|
+
// Single inline template — keeps the binary self-contained (no
|
|
313
|
+
// bundled assets) and survives offline/firewalled environments.
|
|
314
|
+
return `<!doctype html>
|
|
315
|
+
<html lang="en">
|
|
316
|
+
<head>
|
|
317
|
+
<meta charset="utf-8">
|
|
318
|
+
<title>${title}</title>
|
|
319
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
320
|
+
<style>
|
|
321
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
322
|
+
background: #0b0d12; color: #f4f6fb; margin: 0; min-height: 100vh;
|
|
323
|
+
display: grid; place-items: center; }
|
|
324
|
+
.card { max-width: 24rem; padding: 2rem; text-align: center; }
|
|
325
|
+
h1 { font-size: 1.4rem; margin: 0 0 0.5rem 0; font-weight: 600; }
|
|
326
|
+
p { margin: 0; color: #94a3b8; line-height: 1.5; font-size: 0.95rem; }
|
|
327
|
+
.badge { width: 56px; height: 56px; margin: 0 auto 1.25rem; border-radius: 50%;
|
|
328
|
+
background: ${accent}; display: grid; place-items: center; }
|
|
329
|
+
.badge svg { width: 28px; height: 28px; stroke: white; stroke-width: 3; fill: none;
|
|
330
|
+
stroke-linecap: round; stroke-linejoin: round; }
|
|
331
|
+
</style>
|
|
332
|
+
</head>
|
|
333
|
+
<body>
|
|
334
|
+
<div class="card">
|
|
335
|
+
${headline}
|
|
336
|
+
<p>${body}</p>
|
|
337
|
+
</div>
|
|
338
|
+
</body>
|
|
339
|
+
</html>`;
|
|
340
|
+
}
|
|
341
|
+
function successHtml() {
|
|
342
|
+
const headline = `<div class="badge"><svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg></div>
|
|
343
|
+
<h1>Authorized</h1>`;
|
|
344
|
+
return htmlShell('OverSky — Authorized', '#10b981', headline, 'You can close this tab and return to your terminal.');
|
|
345
|
+
}
|
|
346
|
+
function declinedHtml() {
|
|
347
|
+
const headline = `<div class="badge" style="background:#64748b"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></div>
|
|
348
|
+
<h1>Authorization declined</h1>`;
|
|
349
|
+
return htmlShell('OverSky — Authorization declined', '#64748b', headline, 'You can close this tab. Run the CLI command again to retry.');
|
|
350
|
+
}
|
|
351
|
+
function genericFailureHtml() {
|
|
352
|
+
const headline = `<div class="badge" style="background:#ef4444"><svg viewBox="0 0 24 24"><path d="M12 8v4M12 16h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg></div>
|
|
353
|
+
<h1>Authorization error</h1>`;
|
|
354
|
+
return htmlShell('OverSky — Authorization error', '#ef4444', headline, 'Something went wrong. You can close this tab and retry from the terminal.');
|
|
355
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-injected browser opener. Daemon side wraps platform-specific
|
|
3
|
+
* commands (`open`, `xdg-open`, `rundll32 url.dll,FileProtocolHandler`)
|
|
4
|
+
* in this signature. Failure to open the browser is non-fatal — we
|
|
5
|
+
* always print the URL to stdout as well, so a user with a broken
|
|
6
|
+
* default browser association can copy/paste.
|
|
7
|
+
*/
|
|
8
|
+
export type BrowserOpener = (url: string) => void | Promise<void>;
|
|
9
|
+
export interface LoginWithLocalhostOptions {
|
|
10
|
+
/** Server API base URL — e.g. https://api.oversky.ai or http://localhost:3080. */
|
|
11
|
+
serverUrl: string;
|
|
12
|
+
/**
|
|
13
|
+
* Scope to request. Maps directly to DaemonRefreshSession.scope on
|
|
14
|
+
* the issued refresh token (Phase F). 'daemon' for the long-running
|
|
15
|
+
* tool-executor; 'cli' for short-lived REST-only operations.
|
|
16
|
+
*/
|
|
17
|
+
scope?: 'daemon' | 'cli';
|
|
18
|
+
/**
|
|
19
|
+
* User-facing label for the device. Surfaced on the consent page so
|
|
20
|
+
* the user can confirm they're authorizing the right machine.
|
|
21
|
+
* Defaults to a generic label if omitted.
|
|
22
|
+
*/
|
|
23
|
+
deviceName?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Daemon ID to bind the issued refresh token to. Same role as the
|
|
26
|
+
* `daemonId` parameter on the device-code init endpoint. Without it,
|
|
27
|
+
* the server falls back to a synthesized ID and the first refresh
|
|
28
|
+
* rotation may fail (the C1 incident pattern from device-code).
|
|
29
|
+
*/
|
|
30
|
+
daemonId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Port for the local callback. 0 = ephemeral. Pin via
|
|
33
|
+
* `OVERSKY_LOGIN_PORT` for firewall-allowlisted environments.
|
|
34
|
+
*/
|
|
35
|
+
port?: number;
|
|
36
|
+
/**
|
|
37
|
+
* P1 #5 (multica-convergence) — override the host that appears in
|
|
38
|
+
* `redirectUri`. Defaults to `'127.0.0.1'`. Use only when the
|
|
39
|
+
* browser is on a different machine than the CLI (LAN scenario).
|
|
40
|
+
* A warning is logged when non-loopback.
|
|
41
|
+
*/
|
|
42
|
+
host?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Bind-address override (paired with `host`). Defaults to `host`
|
|
45
|
+
* value. Use `0.0.0.0` to listen on all interfaces when pairing with
|
|
46
|
+
* a LAN-reachable `host` IP.
|
|
47
|
+
*/
|
|
48
|
+
bindAddr?: string;
|
|
49
|
+
/** Total timeout (ms) the local server waits for the callback. */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
/** Caller-injected browser opener. */
|
|
52
|
+
openBrowser: BrowserOpener;
|
|
53
|
+
/**
|
|
54
|
+
* fetch override for tests. Defaults to `globalThis.fetch` (Node 20+).
|
|
55
|
+
* Tests can swap in a mock that drives the local callback directly
|
|
56
|
+
* instead of needing a real server.
|
|
57
|
+
*/
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
/**
|
|
60
|
+
* If provided, called once with the authUrl AFTER the local server
|
|
61
|
+
* is bound but BEFORE the browser is opened. Lets the CLI print the
|
|
62
|
+
* URL up front (so a user whose browser fails to open can still
|
|
63
|
+
* paste it). Non-fatal — exceptions are caught and logged.
|
|
64
|
+
*/
|
|
65
|
+
onAuthUrl?: (authUrl: string) => void;
|
|
66
|
+
}
|
|
67
|
+
export interface LoginWithLocalhostResult {
|
|
68
|
+
/**
|
|
69
|
+
* Access token (JWT). Optional after Stage-E #1 step 4: daemon-scope
|
|
70
|
+
* logins ship `opaqueAccessToken` only and omit `token` because the
|
|
71
|
+
* daemon-JWT substrate has been retired. CLI / web logins still
|
|
72
|
+
* receive a JWT here.
|
|
73
|
+
*/
|
|
74
|
+
token?: string;
|
|
75
|
+
/** Access token expiry (ms epoch), if the server returned one. */
|
|
76
|
+
expiresAt?: number;
|
|
77
|
+
/** Rotating refresh token, if issued (Phase E). */
|
|
78
|
+
refreshToken?: string;
|
|
79
|
+
/** Refresh token expiry (ms epoch), if issued. */
|
|
80
|
+
refreshExpiresAt?: number;
|
|
81
|
+
/** Daemon ID the refresh token is bound to (server may have synthesized). */
|
|
82
|
+
daemonId?: string;
|
|
83
|
+
/** Echo of the scope of the issued tokens. */
|
|
84
|
+
scope?: 'daemon' | 'cli' | 'web';
|
|
85
|
+
/**
|
|
86
|
+
* Stage-E #1 — opaque `osk_dmn_*` daemon access token co-issued with
|
|
87
|
+
* the JWT for daemon-scope login. Optional during the dark-ship
|
|
88
|
+
* migration:
|
|
89
|
+
* - undefined → server didn't co-issue (older API, non-daemon
|
|
90
|
+
* scope, or kill switch on). Caller stores JWT only.
|
|
91
|
+
* - string → caller MUST persist via
|
|
92
|
+
* `saveToken(..., { opaqueAccessToken: result.opaqueAccessToken })`
|
|
93
|
+
* so the next handshake prefers it over the JWT.
|
|
94
|
+
*
|
|
95
|
+
* Closes the "fresh login pre-first-refresh" 1h blind spot. Without
|
|
96
|
+
* this, a freshly-logged-in daemon would auth via JWT until its
|
|
97
|
+
* first refresh response (~1h) co-issued an opaque, then switch
|
|
98
|
+
* over. With this, the daemon presents opaque from the very first
|
|
99
|
+
* post-login WS handshake.
|
|
100
|
+
*
|
|
101
|
+
* NEVER null on this surface — login responses don't carry the
|
|
102
|
+
* "explicitly clear" semantic that refresh responses do. A null
|
|
103
|
+
* field on the wire is treated as undefined (field absent).
|
|
104
|
+
*/
|
|
105
|
+
opaqueAccessToken?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* /cli-init failed. `code` distinguishes the failure mode so the
|
|
109
|
+
* caller can branch (FEATURE_DISABLED → fall back to device-code; the
|
|
110
|
+
* rest → surface to user).
|
|
111
|
+
*/
|
|
112
|
+
export declare class LoginInitError extends Error {
|
|
113
|
+
readonly code: 'FEATURE_DISABLED' | 'BAD_REQUEST' | 'RATE_LIMITED' | 'SERVER_ERROR' | 'NETWORK_ERROR' | 'MALFORMED_RESPONSE';
|
|
114
|
+
readonly status?: number;
|
|
115
|
+
readonly responseBody?: string;
|
|
116
|
+
constructor(code: LoginInitError['code'], message: string, status?: number, body?: string);
|
|
117
|
+
}
|
|
118
|
+
/** /cli-token-exchange failed. */
|
|
119
|
+
export declare class LoginExchangeError extends Error {
|
|
120
|
+
readonly code: 'INVALID_GRANT' | 'BAD_REQUEST' | 'SERVER_ERROR' | 'NETWORK_ERROR' | 'MALFORMED_RESPONSE';
|
|
121
|
+
readonly status?: number;
|
|
122
|
+
readonly responseBody?: string;
|
|
123
|
+
constructor(code: LoginExchangeError['code'], message: string, status?: number, body?: string);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Run the full PKCE-via-localhost login flow. See file header for the
|
|
127
|
+
* step-by-step. Throws structured errors on every failure path. The
|
|
128
|
+
* local callback server is always torn down before this function
|
|
129
|
+
* returns or throws.
|
|
130
|
+
*/
|
|
131
|
+
export declare function loginWithLocalhost(opts: LoginWithLocalhostOptions): Promise<LoginWithLocalhostResult>;
|