1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// 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:<port> 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<string, string> = {};
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<boolean> {
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);
}