Googstr/public/goostr.js
Jason Tudisco 21093eb800 Initial commit: Goostr static site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:37:10 -06:00

195 lines
6.0 KiB
JavaScript

// Goostr — Google-account-to-nostr login library.
//
// Flow:
// signIn({ clientId })
// → opens Google sign-in popup
// → requests OAuth scopes: profile, email, drive.appdata
// → looks in Drive App Data for goostr-key-v1.json
// · if found, downloads the 32-byte seed
// · if missing, generates 32 fresh bytes and uploads them
// → returns { signer, profile } where profile is the Google name/picture/email
//
// The 32-byte seed is the nostr private key. It lives in:
// 1. The user's Drive App Data — survives device loss, syncs across devices.
// 2. sessionStorage for the current tab — survives reload, dies on tab close.
// It is never written to localStorage, IndexedDB, or any other disk-persistent
// store on the local device.
import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools";
import {
findAppDataFile,
downloadFile,
uploadAppDataJson,
fetchUserInfo,
} from "./drive.js";
const KEY_FILE = "goostr-key-v1.json";
const SCOPES = [
"https://www.googleapis.com/auth/drive.appdata",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"openid",
].join(" ");
// ---------- Google Identity Services token client ----------
let gisReady = null;
function ensureGisLoaded() {
if (gisReady) return gisReady;
gisReady = new Promise((resolve, reject) => {
if (window.google?.accounts?.oauth2) return resolve();
const s = document.createElement("script");
s.src = "https://accounts.google.com/gsi/client";
s.async = true;
s.defer = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error("Failed to load Google Identity Services script."));
document.head.appendChild(s);
});
return gisReady;
}
async function requestAccessToken(clientId, { silent = false } = {}) {
await ensureGisLoaded();
return new Promise((resolve, reject) => {
const client = google.accounts.oauth2.initTokenClient({
client_id: clientId,
scope: SCOPES,
prompt: silent ? "" : "consent",
callback: (resp) => {
if (resp.error) return reject(new Error(`Google auth: ${resp.error}`));
if (!resp.access_token) return reject(new Error("Google auth: no access token returned."));
resolve(resp.access_token);
},
error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)),
});
client.requestAccessToken();
});
}
// ---------- key bytes / signer ----------
function randomBytes(len) {
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return buf;
}
function b64encBytes(bytes) {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
function b64decBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function makeSigner(privKeyBytes) {
const pubkey = getPublicKey(privKeyBytes);
return {
pubkey,
npub: nip19.npubEncode(pubkey),
sign(unsignedEvent) {
return finalizeEvent(
{
...unsignedEvent,
pubkey,
created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000),
},
privKeyBytes
);
},
};
}
// ---------- session persistence (tab-scoped) ----------
const SESSION_KEY = "goostr.session.v1";
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling
function saveSession(privKeyBytes, profile) {
try {
sessionStorage.setItem(
SESSION_KEY,
JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() })
);
} catch {}
}
export function restoreSession() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
const { k, p, t } = JSON.parse(raw);
if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) {
sessionStorage.removeItem(SESSION_KEY);
return null;
}
return { signer: makeSigner(b64decBytes(k)), profile: p || {} };
} catch {
return null;
}
}
export function clearSession() {
try { sessionStorage.removeItem(SESSION_KEY); } catch {}
}
// ---------- key file on Drive ----------
async function loadOrCreateSeed(accessToken, onStatus) {
onStatus?.("Looking for your key in Google Drive…");
const fileId = await findAppDataFile(accessToken, KEY_FILE);
if (fileId) {
onStatus?.("Found existing key. Unlocking your nostr identity…");
const text = await downloadFile(accessToken, fileId);
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = null; }
if (!parsed?.k) throw new Error("Stored key file is malformed.");
return { bytes: b64decBytes(parsed.k), created: false };
}
onStatus?.("First time on this Google account — generating a new identity…");
const bytes = randomBytes(32);
const payload = JSON.stringify({
v: 1,
k: b64encBytes(bytes),
created_at: new Date().toISOString(),
});
await uploadAppDataJson(accessToken, KEY_FILE, payload, null);
return { bytes, created: true };
}
// ---------- public API ----------
export async function signIn({ clientId, silent = false, onStatus } = {}) {
if (!clientId || clientId.startsWith("PASTE-")) {
throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md.");
}
onStatus?.("Signing in with Google…");
const accessToken = await requestAccessToken(clientId, { silent });
onStatus?.("Reading your Google profile…");
const userInfo = await fetchUserInfo(accessToken);
const profile = {
sub: userInfo.sub,
name: userInfo.name || userInfo.email || "Anonymous",
picture: userInfo.picture || null,
email: userInfo.email || null,
};
const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus);
saveSession(bytes, profile);
return { signer: makeSigner(bytes), profile, isNewIdentity: created };
}
export function isConfigured(clientId) {
return !!clientId && !clientId.startsWith("PASTE-") && clientId.endsWith(".apps.googleusercontent.com");
}