mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Implements centralized-onramps.md §CO5. A host opts in via onramp-url / onramp-name / onramp-providers attributes -- nothing centralized ships by default. The options menu gains the owner-chosen group heading 'Use an account you already have' covering extension, remote signer, key import, and 'Continue with <Provider>' rows whose sub-label IS the custody statement (§CO7): '<Service> manages your sign-in'. The handshake (onramp.ts, pure and unit-tested): popup to the configured URL with a CSPRNG state nonce; exactly one unlock or register message accepted, only from the configured origin with the exact nonce. Returning users flow into the ordinary password login. New users choose a fresh identity or §SF10-import an existing nsec; registration runs client-side and the password is POSTed to the service -- if the store fails, the phrase ceremony is forced immediately rather than deferred, so no account exists that nobody can unlock. Tier B2 otherwise: the dashboard carries a 'Secure your account' card into the existing phrase screens while the phrase is still in session memory, plus a standing '<Service> manages your sign-in' line. Policy pins: on-ramp rows render only inside the menu with the custody label; group heading wording pinned. 12 new tests; verified visually with a configured element. Co-Authored-By: Claude Fable 5 <[email protected]>
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildOnrampAuthUrl,
|
|
newOnrampState,
|
|
onrampProviderLabel,
|
|
parseOnrampConfig,
|
|
storeOnrampCredential,
|
|
validateOnrampMessage,
|
|
type OnrampConfig
|
|
} from "./onramp.js";
|
|
|
|
const CONFIG: OnrampConfig = {
|
|
url: "https://onramp.example/bitlogin",
|
|
origin: "https://onramp.example",
|
|
name: "AcmeSigner",
|
|
providers: ["google", "github"]
|
|
};
|
|
|
|
describe("parseOnrampConfig", () => {
|
|
it("returns undefined when unconfigured, without warning", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
expect(parseOnrampConfig({ url: null, name: null, providers: null })).toBeUndefined();
|
|
expect(warn).not.toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it("parses a full configuration and derives the origin", () => {
|
|
const config = parseOnrampConfig({
|
|
url: "https://onramp.example/bitlogin",
|
|
name: " AcmeSigner ",
|
|
providers: "Google, github ,"
|
|
})!;
|
|
expect(config.origin).toBe("https://onramp.example");
|
|
expect(config.name).toBe("AcmeSigner");
|
|
expect(config.providers).toEqual(["google", "github"]);
|
|
});
|
|
|
|
it("rejects non-https URLs (except loopback), empty providers, and a missing name -- loudly", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
expect(
|
|
parseOnrampConfig({ url: "http://onramp.example/x", name: "A", providers: "google" })
|
|
).toBeUndefined();
|
|
expect(
|
|
parseOnrampConfig({ url: "https://onramp.example/x", name: "A", providers: " , " })
|
|
).toBeUndefined();
|
|
expect(
|
|
parseOnrampConfig({ url: "https://onramp.example/x", name: " ", providers: "google" })
|
|
).toBeUndefined();
|
|
expect(parseOnrampConfig({ url: "not a url", name: "A", providers: "google" })).toBeUndefined();
|
|
expect(warn).toHaveBeenCalledTimes(4);
|
|
expect(
|
|
parseOnrampConfig({ url: "http://localhost:8787/x", name: "Dev", providers: "google" })
|
|
).toBeDefined();
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("auth URL and state", () => {
|
|
it("appends provider, state, and page origin to the configured URL", () => {
|
|
const url = new URL(buildOnrampAuthUrl(CONFIG, "google", "abc123", "https://site.example"));
|
|
expect(url.origin).toBe("https://onramp.example");
|
|
expect(url.pathname).toBe("/bitlogin");
|
|
expect(url.searchParams.get("provider")).toBe("google");
|
|
expect(url.searchParams.get("state")).toBe("abc123");
|
|
expect(url.searchParams.get("origin")).toBe("https://site.example");
|
|
});
|
|
|
|
it("mints distinct high-entropy states", () => {
|
|
const a = newOnrampState();
|
|
const b = newOnrampState();
|
|
expect(a).toMatch(/^[0-9a-f]{32}$/u);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("labels known providers and capitalizes unknown ones", () => {
|
|
expect(onrampProviderLabel("google")).toBe("Google");
|
|
expect(onrampProviderLabel("github")).toBe("GitHub");
|
|
expect(onrampProviderLabel("keycloak")).toBe("Keycloak");
|
|
});
|
|
});
|
|
|
|
describe("validateOnrampMessage", () => {
|
|
const expected = { origin: CONFIG.origin, state: "state1" };
|
|
const unlockMsg = {
|
|
type: "bitlogin:onramp:unlock",
|
|
state: "state1",
|
|
unlock: { loginName: "user1", password: "pw-pw-pw-pw" }
|
|
};
|
|
|
|
it("accepts a well-formed unlock from the exact origin and state", () => {
|
|
expect(validateOnrampMessage({ origin: CONFIG.origin, data: unlockMsg }, expected)).toEqual({
|
|
kind: "unlock",
|
|
loginName: "user1",
|
|
password: "pw-pw-pw-pw"
|
|
});
|
|
});
|
|
|
|
it("accepts a well-formed register branch", () => {
|
|
const result = validateOnrampMessage(
|
|
{
|
|
origin: CONFIG.origin,
|
|
data: {
|
|
type: "bitlogin:onramp:unlock",
|
|
state: "state1",
|
|
register: { registrationToken: "tok", suggestedLoginName: "sunny-otter" }
|
|
}
|
|
},
|
|
expected
|
|
);
|
|
expect(result).toEqual({ kind: "register", registrationToken: "tok", suggestedLoginName: "sunny-otter" });
|
|
});
|
|
|
|
it("rejects wrong origin, wrong state, wrong type, both branches, and malformed shapes", () => {
|
|
expect(
|
|
validateOnrampMessage({ origin: "https://evil.example", data: unlockMsg }, expected)
|
|
).toBeNull();
|
|
expect(
|
|
validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, state: "other" } }, expected)
|
|
).toBeNull();
|
|
expect(
|
|
validateOnrampMessage({ origin: CONFIG.origin, data: { ...unlockMsg, type: "hello" } }, expected)
|
|
).toBeNull();
|
|
expect(
|
|
validateOnrampMessage(
|
|
{
|
|
origin: CONFIG.origin,
|
|
data: { ...unlockMsg, register: { registrationToken: "tok" } }
|
|
},
|
|
expected
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
validateOnrampMessage(
|
|
{
|
|
origin: CONFIG.origin,
|
|
data: { type: "bitlogin:onramp:unlock", state: "state1", unlock: { loginName: "u" } }
|
|
},
|
|
expected
|
|
)
|
|
).toBeNull();
|
|
expect(validateOnrampMessage({ origin: CONFIG.origin, data: null }, expected)).toBeNull();
|
|
expect(validateOnrampMessage({ origin: CONFIG.origin, data: "string" }, expected)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("storeOnrampCredential", () => {
|
|
const payload = { registrationToken: "tok", loginName: "user1", password: "pw" };
|
|
|
|
it("POSTs the credential to the configured URL only, and resolves on ok", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
await storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchImpl.mock.calls[0]!;
|
|
expect(url).toBe(CONFIG.url);
|
|
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
|
type: "bitlogin:onramp:store",
|
|
...payload
|
|
});
|
|
});
|
|
|
|
it("throws a phrase-preserving error when the service refuses", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
|
await expect(
|
|
storeOnrampCredential(CONFIG, payload, fetchImpl as unknown as typeof fetch)
|
|
).rejects.toThrow(/recovery phrase/);
|
|
});
|
|
});
|