// Scripted bsky.social OAuth sign-in for the app's loopback client. // // Adapted from socialweb scripts/oauth-signin.ts for a direct PDS authorize // URL (the page/CLI script expects to return to its own origin after consent; // a native loopback client returns to http://127.0.0.1: instead). // // Reads the credential file itself. Never prints the handle, password, // authorization URL, or its query; screenshots go to a 0700 directory. import puppeteer from "npm:puppeteer-core@23"; import { parseArgs } from "jsr:@std/cli@1/parse-args"; const args = parseArgs(Deno.args, { string: ["creds", "account", "url", "chrome", "out"], }); if (!args.creds || !args.account || !args.url) { console.error("sim-oauth: --creds, --account and --url are required"); Deno.exit(2); } const vars: Record = {}; for (const line of (await Deno.readTextFile(args.creds)).split("\n")) { const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)=(.*)$/); if (match) vars[match[1]] = match[2].replace(/^['"]|['"]$/g, ""); } const handle = vars[`${args.account}_ATPROTO_HANDLE`]; const password = vars[`${args.account}_ATPROTO_PASSWORD`]; if (!handle || !password) { console.error(`sim-oauth: ${args.account}_ATPROTO_HANDLE/PASSWORD missing in the creds file`); Deno.exit(2); } const out = args.out ?? `${Deno.env.get("HOME")}/.tmp/oauth-signin/${new Date().toISOString().replace(/[:.]/g, "")}`; await Deno.mkdir(out, { recursive: true, mode: 0o700 }); const authOrigin = new URL(args.url).origin; const browser = await puppeteer.launch({ executablePath: args.chrome ?? "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const page = await browser.newPage(); page.setDefaultTimeout(60_000); const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function step(name: string) { await page.screenshot({ path: `${out}/${name}.png` }); console.log(JSON.stringify({ step: name })); } async function clickButton(pattern: string): Promise { return await page.evaluate((source: string) => { const want = new RegExp(source, "i"); const button = [...document.querySelectorAll("button, a[role=button], input[type=submit]")] .find((candidate) => want.test( ((candidate as HTMLElement).innerText || (candidate as HTMLInputElement).value || "").trim(), )); if (button) { (button as HTMLElement).click(); return true; } return false; }, pattern); } try { await page.goto(args.url, { waitUntil: "networkidle2" }); await step("start"); // The authorize page asks for the handle first when no session exists. const handleInput = await page.$( '#handle, input[name="handle"], input[placeholder*="handle" i], input[autocomplete="username"]', ); if (handleInput) { await handleInput.type(handle); await page.keyboard.press("Enter"); await page.waitForNavigation({ waitUntil: "networkidle2" }).catch(() => {}); await step("handle"); } let passwordInput = await page.$('input[type="password"]'); if (!passwordInput) { // Some skins show a "Sign in" step before the password field. if (await clickButton("^(sign in|log ?in|continue|next)$")) { await pause(2000); passwordInput = await page.waitForSelector('input[type="password"]', { visible: true }); } } if (!passwordInput) throw new Error("no password field on the authorization server"); await passwordInput.type(password); await page.keyboard.press("Enter"); await pause(3000); await step("password"); // Consent: click through until the browser leaves the authorization server // for the app's loopback listener. for (let attempt = 0; attempt < 10; attempt++) { if (new URL(page.url()).origin !== authOrigin) break; const clicked = await clickButton("^(accept|authorize|allow|continue|approve)$"); await pause(clicked ? 2000 : 1000); await step(`consent-${attempt}`); } if (new URL(page.url()).origin === authOrigin) { throw new Error("still on the authorization server after consent"); } console.log(JSON.stringify({ ok: true })); await browser.close(); Deno.exit(0); } catch (error) { await step("error").catch(() => {}); console.error(JSON.stringify({ ok: false, error: String(error) })); await browser.close(); Deno.exit(1); }