Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/web/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const {
resetLoginThrottles,
appleUid,
ensureOtpAccount,
upsertGoogleAccount,
googleUid,
AccountError,
} = await import("./accounts.js");

Expand Down Expand Up @@ -132,6 +134,84 @@ describe("code-only (OTP) accounts", () => {
});
});

describe("google accounts", () => {
test("first sign-in creates a verified google account", async () => {
const a = await upsertGoogleAccount("108123", "User@Example.com", 1000);
assert.equal(a.uid, googleUid("108123"));
assert.equal(a.kind, "google");
assert.equal(a.email, "user@example.com");
assert.equal(a.verified, true);
assert.equal(a.googleSub, "108123");
});

test("the sub is the anchor: a changed address keeps the same account", async () => {
const first = await upsertGoogleAccount("108123", "old@example.com", 1000);
const second = await upsertGoogleAccount("108123", "new@example.com", 2000);
assert.equal(second.uid, first.uid);
assert.equal(second.email, "new@example.com");
assert.equal(second.lastLoginAt, 2000);
});

test("binds to an existing (unverified) email account, dropping the pre-verification password", async () => {
const made = await createEmailAccount("a@b.co", "password-123");
assert.equal(made.verified, false);
assert.equal(made.sessionVersion, 0);
const g = await upsertGoogleAccount("108123", "a@b.co", 2000);
assert.equal(g.uid, made.uid, "same account — same uid, same balance");
assert.equal(g.kind, "email", "the original kind is kept; Google is now also an entrance");
assert.equal(g.googleSub, "108123");
assert.equal(g.verified, true, "Google vouched for the inbox");
// The pre-verification password can't be trusted (register is open), so it is
// dropped and sessionVersion rotated — no longer authenticates (anti pre-hijacking).
assert.equal(g.scrypt, undefined, "pre-verification password dropped");
assert.equal(g.sessionVersion, 1, "sessionVersion rotated");
assert.equal(await verifyEmailLogin("a@b.co", "password-123", 3000), null, "the pre-set password no longer works");
// Google + code both still open the same account.
assert.equal((await ensureOtpAccount("a@b.co", 3000)).acct.uid, made.uid);
});

test("a Google email change is refused when it collides with another account", async () => {
await createEmailAccount("b@x.co", "password-123"); // a separate account owns b@x.co
await upsertGoogleAccount("108123", "a@x.co", 1000);
// Google now reports this sub's email is b@x.co — overwriting would split the
// balance / break one-address-one-account, so it is refused.
await assert.rejects(upsertGoogleAccount("108123", "b@x.co", 2000), isCode("email_taken"));
});

test("a mailed code signs into a google-owned address rather than forking", async () => {
const g = await upsertGoogleAccount("108123", "a@b.co", 1000);
const viaCode = await ensureOtpAccount("a@b.co", 2000);
assert.equal(viaCode.created, false);
assert.equal(viaCode.acct.uid, g.uid);
});

test("registering a password over a google-owned address is refused", async () => {
await upsertGoogleAccount("108123", "a@b.co", 1000);
await assert.rejects(createEmailAccount("a@b.co", "password-123"), isCode("email_taken"));
});

test("a google account cannot be password-authenticated", async () => {
await upsertGoogleAccount("108123", "a@b.co", 1000);
assert.equal(await verifyEmailLogin("a@b.co", "password-123", 2000), null);
});

test("apple accounts do not bind by address (private relay aliases)", async () => {
const apple = await upsertAppleAccount("001.abc", "a@b.co", 1000);
const g = await upsertGoogleAccount("108123", "a@b.co", 2000);
assert.notEqual(g.uid, apple.uid);
assert.equal(g.kind, "google");
});

test("googleUid sanitizes hostile subs", () => {
assert.ok(!googleUid("a/b").includes("/"));
assert.ok(!googleUid("../../etc").includes("/"));
});

test("a malformed address is refused", async () => {
await assert.rejects(upsertGoogleAccount("108123", "not-an-email", 1000), isCode("invalid_email"));
});
});

describe("apple accounts", () => {
test("upsert creates once, then updates lastLoginAt; uid is fs-safe", async () => {
const a = await upsertAppleAccount("001234.abcdef.5678", "user@example.com", 1000);
Expand Down
99 changes: 94 additions & 5 deletions src/web/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@
* LISA accounts — the cloud edition's user directory
* (docs/PLAN_ACCOUNTS_BILLING_v1.0.md §6.1, milestone B1).
*
* Two account kinds share one store (`$lisaHome()/accounts.json`, 0600):
* Three account kinds share one store (`$lisaHome()/accounts.json`, 0600):
* - **Apple** (`apple-<sub>`): created/updated on every verified Sign in with
* Apple. No password material — Apple is the authority.
* - **Email** (`em-<random>`): self-serve, keyed by the address. Password
* material is OPTIONAL: accounts born from a mailed one-time code
* (src/web/otp.ts) carry no scrypt params at all, and the password path
* rejects them constant-time like any other bad credential. App Review's
* demo account is the password kind (ASC insists on user/pass).
* - **Google** (`g-<sub>`): created on the first verified Google sign-in.
*
* **One address, one account.** Email and Google accounts both *claim* their
* address (`EMAIL_OWNER_KINDS`), so every lookup spans both kinds and a person
* who signs in by Google today and by mailed code tomorrow lands on the same
* uid — and therefore the same balance. Apple is excluded on purpose: its
* private-relay addresses are per-app aliases, not a claim on a real inbox.
*
* Every account carries a `sessionVersion`; session tokens embed it and the
* gate rejects a mismatch — so deleting an account (App Store 5.1.1(v)) or a
Expand All @@ -25,7 +32,17 @@ import path from "node:path";
import crypto from "node:crypto";
import { firestoreEnabled, getDoc, casUpdate } from "../cloud/firestore.js";

export type AccountKind = "apple" | "email";
export type AccountKind = "apple" | "email" | "google";

/**
* Kinds whose `email` is an ownership claim on that inbox, and so must be
* unique across the directory. Apple is absent deliberately (see the header).
*/
const EMAIL_OWNER_KINDS: readonly AccountKind[] = ["email", "google"];

function ownsEmail(a: AccountRecord, normalizedEmail: string): boolean {
return EMAIL_OWNER_KINDS.includes(a.kind) && a.email === normalizedEmail;
}

export interface ScryptParams {
saltHex: string;
Expand All @@ -52,6 +69,8 @@ export interface AccountRecord {
verified: boolean;
/** Bump to invalidate every outstanding session for this uid. */
sessionVersion: number;
/** Google's stable account id — set on any account a Google sign-in owns. */
googleSub?: string;
/** SHA-256 of the outstanding verification token (email kind, unverified). */
verifyTokenHash?: string;
/** Verification-token expiry, ms epoch. */
Expand Down Expand Up @@ -230,9 +249,10 @@ export async function getAccount(uid: string): Promise<AccountRecord | null> {
return (await loadAccounts()).find((a) => a.uid === uid) ?? null;
}

/** The account that owns this address, of whichever email-owning kind. */
export async function getAccountByEmail(email: string): Promise<AccountRecord | null> {
const norm = normalizeEmail(email);
return (await loadAccounts()).find((a) => a.kind === "email" && a.email === norm) ?? null;
return (await loadAccounts()).find((a) => ownsEmail(a, norm)) ?? null;
}

/**
Expand Down Expand Up @@ -260,7 +280,9 @@ export async function createEmailAccount(
sessionVersion: 0,
};
return mutateAccounts((list) => {
if (list.some((a) => a.kind === "email" && a.email === email)) throw new AccountError("email_taken");
// Spans Google too: the address is already spoken for, and a second record
// would split the person's balance across two uids.
if (list.some((a) => ownsEmail(a, email))) throw new AccountError("email_taken");
list.push(rec);
return rec;
});
Expand Down Expand Up @@ -309,7 +331,9 @@ export async function ensureOtpAccount(
if (!validEmail(email)) throw new AccountError("invalid_email");
const uid = `em-${crypto.randomBytes(9).toString("hex")}`;
return mutateAccounts((list) => {
const existing = list.find((a) => a.kind === "email" && a.email === email);
// A Google-owned address counts: proving the inbox signs you into that same
// account rather than forking a second one.
const existing = list.find((a) => ownsEmail(a, email));
if (existing) {
existing.lastLoginAt = now;
// OTP proves inbox control → apply ownership proof. This drops any password
Expand Down Expand Up @@ -398,6 +422,71 @@ export async function upsertAppleAccount(
});
}

/** Stable uid for a Google `sub` (filesystem-safe; Google subs are digits). */
export function googleUid(sub: string): string {
return `g-${sub.replace(/[^A-Za-z0-9._-]/g, "_")}`;
}

/**
* Upsert the account for a verified Google identity (A3). Resolution order:
*
* 1. **by `sub`** — the identity anchor, so a Google user who changes the
* address on their Google account keeps their LISA account and balance;
* 2. **by address** — binds to the email account already using it, which is
* the whole point of the one-address-one-account rule. Safe because the
* caller only reaches here with `email_verified` from Google;
* 3. otherwise create `g-<sub>`.
*
* Binding marks the account verified: Google vouched for the inbox.
*/
export async function upsertGoogleAccount(
sub: string,
emailRaw: string,
now: number = Date.now(),
): Promise<AccountRecord> {
const email = normalizeEmail(emailRaw);
if (!validEmail(email)) throw new AccountError("invalid_email");
const uid = googleUid(sub);
return mutateAccounts((list) => {
const bySub = list.find((a) => a.googleSub === sub);
if (bySub) {
bySub.lastLoginAt = now;
if (bySub.email !== email) {
// Google changed this account's address. Don't overwrite it onto an
// address a *different* local account already owns — that would break
// one-address-one-account and make getAccountByEmail order-dependent
// (split balance). Refuse rather than silently merge.
const conflict = list.find((a) => a !== bySub && ownsEmail(a, email));
if (conflict) throw new AccountError("email_taken");
bySub.email = email;
}
return bySub;
}
const byEmail = list.find((a) => ownsEmail(a, email));
if (byEmail) {
byEmail.googleSub = sub;
byEmail.lastLoginAt = now;
// Google verified this inbox → ownership proof: drop any password set before
// verification and rotate sessionVersion (anti account-pre-hijacking, same
// as the OTP path). A password the owner set AFTER verifying is kept.
markVerifiedByOwnershipProof(byEmail);
return byEmail;
}
const rec: AccountRecord = {
uid,
kind: "google",
email,
googleSub: sub,
createdAt: now,
lastLoginAt: now,
verified: true,
sessionVersion: 0,
};
list.push(rec);
return rec;
});
}

/**
* Delete an account (App Store 5.1.1(v)). Removes the record — which kills all
* of its sessions via the sessionVersion check — and returns true if one existed.
Expand Down
Loading