driggsby 0.6.3 → 0.7.1

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.
@@ -1,8 +1,9 @@
1
1
  // The app-token claim flow's HTTP client, against Driggsby's public
2
- // app-token claim API: create a pending claim, send the user to claim_url,
3
- // poll with the claim id + poll secret until the user approves. The app
4
- // token is returned by exactly one poll; callers must never poll again after
5
- // an approval.
2
+ // app-token claim API: create a pending claim carrying a PKCE challenge,
3
+ // send the user to claim_url, and trade the one-time code the approval
4
+ // produced, with the verifier, for the app token. The poll only tells a
5
+ // waiting sign-in that its claim is gone (denied or expired); Driggsby
6
+ // hands a PKCE claim's token only to the code trade.
6
7
  import { CliError } from "../cli-error.js";
7
8
  import { knownDevice } from "../device.js";
8
9
  import { sanitizeForTerminal } from "../terminal-text.js";
@@ -26,11 +27,18 @@ export const SIGN_IN_START_FAILURE = "We weren't able to start a Driggsby sign-i
26
27
  const SIGN_IN_FINISH_FAILURE = "We weren't able to finish your Driggsby sign-in just now. Please try again\nin a minute.";
27
28
  // device: this computer's name and system (device.ts), so the sign-in shows
28
29
  // up on Driggsby's MCP page as the computer it is; only known values go.
29
- export async function createClaimRequest(baseUrl, device = { name: null, system: null }) {
30
+ export async function createClaimRequest(baseUrl, pkce, device = { name: null, system: null }) {
30
31
  const response = await fetch(`${baseUrl}/app-tokens/claim-requests`, {
31
32
  method: "POST",
32
33
  headers: { "Content-Type": "application/json" },
33
- body: JSON.stringify({ app_name: CLAIM_APP_NAME, scope: CLAIM_SCOPE, ...deviceField(device) }),
34
+ body: JSON.stringify({
35
+ app_name: CLAIM_APP_NAME,
36
+ scope: CLAIM_SCOPE,
37
+ code_challenge: pkce.challenge,
38
+ code_challenge_method: "S256",
39
+ ...(pkce.redirectUri === null ? {} : { redirect_uri: pkce.redirectUri }),
40
+ ...deviceField(device),
41
+ }),
34
42
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
35
43
  // Never follow a redirect: a 307/308 would replay this claim flow —
36
44
  // and, on the poll below, the poll secret — to whatever origin a
@@ -119,6 +127,47 @@ export async function pollClaimRequest(baseUrl, credentials) {
119
127
  return { kind: "transient" };
120
128
  }
121
129
  }
130
+ const CODE_REJECTED = "That sign-in code didn't work. It may have been mistyped, used\nalready, or expired.";
131
+ export async function tradeCode(baseUrl, trade) {
132
+ let response;
133
+ try {
134
+ response = await fetch(`${baseUrl}/app-tokens/claim-requests/token`, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({
138
+ claim_request_id: trade.claimRequestId,
139
+ code: trade.code,
140
+ code_verifier: trade.codeVerifier,
141
+ }),
142
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
143
+ // The verifier and code must never follow a redirect to another origin.
144
+ redirect: "error",
145
+ });
146
+ }
147
+ catch {
148
+ return { kind: "transient" };
149
+ }
150
+ const body = await readJsonBody(response);
151
+ if (response.status === 200) {
152
+ const appToken = stringField(asRecord(body), "app_token");
153
+ if (appToken === null) {
154
+ throw new CliError(SIGN_IN_FINISH_FAILURE, 1);
155
+ }
156
+ return { kind: "approved", appToken };
157
+ }
158
+ // The CLI says what to do next itself (paste again, or start over), so a
159
+ // refused code reads in its own words.
160
+ // 422 too: a paste Driggsby reads as blank (a Unicode space the CLI
161
+ // doesn't trim) is a wrong code, never the end of the sign-in.
162
+ if (response.status === 400 || response.status === 422) {
163
+ return { kind: "rejected", message: CODE_REJECTED };
164
+ }
165
+ if (response.status === 429 || response.status >= 500) {
166
+ return { kind: "transient" };
167
+ }
168
+ // Any other answer means our request is wrong, which a retry can't fix.
169
+ throw new CliError(errorDescription(body) ?? SIGN_IN_FINISH_FAILURE, 1);
170
+ }
122
171
  async function readJsonBody(response) {
123
172
  let text;
124
173
  try {
@@ -1,42 +1,151 @@
1
- // driggsby login: create an app-token claim, send the user to the approval
2
- // page, poll until they approve, and store the token. The token is shown to
3
- // no one — it goes straight from the poll response into the credential
4
- // store.
1
+ // driggsby login: sign in with PKCE. The CLI keeps a verifier, listens on
2
+ // its loopback when it can, and opens Driggsby's approval page itself.
3
+ // Approving there hands the one-time code to this CLI's loopback;
4
+ // approving from any other browser shows the code, to paste here or to
5
+ // finish with `driggsby login --code <CODE>`. Only the code and the
6
+ // verifier together mint the token, which goes straight into the
7
+ // credential store and is shown to no one.
5
8
  import { apiBaseUrl, apiHost, baseUrlMayReceiveSavedSignIn } from "../api/base-url.js";
6
9
  import { blockedNetworkError } from "../api/network-error.js";
7
10
  import { CliError } from "../cli-error.js";
8
11
  import { describeDevice } from "../device.js";
9
12
  import { defaultCredentialEnvironment, describeStorageLocation, environmentToken, saveToken, } from "../credentials/store.js";
10
13
  import { sanitizeForTerminal, wrapProse } from "../terminal-text.js";
11
- import { createClaimRequest, pollClaimRequest, SIGN_IN_START_FAILURE, } from "./claim-client.js";
14
+ import { awaitToken, ClaimGone, CODE_COMMAND, CODE_WINDOW_MS, LOGIN_RETRY_COMMAND, tradeWithRetries, } from "./await-token.js";
15
+ import { createClaimRequest, SIGN_IN_START_FAILURE } from "./claim-client.js";
16
+ import { startLoopback } from "./loopback.js";
12
17
  import { tryOpenUrl } from "./open-url.js";
13
- const LOGIN_RETRY_COMMAND = "npx driggsby@latest login";
18
+ import { clearPendingLogin, markPendingLoginFinished, readPendingLogin, savePendingLogin, } from "./pending.js";
19
+ import { createPkcePair } from "./pkce.js";
20
+ import { abortableSleep } from "./sleep.js";
21
+ import { terminalCodePrompt } from "./terminal-prompt.js";
14
22
  const DEFAULT_POLL_INTERVAL_MS = 4_000;
23
+ // How long a waiting login gives `login --code` to mark a spent claim.
24
+ export const FINISH_MARK_WAIT_MS = 3_000;
25
+ // Where a browser opened "here" may really run on another computer (an SSH
26
+ // session, a cloud dev box forwarding its opener), so its approval could
27
+ // never reach this loopback: the page shows the code to paste instead.
28
+ const REMOTE_SESSION_VARIABLES = ["SSH_CONNECTION", "SSH_TTY", "CODESPACES", "GITPOD_WORKSPACE_ID"];
15
29
  function defaultLoginIo() {
16
30
  return {
17
31
  out: (text) => {
18
32
  process.stdout.write(text);
19
33
  },
20
34
  openUrl: tryOpenUrl,
21
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
35
+ sleep: abortableSleep,
22
36
  now: () => Date.now(),
23
37
  pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
38
+ startLoopback,
39
+ codePrompt: terminalCodePrompt,
24
40
  };
25
41
  }
26
42
  export async function runLogin(environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
27
43
  const baseUrl = apiBaseUrl(environment.env);
28
44
  io.out("Sign in to Driggsby\n\n");
29
- const claim = await createClaim(baseUrl);
30
- const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
31
- const browserOpened = await io.openUrl(claimUrl);
32
- io.out(browserOpened
33
- ? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
34
- : "Open this link in your browser to approve access for this machine:\n\n");
35
- io.out(` ${claimUrl}\n\n`);
36
- const minutes = approximateMinutes(claim.expiresInSeconds);
37
- const minutesWord = minutes === 1 ? "minute" : "minutes";
38
- io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
39
- const appToken = await pollUntilApproved(baseUrl, claim, io);
45
+ const pkce = createPkcePair();
46
+ const remoteSession = REMOTE_SESSION_VARIABLES.some((name) => (environment.env[name] ?? "") !== "");
47
+ const loopback = remoteSession ? null : await io.startLoopback();
48
+ let prompt = null;
49
+ try {
50
+ const claim = await createClaim(baseUrl, { challenge: pkce.challenge, redirectUri: loopback?.redirectUri ?? null });
51
+ const claimUrl = approvedClaimUrl(claim.claimUrl, baseUrl);
52
+ const expiresAt = io.now() + claim.expiresInSeconds * 1_000;
53
+ const pending = {
54
+ baseUrl,
55
+ claimRequestId: claim.claimRequestId,
56
+ codeVerifier: pkce.verifier,
57
+ expiresAt: expiresAt + CODE_WINDOW_MS,
58
+ finished: false,
59
+ };
60
+ const remembered = await rememberPendingLogin(environment, pending);
61
+ prompt = io.codePrompt();
62
+ // With no one at a prompt and no loopback, only `login --code` can
63
+ // finish, and it needs the record: refuse before a page opens that
64
+ // nothing could finish.
65
+ if (prompt === null && loopback === null && !remembered) {
66
+ throw cantFinishHere();
67
+ }
68
+ // Only a page this CLI opened itself may hand its code to the
69
+ // loopback; the printed link, opened anywhere, shows the code instead.
70
+ const browserOpened = await io.openUrl(loopback === null ? claimUrl : handoffUrl(claimUrl));
71
+ const minutes = approximateMinutes(claim.expiresInSeconds);
72
+ const minutesWord = minutes === 1 ? "minute" : "minutes";
73
+ // No one at a prompt, and no page this CLI opened can hand the code to
74
+ // the loopback: an agent, or a remote session. Nothing in this process
75
+ // could ever receive the code, so it doesn't wait: the page shows the
76
+ // code, and `login --code` finishes with the record.
77
+ const finishesElsewhere = prompt === null && (loopback === null || !browserOpened);
78
+ if (finishesElsewhere && !remembered) {
79
+ throw cantFinishHere();
80
+ }
81
+ io.out(browserOpened
82
+ ? "Your browser should open a Driggsby approval page. If it doesn't, open\nthis link:\n\n"
83
+ : "Open this link in your browser to approve access for this machine:\n\n");
84
+ io.out(` ${claimUrl}\n\n`);
85
+ if (finishesElsewhere) {
86
+ io.out(`After you approve, the page shows a code. Finish signing in with:\n ${CODE_COMMAND}\n\n`);
87
+ io.out(`The link is good for about ${minutes} ${minutesWord}.\n`);
88
+ return;
89
+ }
90
+ io.out(`Waiting for your approval (the link is good for about ${minutes} ${minutesWord})...\n`);
91
+ if (prompt === null && remembered) {
92
+ io.out(`If the page shows a code instead, finish with:\n ${CODE_COMMAND}\n`);
93
+ }
94
+ const finish = await awaitTokenOrElsewhere(pending, awaitToken({
95
+ baseUrl,
96
+ claimRequestId: claim.claimRequestId,
97
+ pollSecret: claim.pollSecret,
98
+ codeVerifier: pkce.verifier,
99
+ deadline: expiresAt,
100
+ landingUrl: claimUrl,
101
+ loopback,
102
+ prompt,
103
+ promptQuestion: "If the page shows a code instead, paste it here: ",
104
+ }, io), remembered, environment, io);
105
+ prompt?.close();
106
+ prompt = null;
107
+ // The claim is spent either way; only this sign-in's own record goes.
108
+ await forgetPendingLogin(environment, pending, io);
109
+ if (finish.kind === "elsewhere") {
110
+ io.out("\nThis sign-in was finished by npx driggsby@latest login --code in\n" +
111
+ "another command; its output says where the token was saved.\n");
112
+ return;
113
+ }
114
+ await finishSignIn(finish.appToken, environment, io);
115
+ }
116
+ finally {
117
+ prompt?.close();
118
+ loopback?.close();
119
+ }
120
+ }
121
+ // driggsby login --code <CODE>: finish the sign-in waiting on this computer
122
+ // with the code its approval page showed.
123
+ export async function runLoginWithCode(code, environment = defaultCredentialEnvironment(), io = defaultLoginIo()) {
124
+ const baseUrl = apiBaseUrl(environment.env);
125
+ const pending = await readPendingLogin(environment.homeDirectory, io.now());
126
+ if (pending?.baseUrl !== baseUrl) {
127
+ throw new CliError("No sign-in is waiting for a code on this computer. It may have expired.\n\n" +
128
+ `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
129
+ }
130
+ if (pending.finished) {
131
+ throw new CliError(`This sign-in already finished.\n\nTo sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
132
+ }
133
+ const result = await tradeWithRetries(pending, code.trim(), io);
134
+ if (result.kind === "unreachable") {
135
+ throw new CliError(`${result.message}\n\nYour code may still work: run the same command again in a minute.\n` +
136
+ `Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
137
+ }
138
+ if (result.kind === "rejected") {
139
+ // One record per computer: a newer `login` replaced an older one's.
140
+ throw new CliError(`${result.message}\n\nCheck the code and run the command again. Only the code from the latest\n` +
141
+ `sign-in started on this computer works here. Or start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
142
+ }
143
+ // Marked once the claim is spent, before the token is saved: a login
144
+ // still waiting on this claim sees it finished here, not failed.
145
+ await markFinished(environment, pending, io);
146
+ await finishSignIn(result.appToken, environment, io);
147
+ }
148
+ async function finishSignIn(appToken, environment, io) {
40
149
  // Warnings are held back and printed after the "Approved." line — and when
41
150
  // one fires, it replaces the all-clear closer: the last thing the user
42
151
  // reads must never contradict a warning they were just shown.
@@ -53,6 +162,81 @@ export async function runLogin(environment = defaultCredentialEnvironment(), io
53
162
  }
54
163
  io.out("\nNext:\n You're all set — driggsby commands will use this saved token\n automatically. Deploy an app with npx driggsby@latest deploy.\n");
55
164
  }
165
+ // The record `login --code` finishes from. Best effort: without it, the
166
+ // loopback and the prompt still work. True once it is saved.
167
+ async function rememberPendingLogin(environment, pending) {
168
+ try {
169
+ await savePendingLogin(environment.homeDirectory, pending);
170
+ return true;
171
+ }
172
+ catch {
173
+ // The credential save reports an unwritable home directory itself.
174
+ return false;
175
+ }
176
+ }
177
+ // The wait's token, or "elsewhere" when `login --code` in another command
178
+ // finished this very sign-in meanwhile: the claim reads gone, and this
179
+ // claim's record says finished. Any other failure also ends this sign-in
180
+ // for good, so its record goes with it.
181
+ async function awaitTokenOrElsewhere(pending, wait, remembered, environment, io) {
182
+ try {
183
+ return { kind: "token", appToken: await wait };
184
+ }
185
+ catch (error) {
186
+ if (remembered && error instanceof ClaimGone && (await finishedElsewhere(environment, pending, io))) {
187
+ return { kind: "elsewhere" };
188
+ }
189
+ await forgetPendingLogin(environment, pending, io);
190
+ throw error;
191
+ }
192
+ }
193
+ // `login --code` marks the record just after its trade spends the claim,
194
+ // so a poll can read gone a moment before the mark lands: a record still
195
+ // this claim's but not yet finished is read once more after a pause.
196
+ async function finishedElsewhere(environment, pending, io) {
197
+ for (let look = 1;; look += 1) {
198
+ let current;
199
+ try {
200
+ current = await readPendingLogin(environment.homeDirectory, io.now());
201
+ }
202
+ catch {
203
+ return false;
204
+ }
205
+ if (current?.claimRequestId !== pending.claimRequestId) {
206
+ return false;
207
+ }
208
+ if (current.finished || look >= 2) {
209
+ return current.finished;
210
+ }
211
+ await io.sleep(FINISH_MARK_WAIT_MS);
212
+ }
213
+ }
214
+ async function forgetPendingLogin(environment, pending, io) {
215
+ try {
216
+ await clearPendingLogin(environment.homeDirectory, pending.claimRequestId, io.now());
217
+ }
218
+ catch {
219
+ // It expires on its own.
220
+ }
221
+ }
222
+ async function markFinished(environment, pending, io) {
223
+ try {
224
+ await markPendingLoginFinished(environment.homeDirectory, pending.claimRequestId, io.now());
225
+ }
226
+ catch {
227
+ // Only a login still waiting on this claim reads it, and it then
228
+ // reports the claim gone: the token below is saved all the same.
229
+ }
230
+ }
231
+ function cantFinishHere() {
232
+ return new CliError("We couldn't save this sign-in on this machine to finish it later, so it\n" +
233
+ `can't finish here. Run it in a terminal instead:\n ${LOGIN_RETRY_COMMAND}`, 1);
234
+ }
235
+ function handoffUrl(claimUrl) {
236
+ const url = new URL(claimUrl);
237
+ url.searchParams.set("handoff", "loopback");
238
+ return url.href;
239
+ }
56
240
  // The one-time token is already consumed by the time saving starts, so a
57
241
  // storage failure must say clearly that a fresh sign-in is needed — never
58
242
  // the generic "something went wrong".
@@ -68,12 +252,12 @@ async function storeApprovedToken(appToken, environment, warn) {
68
252
  `on this machine. Please sign in again:\n ${LOGIN_RETRY_COMMAND}`, 1);
69
253
  }
70
254
  }
71
- async function createClaim(baseUrl) {
255
+ async function createClaim(baseUrl, pkce) {
72
256
  try {
73
257
  // This computer's name goes only to Driggsby (or a local run), never
74
258
  // to another host a base-URL override names.
75
259
  const device = baseUrlMayReceiveSavedSignIn(baseUrl) ? describeDevice() : { name: null, system: null };
76
- return await createClaimRequest(baseUrl, device);
260
+ return await createClaimRequest(baseUrl, pkce, device);
77
261
  }
78
262
  catch (error) {
79
263
  const blocked = blockedNetworkError(error, {
@@ -112,27 +296,6 @@ function approvedClaimUrl(rawClaimUrl, baseUrl) {
112
296
  }
113
297
  return sanitizeForTerminal(parsed.href);
114
298
  }
115
- async function pollUntilApproved(baseUrl, claim, io) {
116
- const deadline = io.now() + claim.expiresInSeconds * 1_000;
117
- const credentials = { claimRequestId: claim.claimRequestId, pollSecret: claim.pollSecret };
118
- while (io.now() < deadline) {
119
- await io.sleep(io.pollIntervalMs);
120
- const result = await pollClaimRequest(baseUrl, credentials);
121
- switch (result.kind) {
122
- case "approved":
123
- // The token is delivered exactly once; from here on, never poll again.
124
- return result.appToken;
125
- case "gone":
126
- throw new CliError("This sign-in link is no longer active — it may have been declined or timed out.\n\n" +
127
- `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
128
- case "pending":
129
- case "transient":
130
- break;
131
- }
132
- }
133
- throw new CliError("The sign-in link expired before it was approved.\n\n" +
134
- `Start a fresh sign-in:\n ${LOGIN_RETRY_COMMAND}`, 1);
135
- }
136
299
  // DRIGGSBY_TOKEN outranks every saved token, so a login that saved one has
137
300
  // not actually changed which token commands use. The warning joins the same
138
301
  // held-back channel as storage warnings: after the "Approved." line, in
@@ -0,0 +1,153 @@
1
+ // The loopback listener for driggsby login (RFC 8252). When this CLI opens
2
+ // Driggsby's approval page itself, approving there sends the browser to
3
+ // http://127.0.0.1:<port>/callback with the one-time code (or
4
+ // error=access_denied). A browser on any other computer lands on its own
5
+ // loopback, where nothing listens, so the code reaches only this machine.
6
+ //
7
+ // Any page in any browser on this computer can also reach the loopback, so
8
+ // a callback proves nothing by itself: the caller trades each code (only
9
+ // the approval's real code works with this CLI's verifier) and confirms a
10
+ // denial with Driggsby before believing it. Each callback's browser waits
11
+ // until the caller answers it: a real one goes back to the Driggsby page
12
+ // that says how the sign-in went, and a refused one learns nothing, not
13
+ // even where that page is. Once the sign-in has ended, a callback taken
14
+ // before the listener closes goes back to that page, which by then can only
15
+ // say it is over; one still held when it closes is told it can close the
16
+ // tab.
17
+ import { createServer } from "node:http";
18
+ const HOST = "127.0.0.1";
19
+ const CALLBACK_PATH = "/callback";
20
+ // Driggsby's codes are four groups of five; anything else is not one.
21
+ const CODE_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
22
+ // A browser is never left hanging if a callback goes unanswered.
23
+ const HELD_RESPONSE_MS = 30_000;
24
+ // Callbacks waiting for the caller; more than this is someone knocking.
25
+ const MAX_QUEUED = 32;
26
+ const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" };
27
+ // null when this environment can't listen on the loopback (a sandbox that
28
+ // forbids it): the approval page then shows the code to paste instead.
29
+ export function startLoopback() {
30
+ return new Promise((resolve) => {
31
+ const queued = [];
32
+ const waiting = [];
33
+ // Each waiting browser, with the timer that answers it if no one does.
34
+ const held = new Map();
35
+ let port = 0;
36
+ const server = createServer((request, response) => {
37
+ const parsed = readCallback(request, port);
38
+ if (parsed === null || queued.length >= MAX_QUEUED) {
39
+ response.writeHead(404, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
40
+ response.end("Not found\n");
41
+ return;
42
+ }
43
+ const settle = (landingUrl, refused) => {
44
+ clearTimeout(held.get(response));
45
+ held.delete(response);
46
+ answer(response, landingUrl, refused);
47
+ };
48
+ held.set(response, setTimeout(() => {
49
+ settle(null, false);
50
+ }, HELD_RESPONSE_MS));
51
+ const reply = {
52
+ answer: (landingUrl) => {
53
+ settle(landingUrl, false);
54
+ },
55
+ refuse: () => {
56
+ settle(null, true);
57
+ },
58
+ };
59
+ const callback = parsed.kind === "code" ? { kind: "code", code: parsed.code, ...reply } : { kind: "denied", ...reply };
60
+ const taker = waiting.shift();
61
+ if (taker === undefined) {
62
+ queued.push(callback);
63
+ }
64
+ else {
65
+ taker(callback);
66
+ }
67
+ });
68
+ let stopped = false;
69
+ const stop = () => {
70
+ if (stopped) {
71
+ return;
72
+ }
73
+ stopped = true;
74
+ for (const [response, timer] of held) {
75
+ clearTimeout(timer);
76
+ answer(response, null, false);
77
+ }
78
+ held.clear();
79
+ server.close();
80
+ server.closeAllConnections();
81
+ };
82
+ // Before listening, an error means this CLI can't listen here; after,
83
+ // any error (an accept failure, say) stops the listener rather than
84
+ // the command.
85
+ let listening = false;
86
+ server.on("error", () => {
87
+ if (listening) {
88
+ stop();
89
+ }
90
+ else {
91
+ resolve(null);
92
+ }
93
+ });
94
+ server.listen(0, HOST, () => {
95
+ const address = server.address();
96
+ if (address === null || typeof address === "string") {
97
+ stop();
98
+ resolve(null);
99
+ return;
100
+ }
101
+ port = address.port;
102
+ listening = true;
103
+ resolve({
104
+ redirectUri: `http://${HOST}:${port}${CALLBACK_PATH}`,
105
+ next: () => {
106
+ const ready = queued.shift();
107
+ return ready === undefined ? new Promise((take) => waiting.push(take)) : Promise.resolve(ready);
108
+ },
109
+ close: stop,
110
+ });
111
+ });
112
+ });
113
+ }
114
+ // Only a GET for /callback, addressed to this very listener (a Host header
115
+ // naming anything else is a DNS-rebinding page, not Driggsby's redirect),
116
+ // carrying a code or Driggsby's denial. Everything else is ignored.
117
+ function readCallback(request, port) {
118
+ if (request.method !== "GET" || request.headers.host !== `${HOST}:${port}`) {
119
+ return null;
120
+ }
121
+ let url;
122
+ try {
123
+ url = new URL(request.url ?? "", `http://${HOST}:${port}`);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ if (url.pathname !== CALLBACK_PATH) {
129
+ return null;
130
+ }
131
+ const code = url.searchParams.get("code");
132
+ if (code !== null && CODE_PATTERN.test(code)) {
133
+ return { kind: "code", code };
134
+ }
135
+ return url.searchParams.get("error") === "access_denied" ? { kind: "denied" } : null;
136
+ }
137
+ function answer(response, landingUrl, refused) {
138
+ if (response.writableEnded) {
139
+ return;
140
+ }
141
+ if (refused) {
142
+ response.writeHead(400, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
143
+ response.end("This sign-in callback wasn't accepted.\n");
144
+ return;
145
+ }
146
+ if (landingUrl === null) {
147
+ response.writeHead(200, { ...HEADERS, "Content-Type": "text/plain; charset=utf-8" });
148
+ response.end("You can close this tab and go back to your terminal.\n");
149
+ return;
150
+ }
151
+ response.writeHead(303, { ...HEADERS, Location: landingUrl });
152
+ response.end();
153
+ }
@@ -0,0 +1,68 @@
1
+ // The sign-in waiting for its code, kept at ~/.driggsby/pending-login.json
2
+ // (owner-only, like the saved token) so `driggsby login --code <CODE>` can
3
+ // finish it from another command: an agent without a terminal prompt runs
4
+ // login, the person approves in a browser elsewhere, and the agent finishes
5
+ // with the code the page showed. The verifier alone mints nothing, and a
6
+ // record is ignored once it expires.
7
+ //
8
+ // Only the command that started a sign-in removes its record. `login
9
+ // --code` marks it finished instead, so a login still waiting on the same
10
+ // claim can tell the sign-in finished there rather than failed.
11
+ import { readFile, rm } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { driggsbyDirectory, writeOwnerOnlyFile } from "../owner-only-file.js";
14
+ const PENDING_FILE_NAME = "pending-login.json";
15
+ export async function savePendingLogin(homeDirectory, pending) {
16
+ const body = {
17
+ base_url: pending.baseUrl,
18
+ claim_request_id: pending.claimRequestId,
19
+ code_verifier: pending.codeVerifier,
20
+ expires_at: pending.expiresAt,
21
+ finished: pending.finished,
22
+ };
23
+ await writeOwnerOnlyFile(homeDirectory, PENDING_FILE_NAME, `${JSON.stringify(body, null, 2)}\n`);
24
+ }
25
+ // null when nothing is waiting, the record is unreadable, or it expired.
26
+ export async function readPendingLogin(homeDirectory, now) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(await readFile(pendingPath(homeDirectory), "utf8"));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null) {
35
+ return null;
36
+ }
37
+ const record = parsed;
38
+ const { base_url: baseUrl, claim_request_id: claimRequestId, code_verifier: codeVerifier } = record;
39
+ const expiresAt = record.expires_at;
40
+ if (typeof baseUrl !== "string" ||
41
+ typeof claimRequestId !== "string" ||
42
+ typeof codeVerifier !== "string" ||
43
+ typeof expiresAt !== "number" ||
44
+ expiresAt <= now) {
45
+ return null;
46
+ }
47
+ return { baseUrl, claimRequestId, codeVerifier, expiresAt, finished: record.finished === true };
48
+ }
49
+ // Removes the record only while it is still this claim's: a newer sign-in
50
+ // may have replaced it.
51
+ export async function clearPendingLogin(homeDirectory, claimRequestId, now) {
52
+ const current = await readPendingLogin(homeDirectory, now);
53
+ if (current === null || current.claimRequestId === claimRequestId) {
54
+ await rm(pendingPath(homeDirectory), { force: true });
55
+ }
56
+ }
57
+ // Marks the record finished only while it is still this claim's (a newer
58
+ // sign-in may have replaced it), and drops the spent verifier with it.
59
+ export async function markPendingLoginFinished(homeDirectory, claimRequestId, now) {
60
+ const current = await readPendingLogin(homeDirectory, now);
61
+ if (current?.claimRequestId !== claimRequestId) {
62
+ return;
63
+ }
64
+ await savePendingLogin(homeDirectory, { ...current, codeVerifier: "", finished: true });
65
+ }
66
+ function pendingPath(homeDirectory) {
67
+ return join(driggsbyDirectory(homeDirectory), PENDING_FILE_NAME);
68
+ }
@@ -0,0 +1,14 @@
1
+ // PKCE for driggsby login (RFC 7636, S256). The verifier never leaves this
2
+ // process except to trade the one-time code the approval page produced;
3
+ // Driggsby mints the token only for the code and the verifier together, so
4
+ // whoever else holds the approval link gets nothing from someone's approval.
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ export function createPkcePair() {
7
+ // 32 random bytes are 43 base64url characters: the RFC's minimum length
8
+ // and 256 bits of entropy.
9
+ const verifier = randomBytes(32).toString("base64url");
10
+ return { verifier, challenge: challengeFor(verifier) };
11
+ }
12
+ export function challengeFor(verifier) {
13
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
14
+ }
@@ -0,0 +1,21 @@
1
+ // A sleep that ends early, quietly, once its signal aborts, so a finished
2
+ // wait never holds the process open. Its abort listener goes when the
3
+ // timer fires too: a wait that sleeps every few seconds on one signal
4
+ // must not pile listeners onto it (Node warns past ten).
5
+ export function abortableSleep(ms, signal) {
6
+ return new Promise((resolve) => {
7
+ if (signal?.aborted === true) {
8
+ resolve();
9
+ return;
10
+ }
11
+ const onAbort = () => {
12
+ clearTimeout(timer);
13
+ resolve();
14
+ };
15
+ const timer = setTimeout(() => {
16
+ signal?.removeEventListener("abort", onAbort);
17
+ resolve();
18
+ }, ms);
19
+ signal?.addEventListener("abort", onAbort, { once: true });
20
+ });
21
+ }