diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index a6a16a82..47bab9f5 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -27,7 +27,7 @@ jobs:
echo deb http://packages.prosody.im/debian $(lsb_release -sc) main | sudo tee /etc/apt/sources.list.d/prosody.list
sudo wget https://prosody.im/files/prosody-debian-packages.key -O/etc/apt/trusted.gpg.d/prosody.gpg
sudo apt-get update
- sudo apt-get -y install prosody lua-bitop lua-sec
+ sudo apt-get -y install lua5.3 liblua5.3-dev prosody-trunk lua-bitop lua-sec luarocks
sudo service prosody stop
# - run: npm install -g npm
diff --git a/.gitignore b/.gitignore
index f10012a2..2e1fdeb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,8 @@ server/certs/
server/prosody.err
server/prosody.log
server/prosody.pid
+server/modules
+server/.cache
!.gitkeep
!.editorconfig
diff --git a/Makefile b/Makefile
index 487ab4ea..d1f0b5e3 100644
--- a/Makefile
+++ b/Makefile
@@ -28,10 +28,15 @@ ci:
make bundlesize
unit:
- npx jest
+ npm run test
e2e:
- NODE_TLS_REJECT_UNAUTHORIZED=0 npx jest --runInBand --config e2e.config.cjs
+ $(warning e2e tests require prosody-trunk and luarocks)
+ cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2 > /dev/null
+ cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_bind2 > /dev/null
+ cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_fast > /dev/null
+ cd server && prosodyctl --config prosody.cfg.lua install mod_sasl2_sm > /dev/null
+ npm run e2e
clean:
make stop
diff --git a/package-lock.json b/package-lock.json
index ceef3302..73fc2e01 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4569,6 +4569,10 @@
"resolved": "packages/sasl-anonymous",
"link": true
},
+ "node_modules/@xmpp/sasl-ht-sha-256-none": {
+ "resolved": "packages/sasl-ht-sha-256-none",
+ "link": true
+ },
"node_modules/@xmpp/sasl-plain": {
"resolved": "packages/sasl-plain",
"link": true
@@ -4577,6 +4581,10 @@
"resolved": "packages/sasl-scram-sha-1",
"link": true
},
+ "node_modules/@xmpp/sasl2": {
+ "resolved": "packages/sasl2",
+ "link": true
+ },
"node_modules/@xmpp/session-establishment": {
"resolved": "packages/session-establishment",
"link": true
@@ -13095,6 +13103,8 @@
},
"node_modules/saslmechanisms": {
"version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/saslmechanisms/-/saslmechanisms-0.1.1.tgz",
+ "integrity": "sha512-pVlvK5ysevz8MzybRnDIa2YMxn0OJ7b9lDiWhMoaKPoJ7YkAg/7YtNjUgaYzElkwHxsw8dBMhaEn7UP6zxEwPg==",
"engines": {
"node": ">= 0.4.0"
}
@@ -14557,15 +14567,18 @@
"@xmpp/resource-binding": "^0.13.2",
"@xmpp/sasl": "^0.13.2",
"@xmpp/sasl-anonymous": "^0.13.2",
+ "@xmpp/sasl-ht-sha-256-none": "^0.13.0",
"@xmpp/sasl-plain": "^0.13.2",
"@xmpp/sasl-scram-sha-1": "^0.13.2",
+ "@xmpp/sasl2": "^0.13.0",
"@xmpp/session-establishment": "^0.13.2",
"@xmpp/starttls": "^0.13.2",
"@xmpp/stream-features": "^0.13.2",
"@xmpp/stream-management": "^0.13.2",
"@xmpp/tcp": "^0.13.2",
"@xmpp/tls": "^0.13.2",
- "@xmpp/websocket": "^0.13.2"
+ "@xmpp/websocket": "^0.13.2",
+ "saslmechanisms": "^0.1.1"
},
"engines": {
"node": ">= 20"
@@ -14753,8 +14766,7 @@
"dependencies": {
"@xmpp/base64": "^0.13.2",
"@xmpp/error": "^0.13.2",
- "@xmpp/xml": "^0.13.2",
- "saslmechanisms": "^0.1.1"
+ "@xmpp/xml": "^0.13.2"
},
"engines": {
"node": ">= 20"
@@ -14771,6 +14783,17 @@
"node": ">= 20"
}
},
+ "packages/sasl-ht-sha-256-none": {
+ "name": "@xmpp/sasl-ht-sha-256-none",
+ "version": "0.13.0",
+ "license": "ISC",
+ "dependencies": {
+ "create-hmac": "^1.1.7"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"packages/sasl-plain": {
"name": "@xmpp/sasl-plain",
"version": "0.13.2",
@@ -14793,6 +14816,21 @@
"node": ">= 20"
}
},
+ "packages/sasl2": {
+ "name": "@xmpp/sasl2",
+ "version": "0.13.0",
+ "license": "ISC",
+ "dependencies": {
+ "@xmpp/base64": "^0.13.0",
+ "@xmpp/error": "^0.13.0",
+ "@xmpp/jid": "^0.13.0",
+ "@xmpp/sasl": "^0.13.2",
+ "@xmpp/xml": "^0.13.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"packages/session-establishment": {
"name": "@xmpp/session-establishment",
"version": "0.13.2",
@@ -14945,8 +14983,10 @@
"@xmpp/resource-binding": "^0.13.2",
"@xmpp/sasl": "^0.13.2",
"@xmpp/sasl-anonymous": "^0.13.2",
+ "@xmpp/sasl-ht-sha-256-none": "^0.13.0",
"@xmpp/sasl-plain": "^0.13.2",
"@xmpp/sasl-scram-sha-1": "^0.13.2",
+ "@xmpp/sasl2": "^0.13.0",
"@xmpp/session-establishment": "^0.13.2",
"@xmpp/starttls": "^0.13.2",
"@xmpp/stream-features": "^0.13.2",
diff --git a/package.json b/package.json
index 8629245d..fdd16165 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,8 @@
"selfsigned": "^2.4.1"
},
"scripts": {
+ "test": "npx jest",
+ "e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 npx jest --runInBand --config e2e.config.cjs",
"preversion": "make bundle"
},
"engines": {
diff --git a/packages/client/browser.js b/packages/client/browser.js
index 3812fb45..baecf921 100644
--- a/packages/client/browser.js
+++ b/packages/client/browser.js
@@ -1,5 +1,6 @@
import { xml, jid, Client } from "@xmpp/client-core";
import getDomain from "./lib/getDomain.js";
+import SASLFactory from "saslmechanisms";
import _reconnect from "@xmpp/reconnect";
import _websocket from "@xmpp/websocket";
@@ -8,7 +9,7 @@ import _streamFeatures from "@xmpp/stream-features";
import _iqCaller from "@xmpp/iq/caller.js";
import _iqCallee from "@xmpp/iq/callee.js";
import _resolve from "@xmpp/resolve";
-
+import _sasl2 from "@xmpp/sasl2";
import _sasl from "@xmpp/sasl";
import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
@@ -19,6 +20,7 @@ import anonymous from "@xmpp/sasl-anonymous";
function client(options = {}) {
const { resource, credentials, username, password, ...params } = options;
+ const { clientId, software, device } = params;
const { domain, service } = params;
if (!domain && service) {
@@ -36,11 +38,30 @@ function client(options = {}) {
const iqCallee = _iqCallee({ middleware, entity });
const resolve = _resolve({ entity });
// Stream features - order matters and define priority
- const sasl = _sasl({ streamFeatures }, credentials || { username, password });
+
+ const saslFactory = new SASLFactory();
+ // SASL mechanisms - order matters and define priority
+ const mechanisms = Object.entries({
+ plain,
+ anonymous,
+ }).map(([k, v]) => ({ [k]: v(saslFactory) }));
+
+ const sasl2 = _sasl2(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ { clientId, software, device },
+ );
+
+ const sasl = _sasl(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ );
+
const streamManagement = _streamManagement({
streamFeatures,
entity,
middleware,
+ sasl2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
@@ -50,10 +71,6 @@ function client(options = {}) {
iqCaller,
streamFeatures,
});
- // SASL mechanisms - order matters and define priority
- const mechanisms = Object.entries({ plain, anonymous }).map(([k, v]) => ({
- [k]: v(sasl),
- }));
return Object.assign(entity, {
entity,
@@ -64,6 +81,8 @@ function client(options = {}) {
iqCaller,
iqCallee,
resolve,
+ saslFactory,
+ sasl2,
sasl,
resourceBinding,
sessionEstablishment,
diff --git a/packages/client/index.js b/packages/client/index.js
index e944db40..2828ccc8 100644
--- a/packages/client/index.js
+++ b/packages/client/index.js
@@ -1,5 +1,6 @@
import { xml, jid, Client } from "@xmpp/client-core";
import getDomain from "./lib/getDomain.js";
+import SASLFactory from "saslmechanisms";
import _reconnect from "@xmpp/reconnect";
import _websocket from "@xmpp/websocket";
@@ -10,19 +11,21 @@ import _streamFeatures from "@xmpp/stream-features";
import _iqCaller from "@xmpp/iq/caller.js";
import _iqCallee from "@xmpp/iq/callee.js";
import _resolve from "@xmpp/resolve";
-
import _starttls from "@xmpp/starttls/client.js";
+import _sasl2 from "@xmpp/sasl2";
import _sasl from "@xmpp/sasl";
import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
import _streamManagement from "@xmpp/stream-management";
import scramsha1 from "@xmpp/sasl-scram-sha-1";
+import htsha256 from "@xmpp/sasl-ht-sha-256-none";
import plain from "@xmpp/sasl-plain";
import anonymous from "@xmpp/sasl-anonymous";
function client(options = {}) {
const { resource, credentials, username, password, ...params } = options;
+ const { clientId, software, device } = params;
const { domain, service } = params;
if (!domain && service) {
@@ -43,11 +46,32 @@ function client(options = {}) {
const resolve = _resolve({ entity });
// Stream features - order matters and define priority
const starttls = _starttls({ streamFeatures });
- const sasl = _sasl({ streamFeatures }, credentials || { username, password });
+
+ const saslFactory = new SASLFactory();
+ // SASL mechanisms - order matters and define priority
+ const mechanisms = Object.entries({
+ scramsha1,
+ htsha256,
+ plain,
+ anonymous,
+ }).map(([k, v]) => ({ [k]: v(saslFactory) }));
+
+ const sasl2 = _sasl2(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ { clientId, software, device },
+ );
+
+ const sasl = _sasl(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ );
+
const streamManagement = _streamManagement({
streamFeatures,
entity,
middleware,
+ sasl2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
@@ -57,12 +81,6 @@ function client(options = {}) {
iqCaller,
streamFeatures,
});
- // SASL mechanisms - order matters and define priority
- const mechanisms = Object.entries({
- scramsha1,
- plain,
- anonymous,
- }).map(([k, v]) => ({ [k]: v(sasl) }));
return Object.assign(entity, {
entity,
@@ -76,6 +94,8 @@ function client(options = {}) {
iqCallee,
resolve,
starttls,
+ saslFactory,
+ sasl2,
sasl,
resourceBinding,
sessionEstablishment,
diff --git a/packages/client/package.json b/packages/client/package.json
index 59b6cdfb..b71d73da 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -16,15 +16,18 @@
"@xmpp/resource-binding": "^0.13.2",
"@xmpp/sasl": "^0.13.2",
"@xmpp/sasl-anonymous": "^0.13.2",
+ "@xmpp/sasl-ht-sha-256-none": "^0.13.0",
"@xmpp/sasl-plain": "^0.13.2",
"@xmpp/sasl-scram-sha-1": "^0.13.2",
+ "@xmpp/sasl2": "^0.13.0",
"@xmpp/session-establishment": "^0.13.2",
"@xmpp/starttls": "^0.13.2",
"@xmpp/stream-features": "^0.13.2",
"@xmpp/stream-management": "^0.13.2",
"@xmpp/tcp": "^0.13.2",
"@xmpp/tls": "^0.13.2",
- "@xmpp/websocket": "^0.13.2"
+ "@xmpp/websocket": "^0.13.2",
+ "saslmechanisms": "^0.1.1"
},
"browser": "./browser.js",
"react-native": "./react-native.js",
diff --git a/packages/client/react-native.js b/packages/client/react-native.js
index 3812fb45..baecf921 100644
--- a/packages/client/react-native.js
+++ b/packages/client/react-native.js
@@ -1,5 +1,6 @@
import { xml, jid, Client } from "@xmpp/client-core";
import getDomain from "./lib/getDomain.js";
+import SASLFactory from "saslmechanisms";
import _reconnect from "@xmpp/reconnect";
import _websocket from "@xmpp/websocket";
@@ -8,7 +9,7 @@ import _streamFeatures from "@xmpp/stream-features";
import _iqCaller from "@xmpp/iq/caller.js";
import _iqCallee from "@xmpp/iq/callee.js";
import _resolve from "@xmpp/resolve";
-
+import _sasl2 from "@xmpp/sasl2";
import _sasl from "@xmpp/sasl";
import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
@@ -19,6 +20,7 @@ import anonymous from "@xmpp/sasl-anonymous";
function client(options = {}) {
const { resource, credentials, username, password, ...params } = options;
+ const { clientId, software, device } = params;
const { domain, service } = params;
if (!domain && service) {
@@ -36,11 +38,30 @@ function client(options = {}) {
const iqCallee = _iqCallee({ middleware, entity });
const resolve = _resolve({ entity });
// Stream features - order matters and define priority
- const sasl = _sasl({ streamFeatures }, credentials || { username, password });
+
+ const saslFactory = new SASLFactory();
+ // SASL mechanisms - order matters and define priority
+ const mechanisms = Object.entries({
+ plain,
+ anonymous,
+ }).map(([k, v]) => ({ [k]: v(saslFactory) }));
+
+ const sasl2 = _sasl2(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ { clientId, software, device },
+ );
+
+ const sasl = _sasl(
+ { streamFeatures, saslFactory },
+ credentials || { username, password },
+ );
+
const streamManagement = _streamManagement({
streamFeatures,
entity,
middleware,
+ sasl2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
@@ -50,10 +71,6 @@ function client(options = {}) {
iqCaller,
streamFeatures,
});
- // SASL mechanisms - order matters and define priority
- const mechanisms = Object.entries({ plain, anonymous }).map(([k, v]) => ({
- [k]: v(sasl),
- }));
return Object.assign(entity, {
entity,
@@ -64,6 +81,8 @@ function client(options = {}) {
iqCaller,
iqCallee,
resolve,
+ saslFactory,
+ sasl2,
sasl,
resourceBinding,
sessionEstablishment,
diff --git a/packages/connection/index.js b/packages/connection/index.js
index 5ab1a714..ca6cb249 100644
--- a/packages/connection/index.js
+++ b/packages/connection/index.js
@@ -256,6 +256,15 @@ class Connection extends EventEmitter {
const headerElement = this.headerElement();
headerElement.attrs.to = domain;
+ if (
+ this.socket.secure &&
+ this.socket.secure() &&
+ (this.streamFrom || this.jid)
+ ) {
+ // When the stream is secure there is no leak to setting the stream from
+ // This is recommended in general and required for FAST implementations
+ headerElement.attrs.from = (this.streamFrom || this.jid).toString();
+ }
headerElement.attrs["xml:lang"] = lang;
this.root = headerElement;
diff --git a/packages/error/test.js b/packages/error/test.js
index da3b3867..5905200d 100644
--- a/packages/error/test.js
+++ b/packages/error/test.js
@@ -20,6 +20,7 @@ test("fromElement", () => {
const error = XMPPError.fromElement(nonza);
expect(error instanceof Error).toBe(true);
+ expect(error instanceof XMPPError).toBe(true);
expect(error.name).toBe("XMPPError");
expect(error.condition).toBe("some-condition");
expect(error.text).toBe("foo");
@@ -42,6 +43,7 @@ test("fromElement - whitespaces", () => {
const error = XMPPError.fromElement(nonza);
expect(error instanceof Error).toBe(true);
+ expect(error instanceof XMPPError).toBe(true);
expect(error.name).toBe("XMPPError");
expect(error.condition).toBe("some-condition");
expect(error.text).toBe("\n foo\n ");
diff --git a/packages/sasl-ht-sha-256-none/index.js b/packages/sasl-ht-sha-256-none/index.js
new file mode 100644
index 00000000..5a38015a
--- /dev/null
+++ b/packages/sasl-ht-sha-256-none/index.js
@@ -0,0 +1,27 @@
+// https://datatracker.ietf.org/doc/draft-schmaus-kitten-sasl-ht/
+import createHmac from "create-hmac";
+
+function Mechanism() {}
+
+Mechanism.prototype.Mechanism = Mechanism;
+Mechanism.prototype.name = "HT-SHA-256-NONE";
+Mechanism.prototype.clientFirst = true;
+
+Mechanism.prototype.response = function response(cred) {
+ this.password = cred.password;
+ const hmac = createHmac("sha256", this.password);
+ hmac.update("Initiator");
+ return cred.username + "\0" + hmac.digest("latin1");
+};
+
+Mechanism.prototype.final = function final(data) {
+ const hmac = createHmac("sha256", this.password);
+ hmac.update("Responder");
+ if (hmac.digest("latin1") !== data) {
+ throw new Error("Responder message from server was wrong");
+ }
+};
+
+export default function saslHashedToken(sasl) {
+ sasl.use(Mechanism);
+}
diff --git a/packages/sasl-ht-sha-256-none/package.json b/packages/sasl-ht-sha-256-none/package.json
new file mode 100644
index 00000000..3951a476
--- /dev/null
+++ b/packages/sasl-ht-sha-256-none/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@xmpp/sasl-ht-sha-256-none",
+ "description": "XMPP SASL HT-SHA-256-NONE for JavaScript",
+ "repository": "github:xmppjs/xmpp.js",
+ "homepage": "https://github.com/xmppjs/xmpp.js/tree/main/packages/sasl-ht-sha-256-none",
+ "bugs": "http://github.com/xmppjs/xmpp.js/issues",
+ "version": "0.13.0",
+ "license": "ISC",
+ "type": "module",
+ "keywords": [
+ "XMPP",
+ "sasl",
+ "plain"
+ ],
+ "dependencies": {
+ "create-hmac": "^1.1.7"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/sasl/README.md b/packages/sasl/README.md
index c29bccd2..8efc7145 100644
--- a/packages/sasl/README.md
+++ b/packages/sasl/README.md
@@ -36,7 +36,7 @@ import { xmpp } from "@xmpp/client";
const client = xmpp({ credentials: authenticate });
-async function authenticate(auth, mechanism) {
+async function authenticate(saslFactory, mechanism) {
console.debug("authenticate", mechanism);
const credentials = {
username: await prompt("enter username"),
diff --git a/packages/sasl/index.js b/packages/sasl/index.js
index 42f6297d..fdc293e1 100644
--- a/packages/sasl/index.js
+++ b/packages/sasl/index.js
@@ -1,7 +1,6 @@
import { encode, decode } from "@xmpp/base64";
import SASLError from "./lib/SASLError.js";
import xml from "@xmpp/xml";
-import SASLFactory from "saslmechanisms";
// https://xmpp.org/rfcs/rfc6120.html#sasl
@@ -14,8 +13,8 @@ function getMechanismNames(features) {
.map((el) => el.text());
}
-async function authenticate(SASL, entity, mechname, credentials) {
- const mech = SASL.create([mechname]);
+async function authenticate(saslFactory, entity, mechname, credentials) {
+ const mech = saslFactory.create([mechname]);
if (!mech) {
throw new Error("No compatible mechanism");
}
@@ -74,12 +73,10 @@ async function authenticate(SASL, entity, mechname, credentials) {
});
}
-export default function sasl({ streamFeatures }, credentials) {
- const SASL = new SASLFactory();
-
+export default function sasl({ streamFeatures, saslFactory }, credentials) {
streamFeatures.use("mechanisms", NS, async ({ stanza, entity }) => {
const offered = getMechanismNames(stanza);
- const supported = SASL._mechs.map(({ name }) => name);
+ const supported = saslFactory._mechs.map(({ name }) => name);
// eslint-disable-next-line unicorn/prefer-array-find
const intersection = supported.filter((mech) => {
return offered.includes(mech);
@@ -88,7 +85,7 @@ export default function sasl({ streamFeatures }, credentials) {
if (typeof credentials === "function") {
await credentials(
- (creds) => authenticate(SASL, entity, mech, creds, stanza),
+ (creds) => authenticate(saslFactory, entity, mech, creds, stanza),
mech,
);
} else {
@@ -96,15 +93,11 @@ export default function sasl({ streamFeatures }, credentials) {
mech = "ANONYMOUS";
}
- await authenticate(SASL, entity, mech, credentials, stanza);
+ await authenticate(saslFactory, entity, mech, credentials, stanza);
}
await entity.restart();
});
- return {
- use(...args) {
- return SASL.use(...args);
- },
- };
+ return {};
}
diff --git a/packages/sasl/lib/SASLError.test.js b/packages/sasl/lib/SASLError.test.js
new file mode 100644
index 00000000..78b7b325
--- /dev/null
+++ b/packages/sasl/lib/SASLError.test.js
@@ -0,0 +1,71 @@
+import XMPPError from "@xmpp/error";
+import SASLError from "./SASLError.js";
+
+// https://xmpp.org/rfcs/rfc6120.html#rfc.section.6.4.5
+// https://xmpp.org/rfcs/rfc6120.html#rfc.section.A.4
+
+test("SASL", () => {
+ const nonza = (
+
+
+
+ );
+
+ const error = SASLError.fromElement(nonza);
+
+ expect(error instanceof Error).toBe(true);
+ expect(error instanceof XMPPError).toBe(true);
+ expect(error instanceof SASLError).toBe(true);
+ expect(error.name).toBe("SASLError");
+ expect(error.condition).toBe("not-authorized");
+ expect(error.text).toBe("");
+});
+
+test("SASL with text", () => {
+ const nonza = (
+
+
+ foo
+
+ );
+ expect(SASLError.fromElement(nonza).text).toBe("foo");
+});
+
+// https://xmpp.org/extensions/xep-0388.html#failure
+// https://github.com/xsf/xeps/pull/1411
+// https://xmpp.org/extensions/xep-0388.html#sect-idm46365286031040
+
+test("SASL2", () => {
+ const nonza = (
+
+
+
+ );
+
+ const error = SASLError.fromElement(nonza);
+
+ expect(error instanceof Error).toBe(true);
+ expect(error instanceof XMPPError).toBe(true);
+ expect(error instanceof SASLError).toBe(true);
+ expect(error.name).toBe("SASLError");
+ expect(error.condition).toBe("aborted");
+});
+
+test("SASL2 with text and application", () => {
+ const application = (
+
+ );
+
+ const nonza = (
+
+
+ This is a terrible example.
+ {application}
+
+ );
+
+ const error = SASLError.fromElement(nonza);
+
+ expect(error.text).toBe("This is a terrible example.");
+ expect(error.application).toBe(application);
+});
diff --git a/packages/sasl/package.json b/packages/sasl/package.json
index 8bb5ef1b..ec63bb0b 100644
--- a/packages/sasl/package.json
+++ b/packages/sasl/package.json
@@ -15,8 +15,7 @@
"dependencies": {
"@xmpp/base64": "^0.13.2",
"@xmpp/error": "^0.13.2",
- "@xmpp/xml": "^0.13.2",
- "saslmechanisms": "^0.1.1"
+ "@xmpp/xml": "^0.13.2"
},
"engines": {
"node": ">= 20"
diff --git a/packages/sasl2/README.md b/packages/sasl2/README.md
new file mode 100644
index 00000000..a240a81e
--- /dev/null
+++ b/packages/sasl2/README.md
@@ -0,0 +1,81 @@
+# SASL2
+
+SASL2 Negotiation for `@xmpp/client` (including optional BIND2 and FAST).
+
+Note that if you set clientId then BIND2 will be used so you will not get offline messages (and are expected to do a MAM sync instead if you want that).
+
+Included and enabled in `@xmpp/client`.
+
+## Usage
+
+### object
+
+```js
+import { xmpp } from "@xmpp/client";
+const client = xmpp({
+ credentials: {
+ username: "foo",
+ password: "bar",
+ clientId: "Some UUID for this client/server pair (optional)",
+ software: "Name of this software (optional)",
+ device: "Description of this device (optional)",
+ },
+});
+```
+
+### function
+
+Instead, you can provide a function that will be called every time authentication occurs (every (re)connect).
+
+Uses cases:
+
+- Have the user enter the password every time
+- Do not ask for password before connection is made
+- Debug authentication
+- Using a SASL mechanism with specific requirements (such as FAST)
+- Perform an asynchronous operation to get credentials
+
+```js
+import { xmpp } from "@xmpp/client";
+const client = xmpp({
+ credentials: authenticate,
+ clientId: "Some UUID for this client/server pair (optional)",
+ software: "Name of this software (optional)",
+ device: "Description of this device (optional)",
+});
+
+async function authenticate(callback, mechanisms) {
+ const fast = mechanisms.find((mech) => mech.canFast)?.name;
+ const mech = mechanisms.find((mech) => mech.canOther)?.name;
+
+ if (fast) {
+ const [token, count] = await db.lookupFast(clientId);
+ if (token) {
+ await db.incrementFastCount(clientId);
+ return callback(
+ {
+ username: await prompt("enter username"),
+ password: token,
+ fastCount: count,
+ },
+ fast,
+ );
+ }
+ }
+
+ return callback(
+ {
+ username: await prompt("enter username"),
+ password: await prompt("enter password"),
+ requestToken: fast,
+ },
+ mech,
+ );
+}
+```
+
+## References
+
+- [SASL2](https://xmpp.org/extensions/xep-0388.html)
+- [BIND2](https://xmpp.org/extensions/xep-0386.html)
+- [FAST](https://xmpp.org/extensions/xep-0484.html)
diff --git a/packages/sasl2/index.js b/packages/sasl2/index.js
new file mode 100644
index 00000000..bc2ae17a
--- /dev/null
+++ b/packages/sasl2/index.js
@@ -0,0 +1,239 @@
+import { encode, decode } from "@xmpp/base64";
+import SASLError from "@xmpp/sasl/lib/SASLError.js";
+import jid from "@xmpp/jid";
+import xml from "@xmpp/xml";
+
+// https://xmpp.org/extensions/xep-0388.html
+// https://xmpp.org/extensions/xep-0386.html
+// https://xmpp.org/extensions/xep-0484.html
+
+const NS = "urn:xmpp:sasl:2";
+const BIND2_NS = "urn:xmpp:bind:0";
+const FAST_NS = "urn:xmpp:fast:0";
+
+async function authenticate(
+ saslFactory,
+ inlineHandlers,
+ bindInlineHandlers,
+ entity,
+ mechname,
+ credentials,
+ userAgent,
+ features,
+) {
+ const mech = saslFactory.create([mechname]);
+ if (!mech) {
+ throw new Error("No compatible mechanism");
+ }
+
+ const { domain } = entity.options;
+ const creds = {
+ username: null,
+ password: null,
+ server: domain,
+ host: domain,
+ realm: domain,
+ serviceType: "xmpp",
+ serviceName: domain,
+ ...credentials,
+ };
+
+ const promise = new Promise((resolve, reject) => {
+ const handler = (element) => {
+ if (element.attrs.xmlns !== NS) {
+ return;
+ }
+
+ if (element.name === "challenge") {
+ mech.challenge(decode(element.text()));
+ const resp = mech.response(creds);
+ entity.send(
+ xml(
+ "response",
+ { xmlns: NS, mechanism: mech.name },
+ typeof resp === "string" ? encode(resp) : "",
+ ),
+ );
+ return;
+ }
+
+ switch (element.name) {
+ case "failure": {
+ reject(SASLError.fromElement(element));
+ break;
+ }
+ case "continue": {
+ // No tasks supported yet
+ reject();
+ break;
+ }
+ case "success": {
+ const additionalData = element.getChild("additional-data")?.text();
+ if (additionalData && mech.final) {
+ mech.final(decode(additionalData));
+ }
+ // This jid will be bare unless we do inline bind2 then it will be the bound full jid
+ const aid = element.getChild("authorization-identifier")?.text();
+ if (aid) {
+ if (!entity.jid?.resource) {
+ // No jid or bare jid, so update it
+ entity._jid(aid);
+ } else if (jid(aid).resource) {
+ // We have a full jid so use it
+ entity._jid(aid);
+ }
+ }
+ const token = element.getChild("token", FAST_NS);
+ if (token) {
+ entity.emit("fast-token", token);
+ }
+ resolve(element);
+ break;
+ }
+ }
+
+ entity.removeListener("nonza", handler);
+ };
+
+ entity.on("nonza", handler);
+ });
+
+ const sendInline = [];
+ const hPromises = [];
+ const inline = features.getChild("authentication", NS).getChild("inline");
+ for (const el of inline?.children || []) {
+ const h = inlineHandlers["{" + el.attrs.xmlns + "}" + el.name];
+ if (h) {
+ hPromises.push(
+ h(el, (addEl) => {
+ sendInline.push(addEl);
+ return promise;
+ }),
+ );
+ }
+ }
+
+ const bindInline = [];
+ const bind2 = inline?.getChild("bind", BIND2_NS);
+ for (const el of bind2?.getChild("inline")?.getChildren("feature") || []) {
+ const h = bindInlineHandlers[el.attrs.var];
+ if (h) {
+ hPromises.push(
+ h((addEl) => {
+ bindInline.push(addEl);
+ return promise;
+ }),
+ );
+ }
+ }
+
+ entity.send(
+ xml("authenticate", { xmlns: NS, mechanism: mech.name }, [
+ mech.clientFirst &&
+ xml("initial-response", {}, encode(mech.response(creds))),
+ (userAgent?.clientId || userAgent?.software || userAgent?.device) &&
+ xml(
+ "user-agent",
+ userAgent.clientId ? { id: userAgent.clientId } : {},
+ [
+ userAgent.software && xml("software", {}, userAgent.software),
+ userAgent.device && xml("device", {}, userAgent.device),
+ ],
+ ),
+ bind2 != null &&
+ userAgent?.clientId &&
+ xml("bind", { xmlns: BIND2_NS }, [
+ userAgent?.software && xml("tag", {}, userAgent.software),
+ ...bindInline,
+ ]),
+ credentials.requestToken &&
+ xml(
+ "request-token",
+ { xmlns: FAST_NS, mechanism: credentials.requestToken },
+ [],
+ ),
+ (credentials.fastCount || credentials.fastCount === 0) &&
+ xml("fast", { xmlns: FAST_NS, count: credentials.fastCount }, []),
+ ...sendInline,
+ ]),
+ );
+
+ await Promise.all([promise, ...hPromises]);
+}
+
+export default function sasl2(
+ { streamFeatures, saslFactory },
+ credentials,
+ userAgent,
+) {
+ const handlers = {};
+ const bindHandlers = {};
+
+ streamFeatures.use("authentication", NS, async ({ stanza, entity }) => {
+ const offered = new Set(
+ stanza
+ .getChild("authentication", NS)
+ .getChildren("mechanism", NS)
+ .map((m) => m.text()),
+ );
+ const fast = new Set(
+ stanza
+ .getChild("authentication", NS)
+ .getChild("inline")
+ ?.getChild("fast", FAST_NS)
+ ?.getChildren("mechanism", FAST_NS)
+ ?.map((m) => m.text()) || [],
+ );
+ const supported = saslFactory._mechs.map(({ name }) => name);
+
+ const intersection = supported
+ .map((mech) => ({
+ name: mech,
+ canFast: fast.has(mech),
+ canOther: offered.has(mech),
+ }))
+ .filter((mech) => mech.canFast || mech.canOther);
+
+ if (typeof credentials === "function") {
+ await credentials((creds, mech) => {
+ authenticate(
+ saslFactory,
+ handlers,
+ bindHandlers,
+ entity,
+ mech,
+ creds,
+ userAgent,
+ stanza,
+ );
+ }, intersection);
+ } else {
+ let mech = intersection[0]?.name;
+ if (!credentials.username && !credentials.password) {
+ mech = "ANONYMOUS";
+ }
+
+ await authenticate(
+ saslFactory,
+ handlers,
+ bindHandlers,
+ entity,
+ mech,
+ credentials,
+ userAgent,
+ stanza,
+ );
+ }
+
+ return true; // Not online yet, wait for next features
+ });
+
+ return {
+ inline(name, xmlns, handler) {
+ handlers["{" + xmlns + "}" + name] = handler;
+ },
+ bindInline(feature, handler) {
+ bindHandlers[feature] = handler;
+ },
+ };
+}
diff --git a/packages/sasl2/package.json b/packages/sasl2/package.json
new file mode 100644
index 00000000..15705e48
--- /dev/null
+++ b/packages/sasl2/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@xmpp/sasl2",
+ "description": "XMPP SASL2 for JavaScript",
+ "repository": "github:xmppjs/xmpp.js",
+ "homepage": "https://github.com/xmppjs/xmpp.js/tree/main/packages/sasl2",
+ "bugs": "http://github.com/xmppjs/xmpp.js/issues",
+ "version": "0.13.0",
+ "license": "ISC",
+ "type": "module",
+ "keywords": [
+ "XMPP",
+ "sasl"
+ ],
+ "dependencies": {
+ "@xmpp/base64": "^0.13.0",
+ "@xmpp/error": "^0.13.0",
+ "@xmpp/sasl": "^0.13.2",
+ "@xmpp/jid": "^0.13.0",
+ "@xmpp/xml": "^0.13.0"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/sasl2/test.js b/packages/sasl2/test.js
new file mode 100644
index 00000000..844430ea
--- /dev/null
+++ b/packages/sasl2/test.js
@@ -0,0 +1,286 @@
+import { mockClient, promise } from "@xmpp/test";
+import parse from "@xmpp/xml/lib/parse.js";
+
+const username = "foo";
+const password = "bar";
+const credentials = { username, password };
+
+test("no compatibles mechanisms", async () => {
+ const { entity } = mockClient({ username, password });
+
+ entity.mockInput(
+
+
+ FOO
+
+ ,
+ );
+
+ const error = await promise(entity, "error");
+ expect(error instanceof Error).toBe(true);
+ expect(error.message).toBe("No compatible mechanism");
+});
+
+test("with object credentials", async () => {
+ const { entity } = mockClient({ credentials });
+
+ entity.mockInput(
+
+
+ PLAIN
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+ AGZvbwBiYXI=
+ ,
+ );
+
+ entity.mockInput();
+ entity.mockInput();
+
+ await promise(entity, "online");
+});
+
+test("with function credentials", async () => {
+ const mech = "PLAIN";
+
+ function authenticate(auth, mechanisms) {
+ expect(mechanisms).toEqual([
+ { name: mech, canFast: false, canOther: true },
+ ]);
+ return auth(credentials, mech);
+ }
+
+ const { entity } = mockClient({ credentials: authenticate });
+
+ entity.mockInput(
+
+
+ {mech}
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+ AGZvbwBiYXI=
+ ,
+ );
+
+ entity.mockInput();
+ entity.mockInput();
+
+ await promise(entity, "online");
+});
+
+test("failure", async () => {
+ const { entity } = mockClient({ credentials });
+
+ entity.mockInput(
+
+
+ PLAIN
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+ AGZvbwBiYXI=
+ ,
+ );
+
+ const failure = (
+
+
+
+ );
+
+ entity.mockInput(failure);
+
+ const error = await promise(entity, "error");
+ expect(error instanceof Error).toBe(true);
+ expect(error.name).toBe("SASLError");
+ expect(error.condition).toBe("some-condition");
+ expect(error.element).toBe(failure);
+});
+
+test("prefers SCRAM-SHA-1", async () => {
+ const { entity } = mockClient({ credentials });
+
+ entity.mockInput(
+
+
+ ANONYMOUS
+ PLAIN
+ SCRAM-SHA-1
+
+ ,
+ );
+
+ const result = await promise(entity, "send");
+ expect(result.attrs.mechanism).toEqual("SCRAM-SHA-1");
+});
+
+test("use ANONYMOUS if username and password are not provided", async () => {
+ const { entity } = mockClient();
+
+ entity.mockInput(
+
+
+ ANONYMOUS
+ PLAIN
+ SCRAM-SHA-1
+
+ ,
+ );
+
+ const result = await promise(entity, "send");
+ expect(result.attrs.mechanism).toEqual("ANONYMOUS");
+});
+
+test("with whitespaces", async () => {
+ const { entity } = mockClient();
+
+ entity.mockInput(
+ parse(
+ `
+
+
+ ANONYMOUS
+ PLAIN
+ SCRAM-SHA-1
+
+
+ `.trim(),
+ ),
+ );
+
+ const result = await promise(entity, "send");
+ expect(result.attrs.mechanism).toEqual("ANONYMOUS");
+});
+
+test("with bind2", async () => {
+ const { entity } = mockClient({
+ credentials,
+ clientId: "uniqueid",
+ software: "xmpp.js",
+ device: "script",
+ });
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+ AGZvbwBiYXI=
+
+ xmpp.js
+ script
+
+
+ xmpp.js
+
+ ,
+ );
+
+ entity.mockInput(
+
+ {entity.jid}
+
+ ,
+ );
+ entity.mockInput();
+
+ await promise(entity, "online");
+});
+
+test("with FAST", async () => {
+ const { entity } = mockClient({
+ credentials: (callback, mechanisms) => {
+ expect(mechanisms).toEqual([
+ { canFast: true, canOther: false, name: "HT-SHA-256-NONE" },
+ { canFast: false, canOther: true, name: "PLAIN" },
+ ]);
+ callback(
+ { ...credentials, requestToken: mechanisms[0].name },
+ mechanisms[1].name,
+ );
+ },
+ });
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+ HT-SHA-256-NONE
+
+
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+ AGZvbwBiYXI=
+
+ ,
+ );
+
+ entity.mockInput();
+ entity.mockInput();
+
+ await promise(entity, "online");
+});
+
+test("with FAST token", async () => {
+ const { entity } = mockClient({
+ credentials: (callback, mechanisms) => {
+ expect(mechanisms).toEqual([
+ { canFast: true, canOther: false, name: "HT-SHA-256-NONE" },
+ { canFast: false, canOther: true, name: "PLAIN" },
+ ]);
+ callback({ password: "TOKEN", fastCount: 2 }, mechanisms[0].name);
+ },
+ });
+
+ entity.mockInput(
+
+
+ PLAIN
+
+
+ HT-SHA-256-NONE
+
+
+
+ ,
+ );
+
+ expect(await promise(entity, "send")).toEqual(
+
+
+ bnVsbAAAXAywUfR/w4Mr9SUDUtNAgPDajNI073fqfiZLMYcmfA==
+
+
+ ,
+ );
+
+ entity.mockInput();
+ entity.mockInput();
+
+ await promise(entity, "online");
+});
diff --git a/packages/stream-features/route.js b/packages/stream-features/route.js
index 03e6ceb8..ee81628b 100644
--- a/packages/stream-features/route.js
+++ b/packages/stream-features/route.js
@@ -4,6 +4,9 @@ export default function route() {
return next();
const prevent = await next();
- if (!prevent && entity.jid) entity._status("online", entity.jid);
+ // BIND2 inline handler may have already set to online, eg inline SM resume
+ if (!prevent && entity.jid && entity.status !== "online") {
+ entity._status("online", entity.jid);
+ }
};
}
diff --git a/packages/stream-management/index.js b/packages/stream-management/index.js
index 4ec2bff7..5a148344 100644
--- a/packages/stream-management/index.js
+++ b/packages/stream-management/index.js
@@ -42,6 +42,7 @@ export default function streamManagement({
streamFeatures,
entity,
middleware,
+ sasl2,
}) {
let address = null;
@@ -53,6 +54,7 @@ export default function streamManagement({
outbound: 0,
inbound: 0,
max: null,
+ outbound_q: [],
};
entity.on("online", (jid) => {
@@ -86,23 +88,29 @@ export default function streamManagement({
// https://xmpp.org/extensions/xep-0198.html#enable
// For client-to-server connections, the client MUST NOT attempt to enable stream management until after it has completed Resource Binding unless it is resuming a previous session
+ function resumeSuccess() {
+ sm.enabled = true;
+ if (address) entity.jid = address;
+ entity.status = "online";
+ }
+
+ function resumeFailed() {
+ sm.id = "";
+ sm.enabled = false;
+ sm.outbound = 0;
+ }
+
streamFeatures.use("sm", NS, async (context, next) => {
// Resuming
if (sm.id) {
try {
- await resume(entity, sm.inbound, sm.id);
- sm.enabled = true;
- entity.jid = address;
- entity.status = "online";
+ resumeSuccess(await resume(entity, sm.inbound, sm.id));
return true;
// If resumption fails, continue with session establishment
} catch {
- sm.id = "";
- sm.enabled = false;
- sm.outbound = 0;
+ resumeFailed();
}
}
-
// Enabling
// Resource binding first
@@ -125,5 +133,48 @@ export default function streamManagement({
sm.inbound = 0;
});
+ sasl2?.inline("sm", NS, async (_, addInline) => {
+ if (sm.id) {
+ const success = await addInline(
+ xml("resume", { xmlns: NS, h: sm.inbound, previd: sm.id }),
+ );
+ const resumed = success.getChild("resumed", NS);
+ if (resumed) {
+ resumeSuccess(resumed);
+ } else {
+ resumeFailed();
+ }
+ }
+ });
+
+ sasl2?.bindInline(NS, async (addInline) => {
+ const success = await addInline(
+ xml("enable", {
+ xmlns: NS,
+ max: sm.preferredMaximum,
+ resume: sm.allowResume ? "true" : undefined,
+ }),
+ );
+ const bound = success.getChild("bound", "urn:xmpp:bind:0");
+ if (!bound) return; // Did a resume or something, don't need this
+
+ const enabled = bound?.getChild("enabled", NS);
+ if (enabled) {
+ if (sm.outbound_q.length > 0) {
+ throw new Error(
+ "Stream Management assertion failure, queue should be empty after enable",
+ );
+ }
+ sm.outbound = 0;
+ sm.enabled = true;
+ sm.id = enabled.attrs.id;
+ sm.max = enabled.attrs.max;
+ } else {
+ sm.enabled = false;
+ }
+
+ sm.inbound = 0;
+ });
+
return sm;
}
diff --git a/packages/tls/lib/Socket.js b/packages/tls/lib/Socket.js
index 0f0c5ad4..43550cde 100644
--- a/packages/tls/lib/Socket.js
+++ b/packages/tls/lib/Socket.js
@@ -8,6 +8,10 @@ class Socket extends EventEmitter {
this.timeout = null;
}
+ secure() {
+ return true;
+ }
+
connect(...args) {
this._attachSocket(tls.connect(...args));
}
diff --git a/packages/websocket/lib/Socket.js b/packages/websocket/lib/Socket.js
index 171e7b0a..dd9cd48c 100644
--- a/packages/websocket/lib/Socket.js
+++ b/packages/websocket/lib/Socket.js
@@ -12,6 +12,10 @@ export default class Socket extends EventEmitter {
this.listeners = Object.create(null);
}
+ secure() {
+ return this.url.startsWith("wss") || this.url.startsWith("ws://localhost");
+ }
+
connect(url) {
this.url = url;
this._attachSocket(new WebSocket(url, ["xmpp"]));
diff --git a/packages/xmpp.js/package.json b/packages/xmpp.js/package.json
index a5655768..9645a894 100644
--- a/packages/xmpp.js/package.json
+++ b/packages/xmpp.js/package.json
@@ -7,7 +7,6 @@
"version": "0.13.2",
"license": "ISC",
"type": "module",
- "main": "index.js",
"keywords": [
"XMPP",
"jabber",
@@ -37,8 +36,10 @@
"@xmpp/resource-binding": "^0.13.2",
"@xmpp/sasl": "^0.13.2",
"@xmpp/sasl-anonymous": "^0.13.2",
+ "@xmpp/sasl-ht-sha-256-none": "^0.13.0",
"@xmpp/sasl-plain": "^0.13.2",
"@xmpp/sasl-scram-sha-1": "^0.13.2",
+ "@xmpp/sasl2": "^0.13.0",
"@xmpp/session-establishment": "^0.13.2",
"@xmpp/starttls": "^0.13.2",
"@xmpp/stream-features": "^0.13.2",
diff --git a/server/index.js b/server/index.js
index e9b405f9..2f9c4c22 100644
--- a/server/index.js
+++ b/server/index.js
@@ -87,7 +87,7 @@ async function _start() {
makeCertificate();
- await exec("prosody", {
+ await exec("prosody -D", {
cwd: DATA_PATH,
env: {
...process.env,
diff --git a/server/prosody.cfg.lua b/server/prosody.cfg.lua
index 480c0021..cb3eb0f8 100644
--- a/server/prosody.cfg.lua
+++ b/server/prosody.cfg.lua
@@ -4,6 +4,10 @@
local lfs = require "lfs";
+plugin_paths = { "modules" }
+plugin_server = "https://modules.prosody.im/rocks/"
+installer_plugin_path = lfs.currentdir() .. "/modules";
+
modules_enabled = {
"roster";
"saslauth";
@@ -18,13 +22,16 @@ modules_enabled = {
"time";
"version";
"smacks";
+ "sasl2";
+ "sasl2_bind2";
+ "sasl2_fast";
+ "sasl2_sm";
};
modules_disabled = {
"s2s";
}
-daemonize = true;
pidfile = lfs.currentdir() .. "/prosody.pid";
allow_registration = true;
diff --git a/test/client.js b/test/client.js
index 167eb1f4..98cd9cda 100644
--- a/test/client.js
+++ b/test/client.js
@@ -42,6 +42,73 @@ test("client", async () => {
expect(address.bare().toString()).toBe(JID);
});
+test("bind2", async () => {
+ expect.assertions(6);
+
+ xmpp = client({
+ credentials,
+ service: domain,
+ clientId: "75b2d490-3e3b-4d96-bca1-624b30ea6f82",
+ software: "xmpp.js tests",
+ device: "Test Device",
+ });
+ debug(xmpp);
+
+ xmpp.on("connect", () => {
+ expect().pass();
+ });
+
+ xmpp.once("open", (el) => {
+ expect(el).toBeInstanceOf(xml.Element);
+ });
+
+ xmpp.on("online", (address) => {
+ expect(address).toBeInstanceOf(jid.JID);
+ expect(address.bare().toString()).toBe(JID);
+ });
+
+ const address = await xmpp.start();
+ expect(address).toBeInstanceOf(jid.JID);
+ expect(address.bare().toString()).toBe(JID);
+});
+
+test("FAST", async () => {
+ expect.assertions(7);
+
+ xmpp = client({
+ service: domain,
+ credentials: {
+ ...credentials,
+ requestToken: true,
+ },
+ clientId: "75b2d490-3e3b-4d96-bca1-624b30ea6f82",
+ software: "xmpp.js tests",
+ device: "Test Device",
+ });
+ debug(xmpp);
+
+ xmpp.on("connect", () => {
+ expect().pass();
+ });
+
+ xmpp.once("open", (el) => {
+ expect(el).toBeInstanceOf(xml.Element);
+ });
+
+ xmpp.once("fast-token", (el) => {
+ expect(typeof el.attrs.token === "string").toBe(true);
+ });
+
+ xmpp.on("online", (address) => {
+ expect(address).toBeInstanceOf(jid.JID);
+ expect(address.bare().toString()).toBe(JID);
+ });
+
+ const address = await xmpp.start();
+ expect(address).toBeInstanceOf(jid.JID);
+ expect(address.bare().toString()).toBe(JID);
+});
+
test("bad credentials", async () => {
expect.assertions(6);