mirror of
https://github.com/prdlk/x-bookmarks-rss.git
synced 2026-08-02 09:31:37 +00:00
feat(scripts): add scripts for minting and seeding X OAuth2 tokens
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
// One-time local OAuth2 PKCE helper. Mints a correctly-scoped X refresh token and
|
||||||
|
// seeds it (+ user id) into D1. Zero dependencies — Node 18+ built-ins only.
|
||||||
|
//
|
||||||
|
// Prereq: add this exact Callback URL to your X app's user-auth settings:
|
||||||
|
// http://127.0.0.1:8788/callback
|
||||||
|
//
|
||||||
|
// Run: node scripts/mint-token.mjs (reads X_CLIENT_ID/SECRET from env or .dev.vars.example)
|
||||||
|
import http from "node:http";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url);
|
||||||
|
const envText = (() => {
|
||||||
|
try { return readFileSync(new URL(".dev.vars.example", root), "utf8"); } catch { return ""; }
|
||||||
|
})();
|
||||||
|
const cfg = (k) => process.env[k] ?? envText.match(new RegExp(`^${k}="?([^"\\n]+)"?`, "m"))?.[1];
|
||||||
|
|
||||||
|
const CLIENT_ID = cfg("X_CLIENT_ID");
|
||||||
|
const CLIENT_SECRET = cfg("X_CLIENT_SECRET");
|
||||||
|
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||||
|
console.error("Missing X_CLIENT_ID / X_CLIENT_SECRET (env or .dev.vars.example).");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = 8788;
|
||||||
|
const REDIRECT = `http://127.0.0.1:${PORT}/callback`;
|
||||||
|
const SCOPES = "tweet.read users.read bookmark.read offline.access";
|
||||||
|
|
||||||
|
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
const verifier = b64url(crypto.randomBytes(32));
|
||||||
|
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
||||||
|
const state = b64url(crypto.randomBytes(16));
|
||||||
|
|
||||||
|
const authUrl =
|
||||||
|
"https://twitter.com/i/oauth2/authorize?" +
|
||||||
|
new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT,
|
||||||
|
scope: SCOPES,
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(new URL(".authorize_url.txt", root), authUrl);
|
||||||
|
console.log(`\nCallback URL to register on your X app:\n ${REDIRECT}\n`);
|
||||||
|
console.log(`Open this URL and Authorize:\n ${authUrl}\n`);
|
||||||
|
|
||||||
|
const done = (code) => setTimeout(() => { server.close(); process.exit(code); }, 300);
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const u = new URL(req.url, REDIRECT);
|
||||||
|
if (u.pathname !== "/callback") { res.writeHead(404); res.end(); return; }
|
||||||
|
const code = u.searchParams.get("code");
|
||||||
|
if (u.searchParams.get("state") !== state || !code) { res.writeHead(400); res.end("bad state/code"); return; }
|
||||||
|
try {
|
||||||
|
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
|
||||||
|
const tokRes = await fetch("https://api.x.com/2/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` },
|
||||||
|
body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT_ID }),
|
||||||
|
});
|
||||||
|
const tok = await tokRes.json();
|
||||||
|
if (!tok.access_token || !tok.refresh_token) throw new Error("token exchange failed: " + JSON.stringify(tok));
|
||||||
|
if (!String(tok.scope || "").includes("bookmark.read")) throw new Error("scope missing bookmark.read: " + tok.scope);
|
||||||
|
|
||||||
|
const me = await (await fetch("https://api.x.com/2/users/me", { headers: { Authorization: `Bearer ${tok.access_token}` } })).json();
|
||||||
|
const userId = me?.data?.id;
|
||||||
|
if (!userId) throw new Error("/users/me failed: " + JSON.stringify(me));
|
||||||
|
|
||||||
|
const sql =
|
||||||
|
`INSERT INTO meta (key,value) VALUES ('refresh_token','${tok.refresh_token}') ON CONFLICT(key) DO UPDATE SET value=excluded.value;` +
|
||||||
|
`INSERT INTO meta (key,value) VALUES ('user_id','${userId}') ON CONFLICT(key) DO UPDATE SET value=excluded.value;` +
|
||||||
|
`DELETE FROM meta WHERE key='last_sync';`;
|
||||||
|
execFileSync("npx", ["wrangler", "d1", "execute", "x-bookmarks-db", "--remote", "--command", sql], { stdio: "inherit", cwd: new URL(root).pathname });
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" });
|
||||||
|
res.end(`<h1>Authorized ✅</h1><p>scope: ${tok.scope}</p><p>Seeded into D1 — close this tab.</p>`);
|
||||||
|
console.log(`\n✅ scope: ${tok.scope}\n✅ user id: ${userId}\n✅ seeded refresh_token + user_id into D1.`);
|
||||||
|
done(0);
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(500); res.end(String(e?.message || e));
|
||||||
|
console.error("\n❌ " + (e?.message || e));
|
||||||
|
done(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(PORT, "127.0.0.1", () => console.log(`Waiting for authorization on ${REDIRECT} ...`));
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Seed a fresh X OAuth2 refresh token into D1. Usage: scripts/seed-token.sh <REFRESH_TOKEN>
|
||||||
|
# Refreshing rotates the token, so we capture the ROTATED one and store that — never the input.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
IN_RT="${1:?usage: seed-token.sh <REFRESH_TOKEN>}"
|
||||||
|
CID="${X_CLIENT_ID:?set X_CLIENT_ID}"
|
||||||
|
CSEC="${X_CLIENT_SECRET:?set X_CLIENT_SECRET}"
|
||||||
|
DB="x-bookmarks-db"
|
||||||
|
|
||||||
|
echo "== refreshing (validates token + rotates it) =="
|
||||||
|
RESP=$(curl -sS -X POST https://api.x.com/2/oauth2/token -u "$CID:$CSEC" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
--data-urlencode "grant_type=refresh_token" \
|
||||||
|
--data-urlencode "refresh_token=$IN_RT" \
|
||||||
|
--data-urlencode "client_id=$CID")
|
||||||
|
|
||||||
|
AT=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
|
||||||
|
RT=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
|
||||||
|
SCOPE=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("scope",""))')
|
||||||
|
|
||||||
|
if [ -z "$AT" ] || [ -z "$RT" ]; then
|
||||||
|
echo "!! refresh failed: $RESP"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Back up the rotated token immediately — single-use, must survive any later check.
|
||||||
|
printf '%s' "$RT" > ./.rotated_refresh_token.txt
|
||||||
|
echo "(rotated token backed up to .rotated_refresh_token.txt)"
|
||||||
|
|
||||||
|
echo "scope: $SCOPE"
|
||||||
|
case "$SCOPE" in *bookmark.read*) ;; *) echo "!! token lacks bookmark.read scope, aborting"; exit 1;; esac
|
||||||
|
|
||||||
|
echo "== resolving user id =="
|
||||||
|
USERID=$(curl -sS https://api.x.com/2/users/me -H "Authorization: Bearer $AT" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("data",{}).get("id",""))')
|
||||||
|
[ -n "$USERID" ] || { echo "!! could not resolve user id"; exit 1; }
|
||||||
|
echo "user id: $USERID"
|
||||||
|
|
||||||
|
echo "== seeding D1 meta =="
|
||||||
|
SQL="INSERT INTO meta (key,value) VALUES ('refresh_token','$RT') ON CONFLICT(key) DO UPDATE SET value=excluded.value;
|
||||||
|
INSERT INTO meta (key,value) VALUES ('user_id','$USERID') ON CONFLICT(key) DO UPDATE SET value=excluded.value;
|
||||||
|
DELETE FROM meta WHERE key='last_sync';"
|
||||||
|
npx wrangler d1 execute "$DB" --remote --command "$SQL" >/dev/null
|
||||||
|
echo "== done: refresh_token + user_id seeded, last_sync cleared =="
|
||||||
Reference in New Issue
Block a user