git » sdk » commit 706388c

Recover from rejected FAST tokens

author Phillip Davis
2026-08-21 02:29:53 UTC
committer Stephen Paul Weber
2026-08-21 14:11:07 UTC
parent 89559d2fb705b1270b02f25e42381d8e66224994

Recover from rejected FAST tokens

Makefile +1 -1
borogove/streams/XmppJsStream.hx +18 -1
npm/test/no-dns.mjs +4 -0
test/fast-auth.spec.ts +157 -0

diff --git a/Makefile b/Makefile
index 15ee8b3..a85b64f 100644
--- a/Makefile
+++ b/Makefile
@@ -68,7 +68,7 @@ npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogov
 	cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d persistence-browser.ts
 
 playwright/.cache/borogove.js: npm
-	cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@
+	cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=./test/no-dns.mjs" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@
 
 playwright/.cache/sqlite-wasm.js: npm
 	cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_Channel as Channel }"  --outfile=../$@
diff --git a/borogove/streams/XmppJsStream.hx b/borogove/streams/XmppJsStream.hx
index b4c6319..aaceaf5 100644
--- a/borogove/streams/XmppJsStream.hx
+++ b/borogove/streams/XmppJsStream.hx
@@ -9,6 +9,7 @@ using StringTools;
 
 import borogove.FSM;
 import borogove.GenericStream;
+import borogove.ID;
 import borogove.Stanza;
 import borogove.Util;
 
@@ -46,7 +47,8 @@ extern class XmppJsClient {
 	};
 	var saslFactory: Dynamic;
 	var fast: {
-		saveToken: ({ token: String, expiry: String, mechanism: String })->Promise<Any>
+		saveToken: ({ token: String, expiry: String, mechanism: String })->Promise<Any>,
+		deleteToken: ()->Promise<Any>
 	};
 }
 
@@ -243,6 +245,7 @@ class XmppJsStream extends GenericStream {
 		});
 
 		final clientId = jid.resource;
+		var credentials: Dynamic = null;
 		final xmpp = new XmppJsClient({
 			service: jid.domain,
 			resource: jid.resource,
@@ -267,12 +270,26 @@ class XmppJsStream extends GenericStream {
 							creds = { password: null, fastCount: null, username: jid.local, token: { token: "fail", mechanism: creds.mechanism }, mechanism: null };
 						}
 					}
+					credentials = creds;
 					return callback(creds, creds.mechanism ?? mech, new XmppJsXml("user-agent", { id: clientId }));
 				});
 			}
 		});
 		new XmppJsScramSha1(xmpp.saslFactory);
 		xmpp.jid = this.jid;
+		// As of writing, xmpp.js will fall back to SASL
+		// even if password is absent or empty, which crashes irrecoverably
+		// when that empty-or-null password is passed to HMAC.
+		// So we set it to some random nonsense, since recovery from a bad
+		// password works.
+		xmpp.fast.deleteToken = () -> {
+			if (credentials.password == null || credentials.password == "") {
+				credentials.password = ID.unique();
+			}
+			credentials.token = null;
+			this.trigger("fast-token", { token: null });
+			return Promise.resolve(null);
+		};
 
 		xmpp.streamFeatures.use("csi", "urn:xmpp:csi:0", (ctx, next, feature) -> {
 			csi = true;
diff --git a/npm/test/no-dns.mjs b/npm/test/no-dns.mjs
new file mode 100644
index 0000000..6d2cc79
--- /dev/null
+++ b/npm/test/no-dns.mjs
@@ -0,0 +1,4 @@
+export default {
+	lookup: (x, y, cb) => cb(null, []),
+	resolveSrv: (x, cb) => cb(null, []),
+};
diff --git a/test/fast-auth.spec.ts b/test/fast-auth.spec.ts
new file mode 100644
index 0000000..e14e6df
--- /dev/null
+++ b/test/fast-auth.spec.ts
@@ -0,0 +1,157 @@
+import { expect, test } from "@playwright/test";
+import fs from "fs";
+import xml from "@xmpp/xml";
+import { parse } from "ltx";
+
+const BIND2 = "urn:xmpp:bind:0";
+const SASL2 = "urn:xmpp:sasl:2";
+const FRAMING = "urn:ietf:params:xml:ns:xmpp-framing";
+const SASL = "urn:ietf:params:xml:ns:xmpp-sasl";
+const STREAM = "http://etherx.jabber.org/streams";
+const BOROGOVE_CODE = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+
+const HOST_META = xml(
+	"XRD",
+	{ xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0" },
+	xml("Link", {
+		rel: "urn:xmpp:alt-connections:websocket",
+		href: "ws://127.0.0.1/xmpp",
+	}),
+).toString();
+const OPEN = xml("open", {
+	from: "127.0.0.1",
+	id: "stream",
+	version: "1.0",
+	xmlns: FRAMING,
+}).toString();
+const FEATURES = xml(
+	"stream:features",
+	{ xmlns: "jabber:client", "xmlns:stream": STREAM },
+	xml(
+		"authentication",
+		{ xmlns: SASL2 },
+		xml("mechanism", {}, "PLAIN"),
+		xml("mechanism", {}, "SCRAM-SHA-1"),
+		xml(
+			"inline",
+			{},
+			xml(
+				"fast",
+				{ xmlns: "urn:xmpp:fast:0" },
+				xml("mechanism", {}, "HT-SHA-256-NONE"),
+			),
+			xml("bind", { xmlns: BIND2 }),
+		),
+	),
+).toString();
+const NOT_AUTHORIZED = xml(
+	"failure",
+	{ xmlns: SASL2 },
+	xml("not-authorized", { xmlns: SASL }),
+).toString();
+const CLOSE = xml("close", { xmlns: FRAMING }).toString();
+
+test.beforeEach(async ({ page }) => {
+	await page.route("https://localhost/", (route) =>
+		route.fulfill({ body: "<html></html>" }),
+	);
+	await page.route("https://127.0.0.1/.well-known/host-meta", (route) =>
+		route.fulfill({
+			body: HOST_META,
+			contentType: "application/xrd+xml",
+		}),
+	);
+});
+
+test("a rejected unexpired FAST token uses a non-empty password before reconnecting", async ({
+	page,
+}) => {
+	let connectionCount = 0;
+	let resolveSocketClosed: () => void;
+	const socketClosed = new Promise<void>((resolve) => {
+		resolveSocketClosed = resolve;
+	});
+	await page.routeWebSocket("ws://127.0.0.1/xmpp", (socket) => {
+		connectionCount++;
+		socket.onMessage((message: string) => {
+			const stanza = parse(message);
+			expect(stanza.is("open", FRAMING)).toBe(true);
+			if (connectionCount === 2) {
+				socket.onMessage((message: string) => {
+					const stanza = parse(message);
+					expect(stanza.is("close", FRAMING)).toBe(true);
+					socket.send(CLOSE);
+				});
+				socket.send(OPEN);
+				socket.send(FEATURES);
+				return;
+			}
+			socket.onMessage((message: string) => {
+				const stanza = parse(message);
+				expect(stanza.is("authenticate", SASL2)).toBe(true);
+				expect(stanza.attrs.mechanism).toBe("HT-SHA-256-NONE");
+				expect(stanza.getChildText("initial-response")).toBe(
+					"dGVzdGVyANlEKT65Z6grrZHbeSjhb05VNRnDs6X1T3Wh0pHQg7cv",
+				);
+				socket.onMessage((message: string) => {
+					const stanza = parse(message);
+					expect(stanza.is("authenticate", SASL2)).toBe(true);
+					expect(stanza.attrs.mechanism).toBe("PLAIN");
+					const [, username, password] = atob(
+						stanza.getChildText("initial-response"),
+					).split("\0");
+					expect(username).toBe("tester");
+					expect(password).not.toBe("");
+					expect(password).not.toBe("persisted secret");
+					socket.send(NOT_AUTHORIZED);
+				});
+				socket.send(NOT_AUTHORIZED);
+			});
+			socket.send(OPEN);
+			socket.send(FEATURES);
+		});
+		socket.onClose(() => resolveSocketClosed());
+	});
+	await page.goto("https://localhost/");
+
+	await page.evaluate(
+		async ({ code }) => {
+			const moduleUrl = URL.createObjectURL(
+				new Blob([code], { type: "text/javascript" }),
+			);
+			const borogove = await import(moduleUrl);
+			const persistence = new borogove.persistence.Dummy();
+			persistence.getLogin = async () => ({
+				clientId: "test-client",
+				displayName: "Test",
+				fastCount: 0,
+				token: JSON.stringify({
+					token: "persisted secret",
+					expiry: "2099-01-01T00:00:00Z",
+					mechanism: "HT-SHA-256-NONE",
+				}),
+			});
+			persistence.getStreamManagement = async () => ({
+				sortId: "a ",
+				sm: null,
+			});
+			persistence.getChats = async () => [];
+			persistence.getChatsUnreadDetails = async () => [];
+			const client = new borogove.Client(
+				"tester@127.0.0.1",
+				persistence,
+			);
+			client.stream.debug = false;
+			const passwordRequested = Promise.withResolvers<void>();
+			client.addPasswordNeededListener(() => {
+				passwordRequested.resolve();
+			});
+			client.start();
+			await passwordRequested.promise;
+			client.stream.disconnect();
+		},
+		{ code: BOROGOVE_CODE },
+	);
+	await socketClosed;
+	expect(connectionCount).toBe(2);
+});