From 21720f0626693b7eccd84e7469aa0ee12cbb0507 Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Sun, 22 Dec 2024 22:13:51 +0100 Subject: [PATCH] Move SASL mechanism logic from sasl to client This will be used by sasl2 --- eslint.config.js | 1 + packages/client/browser.js | 20 +++-- packages/client/index.js | 5 +- packages/client/lib/createOnAuthenticate.js | 21 ++++++ .../getDomain.js => lib/getDomain.test.js} | 0 packages/client/package.json | 4 +- packages/client/react-native.js | 75 ------------------- packages/sasl/README.md | 10 +-- packages/sasl/index.js | 38 +++++----- packages/sasl/test.js | 63 ++++++++++++++-- packages/starttls/{client.js => index.js} | 0 11 files changed, 121 insertions(+), 116 deletions(-) create mode 100644 packages/client/lib/createOnAuthenticate.js rename packages/client/{test/getDomain.js => lib/getDomain.test.js} (100%) delete mode 100644 packages/client/react-native.js rename packages/starttls/{client.js => index.js} (100%) diff --git a/eslint.config.js b/eslint.config.js index 88a10dee..73236769 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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/ diff --git a/packages/client/browser.js b/packages/client/browser.js index 3812fb45..b77c440f 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 createOnAuthenticate from "./lib/createOnAuthenticate.js"; import _reconnect from "@xmpp/reconnect"; import _websocket from "@xmpp/websocket"; @@ -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"; @@ -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, @@ -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, @@ -69,6 +78,7 @@ function client(options = {}) { sessionEstablishment, streamManagement, mechanisms, + saslFactory, }); } diff --git a/packages/client/index.js b/packages/client/index.js index 36dbc494..24ac2a1e 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 createOnAuthenticate from "./lib/createOnAuthenticate.js"; import _reconnect from "@xmpp/reconnect"; import _websocket from "@xmpp/websocket"; @@ -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"; @@ -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, diff --git a/packages/client/lib/createOnAuthenticate.js b/packages/client/lib/createOnAuthenticate.js new file mode 100644 index 00000000..d8b142f3 --- /dev/null +++ b/packages/client/lib/createOnAuthenticate.js @@ -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]); + }; +} diff --git a/packages/client/test/getDomain.js b/packages/client/lib/getDomain.test.js similarity index 100% rename from packages/client/test/getDomain.js rename to packages/client/lib/getDomain.test.js diff --git a/packages/client/package.json b/packages/client/package.json index e63da41f..4bf39f21 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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" }, diff --git a/packages/client/react-native.js b/packages/client/react-native.js deleted file mode 100644 index 3812fb45..00000000 --- a/packages/client/react-native.js +++ /dev/null @@ -1,75 +0,0 @@ -import { xml, jid, Client } from "@xmpp/client-core"; -import getDomain from "./lib/getDomain.js"; - -import _reconnect from "@xmpp/reconnect"; -import _websocket from "@xmpp/websocket"; -import _middleware from "@xmpp/middleware"; -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 _sasl from "@xmpp/sasl"; -import _resourceBinding from "@xmpp/resource-binding"; -import _sessionEstablishment from "@xmpp/session-establishment"; -import _streamManagement from "@xmpp/stream-management"; - -import plain from "@xmpp/sasl-plain"; -import anonymous from "@xmpp/sasl-anonymous"; - -function client(options = {}) { - const { resource, credentials, username, password, ...params } = options; - - const { domain, service } = params; - if (!domain && service) { - params.domain = getDomain(service); - } - - const entity = new Client(params); - - const reconnect = _reconnect({ entity }); - const websocket = _websocket({ entity }); - - const middleware = _middleware({ entity }); - const streamFeatures = _streamFeatures({ middleware }); - const iqCaller = _iqCaller({ middleware, entity }); - const iqCallee = _iqCallee({ middleware, entity }); - const resolve = _resolve({ entity }); - // Stream features - order matters and define priority - const sasl = _sasl({ streamFeatures }, credentials || { username, password }); - const streamManagement = _streamManagement({ - streamFeatures, - entity, - middleware, - }); - const resourceBinding = _resourceBinding( - { iqCaller, streamFeatures }, - resource, - ); - const sessionEstablishment = _sessionEstablishment({ - 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, - reconnect, - websocket, - middleware, - streamFeatures, - iqCaller, - iqCallee, - resolve, - sasl, - resourceBinding, - sessionEstablishment, - streamManagement, - mechanisms, - }); -} - -export { xml, jid, client }; diff --git a/packages/sasl/README.md b/packages/sasl/README.md index c29bccd2..41a5a6de 100644 --- a/packages/sasl/README.md +++ b/packages/sasl/README.md @@ -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); diff --git a/packages/sasl/index.js b/packages/sasl/index.js index cbbfc37f..8a64e38c 100644 --- a/packages/sasl/index.js +++ b/packages/sasl/index.js @@ -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; @@ -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(); }); } diff --git a/packages/sasl/test.js b/packages/sasl/test.js index 2521ada1..4b8a32de 100644 --- a/packages/sasl/test.js +++ b/packages/sasl/test.js @@ -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( @@ -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 () => { @@ -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(); @@ -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( + + + FOO + + , + ); + + 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( + + + {mech} + + , + ); + + entity.on("error", (err) => { + expect(err).toBe(error); + done(); + }); +}); + test("failure", async () => { const { entity } = mockClient({ credentials }); @@ -135,9 +184,9 @@ test("use ANONYMOUS if username and password are not provided", async () => { entity.mockInput( - ANONYMOUS PLAIN SCRAM-SHA-1 + ANONYMOUS , ); diff --git a/packages/starttls/client.js b/packages/starttls/index.js similarity index 100% rename from packages/starttls/client.js rename to packages/starttls/index.js