Monorepo for Aesthetic.Computer
aesthetic.computer
1import {
2 authentication,
3 AuthenticationProvider,
4 AuthenticationProviderAuthenticationSessionsChangeEvent,
5 AuthenticationSession,
6 Disposable,
7 env,
8 EventEmitter,
9 ExtensionContext,
10 ProgressLocation,
11 Uri,
12 UriHandler,
13 window as win,
14} from "vscode";
15import { PromiseAdapter, promiseFromEvent } from "./util";
16
17// Isomorphic use of web `crypto` api across desktop (node) and
18// a web worker (vscode.dev).
19let icrypto: any;
20if (typeof self === "undefined") {
21 icrypto = require("crypto").webcrypto;
22} else {
23 icrypto = crypto;
24}
25
26let remoteOutput = win.createOutputChannel("aesthetic");
27
28interface TokenInformation {
29 access_token: string;
30 refresh_token: string;
31}
32
33interface AestheticAuthenticationSession extends AuthenticationSession {
34 refreshToken: string;
35}
36
37class UriEventHandler extends EventEmitter<Uri> implements UriHandler {
38 public handleUri(uri: Uri) {
39 this.fire(uri);
40 }
41}
42
43const uriHandler = new UriEventHandler();
44win.registerUriHandler(uriHandler);
45
46export class AestheticAuthenticationProvider
47 implements AuthenticationProvider, Disposable
48{
49 private _sessionChangeEmitter =
50 new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();
51 private _disposable: Disposable;
52 private _pendingStates: string[] = [];
53 private _codeExchangePromises = new Map<
54 string,
55 { promise: Promise<TokenInformation>; cancel: EventEmitter<void> }
56 >();
57 private _codeVerfifiers = new Map<string, string>();
58 private _scopes = new Map<string, string[]>();
59 private env = {
60 AUTH_TYPE: "",
61 AUTH_NAME: "",
62 CLIENT_ID: "",
63 AUTH0_DOMAIN: "",
64 REDIRECT_URL: "",
65 SESSIONS_SECRET_KEY: "",
66 };
67
68 private static looksLikeEmail(value: string | undefined): boolean {
69 if (!value) return false;
70 // Rough heuristic: `@` + a dot in the domain part.
71 const at = value.indexOf("@");
72 if (at <= 0) return false;
73 const domain = value.slice(at + 1);
74 return domain.includes(".") && !value.startsWith("@");
75 }
76
77 private async getTenantHandleBySub(sub: string): Promise<string | undefined> {
78 if (!sub) return undefined;
79
80 // Fetch handle using the same method as chat.mjs
81 const prefix = this.env.AUTH_TYPE === "sotce" ? "sotce-" : "";
82 const host = this.env.AUTH_TYPE === "sotce" ? "https://sotce.net" : "https://aesthetic.computer";
83 const url = `${host}/handle?for=${prefix}${sub}`;
84
85 try {
86 const response = await fetch(url);
87 if (!response.ok) return undefined;
88 const data = (await response.json()) as { handle?: string };
89 return data.handle ? data.handle.trim() : undefined;
90 } catch (error) {
91 console.error("Failed to fetch handle:", error);
92 return undefined;
93 }
94 }
95
96 private async getTenantHandleByEmail(email: string): Promise<string | undefined> {
97 if (!email) return undefined;
98
99 // This is a Netlify function on the public site.
100 const host = this.env.AUTH_TYPE === "sotce" ? "https://sotce.net" : "https://aesthetic.computer";
101 const url = `${host}/user?from=${encodeURIComponent(email)}&withHandle=true&tenant=${encodeURIComponent(this.env.AUTH_TYPE)}`;
102
103 const response = await fetch(url);
104 if (!response.ok) return undefined;
105 const data = (await response.json()) as { handle?: unknown };
106 return typeof data.handle === "string" && data.handle.trim() ? data.handle.trim() : undefined;
107 }
108
109 private async resolveAccountLabel(accessToken: string): Promise<string | undefined> {
110 const userinfo = (await this.getUserInfo(accessToken)) as {
111 name?: string;
112 email?: string;
113 nickname?: string;
114 handle?: string;
115 };
116
117 const directHandle = typeof userinfo.handle === "string" ? userinfo.handle : undefined;
118 const email = typeof userinfo.email === "string" ? userinfo.email : undefined;
119
120 const tenantHandle = email
121 ? await this.getTenantHandleByEmail(email).catch(() => undefined)
122 : undefined;
123
124 const handle = tenantHandle || directHandle;
125 if (handle) return handle.startsWith("@") ? handle : `@${handle}`;
126
127 return userinfo.nickname || userinfo.name || userinfo.email;
128 }
129
130 constructor(
131 private readonly context: ExtensionContext,
132 local: boolean,
133 tenant: string,
134 ) {
135 if (tenant === "aesthetic") {
136 this.env.AUTH_TYPE = `aesthetic`;
137 this.env.AUTH_NAME = `Aesthetic Computer`;
138 this.env.CLIENT_ID = `LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt`;
139 this.env.AUTH0_DOMAIN = `hi.aesthetic.computer`;
140 this.env.REDIRECT_URL = `https://${
141 local ? "localhost:8888" : "aesthetic.computer"
142 }/redirect-proxy`;
143 } else if (tenant === "sotce") {
144 this.env.AUTH_TYPE = `sotce`;
145 this.env.AUTH_NAME = `Sotce Net`;
146 this.env.CLIENT_ID = `3SvAbUDFLIFZCc1lV7e4fAAGKWXwl2B0`;
147 this.env.AUTH0_DOMAIN = `hi.sotce.net`;
148 this.env.REDIRECT_URL = `https://${
149 local ? "localhost:8888" : "sotce.net"
150 }/redirect-proxy-sotce`;
151 }
152
153 this.env.SESSIONS_SECRET_KEY = `${this.env.AUTH_TYPE}.sessions`;
154
155 // console.log("Authentication provider registering...", AUTH_TYPE, AUTH_NAME);
156
157 this._disposable = Disposable.from(
158 authentication.registerAuthenticationProvider(
159 this.env.AUTH_TYPE,
160 this.env.AUTH_NAME,
161 this,
162 { supportsMultipleAccounts: false },
163 ),
164 );
165 }
166
167 get onDidChangeSessions() {
168 return this._sessionChangeEmitter.event;
169 }
170
171 get redirectUri() {
172 const publisher = this.context.extension.packageJSON.publisher;
173 const name = this.context.extension.packageJSON.name;
174
175 let callbackUrl = `${env.uriScheme}://${publisher}.${name}`;
176 return callbackUrl;
177 }
178
179 /**
180 * Get the existing sessions
181 * @param scopes
182 * @returns
183 */
184 public async getSessions(
185 scopes?: string[],
186 ): Promise<readonly AestheticAuthenticationSession[]> {
187 try {
188 const allSessions = await this.context.secrets.get(
189 this.env.SESSIONS_SECRET_KEY,
190 );
191 if (!allSessions) {
192 return [];
193 }
194
195 // Get all required scopes
196 const allScopes = this.getScopes(scopes || []) as string[];
197
198 const sessions = JSON.parse(
199 allSessions,
200 ) as AestheticAuthenticationSession[];
201 if (sessions) {
202 // Best-effort migration: older sessions often used email as the label.
203 // Upgrade to @handle without forcing a logout.
204 const changedSessions: AestheticAuthenticationSession[] = [];
205 const upgradedSessions = await Promise.all(
206 sessions.map(async (session) => {
207 const currentLabel = session.account?.label;
208 if (!AestheticAuthenticationProvider.looksLikeEmail(currentLabel)) return session;
209
210 try {
211 let accessToken = session.accessToken;
212 let label: string | undefined;
213
214 try {
215 label = await this.resolveAccountLabel(accessToken);
216 } catch {
217 // If the access token is stale, try to refresh and retry once.
218 if (session.refreshToken) {
219 const refreshed = await this.getAccessToken(
220 session.refreshToken,
221 this.env.CLIENT_ID,
222 );
223 if (refreshed.access_token) {
224 accessToken = refreshed.access_token;
225 label = await this.resolveAccountLabel(accessToken);
226 }
227 }
228 }
229
230 if (!label || label === currentLabel) return session;
231 const updated = {
232 ...session,
233 accessToken,
234 account: {
235 ...session.account,
236 label,
237 },
238 } as AestheticAuthenticationSession;
239 changedSessions.push(updated);
240 return updated;
241 } catch {
242 return session;
243 }
244 }),
245 );
246
247 if (changedSessions.length) {
248 await this.context.secrets.store(
249 this.env.SESSIONS_SECRET_KEY,
250 JSON.stringify(upgradedSessions),
251 );
252 this._sessionChangeEmitter.fire({
253 added: [],
254 removed: [],
255 changed: changedSessions,
256 });
257 }
258
259 if (allScopes && scopes) {
260 const session = upgradedSessions.find((s) =>
261 scopes.every((scope) => s.scopes.includes(scope)),
262 );
263 if (session && session.refreshToken) {
264 const refreshToken = session.refreshToken;
265 const { access_token } = await this.getAccessToken(
266 refreshToken,
267 this.env.CLIENT_ID,
268 );
269
270 if (access_token) {
271 const updatedSession = Object.assign({}, session, {
272 accessToken: access_token,
273 scopes: scopes,
274 });
275 return [updatedSession];
276 } else {
277 this.removeSession(session.id);
278 }
279 }
280 } else {
281 return upgradedSessions;
282 }
283 }
284 } catch (e) {
285 // Nothing to do
286 }
287
288 return [];
289 }
290
291 /**
292 * Create a new auth session
293 * @param scopes
294 * @returns
295 */
296 public async createSession(
297 scopes: string[],
298 ): Promise<AestheticAuthenticationSession> {
299 try {
300 const { access_token, refresh_token } = await this.login(scopes);
301 if (!access_token) {
302 throw new Error(`${this.env.AUTH_NAME} login failure`);
303 }
304
305 const userinfo: { name: string; email: string; sub: string; nickname?: string; handle?: string } =
306 await this.getUserInfo(access_token);
307
308 const tenantHandle = userinfo.sub
309 ? await this.getTenantHandleBySub(userinfo.sub).catch(() => undefined)
310 : undefined;
311 const handle = tenantHandle || userinfo.handle;
312 const label =
313 (handle ? (handle.startsWith("@") ? handle : `@${handle}`) : undefined) ||
314 userinfo.nickname ||
315 userinfo.name ||
316 userinfo.email;
317
318 const session: AestheticAuthenticationSession = {
319 id: generateRandomString(12),
320 accessToken: access_token,
321 refreshToken: refresh_token,
322 account: {
323 label,
324 id: userinfo.sub,
325 },
326 scopes: this.getScopes(scopes),
327 };
328
329 await this.context.secrets.store(
330 this.env.SESSIONS_SECRET_KEY,
331 JSON.stringify([session]),
332 );
333
334 this._sessionChangeEmitter.fire({
335 added: [session],
336 removed: [],
337 changed: [],
338 });
339
340 return session;
341 } catch (e) {
342 win.showErrorMessage(`🔴 Log in failed: ${e}`);
343 throw e;
344 }
345 }
346
347 /**
348 * Remove an existing session
349 * @param sessionId
350 */
351 public async removeSession(sessionId: string): Promise<void> {
352 const allSessions = await this.context.secrets.get(
353 this.env.SESSIONS_SECRET_KEY,
354 );
355 if (allSessions) {
356 let sessions = JSON.parse(allSessions) as AuthenticationSession[];
357 const sessionIdx = sessions.findIndex((s) => s.id === sessionId);
358 const session = sessions[sessionIdx];
359 sessions.splice(sessionIdx, 1);
360
361 await this.context.secrets.store(
362 this.env.SESSIONS_SECRET_KEY,
363 JSON.stringify(sessions),
364 );
365
366 if (session) {
367 this._sessionChangeEmitter.fire({
368 added: [],
369 removed: [session],
370 changed: [],
371 });
372 }
373 }
374 }
375
376 /**
377 * Dispose the registered services
378 */
379 public async dispose() {
380 this._disposable.dispose();
381 }
382
383 /**
384 * Log in to Aesthetic Computer
385 */
386 private async login(scopes: string[] = []): Promise<TokenInformation> {
387 return await win.withProgress<TokenInformation>(
388 {
389 location: ProgressLocation.Notification,
390 title: `🟡 Logging in to ${this.env.AUTH_NAME}...`,
391 cancellable: true,
392 },
393 async (_, token) => {
394 const nonceId = generateRandomString(12);
395
396 const scopeString = scopes.join(" ");
397
398 // Retrieve all required scopes
399 scopes = this.getScopes(scopes);
400
401 const codeVerifier = generateRandomString(32);
402 const codeChallenge = await sha256(codeVerifier);
403
404 let callbackUri = await env.asExternalUri(Uri.parse(this.redirectUri));
405
406 remoteOutput.appendLine(`Callback URI: ${callbackUri.toString(true)}`);
407
408 const callbackQuery = new URLSearchParams(callbackUri.query);
409 const stateId = callbackQuery.get("state") || nonceId;
410
411 remoteOutput.appendLine(`State ID: ${stateId}`);
412 remoteOutput.appendLine(`Nonce ID: ${nonceId}`);
413
414 callbackQuery.set("state", encodeURIComponent(stateId));
415 callbackQuery.set("nonce", encodeURIComponent(nonceId));
416 callbackUri = callbackUri.with({
417 query: callbackQuery.toString(),
418 });
419
420 this._pendingStates.push(stateId);
421 this._codeVerfifiers.set(stateId, codeVerifier);
422 this._scopes.set(stateId, scopes);
423
424 const searchParams = new URLSearchParams([
425 ["response_type", "code"],
426 ["client_id", this.env.CLIENT_ID],
427 ["redirect_uri", this.env.REDIRECT_URL],
428 ["state", encodeURIComponent(callbackUri.toString(true))],
429 ["scope", scopes.join(" ")],
430 ["prompt", "login"],
431 ["code_challenge_method", "S256"],
432 ["code_challenge", codeChallenge],
433 ]);
434 const uri = Uri.parse(
435 `https://${
436 this.env.AUTH0_DOMAIN
437 }/authorize?${searchParams.toString()}`,
438 );
439
440 remoteOutput.appendLine(`Login URI: ${uri.toString(true)}`);
441
442 await env.openExternal(uri);
443
444 let codeExchangePromise = this._codeExchangePromises.get(scopeString);
445 if (!codeExchangePromise) {
446 codeExchangePromise = promiseFromEvent(
447 uriHandler.event,
448 this.handleUri(scopes),
449 );
450 this._codeExchangePromises.set(scopeString, codeExchangePromise);
451 }
452
453 try {
454 return await Promise.race([
455 codeExchangePromise.promise,
456 new Promise<string>((_, reject) =>
457 setTimeout(() => reject("Cancelled"), 60000),
458 ),
459 promiseFromEvent<any, any>(
460 token.onCancellationRequested,
461 (_, __, reject) => {
462 reject("User Cancelled");
463 },
464 ).promise,
465 ]);
466 } finally {
467 this._pendingStates = this._pendingStates.filter(
468 (n) => n !== stateId,
469 );
470 codeExchangePromise?.cancel.fire();
471 this._codeExchangePromises.delete(scopeString);
472 this._codeVerfifiers.delete(stateId);
473 this._scopes.delete(stateId);
474 }
475 },
476 );
477 }
478
479 /**
480 * Handle the redirect to VS Code (after sign in from Aesthetic Computer)
481 * @param scopes
482 * @returns
483 */
484 private handleUri: (
485 scopes: readonly string[],
486 ) => PromiseAdapter<Uri, TokenInformation> =
487 (scopes) => async (uri, resolve, reject) => {
488 const query = new URLSearchParams(uri.query);
489 const code = query.get("code");
490 const stateId = query.get("state");
491
492 if (!code) {
493 reject(new Error("No code"));
494 return;
495 }
496 if (!stateId) {
497 reject(new Error("No state"));
498 return;
499 }
500
501 const codeVerifier = this._codeVerfifiers.get(stateId);
502 if (!codeVerifier) {
503 reject(new Error("No code verifier"));
504 return;
505 }
506
507 // Check if it is a valid auth request started by the extension
508 if (!this._pendingStates.some((n) => n === stateId)) {
509 reject(new Error("State not found"));
510 return;
511 }
512
513 const postData = new URLSearchParams({
514 grant_type: "authorization_code",
515 client_id: this.env.CLIENT_ID,
516 code,
517 code_verifier: codeVerifier,
518 redirect_uri: this.env.REDIRECT_URL,
519 }).toString();
520
521 const response = await fetch(
522 `https://${this.env.AUTH0_DOMAIN}/oauth/token`,
523 {
524 method: "POST",
525 headers: {
526 "Content-Type": "application/x-www-form-urlencoded",
527 "Content-Length": postData.length.toString(),
528 },
529 body: postData,
530 },
531 );
532
533 const { access_token, refresh_token } = await response.json();
534
535 resolve({
536 access_token,
537 refresh_token,
538 });
539 };
540
541 /**
542 * Get the user info from Aesthetic Computer
543 * @param token
544 * @returns
545 */
546 private async getUserInfo(token: string) {
547 const response = await fetch(`https://${this.env.AUTH0_DOMAIN}/userinfo`, {
548 headers: {
549 Authorization: `Bearer ${token}`,
550 },
551 });
552 return await response.json();
553 }
554
555 /**
556 * Get all required scopes
557 * @param scopes
558 */
559 private getScopes(scopes: string[] = []): string[] {
560 let modifiedScopes = [...scopes];
561
562 if (!modifiedScopes.includes("offline_access")) {
563 modifiedScopes.push("offline_access");
564 }
565 if (!modifiedScopes.includes("openid")) {
566 modifiedScopes.push("openid");
567 }
568 if (!modifiedScopes.includes("profile")) {
569 modifiedScopes.push("profile");
570 }
571 if (!modifiedScopes.includes("email")) {
572 modifiedScopes.push("email");
573 }
574
575 return modifiedScopes.sort();
576 }
577
578 /**
579 * Retrieve a new access token by the refresh token
580 * @param refreshToken
581 * @param clientId
582 * @returns
583 */
584 private async getAccessToken(
585 refreshToken: string,
586 clientId: string,
587 ): Promise<TokenInformation> {
588 const postData = new URLSearchParams({
589 grant_type: "refresh_token",
590 client_id: clientId,
591 refresh_token: refreshToken,
592 }).toString();
593
594 const response = await fetch(
595 `https://${this.env.AUTH0_DOMAIN}/oauth/token`,
596 {
597 method: "POST",
598 headers: {
599 "Content-Type": "application/x-www-form-urlencoded",
600 "Content-Length": postData.length.toString(),
601 },
602 body: postData,
603 },
604 );
605
606 const { access_token } = await response.json();
607
608 return { access_token, refresh_token: "" };
609 }
610}
611
612// 📚 Library
613
614function generateRandomString(n: number): string {
615 const buffer = new Uint8Array(n);
616 icrypto.getRandomValues(buffer);
617 return toBase64UrlEncoding(buffer);
618}
619
620export function toBase64UrlEncoding(buffer: Uint8Array): string {
621 let base64String;
622
623 if (typeof Buffer !== "undefined") {
624 // Node.js environment
625 base64String = Buffer.from(buffer).toString("base64");
626 } else {
627 // Browser environment
628 const binaryString = Array.from(buffer)
629 .map((byte) => {
630 return String.fromCharCode(byte);
631 })
632 .join("");
633 base64String = btoa(binaryString);
634 }
635
636 return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
637}
638
639export async function sha256(buffer: string | Uint8Array): Promise<string> {
640 const data =
641 typeof buffer === "string" ? new TextEncoder().encode(buffer) : buffer;
642 const hashBuffer = await icrypto.subtle.digest("SHA-256", data);
643 return toBase64UrlEncoding(new Uint8Array(hashBuffer));
644}