195 lines
6.0 KiB
JavaScript
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");
|
|
}
|