packages/cli/src/commands/login.ts 8.3 K raw
1
import * as http from "node:http";
2
import { log, note, select, spinner, text } from "@clack/prompts";
3
import { command, flag, option, optional, string } from "cmd-ts";
4
import { resolveHandleToDid } from "../lib/atproto";
5
import {
6
	getCallbackPort,
7
	getOAuthClient,
8
	getOAuthScope,
9
} from "../lib/oauth-client";
10
import {
11
	deleteOAuthSession,
12
	getOAuthStorePath,
13
	listOAuthSessions,
14
	listOAuthSessionsWithHandles,
15
	setOAuthHandle,
16
} from "../lib/oauth-store";
17
import { exitOnCancel } from "../lib/prompts";
18
19
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
20
21
export const loginCommand = command({
22
	name: "login",
23
	description: "Login with OAuth (browser-based authentication)",
24
	args: {
25
		logout: option({
26
			long: "logout",
27
			description: "Remove OAuth session for a specific DID",
28
			type: optional(string),
29
		}),
30
		list: flag({
31
			long: "list",
32
			description: "List all stored OAuth sessions",
33
		}),
34
	},
35
	handler: async ({ logout, list }) => {
36
		// List sessions
37
		if (list) {
38
			const sessions = await listOAuthSessionsWithHandles();
39
			if (sessions.length === 0) {
40
				log.info("No OAuth sessions stored");
41
			} else {
42
				log.info("OAuth sessions:");
43
				for (const { did, handle } of sessions) {
44
					console.log(`  - ${handle || did} (${did})`);
45
				}
46
			}
47
			return;
48
		}
49
50
		// Logout
51
		if (logout !== undefined) {
52
			const did = logout || undefined;
53
54
			if (!did) {
55
				// No DID provided - show available and prompt
56
				const sessions = await listOAuthSessions();
57
				if (sessions.length === 0) {
58
					log.info("No OAuth sessions found");
59
					return;
60
				}
61
				if (sessions.length === 1) {
62
					const deleted = await deleteOAuthSession(sessions[0]!);
63
					if (deleted) {
64
						log.success(`Removed OAuth session for ${sessions[0]}`);
65
					}
66
					return;
67
				}
68
				// Multiple sessions - prompt
69
				const selected = exitOnCancel(
70
					await select({
71
						message: "Select session to remove:",
72
						options: sessions.map((d) => ({ value: d, label: d })),
73
					}),
74
				);
75
				const deleted = await deleteOAuthSession(selected);
76
				if (deleted) {
77
					log.success(`Removed OAuth session for ${selected}`);
78
				}
79
				return;
80
			}
81
82
			const deleted = await deleteOAuthSession(did);
83
			if (deleted) {
84
				log.success(`Removed OAuth session for ${did}`);
85
			} else {
86
				log.info(`No OAuth session found for ${did}`);
87
			}
88
			return;
89
		}
90
91
		// OAuth login flow
92
		note(
93
			"OAuth login will open your browser to authenticate.\n\n" +
94
				"This is more secure than app passwords and tokens refresh automatically.",
95
			"OAuth Login",
96
		);
97
98
		const handle = exitOnCancel(
99
			await text({
100
				message: "Handle or DID:",
101
				placeholder: "yourhandle.bsky.social",
102
			}),
103
		);
104
105
		if (!handle) {
106
			log.error("Handle is required");
107
			process.exit(1);
108
		}
109
110
		const s = spinner();
111
		s.start("Resolving identity...");
112
113
		let did: string;
114
		try {
115
			did = await resolveHandleToDid(handle);
116
			s.stop(`Identity resolved`);
117
		} catch (error) {
118
			s.stop("Failed to resolve identity");
119
			if (error instanceof Error) {
120
				log.error(`Error: ${error.message}`);
121
			} else {
122
				log.error(`Error: ${error}`);
123
			}
124
			process.exit(1);
125
		}
126
127
		s.start("Initializing OAuth...");
128
129
		try {
130
			const client = await getOAuthClient();
131
132
			// Generate authorization URL using the resolved DID
133
			const authUrl = await client.authorize(did, {
134
				scope: getOAuthScope(),
135
			});
136
137
			log.info(`Login URL: ${authUrl}`);
138
139
			s.message("Opening browser...");
140
141
			// Try to open browser
142
			let browserOpened = true;
143
			try {
144
				const open = (await import("open")).default;
145
				await open(authUrl.toString());
146
			} catch {
147
				browserOpened = false;
148
			}
149
150
			s.message("Waiting for authentication...");
151
152
			// Show URL info
153
			if (!browserOpened) {
154
				s.stop("Could not open browser automatically");
155
				log.warn("Please open the following URL in your browser:");
156
				log.info(authUrl.toString());
157
				s.start("Waiting for authentication...");
158
			}
159
160
			// Start HTTP server to receive callback
161
			const result = await waitForCallback();
162
163
			if (!result.success) {
164
				s.stop("Authentication failed");
165
				log.error(result.error || "OAuth callback failed");
166
				process.exit(1);
167
			}
168
169
			s.message("Completing authentication...");
170
171
			// Exchange code for tokens
172
			const { session } = await client.callback(
173
				new URLSearchParams(result.params!),
174
			);
175
176
			// Store the handle for friendly display
177
			// Use the original handle input (unless it was a DID)
178
			const handleToStore = handle.startsWith("did:") ? undefined : handle;
179
			if (handleToStore) {
180
				await setOAuthHandle(session.did, handleToStore);
181
			}
182
183
			// Try to get the handle for display (use the original handle input as fallback)
184
			const displayName = handleToStore || session.did;
185
186
			s.stop(`Logged in as ${displayName}`);
187
188
			log.success(`OAuth session saved to ${getOAuthStorePath()}`);
189
			log.info("Your session will refresh automatically when needed.");
190
191
			// Exit cleanly - the OAuth client may have background processes
192
			process.exit(0);
193
		} catch (error) {
194
			s.stop("OAuth login failed");
195
			if (error instanceof Error) {
196
				log.error(`Error: ${error.message}`);
197
			} else {
198
				log.error(`Error: ${error}`);
199
			}
200
			process.exit(1);
201
		}
202
	},
203
});
204
205
interface CallbackResult {
206
	success: boolean;
207
	params?: Record<string, string>;
208
	error?: string;
209
}
210
211
function waitForCallback(): Promise<CallbackResult> {
212
	return new Promise((resolve) => {
213
		const port = getCallbackPort();
214
		let timeoutId: ReturnType<typeof setTimeout> | undefined;
215
216
		const server = http.createServer((req, res) => {
217
			const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
218
219
			if (url.pathname === "/oauth/callback") {
220
				const params: Record<string, string> = {};
221
				url.searchParams.forEach((value, key) => {
222
					params[key] = value;
223
				});
224
225
				// Clear the timeout
226
				if (timeoutId) clearTimeout(timeoutId);
227
228
				// Check for error
229
				if (params.error) {
230
					res.writeHead(200, { "Content-Type": "text/html" });
231
					res.end(`
232
						<html>
233
							<head>
234
								<link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet">
235
							</head>
236
							<body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
237
					  <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" />
238
								<h1 style="font-weight: 400;">Authentication Failed</h1>
239
								<p>${params.error_description || params.error}</p>
240
								<p>You can close this window.</p>
241
							</body>
242
						</html>
243
					`);
244
					server.close(() => {
245
						resolve({
246
							success: false,
247
							error: params.error_description || params.error,
248
						});
249
					});
250
					return;
251
				}
252
253
				// Success
254
				res.writeHead(200, { "Content-Type": "text/html" });
255
				res.end(`
256
					<html>
257
						<head>
258
							<link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet">
259
						</head>
260
						<body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
261
						  <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" />
262
							<h1 style="font-weight: 400;">Authentication Successful</h1>
263
							<p>You can close this window and return to the terminal.</p>
264
						</body>
265
					</html>
266
				`);
267
				server.close(() => {
268
					resolve({ success: true, params });
269
				});
270
				return;
271
			}
272
273
			// Not the callback path
274
			res.writeHead(404);
275
			res.end("Not found");
276
		});
277
278
		server.on("error", (err: NodeJS.ErrnoException) => {
279
			if (timeoutId) clearTimeout(timeoutId);
280
			if (err.code === "EADDRINUSE") {
281
				resolve({
282
					success: false,
283
					error: `Port ${port} is already in use. Please close the application using that port and try again.`,
284
				});
285
			} else {
286
				resolve({
287
					success: false,
288
					error: `Server error: ${err.message}`,
289
				});
290
			}
291
		});
292
293
		server.listen(port, "127.0.0.1");
294
295
		// Timeout after 5 minutes
296
		timeoutId = setTimeout(() => {
297
			server.close(() => {
298
				resolve({
299
					success: false,
300
					error: "Timeout waiting for OAuth callback. Please try again.",
301
				});
302
			});
303
		}, CALLBACK_TIMEOUT_MS);
304
	});
305
}