Skip to content

Commit

Permalink
Move SASL mechanism logic from sasl to client
Browse files Browse the repository at this point in the history
This will be used by sasl2
  • Loading branch information
sonnyp committed Dec 22, 2024
1 parent 17b4373 commit 21720f0
Show file tree
Hide file tree
Showing 11 changed files with 121 additions and 116 deletions.
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default [
],
"prefer-arrow-callback": ["error", { allowNamedFunctions: true }],
"no-redeclare": ["error", { builtinGlobals: false }],
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],

// node
// https://github.com/eslint-community/eslint-plugin-n/
Expand Down
20 changes: 15 additions & 5 deletions packages/client/browser.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { xml, jid, Client } from "@xmpp/client-core";
import getDomain from "./lib/getDomain.js";
import createOnAuthenticate from "./lib/createOnAuthenticate.js";

import _reconnect from "@xmpp/reconnect";
import _websocket from "@xmpp/websocket";
Expand All @@ -14,6 +15,7 @@ import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
import _streamManagement from "@xmpp/stream-management";

import SASLFactory from "saslmechanisms";
import plain from "@xmpp/sasl-plain";
import anonymous from "@xmpp/sasl-anonymous";

Expand All @@ -35,8 +37,19 @@ function client(options = {}) {
const iqCaller = _iqCaller({ middleware, entity });
const iqCallee = _iqCallee({ middleware, entity });
const resolve = _resolve({ entity });

// SASL mechanisms - order matters and define priority
const saslFactory = new SASLFactory();
const mechanisms = Object.entries({
plain,
anonymous,
}).map(([k, v]) => ({ [k]: v(saslFactory) }));

// Stream features - order matters and define priority
const sasl = _sasl({ streamFeatures }, credentials || { username, password });
const sasl = _sasl(
{ streamFeatures, saslFactory },
createOnAuthenticate(credentials ?? { username, password }),
);
const streamManagement = _streamManagement({
streamFeatures,
entity,
Expand All @@ -50,10 +63,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,
Expand All @@ -69,6 +78,7 @@ function client(options = {}) {
sessionEstablishment,
streamManagement,
mechanisms,
saslFactory,
});
}

Expand Down
5 changes: 3 additions & 2 deletions packages/client/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { xml, jid, Client } from "@xmpp/client-core";
import getDomain from "./lib/getDomain.js";
import createOnAuthenticate from "./lib/createOnAuthenticate.js";

import _reconnect from "@xmpp/reconnect";
import _websocket from "@xmpp/websocket";
Expand All @@ -11,7 +12,7 @@ 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 _starttls from "@xmpp/starttls";
import _sasl from "@xmpp/sasl";
import _resourceBinding from "@xmpp/resource-binding";
import _sessionEstablishment from "@xmpp/session-establishment";
Expand Down Expand Up @@ -55,7 +56,7 @@ function client(options = {}) {
const starttls = _starttls({ streamFeatures });
const sasl = _sasl(
{ streamFeatures, saslFactory },
credentials || { username, password },
createOnAuthenticate(credentials ?? { username, password }),
);
const streamManagement = _streamManagement({
streamFeatures,
Expand Down
21 changes: 21 additions & 0 deletions packages/client/lib/createOnAuthenticate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ANONYMOUS = "ANONYMOUS";

export default function createOnAuthenticate(credentials) {
return async function onAuthenticate(authenticate, mechanisms) {
if (typeof credentials === "function") {
await credentials(authenticate, mechanisms);
return;
}

if (
!credentials?.username &&
!credentials?.password &&
mechanisms.includes(ANONYMOUS)
) {
await authenticate(credentials, ANONYMOUS);
return;
}

await authenticate(credentials, mechanisms[0]);
};
}
File renamed without changes.
4 changes: 2 additions & 2 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"@xmpp/websocket": "^0.13.2",
"saslmechanisms": "^0.1.1"
},
"browser": "./browser.js",
"react-native": "./react-native.js",
"browser": "browser.js",
"react-native": "browser.js",
"engines": {
"node": ">= 20"
},
Expand Down
75 changes: 0 additions & 75 deletions packages/client/react-native.js

This file was deleted.

10 changes: 5 additions & 5 deletions packages/sasl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,22 @@ Uses cases:
- Do not ask for password before connection is made
- Debug authentication
- Using a SASL mechanism with specific requirements
- Perform an asynchronous operation to get credentials
- Fetch credentials from a secure database

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

const client = xmpp({ credentials: authenticate });
const client = xmpp({ credentials: onAuthenticate });

async function authenticate(auth, mechanism) {
console.debug("authenticate", mechanism);
async function onAuthenticate(authenticate, mechanisms) {
console.debug("authenticate", mechanisms);
const credentials = {
username: await prompt("enter username"),
password: await prompt("enter password"),
};
console.debug("authenticating");
try {
await auth(credentials);
await authenticate(credentials, mechanisms[0]);
console.debug("authenticated");
} catch (err) {
console.error(err);
Expand Down
38 changes: 18 additions & 20 deletions packages/sasl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ function getMechanismNames(features) {
.map((el) => el.text());
}

async function authenticate(SASL, entity, mechname, credentials) {
const mech = SASL.create([mechname]);
async function authenticate({ saslFactory, entity, mechanism, credentials }) {
const mech = saslFactory.create([mechanism]);
if (!mech) {
throw new Error("No compatible mechanism");
throw new Error(`SASL: Mechanism ${mechanism} not found.`);
}

const { domain } = entity.options;
Expand Down Expand Up @@ -73,29 +73,27 @@ async function authenticate(SASL, entity, mechname, credentials) {
});
}

export default function sasl({ streamFeatures, saslFactory }, credentials) {
export default function sasl({ streamFeatures, saslFactory }, onAuthenticate) {
streamFeatures.use("mechanisms", NS, async ({ stanza, entity }) => {
const offered = getMechanismNames(stanza);
const supported = saslFactory._mechs.map(({ name }) => name);
// eslint-disable-next-line unicorn/prefer-array-find
const intersection = supported.filter((mech) => {
return offered.includes(mech);
});
let mech = intersection[0];

if (typeof credentials === "function") {
await credentials(
(creds) => authenticate(saslFactory, entity, mech, creds, stanza),
mech,
);
} else {
if (!credentials.username && !credentials.password) {
mech = "ANONYMOUS";
}
const intersection = supported.filter((mech) => offered.includes(mech));

await authenticate(saslFactory, entity, mech, credentials, stanza);
if (intersection.length === 0) {
throw new SASLError("SASL: No compatible mechanism available.");
}

async function done(credentials, mechanism) {
await authenticate({
saslFactory,
entity,
mechanism,
credentials,
});
}

await onAuthenticate(done, intersection);

await entity.restart();
});
}
63 changes: 56 additions & 7 deletions packages/sasl/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const username = "foo";
const password = "bar";
const credentials = { username, password };

test("no compatibles mechanisms", async () => {
test("No compatible mechanism available", async () => {
const { entity } = mockClient({ username, password });

entity.mockInput(
Expand All @@ -18,7 +18,7 @@ test("no compatibles mechanisms", async () => {

const error = await promise(entity, "error");
expect(error instanceof Error).toBe(true);
expect(error.message).toBe("No compatible mechanism");
expect(error.message).toBe("SASL: No compatible mechanism available.");
});

test("with object credentials", async () => {
Expand Down Expand Up @@ -48,14 +48,16 @@ test("with object credentials", async () => {
});

test("with function credentials", async () => {
expect.assertions(2);

const mech = "PLAIN";

function authenticate(auth, mechanism) {
expect(mechanism).toBe(mech);
return auth(credentials);
async function onAuthenticate(authenticate, mechanisms) {
expect(mechanisms).toEqual([mech]);
await authenticate(credentials, mech);
}

const { entity } = mockClient({ credentials: authenticate });
const { entity } = mockClient({ credentials: onAuthenticate });
entity.restart = () => {
entity.emit("open");
return Promise.resolve();
Expand All @@ -80,6 +82,53 @@ test("with function credentials", async () => {
await promise(entity, "online");
});

test("Mechanism not found", async () => {
const { entity } = mockClient({
async credentials(authenticate, _mechanisms) {
await authenticate({ username, password }, "foo");
},
});

entity.mockInput(
<features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>FOO</mechanism>
</mechanisms>
</features>,
);

const error = await promise(entity, "error");
expect(error instanceof Error).toBe(true);
expect(error.message).toBe("SASL: No compatible mechanism available.");
});

test("with function credentials that rejects", (done) => {
expect.assertions(1);

const mech = "PLAIN";

const error = {};

async function onAuthenticate() {
throw error;
}

const { entity } = mockClient({ credentials: onAuthenticate });

entity.entity.mockInput(
<features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>{mech}</mechanism>
</mechanisms>
</features>,
);

entity.on("error", (err) => {
expect(err).toBe(error);
done();
});
});

test("failure", async () => {
const { entity } = mockClient({ credentials });

Expand Down Expand Up @@ -135,9 +184,9 @@ test("use ANONYMOUS if username and password are not provided", async () => {
entity.mockInput(
<features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>ANONYMOUS</mechanism>
<mechanism>PLAIN</mechanism>
<mechanism>SCRAM-SHA-1</mechanism>
<mechanism>ANONYMOUS</mechanism>
</mechanisms>
</features>,
);
Expand Down
File renamed without changes.

0 comments on commit 21720f0

Please sign in to comment.