Serverless 'Continue with Google': the credential lives in the user's own Drive

Implements centralized-onramps.md §CO3.3, per the owner call that static
only is the way. No server anywhere: the host registers a Google OAuth
client ID (onramp-google-client-id) and the widget does the rest -- a
popup runs the plain implicit grant (no external script, so
script-src 'self' survives), scoped to drive.appdata only, redirecting
back to the widget page itself, which relays the token fragment to its
opener under the same origin+nonce discipline as the service handshake.

Returning users: the credential file is read from the user's app-data
folder and fed into the ordinary password login. First-timers: fresh
identity or §SF10 import, client-side registration, credential uploaded
to their own Drive -- with the no-orphan rule forcing the phrase
ceremony if the upload fails. Rotating the password later IS the
graduation step; the rotate screen says so for managed sessions, and
the dashboard states the custody plainly: your sign-in key is stored
in your own Google Drive.

driveOnramp.ts is pure and unit-tested (13 tests); new pins keep all
network I/O out of the element and constrain Drive fetches to the two
Google API constants. Hosts add https://www.googleapis.com to
connect-src and register the widget page URL as the redirect URI --
documented in the guide, llms.txt, and the design doc (v0.3), including
the recorded dependency on the legacy implicit grant.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 11:48:51 -04:00
1 parent ff1470a849
commit 37f5bbd243
8 files changed
+628 -37

No files matched your search

+48 -2
View File
@@ -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 <Provider>" 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
+10 -6
View File
@@ -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.</p>
<p>Sites that want a "Continue with Google/GitHub" on-ramp can additionally point the widget at an OAuth
unlock service (<code>onramp-url</code>, <code>onramp-name</code>, <code>onramp-providers</code> attributes —
nothing centralized ships unless you configure it, and your CSP's <code>connect-src</code> 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
<code>docs/centralized-onramps.md</code> in the repo for the handshake contract and threat model.</p>
<p>Sites that want a <strong>"Continue with Google"</strong> option can have it with no server anywhere:
set <code>onramp-google-client-id</code> to your own Google OAuth client ID, register this page's exact URL
as a redirect URI, and add <code>https://www.googleapis.com</code> to your CSP's <code>connect-src</code>.
The credential of a real, portable Nostr account is stored in the <em>user's own</em> 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.</p>
<p>For providers without browser-accessible storage (GitHub, Facebook, …) the widget can instead point at an
external OAuth unlock service (<code>onramp-url</code>, <code>onramp-name</code>, <code>onramp-providers</code>;
the service's origin goes in <code>connect-src</code>). Nothing centralized ships unless you configure it.
See <code>docs/centralized-onramps.md</code> in the repo for contracts and threat models.</p>
<p>Two different questions have two different APIs, and conflating them is the classic integration bug:</p>
<p><strong>"Is someone signed in through BitLogin?"</strong> — use <code>window.bitlogin.activeMethod()</code>
(or the richer <code>activeSession()</code>), or listen for <code>bitlogin-login</code>. This answers correctly
+12 -4
View File
@@ -28,10 +28,18 @@ deployment above.
- A `<bitlogin-auth>` 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
("<Service> manages your sign-in") as the sub-label, new users can create
+10 -4
View File
@@ -1,13 +1,18 @@
/** Host-page-configurable options for <bitlogin-auth> (§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"))
};
}
+160
View File
@@ -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<string, string>)["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/);
});
});
+203
View File
@@ -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<GoogleTokenMessage> | 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<string, string> {
return { authorization: `Bearer ${accessToken}` };
}
async function requireOk(response: Response, doing: string): Promise<Response> {
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<DriveCredential | null> {
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<void> {
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.`
);
}
}
+167 -21
View File
@@ -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<void> {
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<void> {
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 {
</button>`;
})
.join("");
const onrampWaiting = this.onrampAttempt
? `<div class="notice info">Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.</div>`
// Serverless Google (§CO3.3): no third party at all -- the sub-label
// says where the credential actually lives.
const googleRow = this.onrampGoogle
? `
<button class="option-row" type="button" data-action="google-signin">
<span class="option-icon">${OPTION_ICONS.onramp}</span>
<span class="option-text"><span>Continue with Google</span><span class="option-sub">Your sign-in key stays in your own Google Drive</span></span>
</button>`
: "";
const waitingProvider = this.googleAttempt
? "Google"
: this.onrampAttempt
? onrampProviderLabel(this.onrampAttempt.provider)
: null;
const onrampWaiting = waitingProvider
? `<div class="notice info">Finish signing in with ${escapeHtml(waitingProvider)} in the window that opened, then return here.</div>`
: "";
const optionsMenu = this.moreOptionsOpen
? `<div class="option-menu">
@@ -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")}
</div>`
@@ -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")}
<p class="sub">${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:</p>
<p class="sub">${custodyLine} Your account itself lives on the open Nostr network — choose how to set up its identity:</p>
${this.renderError()}
<button class="primary" type="button" data-action="onramp-new" ${this.busy ? "disabled" : ""}>
${this.busy ? '<span class="spinner"></span>Creating your account…' : "Create a fresh identity"}
@@ -1905,12 +2040,16 @@ export class BitLoginAuthElement extends HTMLElement {
<button class="secondary" type="button" data-action="goto-export">Export identity</button>`;
// Tier B2 (§CO4): the phrase exists only in this session's memory
// until the user claims it -- the one nudge that stays until acted on.
const custodian =
this.onrampKind === "google" ? "your Google account" : (this.onramp?.name ?? "the sign-in service");
const securePhraseCard = this.securePhrasePending
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even without ${escapeHtml(this.onramp?.name ?? "the sign-in service")}.
? `<div class="notice warn">Secure your account: your recovery phrase is available <strong>only during this session</strong>. Save it now and your account stays yours even without ${escapeHtml(custodian)}.
<button class="link-inline" type="button" data-action="goto-confirm-phrase">View and save it</button></div>`
: "";
const onrampStanding = this.onrampSession
? `<p class="small">Signed in via ${escapeHtml(this.onramp?.name ?? "a sign-in service")} — they manage your sign-in; your identity lives on the open Nostr network.</p>`
? this.onrampKind === "google"
? `<p class="small">Signed in with Google — your sign-in key is stored in your own Google Drive; your identity lives on the open Nostr network.</p>`
: `<p class="small">Signed in via ${escapeHtml(this.onramp?.name ?? "a sign-in service")} — they manage your sign-in; your identity lives on the open Nostr network.</p>`
: "";
return `
<h2>Signed in</h2>
@@ -2047,6 +2186,13 @@ export class BitLoginAuthElement extends HTMLElement {
case "change-password":
return `
<h2>Rotate password</h2>
${
this.onrampSession
? `<div class="notice info">This takes over from ${
this.onrampKind === "google" ? "Google sign-in" : "your managed sign-in"
}: the stored copy of your old password stops working, and from then on you sign in with the new password below — you hold it, nobody else. This is the graduation step.</div>`
: ""
}
<p class="sub">Your old password's capsule will be tombstoned and a deletion request issued. This does not erase copies an attacker may already have downloaded, and a relay that hasn't processed the deletion may keep serving the old password's capsule until a device that has already seen the new generation refuses it.</p>
<div class="credential-box" id="credential-box">${escapeHtml(this.changePasswordNewCredential)}</div>
<button class="secondary" type="button" data-action="copy-credential">Copy generated password</button>
@@ -77,10 +77,28 @@ describe("widget security policy", () => {
expect(widgetSource).toContain("manages your sign-in</span>");
// One rendering site (the onrampRows template) + one onClick case.
expect(widgetSource.split('"onramp-signin"').length - 1).toBe(2);
// Same for the serverless Google row (§CO3.3), whose sub-label states the
// stronger custody fact: the credential lives in the user's own Drive.
expect(menu).toContain("${googleRow}");
expect(widgetSource).toContain("Your sign-in key stays in your own Google Drive");
expect(widgetSource.split('"google-signin"').length - 1).toBe(2);
// The group heading is the owner-chosen wording.
expect(menu).toContain(">Use an account you already have</div>");
});
it("keeps all network I/O out of the element", () => {
// The element orchestrates; every network call lives in an auditable
// module (worker RPC, onramp.ts, driveOnramp.ts) where its destination
// is constrained -- Drive tokens and passwords must never ride an
// ad-hoc fetch added to UI code.
expect(widgetSource).not.toContain("fetch(");
const driveSource = readFileSync(new URL("./driveOnramp.ts", import.meta.url), "utf8");
// Drive I/O only ever targets the two Google API base constants.
expect(driveSource).toContain('GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3"');
expect(driveSource.match(/fetchImpl\(`\$\{GOOGLE_DRIVE(?:_UPLOAD)?_API\}/gu)!.length).toBe(3);
expect(driveSource.match(/fetchImpl\(/gu)!.length).toBe(3);
});
it("treats the bunker URI as a secret and keeps the element storage-free", () => {
// A bunker:// URI carries a connection token; it gets a password input
// (no shoulder-surfing, no autofill heuristics), and the element itself