Monorepo for Aesthetic.Computer aesthetic.computer
at main 644 lines 20 kB view raw
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}