diff --git a/docs/centralized-onramps.md b/docs/centralized-onramps.md
index 2a6f772..3537987 100644
--- a/docs/centralized-onramps.md
+++ b/docs/centralized-onramps.md
@@ -1,7 +1,10 @@
# Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody
-**Document version:** 0.2
-**Status:** The **widget side of §CO5 is implemented** (2026-08-04):
+**Document version:** 0.3
+**Status:** **§CO3.3 (serverless Google, provider storage) is implemented
+end to end** — it needs no service, so nothing remains to build for it
+beyond a host registering an OAuth client ID. The **widget side of §CO5 is
+implemented** (2026-08-04):
`onramp-url` / `onramp-name` / `onramp-providers` attributes, the popup
handshake below, "Continue with " rows under the "Use an account
you already have" group (§CO11.1 resolved: that heading covers the whole
@@ -90,6 +93,49 @@ NIP-46 support (§LM5) is the whole client.
compatibility answer for services that already exist. Not the recommended
destination.
+## CO3.3 Architecture C — provider storage: no server anywhere (implemented)
+
+**Owner decision (2026-08-04): static only is the way.** Architecture B still
+assumes *someone* runs the unlock service. For providers that expose
+user-owned storage to pure browser clients, even that party disappears: the
+credential of a standard capsule account lives in **the user's own cloud
+storage**, readable only through the host's OAuth client ID. Google
+authenticates the user; Google stores the user's own secret; the static
+widget does everything else. Nobody else exists to trust, pay, or lose.
+
+Implemented for Google (`onramp-google-client-id` attribute):
+
+1. The "Continue with Google" row opens a popup on Google's OAuth endpoint —
+ plain implicit grant, `response_type=token`, no external script, so a
+ strict `script-src 'self'` CSP survives. Scope is `drive.appdata` only:
+ no identity claims, no email, minimum disclosure.
+2. The registered redirect URI is **the widget page itself** — no callback
+ artifact to deploy. The widget instance in the popup recognizes the token
+ fragment, relays it to the opener (same-origin, exact state nonce),
+ scrubs the URL, and closes.
+3. The opener reads `bitlogin-credential-v1.json` from the Drive app-data
+ folder: present → ordinary password login (§16); absent → first-time
+ setup (fresh identity or §SF10 import), client-side registration, then
+ the credential is uploaded. An upload failure triggers the no-orphan
+ rule: the phrase ceremony runs immediately.
+
+Threat model: Google-account compromise = password compromise, exactly the
+spec's §14 analysis; there is no second party to compromise. The app-data
+file is plaintext by necessity — any encryption key would have to come from
+the same OAuth gate it would be protecting against. Deployment notes: the
+host registers its own OAuth client ID and the widget page's exact URL, and
+adds `https://www.googleapis.com` to `connect-src`. Known dependency: the
+implicit grant is legacy at Google; if it is ever sunset, the fallback is
+Google Identity Services, which requires relaxing the CSP for one external
+script — recorded here so that trade arrives as a decision, not a surprise.
+The same pattern extends to Dropbox and Microsoft OneDrive (PKCE browser
+flows with app-scoped storage); GitHub and Facebook offer no equivalent and
+remain Architecture A/B territory.
+
+Password rotation while Google-managed is the graduation step (CO6 step 2):
+the Drive copy simply goes stale and dead, and the rotate screen says so
+before the user commits.
+
## CO3.2 Architecture B — OAuth as the password manager (recommended)
The insight: a BitLogin account requires a high-entropy password, but the
diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html
index f260ade..7020066 100644
--- a/packages/demo/public/docs.html
+++ b/packages/demo/public/docs.html
@@ -162,12 +162,16 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one
surface, and routes every signing call to whichever the user picked. You don't need your own method picker,
and your app code doesn't branch by method.
-
Sites that want a "Continue with Google/GitHub" on-ramp can additionally point the widget at an OAuth
- unlock service (onramp-url, onramp-name, onramp-providers attributes —
- nothing centralized ships unless you configure it, and your CSP's connect-src must include the
- service's origin). The service manages the password of a real, portable Nostr account; users can bring an
- existing key at signup and are nudged toward claiming their recovery phrase over time. See
- docs/centralized-onramps.md in the repo for the handshake contract and threat model.
+
Sites that want a "Continue with Google" option can have it with no server anywhere:
+ set onramp-google-client-id to your own Google OAuth client ID, register this page's exact URL
+ as a redirect URI, and add https://www.googleapis.com to your CSP's connect-src.
+ The credential of a real, portable Nostr account is stored in the user's own Google Drive app data
+ — nobody else holds anything. Users can bring an existing Nostr key at signup and are nudged toward claiming
+ their recovery phrase over time; rotating the password later "graduates" the account to fully self-held.
+
For providers without browser-accessible storage (GitHub, Facebook, …) the widget can instead point at an
+ external OAuth unlock service (onramp-url, onramp-name, onramp-providers;
+ the service's origin goes in connect-src). Nothing centralized ships unless you configure it.
+ See docs/centralized-onramps.md in the repo for contracts and threat models.
Two different questions have two different APIs, and conflating them is the classic integration bug:
"Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod()
(or the richer activeSession()), or listen for bitlogin-login. This answers correctly
diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt
index 94c9858..386cc4b 100644
--- a/packages/demo/public/llms.txt
+++ b/packages/demo/public/llms.txt
@@ -28,10 +28,18 @@ deployment above.
- A `` custom element (Shadow DOM, no CSS collisions) handling
create account, sign in, forgot-password recovery via a 12-word phrase, and
password rotation.
-- Optional centralized on-ramps ("Continue with Google/GitHub/..."): a host
- may point the widget at an OAuth unlock service via `onramp-url`,
- `onramp-name`, and `onramp-providers` attributes -- nothing centralized
- ships by default. The service acts as the password manager for a REAL
+- Serverless "Continue with Google" (`onramp-google-client-id` attribute):
+ NO server anywhere -- the credential of a standard capsule account lives
+ in the user's own Google Drive app data, readable only through the host's
+ OAuth client ID. The host registers the widget page's exact URL as the
+ OAuth redirect URI (the page relays the token to its opener itself; no
+ callback artifact) and adds https://www.googleapis.com to connect-src.
+ Scope is drive.appdata only. Rotating the password is the graduation
+ step: the Drive copy goes stale and the user holds the credential.
+- Optional centralized on-ramps ("Continue with GitHub/Facebook/..." via an
+ external service): a host may point the widget at an OAuth unlock service
+ via `onramp-url`, `onramp-name`, and `onramp-providers` attributes --
+ nothing centralized ships by default. The service acts as the password manager for a REAL
capsule account (it holds the unlock secret, never the key); rows render
under "Use an account you already have" with the custody statement
(" manages your sign-in") as the sub-label, new users can create
diff --git a/packages/widget/src/config.ts b/packages/widget/src/config.ts
index e2a502c..ae5a749 100644
--- a/packages/widget/src/config.ts
+++ b/packages/widget/src/config.ts
@@ -1,13 +1,18 @@
/** Host-page-configurable options for (§19.1, §19.6). */
import { parseOnrampConfig, type OnrampConfig } from "./onramp.js";
+import { parseGoogleOnrampConfig, type GoogleOnrampConfig } from "./driveOnramp.js";
export interface BitLoginConfig {
vaultRelayUrls?: string[];
discoveryRelayUrls?: string[];
- /** Centralized on-ramp (centralized-onramps.md §CO5). Absent unless the
- * host explicitly configures one -- BitLogin ships no centralized
- * dependency by default. */
+ /** Centralized on-ramp via an external service (centralized-onramps.md
+ * §CO3.2/§CO5). Absent unless the host explicitly configures one --
+ * BitLogin ships no centralized dependency by default. */
onramp?: OnrampConfig;
+ /** Serverless Google on-ramp (§CO3.3): the credential lives in the user's
+ * own Google Drive app data; no third party exists. Enabled by the host
+ * registering an OAuth client ID. */
+ onrampGoogle?: GoogleOnrampConfig;
}
export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
@@ -20,6 +25,7 @@ export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
url: el.getAttribute("onramp-url"),
name: el.getAttribute("onramp-name"),
providers: el.getAttribute("onramp-providers")
- })
+ }),
+ onrampGoogle: parseGoogleOnrampConfig(el.getAttribute("onramp-google-client-id"))
};
}
diff --git a/packages/widget/src/driveOnramp.test.ts b/packages/widget/src/driveOnramp.test.ts
new file mode 100644
index 0000000..5c0ba5e
--- /dev/null
+++ b/packages/widget/src/driveOnramp.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildGoogleAuthUrl,
+ buildGoogleTokenMessage,
+ currentRedirectUri,
+ findDriveCredential,
+ newGoogleOnrampState,
+ parseGoogleCallbackFragment,
+ parseGoogleOnrampConfig,
+ storeDriveCredential,
+ validateGoogleTokenMessage
+} from "./driveOnramp.js";
+
+const CLIENT = { clientId: "abc123.apps.googleusercontent.com" };
+
+describe("config and auth URL", () => {
+ it("parses a client id and ignores blanks", () => {
+ expect(parseGoogleOnrampConfig(" abc.apps.googleusercontent.com ")).toEqual({
+ clientId: "abc.apps.googleusercontent.com"
+ });
+ expect(parseGoogleOnrampConfig(null)).toBeUndefined();
+ expect(parseGoogleOnrampConfig(" ")).toBeUndefined();
+ });
+
+ it("builds an implicit-grant URL with the app-data scope only", () => {
+ const url = new URL(buildGoogleAuthUrl(CLIENT, "https://site.example/login.html", "st4te"));
+ expect(url.origin + url.pathname).toBe("https://accounts.google.com/o/oauth2/v2/auth");
+ expect(url.searchParams.get("client_id")).toBe(CLIENT.clientId);
+ expect(url.searchParams.get("redirect_uri")).toBe("https://site.example/login.html");
+ expect(url.searchParams.get("response_type")).toBe("token");
+ expect(url.searchParams.get("scope")).toBe("https://www.googleapis.com/auth/drive.appdata");
+ expect(url.searchParams.get("state")).toBe("st4te");
+ });
+
+ it("derives the redirect URI from the page, stripping query and fragment", () => {
+ expect(currentRedirectUri({ origin: "https://site.example", pathname: "/account.html" })).toBe(
+ "https://site.example/account.html"
+ );
+ });
+
+ it("mints distinct states", () => {
+ expect(newGoogleOnrampState()).not.toBe(newGoogleOnrampState());
+ });
+});
+
+describe("callback fragment and relay message", () => {
+ it("parses a token callback and round-trips through the relay message", () => {
+ const parsed = parseGoogleCallbackFragment("#state=st4te&access_token=ya29.token&token_type=Bearer");
+ expect(parsed).toEqual({ state: "st4te", accessToken: "ya29.token" });
+ expect(buildGoogleTokenMessage(parsed!)).toEqual({
+ type: "bitlogin:onramp:google-token",
+ state: "st4te",
+ accessToken: "ya29.token"
+ });
+ });
+
+ it("parses an error callback", () => {
+ expect(parseGoogleCallbackFragment("#error=access_denied&state=st4te")).toEqual({
+ state: "st4te",
+ error: "access_denied"
+ });
+ });
+
+ it("returns null for ordinary fragments and callbacks without state", () => {
+ expect(parseGoogleCallbackFragment("")).toBeNull();
+ expect(parseGoogleCallbackFragment("#section-2")).toBeNull();
+ expect(parseGoogleCallbackFragment("#access_token=tok")).toBeNull();
+ });
+
+ it("validates the relay message against origin and state", () => {
+ const good = {
+ origin: "https://site.example",
+ data: { type: "bitlogin:onramp:google-token", state: "st4te", accessToken: "tok" }
+ };
+ const expected = { origin: "https://site.example", state: "st4te" };
+ expect(validateGoogleTokenMessage(good, expected)).toEqual({ accessToken: "tok" });
+ expect(
+ validateGoogleTokenMessage({ ...good, origin: "https://evil.example" }, expected)
+ ).toBeNull();
+ expect(
+ validateGoogleTokenMessage(
+ { origin: good.origin, data: { ...good.data, state: "other" } },
+ expected
+ )
+ ).toBeNull();
+ expect(
+ validateGoogleTokenMessage(
+ { origin: good.origin, data: { type: "bitlogin:onramp:google-token", state: "st4te", error: "access_denied" } },
+ expected
+ )
+ ).toEqual({ error: "access_denied" });
+ });
+});
+
+describe("Drive credential I/O", () => {
+ const CRED = { loginName: "sunny-otter", password: "corr-ect-hor-se" };
+
+ function jsonResponse(status: number, body?: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body)
+ } as unknown as Response;
+ }
+
+ it("finds and reads a returning user's credential", async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(200, { files: [{ id: "f1" }] }))
+ .mockResolvedValueOnce(jsonResponse(200, { v: 1, ...CRED }));
+ expect(await findDriveCredential("tok", fetchImpl as unknown as typeof fetch)).toEqual(CRED);
+ const [listUrl, listInit] = fetchImpl.mock.calls[0]!;
+ expect(String(listUrl)).toContain("https://www.googleapis.com/drive/v3/files?");
+ expect(String(listUrl)).toContain("appDataFolder");
+ expect((listInit as RequestInit).headers).toEqual({ authorization: "Bearer tok" });
+ expect(String(fetchImpl.mock.calls[1]![0])).toContain("/files/f1?alt=media");
+ });
+
+ it("resolves null for a first-time user", async () => {
+ const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse(200, { files: [] }));
+ expect(await findDriveCredential("tok", fetchImpl as unknown as typeof fetch)).toBeNull();
+ });
+
+ it("rejects on auth failure and on malformed stored data", async () => {
+ const unauthorized = vi.fn().mockResolvedValueOnce(jsonResponse(401));
+ await expect(findDriveCredential("tok", unauthorized as unknown as typeof fetch)).rejects.toThrow(
+ /did not accept/
+ );
+ const malformed = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse(200, { files: [{ id: "f1" }] }))
+ .mockResolvedValueOnce(jsonResponse(200, { v: 1, loginName: "x" }));
+ await expect(findDriveCredential("tok", malformed as unknown as typeof fetch)).rejects.toThrow(
+ /incomplete/
+ );
+ });
+
+ it("stores a new credential as multipart app-data upload", async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(200, {}));
+ await storeDriveCredential("tok", CRED, fetchImpl as unknown as typeof fetch);
+ const [url, init] = fetchImpl.mock.calls[0]!;
+ expect(String(url)).toBe("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart");
+ const request = init as RequestInit;
+ expect(request.method).toBe("POST");
+ const body = String(request.body);
+ expect(body).toContain('"appDataFolder"');
+ expect(body).toContain('"bitlogin-credential-v1.json"');
+ expect(body).toContain('"sunny-otter"');
+ expect((request.headers as Record)["content-type"]).toMatch(
+ /^multipart\/related; boundary=/u
+ );
+ });
+
+ it("throws a phrase-preserving error when the upload fails", async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(500));
+ await expect(
+ storeDriveCredential("tok", CRED, fetchImpl as unknown as typeof fetch)
+ ).rejects.toThrow(/recovery phrase/);
+ });
+});
diff --git a/packages/widget/src/driveOnramp.ts b/packages/widget/src/driveOnramp.ts
new file mode 100644
index 0000000..0f90a28
--- /dev/null
+++ b/packages/widget/src/driveOnramp.ts
@@ -0,0 +1,203 @@
+/** Serverless Google on-ramp (centralized-onramps.md §CO3.3) — pure logic.
+ *
+ * "Continue with Google" with NO third party and NO server anywhere: the
+ * credential of a standard BitLogin capsule account lives in the USER'S OWN
+ * Google Drive app-data folder, readable only through the host's OAuth client
+ * ID. Google authenticates the user; Google stores the user's own secret; the
+ * static widget does everything else.
+ *
+ * Flow: a popup navigates to Google's OAuth endpoint (plain implicit grant,
+ * `response_type=token` — no external script, so a strict `script-src 'self'`
+ * CSP survives). Google redirects back to the HOST'S OWN page; the widget
+ * instance in the popup finds the token fragment and posts it to the opener
+ * (same-origin, exact state nonce). The opener then talks to the Drive API
+ * directly: read the credential file and run the ordinary password login, or
+ * — for a first visit — register client-side and store the credential.
+ *
+ * Custody statement (§CO7): compromise of the Google account is compromise of
+ * the password (§14 of the spec analyzes exactly that); nobody else holds
+ * anything. Hosts must allow https://www.googleapis.com in connect-src and
+ * register the widget page's exact URL as an OAuth redirect URI.
+ */
+import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
+
+export const GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
+export const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
+export const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
+const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.appdata";
+const CREDENTIAL_FILE_NAME = "bitlogin-credential-v1.json";
+
+export interface GoogleOnrampConfig {
+ clientId: string;
+}
+
+export function parseGoogleOnrampConfig(clientId: string | null): GoogleOnrampConfig | undefined {
+ const trimmed = clientId?.trim();
+ if (!trimmed) return undefined;
+ return { clientId: trimmed };
+}
+
+export function newGoogleOnrampState(): string {
+ return bytesToHex(randomBytes(16));
+}
+
+/**
+ * The popup's destination. redirectUri must be the exact page URL the host
+ * registered with Google — by design the page embedding the widget itself,
+ * so no extra callback artifact needs deploying.
+ */
+export function buildGoogleAuthUrl(config: GoogleOnrampConfig, redirectUri: string, state: string): string {
+ const url = new URL(GOOGLE_AUTH_ENDPOINT);
+ url.searchParams.set("client_id", config.clientId);
+ url.searchParams.set("redirect_uri", redirectUri);
+ url.searchParams.set("response_type", "token");
+ url.searchParams.set("scope", DRIVE_SCOPE);
+ url.searchParams.set("state", state);
+ return url.toString();
+}
+
+/** The current page's URL with query and fragment stripped — what the host
+ * registers as the redirect URI, and what the popup is sent back to. */
+export function currentRedirectUri(location: { origin: string; pathname: string }): string {
+ return `${location.origin}${location.pathname}`;
+}
+
+/**
+ * Parses Google's redirect fragment in the popup. Returns null when the
+ * fragment is not an OAuth callback at all (the common case: every ordinary
+ * page load), a token on success, or the error Google reported.
+ */
+export function parseGoogleCallbackFragment(
+ hash: string
+): { state: string; accessToken: string } | { state: string; error: string } | null {
+ const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
+ if (!fragment) return null;
+ const params = new URLSearchParams(fragment);
+ const state = params.get("state");
+ if (!state) return null;
+ const error = params.get("error");
+ if (error) return { state, error };
+ const accessToken = params.get("access_token");
+ if (!accessToken) return null;
+ return { state, accessToken };
+}
+
+/** The popup -> opener relay frame. Same-origin by construction (the popup IS
+ * a page of this site); still validated against the exact state nonce. */
+export interface GoogleTokenMessage {
+ type: "bitlogin:onramp:google-token";
+ state: string;
+ accessToken?: string;
+ error?: string;
+}
+
+export function buildGoogleTokenMessage(
+ parsed: { state: string; accessToken: string } | { state: string; error: string }
+): GoogleTokenMessage {
+ return "accessToken" in parsed
+ ? { type: "bitlogin:onramp:google-token", state: parsed.state, accessToken: parsed.accessToken }
+ : { type: "bitlogin:onramp:google-token", state: parsed.state, error: parsed.error };
+}
+
+export function validateGoogleTokenMessage(
+ event: { origin: string; data: unknown },
+ expected: { origin: string; state: string }
+): { accessToken: string } | { error: string } | null {
+ if (event.origin !== expected.origin) return null;
+ const data = event.data as Partial | null | undefined;
+ if (!data || typeof data !== "object") return null;
+ if (data.type !== "bitlogin:onramp:google-token") return null;
+ if (data.state !== expected.state) return null;
+ if (typeof data.accessToken === "string" && data.accessToken) return { accessToken: data.accessToken };
+ if (typeof data.error === "string" && data.error) return { error: data.error };
+ return null;
+}
+
+export interface DriveCredential {
+ loginName: string;
+ password: string;
+}
+
+function authHeaders(accessToken: string): Record {
+ return { authorization: `Bearer ${accessToken}` };
+}
+
+async function requireOk(response: Response, doing: string): Promise {
+ if (response.status === 401 || response.status === 403) {
+ throw new Error(`Google did not accept the sign-in while ${doing} (HTTP ${response.status}). Try "Continue with Google" again.`);
+ }
+ if (!response.ok) {
+ throw new Error(`Google Drive failed while ${doing} (HTTP ${response.status}). Try again in a moment.`);
+ }
+ return response;
+}
+
+/**
+ * Looks up the stored credential in the user's app-data folder. Resolves the
+ * credential for a returning user, or null for a first-time user.
+ */
+export async function findDriveCredential(
+ accessToken: string,
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const query = new URLSearchParams({
+ spaces: "appDataFolder",
+ q: `name = '${CREDENTIAL_FILE_NAME}'`,
+ fields: "files(id)"
+ });
+ const listResponse = await requireOk(
+ await fetchImpl(`${GOOGLE_DRIVE_API}/files?${query.toString()}`, { headers: authHeaders(accessToken) }),
+ "looking up your account"
+ );
+ const listing = (await listResponse.json()) as { files?: Array<{ id?: string }> };
+ const fileId = listing.files?.[0]?.id;
+ if (!fileId) return null;
+
+ const readResponse = await requireOk(
+ await fetchImpl(`${GOOGLE_DRIVE_API}/files/${encodeURIComponent(fileId)}?alt=media`, {
+ headers: authHeaders(accessToken)
+ }),
+ "reading your sign-in"
+ );
+ let parsed: { v?: unknown; loginName?: unknown; password?: unknown };
+ try {
+ parsed = (await readResponse.json()) as typeof parsed;
+ } catch {
+ throw new Error("The sign-in data stored in your Google account is unreadable.");
+ }
+ if (typeof parsed.loginName !== "string" || !parsed.loginName || typeof parsed.password !== "string" || !parsed.password) {
+ throw new Error("The sign-in data stored in your Google account is incomplete.");
+ }
+ return { loginName: parsed.loginName, password: parsed.password };
+}
+
+/**
+ * Stores a freshly registered credential as the user's app-data file. Called
+ * once per registration; a failure means nobody is holding the credential, so
+ * the caller must force the phrase ceremony (the no-orphan rule, §CO5).
+ */
+export async function storeDriveCredential(
+ accessToken: string,
+ credential: DriveCredential,
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const boundary = `bitlogin-${bytesToHex(randomBytes(8))}`;
+ const metadata = JSON.stringify({ name: CREDENTIAL_FILE_NAME, parents: ["appDataFolder"] });
+ const content = JSON.stringify({ v: 1, loginName: credential.loginName, password: credential.password });
+ const body =
+ `--${boundary}\r\ncontent-type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
+ `--${boundary}\r\ncontent-type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
+ const response = await fetchImpl(`${GOOGLE_DRIVE_UPLOAD_API}/files?uploadType=multipart`, {
+ method: "POST",
+ headers: {
+ ...authHeaders(accessToken),
+ "content-type": `multipart/related; boundary=${boundary}`
+ },
+ body
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Google Drive could not save your sign-in (HTTP ${response.status}). Your account was created — save the recovery phrase shown next, and you can retry "Continue with Google" later.`
+ );
+ }
+}
diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts
index 0cd5462..3e202ee 100644
--- a/packages/widget/src/element.ts
+++ b/packages/widget/src/element.ts
@@ -28,6 +28,17 @@ import {
validateOnrampMessage,
type OnrampConfig
} from "./onramp.js";
+import {
+ buildGoogleAuthUrl,
+ buildGoogleTokenMessage,
+ currentRedirectUri,
+ findDriveCredential,
+ newGoogleOnrampState,
+ parseGoogleCallbackFragment,
+ storeDriveCredential,
+ validateGoogleTokenMessage,
+ type GoogleOnrampConfig
+} from "./driveOnramp.js";
import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
type Screen =
@@ -133,10 +144,22 @@ export class BitLoginAuthElement extends HTMLElement {
// in memory this session, the ceremony deferred behind a dashboard card.
private onramp: OnrampConfig | undefined;
private onrampAttempt: { state: string; provider: string; popup: Window | null } | null = null;
- private onrampRegistration: { registrationToken: string; provider: string; suggestedLoginName?: string } | null = null;
+ private onrampRegistration:
+ | { kind: "service"; registrationToken: string; provider: string; suggestedLoginName?: string }
+ | { kind: "google"; accessToken: string; provider: string }
+ | null = null;
private onrampSession = false;
+ /** Which rail manages the credential: an external service or the user's own
+ * Google Drive (§CO3.3). Drives the dashboard's custody copy. */
+ private onrampKind: "service" | "google" | null = null;
private securePhrasePending = false;
+
+ // Serverless Google on-ramp (§CO3.3): no third party -- the popup is this
+ // site's own page, which relays Google's token fragment back here.
+ private onrampGoogle: GoogleOnrampConfig | undefined;
+ private googleAttempt: { state: string; popup: Window | null } | null = null;
private readonly onWindowMessage = (event: MessageEvent): void => {
+ if (this.handleGoogleTokenMessage(event)) return;
const attempt = this.onrampAttempt;
if (!attempt || !this.onramp) return;
const result = validateOnrampMessage(
@@ -154,16 +177,46 @@ export class BitLoginAuthElement extends HTMLElement {
// Returning user: the service is simply the password manager -- this is
// the ordinary password login from here on.
this.onrampSession = true;
+ this.onrampKind = "service";
void this.attemptLogin(result.loginName, result.password);
return;
}
this.onrampRegistration = {
+ kind: "service",
registrationToken: result.registrationToken,
provider: attempt.provider,
suggestedLoginName: result.suggestedLoginName
};
this.goto("onramp-create");
};
+
+ /** Serverless Google leg (§CO3.3): the popup (this site's own page) relayed
+ * Google's token. Same-origin plus the exact state nonce, or ignored. */
+ private handleGoogleTokenMessage(event: MessageEvent): boolean {
+ const attempt = this.googleAttempt;
+ if (!attempt) return false;
+ const result = validateGoogleTokenMessage(
+ { origin: event.origin, data: event.data },
+ { origin: window.location.origin, state: attempt.state }
+ );
+ if (!result) return false;
+ this.googleAttempt = null;
+ try {
+ attempt.popup?.close();
+ } catch {
+ // The popup closes itself regardless.
+ }
+ if ("error" in result) {
+ this.errorMessage =
+ result.error === "access_denied"
+ ? "Google sign-in was cancelled."
+ : `Google sign-in failed (${result.error}).`;
+ this.render();
+ return true;
+ }
+ void this.runDriveUnlock(result.accessToken);
+ return true;
+ }
private sessionWarnings: string[] = [];
private lastSignedEventJson = "";
private exportedNsec = "";
@@ -243,7 +296,22 @@ export class BitLoginAuthElement extends HTMLElement {
this.vaultRelayUrls = config.vaultRelayUrls ?? [];
this.discoveryRelayUrls = config.discoveryRelayUrls ?? [];
this.onramp = config.onramp;
+ this.onrampGoogle = config.onrampGoogle;
window.addEventListener("message", this.onWindowMessage);
+
+ // Serverless-Google callback relay (§CO3.3): when this page IS the OAuth
+ // popup, Google put a token in the fragment. Relay it to the opener
+ // (same-origin) and close -- the widget in the opener does everything else.
+ if (this.onrampGoogle && window.opener) {
+ const parsed = parseGoogleCallbackFragment(window.location.hash);
+ if (parsed) {
+ (window.opener as Window).postMessage(buildGoogleTokenMessage(parsed), window.location.origin);
+ history.replaceState(null, "", window.location.pathname + window.location.search);
+ window.close();
+ // If the browser refuses to close the popup, the widget simply renders
+ // its welcome screen here; the opener has what it needs either way.
+ }
+ }
void this.worker
.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls })
.then(() => this.tryRestoreSession());
@@ -327,6 +395,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.altSigner = null;
this.session = null;
this.onrampSession = false;
+ this.onrampKind = null;
this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
@@ -337,6 +406,7 @@ export class BitLoginAuthElement extends HTMLElement {
await this.worker.logout();
this.session = null;
this.onrampSession = false;
+ this.onrampKind = null;
this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
@@ -831,6 +901,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.pendingExtensionSigner = null;
this.onrampAttempt = null;
this.onrampRegistration = null;
+ this.googleAttempt = null;
// Backing out of an unfinished bunker connect abandons the worker-side
// attempt -- but never a live session (altSigner) that's merely
// navigating around.
@@ -849,6 +920,9 @@ export class BitLoginAuthElement extends HTMLElement {
case "onramp-signin":
this.startOnramp(target.dataset.provider ?? "");
return;
+ case "google-signin":
+ this.startGoogleOnramp();
+ return;
case "onramp-new":
this.importKey = "";
this.importPreviewNpub = "";
@@ -1059,6 +1133,42 @@ export class BitLoginAuthElement extends HTMLElement {
this.goto("create-name");
}
+ /** Opens the serverless Google popup for one attempt (§CO3.3). */
+ private startGoogleOnramp(): void {
+ if (!this.onrampGoogle) return;
+ const state = newGoogleOnrampState();
+ const url = buildGoogleAuthUrl(this.onrampGoogle, currentRedirectUri(window.location), state);
+ const popup = window.open(url, "bitlogin-onramp-google", "popup,width=480,height=640");
+ if (!popup) {
+ this.errorMessage = "Your browser blocked the sign-in window. Allow pop-ups for this site and try again.";
+ this.render();
+ return;
+ }
+ this.googleAttempt = { state, popup };
+ this.errorMessage = undefined;
+ this.render();
+ }
+
+ /** Google answered with a Drive token: read the credential from the user's
+ * own app data and sign in, or start first-time setup (§CO3.3). */
+ private async runDriveUnlock(accessToken: string): Promise {
+ this.setBusy(true);
+ try {
+ const credential = await findDriveCredential(accessToken);
+ this.busy = false;
+ if (credential) {
+ this.onrampSession = true;
+ this.onrampKind = "google";
+ await this.attemptLogin(credential.loginName, credential.password);
+ return;
+ }
+ this.onrampRegistration = { kind: "google", accessToken, provider: "google" };
+ this.goto("onramp-create");
+ } catch (err) {
+ this.fail(err);
+ }
+ }
+
/** Opens the on-ramp's OAuth popup for one attempt (§CO5). */
private startOnramp(provider: string): void {
if (!this.onramp) return;
@@ -1085,11 +1195,12 @@ export class BitLoginAuthElement extends HTMLElement {
*/
private async runOnrampRegistration(): Promise {
const registration = this.onrampRegistration;
- const onramp = this.onramp;
- if (!registration || !onramp) return;
+ if (!registration) return;
+ if (registration.kind === "service" && !this.onramp) return;
this.setBusy(true);
try {
- const suggested = registration.suggestedLoginName?.trim().toLowerCase() ?? "";
+ const suggested =
+ registration.kind === "service" ? (registration.suggestedLoginName?.trim().toLowerCase() ?? "") : "";
const loginName = isValidLoginName(suggested) ? suggested : `nostr-${bytesToHex(randomBytes(4))}`;
const password = generatePassphrase().secret;
const result = await this.worker.register({
@@ -1118,16 +1229,21 @@ export class BitLoginAuthElement extends HTMLElement {
dmRelays: this.vaultRelayUrls
});
try {
- await storeOnrampCredential(onramp, {
- registrationToken: registration.registrationToken,
- loginName,
- password
- });
+ if (registration.kind === "google") {
+ await storeDriveCredential(registration.accessToken, { loginName, password });
+ } else {
+ await storeOnrampCredential(this.onramp!, {
+ registrationToken: registration.registrationToken,
+ loginName,
+ password
+ });
+ }
} catch (storeErr) {
- // The account is real and live on relays, but the service is not
- // holding its credential -- the phrase ceremony happens NOW.
+ // The account is real and live on relays, but nobody is holding its
+ // credential -- the phrase ceremony happens NOW (the no-orphan rule).
this.busy = false;
this.onrampSession = false;
+ this.onrampKind = null;
this.securePhrasePending = false;
this.errorMessage = storeErr instanceof Error ? storeErr.message : String(storeErr);
this.noteSignerClaim(this.claimSigner());
@@ -1137,6 +1253,7 @@ export class BitLoginAuthElement extends HTMLElement {
}
this.busy = false;
this.onrampSession = true;
+ this.onrampKind = registration.kind;
this.securePhrasePending = true;
this.noteSignerClaim(this.claimSigner());
this.dispatchLogin();
@@ -1226,6 +1343,7 @@ export class BitLoginAuthElement extends HTMLElement {
const loginName = this.field("loginName").trim().toLowerCase();
const password = this.field("password");
this.onrampSession = false; // typed credentials: the user is their own password manager here
+ this.onrampKind = null;
await this.attemptLogin(loginName, password);
}
@@ -1629,8 +1747,22 @@ export class BitLoginAuthElement extends HTMLElement {
`;
})
.join("");
- const onrampWaiting = this.onrampAttempt
- ? `
Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.
`
+ // Serverless Google (§CO3.3): no third party at all -- the sub-label
+ // says where the credential actually lives.
+ const googleRow = this.onrampGoogle
+ ? `
+ `
+ : "";
+ const waitingProvider = this.googleAttempt
+ ? "Google"
+ : this.onrampAttempt
+ ? onrampProviderLabel(this.onrampAttempt.provider)
+ : null;
+ const onrampWaiting = waitingProvider
+ ? `
Finish signing in with ${escapeHtml(waitingProvider)} in the window that opened, then return here.
@@ -1638,6 +1770,7 @@ export class BitLoginAuthElement extends HTMLElement {
${extensionRow}
${optionRow("goto-bunker", OPTION_ICONS.remote, "Remote signer", "Scan a code with Amber or another signer app")}
${optionRow("goto-import", OPTION_ICONS.importKey, "Import a Nostr key", "Wrap an existing identity in a login name and password")}
+ ${googleRow}
${onrampRows}
${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
`
@@ -1657,14 +1790,16 @@ export class BitLoginAuthElement extends HTMLElement {
}
case "onramp-create": {
- const providerLabel = this.onrampRegistration
- ? onrampProviderLabel(this.onrampRegistration.provider)
- : "";
+ const registration = this.onrampRegistration;
+ const custodyLine =
+ registration?.kind === "google"
+ ? "Your sign-in will be kept in your own Google Drive — no one else holds it."
+ : `${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${
+ registration ? ` through ${escapeHtml(onrampProviderLabel(registration.provider))}` : ""
+ }.`;
return `
${this.renderScreenHead("Almost there")}
-
${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${
- providerLabel ? ` through ${escapeHtml(providerLabel)}` : ""
- }. Your account itself lives on the open Nostr network — choose how to set up its identity:
+
${custodyLine} Your account itself lives on the open Nostr network — choose how to set up its identity: