Skip to content

Commit

Permalink
Implement Bind 2 (#1036)
Browse files Browse the repository at this point in the history
  • Loading branch information
sonnyp committed Dec 23, 2024
1 parent 147e427 commit 747679a
Show file tree
Hide file tree
Showing 16 changed files with 367 additions and 119 deletions.
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ unit:
e2e:
$(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_sm > /dev/null
# https://github.com/xmppjs/xmpp.js/pull/1006
# 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:
Expand All @@ -46,6 +46,8 @@ clean:
rm -f server/prosody.err
rm -f server/prosody.log
rm -f server/prosody.pid
rm -rf server/modules
rm -rf server/.cache
npx lerna clean --yes
rm -rf node_modules/
rm -f packages/*/dist/*.js
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions packages/client-core/src/bind2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# bind2

bind2 for `@xmpp/client`.

Included and enabled in `@xmpp/client`.

## Usage

Resource is optional and will be chosen by the server if omitted.

### string

```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).

```js
import { xmpp } from "@xmpp/client";

const client = xmpp({ resource: onBind });

async function onBind(bind) {
const resource = await fetchResource();
return resource;
}
```

## References

[XEP-0386: Bind 2](https://xmpp.org/extensions/xep-0386.html)
57 changes: 57 additions & 0 deletions packages/client-core/src/bind2/bind2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import xml from "@xmpp/xml";

const NS_BIND = "urn:xmpp:bind:0";

export default function bind2({ sasl2, entity }, tag) {
const features = new Map();

sasl2.use(
NS_BIND,
async (element) => {
if (!element.is("bind", NS_BIND)) return;

tag = typeof tag === "function" ? await tag() : tag;

const sessionFeatures = await getSessionFeatures({ element, features });

return xml(
"bind",
{ xmlns: "urn:xmpp:bind:0" },
tag && xml("tag", null, tag),
...sessionFeatures,
);
},
(element) => {
const aid = element.root().getChildText("authorization-identifier");
if (aid) entity._jid(aid);

for (const child of element.getChildElements()) {
const feature = features.get(child.getNS());
if (!feature?.[1]) continue;
feature?.[1](child);
}
},
);

return {
use(ns, req, res) {
features.set(ns, [req, res]);
},
};
}

function getSessionFeatures({ element, features }) {
const promises = [];

const inline = element.getChild("inline");
if (!inline) return promises;

for (const element of inline.getChildElements()) {
const xmlns = element.attrs.var;
const feature = features.get(xmlns);
if (!feature) continue;
promises.push(feature[0](element));
}

return Promise.all(promises);
}
117 changes: 117 additions & 0 deletions packages/client-core/src/bind2/bind2.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { mockClient, id, promiseError } from "@xmpp/test";

function mockFeatures(entity) {
entity.mockInput(
<features xmlns="http://etherx.jabber.org/streams">
<authentication xmlns="urn:xmpp:sasl:2">
<mechanism>PLAIN</mechanism>
<inline>
<bind xmlns="urn:xmpp:bind:0" />
</inline>
</authentication>
</features>,
);
}

function catchAuthenticate(entity) {
return entity.catchOutgoing((stanza) => {
if (stanza.is("authenticate", "urn:xmpp:sasl:2")) return true;
});
}

test("without tag", async () => {
const { entity } = mockClient();
mockFeatures(entity);
const stanza = await catchAuthenticate(entity);

await expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual(
(<bind xmlns="urn:xmpp:bind:0" />).toString(),
);
});

test("with string tag", async () => {
const resource = id();
const { entity } = mockClient({ resource });
mockFeatures(entity);
const stanza = await catchAuthenticate(entity);

expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual(
(
<bind xmlns="urn:xmpp:bind:0">
<tag>{resource}</tag>
</bind>
).toString(),
);
});

test("with function resource returning string", async () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
function resource() {
return "1k2k3";
}

const { entity } = mockClient({ resource });
mockFeatures(entity);
const stanza = await catchAuthenticate(entity);

expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual(
(
<bind xmlns="urn:xmpp:bind:0">
<tag>{resource()}</tag>
</bind>
).toString(),
);
});

test("with function resource throwing", async () => {
const error = new Error("foo");


function resource() {
throw error;
}

const { entity } = mockClient({ resource });

const willError = promiseError(entity);

mockFeatures(entity);

expect(await willError).toBe(error);
});

test("with function resource returning resolved promise", async () => {
// eslint-disable-next-line unicorn/consistent-function-scoping
async function resource() {
return "1k2k3";
}

const { entity } = mockClient({ resource });
mockFeatures(entity);
const stanza = await catchAuthenticate(entity);

expect(stanza.getChild("bind", "urn:xmpp:bind:0").toString()).toEqual(
(
<bind xmlns="urn:xmpp:bind:0">
<tag>{await resource()}</tag>
</bind>
).toString(),
);
});

test("with function resource returning rejected promise", async () => {
const error = new Error("foo");


async function resource() {
throw error;
}

const { entity } = mockClient({ resource });

const willError = promiseError(entity);

mockFeatures(entity);

expect(await willError).toBe(error);
});
9 changes: 8 additions & 1 deletion packages/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import _sasl from "@xmpp/sasl";
import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
import _streamManagement from "@xmpp/stream-management";
import _bind2 from "@xmpp/client-core/src/bind2/bind2.js";

import SASLFactory from "saslmechanisms";
import scramsha1 from "@xmpp/sasl-scram-sha-1";
Expand Down Expand Up @@ -59,7 +60,11 @@ function client(options = {}) {
{ streamFeatures, saslFactory },
createOnAuthenticate(credentials ?? { username, password }, userAgent),
);
service;

// SASL2 inline features
const bind2 = _bind2({ sasl2, entity }, resource);

// Stream features - order matters and define priority
const sasl = _sasl(
{ streamFeatures, saslFactory },
createOnAuthenticate(credentials ?? { username, password }, userAgent),
Expand All @@ -68,6 +73,7 @@ function client(options = {}) {
streamFeatures,
entity,
middleware,
bind2,
});
const resourceBinding = _resourceBinding(
{ iqCaller, streamFeatures },
Expand Down Expand Up @@ -97,6 +103,7 @@ function client(options = {}) {
sessionEstablishment,
streamManagement,
mechanisms,
bind2,
});
}

Expand Down
25 changes: 5 additions & 20 deletions packages/resource-binding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,14 @@ const client = xmpp({ resource: "laptop" });

Instead, you can provide a function that will be called every time resource binding occurs (every (re)connect).

Uses cases:

- Have the user choose a resource every time
- Do not ask for resource before connection is made
- Debug resource binding
- Perform an asynchronous operation to get the resource

```js
import { xmpp } from "@xmpp/client";

const client = xmpp({ resource: bindResource });

async function bindResource(bind) {
console.debug("bind");
const value = await prompt("enter resource");
console.debug("binding");
try {
const { resource } = await bind(value);
console.debug("bound", resource);
} catch (err) {
console.error(err);
throw err;
}
const client = xmpp({ resource: onBind });

async function onBind(bind) {
const resource = await fetchResource();
return resource;
}
```

Expand Down
6 changes: 2 additions & 4 deletions packages/resource-binding/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ async function bind(entity, iqCaller, resource) {

function route({ iqCaller }, resource) {
return async ({ entity }, next) => {
await (typeof resource === "function"
? resource((resource) => bind(entity, iqCaller, resource))
: bind(entity, iqCaller, resource));

resource = typeof resource === "function" ? await resource() : resource;
await bind(entity, iqCaller, resource);
next();
};
}
Expand Down
6 changes: 2 additions & 4 deletions packages/resource-binding/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,8 @@ test("with function resource", async () => {
const jid = "foo@bar/" + resource;

const { entity } = mockClient({
resource: async (bind) => {
await delay();
const result = await bind(resource);
expect(result.toString()).toBe(jid);
resource: async () => {
return resource;
},
});

Expand Down
11 changes: 4 additions & 7 deletions packages/sasl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@ import xml from "@xmpp/xml";

const NS = "urn:ietf:params:xml:ns:xmpp-sasl";

function getMechanismNames(stanza) {
return stanza
.getChild("mechanisms", NS)
.getChildElements()
.map((el) => el.text());
function getMechanismNames(element) {
return element.getChildElements().map((el) => el.text());
}

async function authenticate({ saslFactory, entity, mechanism, credentials }) {
Expand Down Expand Up @@ -74,8 +71,8 @@ async function authenticate({ saslFactory, entity, mechanism, credentials }) {
}

export default function sasl({ streamFeatures, saslFactory }, onAuthenticate) {
streamFeatures.use("mechanisms", NS, async ({ stanza, entity }) => {
const offered = getMechanismNames(stanza);
streamFeatures.use("mechanisms", NS, async ({ entity }, _next, element) => {
const offered = getMechanismNames(element);
const supported = saslFactory._mechs.map(({ name }) => name);
const intersection = supported.filter((mech) => offered.includes(mech));

Expand Down
Loading

0 comments on commit 747679a

Please sign in to comment.