mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
ff1470a849
commit
37f5bbd243
8 files changed
+628
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user