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