diff --git a/package-lock.json b/package-lock.json index 26c3679c..5bc4318d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14123,6 +14123,7 @@ "license": "ISC", "dependencies": { "@xmpp/connection": "^0.14.0", + "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0", diff --git a/packages/client-core/package.json b/packages/client-core/package.json index 2316fcd9..c6ed923c 100644 --- a/packages/client-core/package.json +++ b/packages/client-core/package.json @@ -9,6 +9,7 @@ "main": "index.js", "dependencies": { "@xmpp/connection": "^0.14.0", + "@xmpp/events": "^0.14.0", "@xmpp/jid": "^0.14.0", "@xmpp/sasl": "^0.14.0", "@xmpp/xml": "^0.14.0", diff --git a/packages/client-core/src/fast/README.md b/packages/client-core/src/fast/README.md index aa0e02b5..8940b9a3 100644 --- a/packages/client-core/src/fast/README.md +++ b/packages/client-core/src/fast/README.md @@ -1,34 +1,35 @@ # fast -fast for `@xmpp/client`. +fast for `@xmpp/client`. Included and enabled in `@xmpp/client`. -Included and enabled in `@xmpp/client`. +By default `@xmpp/fast` stores the token in memory and as such fast authentication will only be available starting with the first reconnect. -## Usage +You can supply your own functions to store and retrieve the token from a persistent database. -Resource is optional and will be chosen by the server if omitted. +If fast authentication fails, regular authentication with `credentials` will happen. -### string +## Usage ```js import { xmpp } from "@xmpp/client"; -const client = xmpp({ resource: "laptop" }); -``` - -### function - -Instead, you can provide a function that will be called every time resource binding occurs (every (re)connect). +const client = xmpp({ + ... +}); -```js -import { xmpp } from "@xmpp/client"; - -const client = xmpp({ resource: onBind }); +client.fast.fetchToken = async () => { + const value = await secureStorage.get("token") + return JSON.parse(value); +} -async function onBind(bind) { - const resource = await fetchResource(); - return resource; +client.fast.saveToken = async (token) => { + await secureStorage.set("token", JSON.stringify(token)); } + +// Debugging only +client.fast.on("error", (error) => { + console.log("fast error", error); +}) ``` ## References diff --git a/packages/client-core/src/fast/fast.js b/packages/client-core/src/fast/fast.js index 00140d20..2b3adbbc 100644 --- a/packages/client-core/src/fast/fast.js +++ b/packages/client-core/src/fast/fast.js @@ -1,46 +1,113 @@ +import { EventEmitter } from "@xmpp/events"; import { getAvailableMechanisms } from "@xmpp/sasl"; import xml from "@xmpp/xml"; import SASLFactory from "saslmechanisms"; const NS = "urn:xmpp:fast:0"; -export default function fast({ sasl2 }) { +export default function fast({ sasl2 }, { saveToken, fetchToken } = {}) { const saslFactory = new SASLFactory(); - const fast = { - token: null, - expiry: null, + const fast = new EventEmitter(); + + let token; + saveToken ??= async function saveToken(t) { + token = t; + }; + fetchToken ??= async function fetchToken() { + return token; + }; + + Object.assign(fast, { + async saveToken() { + try { + await saveToken(); + } catch (err) { + fast.emit("error", err); + } + }, + async fetchToken() { + try { + return await fetchToken(); + } catch (err) { + fast.emit("error", err); + } + }, saslFactory, - mechanisms: [], - mechanism: null, - available() { - return !!(this.token && this.mechanism); + async auth({ + authenticate, + entity, + userAgent, + token, + credentials, + streamFeatures, + features, + }) { + try { + await authenticate({ + saslFactory: fast.saslFactory, + mechanism: token.mechanism, + credentials: { + ...credentials, + password: token.token, + }, + streamFeatures: [ + ...streamFeatures, + xml("fast", { + xmlns: NS, + }), + ], + entity, + userAgent, + features, + }); + return true; + } catch (err) { + fast.emit("error", err); + return false; + } }, - }; + _requestToken(streamFeatures) { + streamFeatures.push( + xml("request-token", { + xmlns: NS, + mechanism: fast.mechanism, + }), + ); + }, + }); + + function reset() { + fast.mechanism = null; + } + reset(); sasl2.use( NS, async (element) => { - if (!element.is("fast", NS)) return; - fast.mechanisms = getAvailableMechanisms(element, NS, saslFactory); - fast.mechanism = fast.mechanisms[0]; + if (!element.is("fast", NS)) return reset(); - if (!fast.mechanism) return; + fast.available = true; - if (!fast.token) { - return xml("request-token", { - xmlns: NS, - mechanism: fast.mechanism, - }); - } + const mechanisms = getAvailableMechanisms(element, NS, saslFactory); + const mechanism = mechanisms[0]; + + if (!mechanism) return reset(); + fast.mechanism = mechanism; - return xml("fast", { xmlns: NS }); + // The rest is handled by @xmpp/sasl2 }, async (element) => { if (element.is("token", NS)) { - const { token, expiry } = element.attrs; - fast.token = token; - fast.expiry = expiry; + try { + await saveToken({ + mechanism: fast.mechanism, + token: element.attrs.token, + expiry: element.attrs.expiry, + }); + } catch (err) { + fast.emit("error", err); + } } }, ); diff --git a/packages/client/index.js b/packages/client/index.js index ddeb26ef..70f55ae4 100644 --- a/packages/client/index.js +++ b/packages/client/index.js @@ -26,7 +26,7 @@ import anonymous from "@xmpp/sasl-anonymous"; import htsha256none from "@xmpp/sasl-ht-sha-256-none"; function client(options = {}) { - const { resource, credentials, username, password, userAgent, ...params } = + let { resource, credentials, username, password, userAgent, ...params } = options; const { domain, service } = params; @@ -58,11 +58,22 @@ function client(options = {}) { anonymous, }).map(([k, v]) => ({ [k]: v(saslFactory) })); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const id = globalThis.crypto?.randomUUID?.(); + + let user_agent = + userAgent instanceof xml.Element + ? userAgent + : xml("user-agent", { id: userAgent?.id || id }, [ + userAgent?.software && xml("software", {}, userAgent.software), + userAgent?.device && xml("device", {}, userAgent.device), + ]); + // Stream features - order matters and define priority const starttls = setupIfAvailable(_starttls, { streamFeatures }); const sasl2 = _sasl2( { streamFeatures, saslFactory }, - createOnAuthenticate(credentials ?? { username, password }, userAgent), + createOnAuthenticate(credentials ?? { username, password }, user_agent), ); const fast = setupIfAvailable(_fast, { diff --git a/packages/client/lib/createOnAuthenticate.js b/packages/client/lib/createOnAuthenticate.js index c47ca72b..e248eb8f 100644 --- a/packages/client/lib/createOnAuthenticate.js +++ b/packages/client/lib/createOnAuthenticate.js @@ -16,9 +16,7 @@ export default function createOnAuthenticate(credentials, userAgent) { return; } - if (fast?.token) { - credentials.password = fast.token; - } + credentials.token = await fast?.fetchToken?.(); await authenticate(credentials, mechanisms[0], userAgent); }; diff --git a/packages/debug/index.js b/packages/debug/index.js index 0c182163..e1b9e063 100644 --- a/packages/debug/index.js +++ b/packages/debug/index.js @@ -7,6 +7,7 @@ import clone from "ltx/lib/clone.js"; const NS_SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; const NS_SASL2 = "urn:xmpp:sasl:2"; const NS_COMPONENT = "jabber:component:accept"; +const NS_FAST = "urn:xmpp:fast:0"; const SENSITIVES = [ ["handshake", NS_COMPONENT], @@ -39,6 +40,8 @@ export function hideSensitive(element) { hide(element.getChild("initial-response")); } else if (element.getNS() === NS_SASL2) { hide(element.getChild("additional-data")); + const token = element.getChild("token", NS_FAST); + token && (token.attrs.token = "hidden by xmpp.js"); } return element; diff --git a/packages/sasl2/README.md b/packages/sasl2/README.md index 65b91c53..00076e43 100644 --- a/packages/sasl2/README.md +++ b/packages/sasl2/README.md @@ -59,13 +59,8 @@ async function getUserAgent() { id = await crypto.randomUUID(); localStorage.set("user-agent-id", id); } - return ( - // https://xmpp.org/extensions/xep-0388.html#initiation - - xmpp.js - Sonny's Laptop - - ); + // https://xmpp.org/extensions/xep-0388.html#initiation + return { id, software: "xmpp.js", device: "Sonny's Laptop" }; // You can also pass an xml.Element } ``` diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js index 25798486..a00d83a2 100644 --- a/packages/sasl2/index.js +++ b/packages/sasl2/index.js @@ -98,49 +98,46 @@ export default function sasl2({ streamFeatures, saslFactory }, onAuthenticate) { "authentication", NS, async ({ entity }, _next, element) => { - const streamFeatures = await getStreamFeatures({ element, features }); + const mechanisms = getAvailableMechanisms(element, NS, saslFactory); + if (mechanisms.length === 0) { + throw new SASLError("SASL: No compatible mechanism available."); + } - const fastStreamFeature = [...streamFeatures].find((el) => - el?.is("fast", "urn:xmpp:fast:0"), - ); - const is_fast = fastStreamFeature && fast; + const streamFeatures = await getStreamFeatures({ element, features }); + const fast_available = !!fast?.mechanism; + await onAuthenticate(done, mechanisms, fast_available && fast); async function done(credentials, mechanism, userAgent) { - const params = { - entity, - credentials, - userAgent, - streamFeatures, - features, - }; - - if (is_fast) { - try { - await authenticate({ - saslFactory: fast.saslFactory, - mechanism: fast.mechanisms[0], - ...params, + if (fast_available) { + const { token } = credentials; + // eslint-disable-next-line unicorn/no-negated-condition + if (!token) { + fast._requestToken(streamFeatures); + } else { + const success = await fast.auth({ + authenticate, + entity, + userAgent, + token, + streamFeatures, + features, + credentials, }); - return; - } catch { + if (success) return; // If fast authentication fails, continue and try with sasl - streamFeatures.delete(fastStreamFeature); } } await authenticate({ + entity, + userAgent, + streamFeatures, + features, saslFactory, mechanism, - ...params, + credentials, }); } - - const mechanisms = getAvailableMechanisms(element, NS, saslFactory); - if (mechanisms.length === 0) { - throw new SASLError("SASL: No compatible mechanism available."); - } - - await onAuthenticate(done, mechanisms, is_fast && fast); }, ); @@ -167,5 +164,5 @@ async function getStreamFeatures({ element, features }) { promises.push(feature[0](element)); } - return new Set(await Promise.all(promises)); + return Promise.all(promises); } diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js index b2042769..547de79c 100644 --- a/packages/sasl2/test.js +++ b/packages/sasl2/test.js @@ -4,6 +4,13 @@ const username = "foo"; const password = "bar"; const credentials = { username, password }; +const userAgent = ( + + software + device + +); + test("No compatible mechanism available", async () => { const { entity } = mockClient({ username, password }); @@ -21,7 +28,7 @@ test("No compatible mechanism available", async () => { }); test("with object credentials", async () => { - const { entity } = mockClient({ credentials }); + const { entity } = mockClient({ credentials, userAgent }); entity.mockInput( @@ -34,6 +41,7 @@ test("with object credentials", async () => { expect(await promise(entity, "send")).toEqual( AGZvbwBiYXI= + {userAgent} , ); @@ -95,7 +103,7 @@ test("with function credentials", async () => { }); test("failure", async () => { - const { entity } = mockClient({ credentials }); + const { entity } = mockClient({ credentials, userAgent }); entity.mockInput( @@ -108,6 +116,7 @@ test("failure", async () => { expect(await promise(entity, "send")).toEqual( AGZvbwBiYXI= + {userAgent} , ); diff --git a/packages/stream-management/bind2.test.js b/packages/stream-management/bind2.test.js index 4bdfdbbf..a2a93203 100644 --- a/packages/stream-management/bind2.test.js +++ b/packages/stream-management/bind2.test.js @@ -20,14 +20,11 @@ test("enable", async () => { ); const stanza_out = await entity.catchOutgoing(); - expect(stanza_out).toEqual( - - {stanza_out.getChild("initial-response")} - - - - , - ); + const enable = stanza_out + .getChild("bind", "urn:xmpp:bind:0") + .getChild("enable"); + enable.parent = null; + expect(enable).toEqual(); expect(sm.enabled).toBe(false); expect(sm.id).toBe(""); @@ -67,14 +64,11 @@ test("Client failed to enable stream management", async () => { ); const stanza_out = await entity.catchOutgoing(); - expect(stanza_out).toEqual( - - {stanza_out.getChild("initial-response")} - - - - , - ); + const enable = stanza_out + .getChild("bind", "urn:xmpp:bind:0") + .getChild("enable"); + enable.parent = null; + expect(enable).toEqual(); expect(sm.enabled).toBe(false); expect(sm.id).toBe(""); diff --git a/packages/test/index.js b/packages/test/index.js index 88ca81ee..72e940ee 100644 --- a/packages/test/index.js +++ b/packages/test/index.js @@ -2,6 +2,7 @@ import context from "./context.js"; import xml from "@xmpp/xml"; import jid from "@xmpp/jid"; import mockClient from "./mockClient.js"; +import mockClientCore from "./mockClientCore.js"; import { delay, promise, timeout } from "@xmpp/events"; import id from "@xmpp/id"; @@ -11,6 +12,7 @@ export { jid, jid as JID, mockClient, + mockClientCore, delay, promise, timeout, diff --git a/packages/test/mockClientCore.js b/packages/test/mockClientCore.js new file mode 100644 index 00000000..bf1509b8 --- /dev/null +++ b/packages/test/mockClientCore.js @@ -0,0 +1,10 @@ +import { Client } from "@xmpp/client-core"; +import Connection from "@xmpp/connection"; +import context from "./context.js"; + +export default function mockClient(options) { + const xmpp = new Client(options); + xmpp.send = Connection.prototype.send; + const ctx = context(xmpp); + return Object.assign(xmpp, ctx); +}