frontend: log in to the new PDS by DID, not email, during migration
The inbound migration wizards authenticated against the destination PDS with the account's email as the createSession identifier. createSession only resolves DIDs and handles: 34f050a ("discoverable passkey authentication") dropped `OR email = $1` from get_login_full_by_identifier and renamed the lookups from *_by_handle_or_email to *_by_identifier, and 6ca6c45 added the AtIdentifier::new() check, which rejects anything that is not a DID or handle before the lookup. Email logins therefore fail with "Invalid identifier or password". The frontend was never updated. For passkey accounts this strands the user at the passkey step: completePasskeySetup succeeds server-side (passkey registered, app password minted, one-time setup token consumed), then the follow-up login with the email throws, so the wizard never advances to the app-password step and a retry reports "Setup has already been completed or expired". The password path fails after email verification the same way. Log in with the migrating DID instead (state.sourceDid for the inbound wizard, state.userDid for the offline one). The DID is preserved by migration and is always accepted by createSession, including for deactivated accounts via allowDeactivated. Adds login-identifier.test.ts, which models createSession rejecting emails and covers the inbound password and passkey paths and the offline password and passkey paths.
625 unmodified linesExpand all62662762862963063063163263363441 unmodified lines676677678679680680681682683684242 unmodified linesExpand all927928929930931931932933934935168 unmodified linesExpand all625 unmodified linesExpand all }
if (localPassword) { setProgress({ currentOperation: "Authenticating to new PDS..." }); await localClient.loginDeactivated(state.targetEmail, localPassword); await localClient.loginDeactivated(state.sourceDid, localPassword); }
if (!localClient.getAccessToken()) { setError("Email verified! Please enter your password to continue.");41 unmodified lines }
if (!localClient!.getAccessToken()) { await localClient!.loginDeactivated( state.targetEmail, state.sourceDid, state.targetPassword, ); }
242 unmodified linesExpand all appPassword: "***", });
setProgress({ currentOperation: "Authenticating with app password..." }); await localClient.loginDeactivated(state.targetEmail, result.appPassword); await localClient.loginDeactivated(state.sourceDid, result.appPassword); migrationLog("completePasskeyRegistration: Authenticated to new PDS");
state.generatedAppPassword = result.appPassword; state.generatedAppPasswordName = result.appPasswordName;168 unmodified linesExpand all450 unmodified linesExpand all45145245345445545545645745845942 unmodified lines50250350450550650650750850951045 unmodified lines556557558559560560561562563564194 unmodified linesExpand all450 unmodified linesExpand all if (state.authMethod === "passkey") { setStep("passkey-setup"); } else { const session = await api.createSession( state.targetEmail, state.userDid, state.targetPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt;42 unmodified lines }, async onVerified() { if (!state.localAccessToken) { const session = await api.createSession( state.targetEmail, state.userDid, state.targetPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt;45 unmodified lines state.generatedAppPassword = result.appPassword; state.generatedAppPasswordName = result.appPasswordName;
const session = await api.createSession( state.targetEmail, state.userDid, result.appPassword, ); state.localAccessToken = session.accessJwt; state.localRefreshToken = session.refreshJwt;194 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148import { beforeEach, describe, expect, it, vi } from "vitest";import { createInboundMigrationFlow } from "../../lib/migration/flow.svelte.ts";import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte.ts";
vi.mock("../../lib/flows/perform-passkey-registration.ts", () => ({ createPasskeyCredential: vi.fn().mockResolvedValue({ id: "credential" }),}));
const DID = "did:plc:abc123";const EMAIL = "alice@example.com";const APP_PASSWORD = "xxxx-xxxx-xxxx-xxxx";
type Call = { endpoint: string; body: Record<string, unknown> };
function json(data: unknown, status = 200) { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" }, });}
// Mirrors the server's createSession, which only accepts a DID or a handle// (AtIdentifier::new) and rejects emails as "Invalid identifier or password".function installServerMock(): Call[] { const calls: Call[] = []; globalThis.fetch = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const endpoint = url.split("/xrpc/")[1]?.split("?")[0] ?? url; const body = init?.body ? JSON.parse(String(init.body)) : {}; calls.push({ endpoint, body }); switch (endpoint) { case "com.atproto.server.createSession": return body.identifier.includes("@") ? json({ error: "AuthenticationRequired", message: "Invalid identifier or password", }, 401) : json({ accessJwt: "access", refreshJwt: "refresh", did: DID, handle: "alice.pds.example.com", }); case "_account.completePasskeySetup": return json({ appPassword: APP_PASSWORD, appPasswordName: "migration", }); case "_account.verifyToken": return json({ success: true, did: DID, purpose: "migration", channel: "email", }); default: if (url.includes("plc.directory")) { return json({ id: DID, service: [{ type: "AtprotoPersonalDataServer", serviceEndpoint: "https://old.example.com", }], }); } return json({}); } }, ) as typeof fetch; return calls;}
function loginIdentifiers(calls: Call[]): unknown[] { return calls .filter((c) => c.endpoint === "com.atproto.server.createSession") .map((c) => c.body.identifier);}
describe("migration login identifier", () => { beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
async function inboundFlow(authMethod: "password" | "passkey") { const calls = installServerMock(); const flow = createInboundMigrationFlow(); await flow.resolveSourcePds(DID); await flow.loadLocalServerInfo(); flow.updateField("targetEmail", EMAIL); flow.updateField("targetHandle", "alice.pds.example.com"); flow.updateField("authMethod", authMethod); return { calls, flow }; }
it("inbound passkey setup logs in with the DID and reaches app-password", async () => { const { calls, flow } = await inboundFlow("passkey"); flow.updateField("passkeySetupToken", "setup-token");
await flow.completePasskeyRegistration({ id: "credential" });
expect(loginIdentifiers(calls)).toEqual([DID]); expect(flow.state.generatedAppPassword).toBe(APP_PASSWORD); expect(flow.state.step).toBe("app-password"); });
it("inbound password verification logs in with the DID", async () => { const { calls, flow } = await inboundFlow("password");
await flow.submitEmailVerifyToken("123456", "hunter2");
expect(loginIdentifiers(calls)).toEqual([DID]); expect(flow.state.error).toBeNull(); });
describe("offline flow", () => { function offlineFlow() { const calls = installServerMock(); const flow = createOfflineInboundMigrationFlow(); flow.setUserDid(DID); flow.setTargetEmail(EMAIL); flow.setTargetPassword("hunter2"); return { calls, flow }; }
it("password verification logs in with the DID", async () => { const { calls, flow } = offlineFlow(); flow.state.authMethod = "password";
await flow.submitEmailVerifyToken("123456");
expect(loginIdentifiers(calls)).toEqual([DID]); });
it("passkey registration logs in with the DID and reaches app-password", async () => { const { calls, flow } = offlineFlow(); flow.state.authMethod = "passkey"; flow.state.passkeySetupToken = "setup-token";
await flow.registerPasskey();
expect(loginIdentifiers(calls)).toEqual([DID]); expect(flow.state.generatedAppPassword).toBe(APP_PASSWORD); expect(flow.state.step).toBe("app-password"); }); });});