Skip to content

Commit

Permalink
Implement SASL2 (with optional BIND2 and FAST)
Browse files Browse the repository at this point in the history
  • Loading branch information
singpolyma committed Nov 21, 2023
1 parent ce88dbf commit 95e8f61
Show file tree
Hide file tree
Showing 6 changed files with 327 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module.exports = {

parserOptions: {
sourceType: "script",
ecmaVersion: 2019,
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true,
},
Expand Down
31 changes: 31 additions & 0 deletions package-lock.json

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

73 changes: 73 additions & 0 deletions packages/sasl2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SASL

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
const {xmpp} = require('@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
const { xmpp } = require("@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(fast, {
username: await prompt("enter username"),
password: token,
fastCount: count,
});
}
}

return callback(mech, {
username: await prompt("enter username"),
password: await prompt("enter password"),
requestToken: fast,
});
}
```
## References
[SASL2](https://xmpp.org/extensions/xep-0388.html)
[BIND2](https://xmpp.org/extensions/xep-0386.html)
[FAST](https://xmpp.org/extensions/inbox/xep-fast.html)
182 changes: 182 additions & 0 deletions packages/sasl2/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"use strict";

const { encode, decode } = require("@xmpp/base64");
const SASLError = require("./lib/SASLError");
var jid = require("@xmpp/jid");
const xml = require("@xmpp/xml");
const SASLFactory = require("saslmechanisms");

// https://xmpp.org/rfcs/rfc6120.html#sasl

const NS = "urn:xmpp:sasl:2";
const BIND2_NS = "urn:xmpp:bind:0";
const FAST_NS = "urn:xmpp:fast:0";

async function authenticate(
SASL,
entity,
mechname,
credentials,
userAgent,
features,
) {
const mech = SASL.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,
};

return 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();
break;
}
}

entity.removeListener("nonza", handler);
};

entity.on("nonza", handler);

const bind2 = features
.getChild("authentication", NS)
.getChild("inline")
?.getChild("bind", BIND2_NS);

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),
]),
credentials.requestToken &&
xml(
"request-token",
{ xmlns: FAST_NS, mechanism: credentials.requestToken },
[],
),
credentials.fastCount &&
xml("fast", { xmlns: FAST_NS, count: credentials.fastCount }, []),
]),
);
});
}

module.exports = function sasl({ streamFeatures }, credentials, userAgent) {
const SASL = new SASLFactory();

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 = SASL._mechs.map(({ name }) => name);
// eslint-disable-next-line unicorn/prefer-array-find
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(
(mech, creds) =>
authenticate(SASL, entity, mech, creds, userAgent, stanza),
intersection,
);
} else {
let mech = intersection[0]?.name;
if (!credentials.username && !credentials.password) {
mech = "ANONYMOUS";
}

await authenticate(SASL, entity, mech, credentials, userAgent, stanza);
}
});

return {
use(...args) {
return SASL.use(...args);
},
};
};
14 changes: 14 additions & 0 deletions packages/sasl2/lib/SASLError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use strict";

const XMPPError = require("@xmpp/error");

// https://xmpp.org/rfcs/rfc6120.html#sasl-errors

class SASLError extends XMPPError {
constructor(...args) {
super(...args);
this.name = "SASLError";
}
}

module.exports = SASLError;
26 changes: 26 additions & 0 deletions packages/sasl2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"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",
"keywords": [
"XMPP",
"sasl"
],
"dependencies": {
"@xmpp/base64": "^0.13.0",
"@xmpp/error": "^0.13.0",
"@xmpp/jid": "^0.13.0",
"@xmpp/xml": "^0.13.0",
"saslmechanisms": "^0.1.1"
},
"engines": {
"node": ">= 14"
},
"publishConfig": {
"access": "public"
}
}

0 comments on commit 95e8f61

Please sign in to comment.