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 0fb6c03 commit f987c4e
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 33 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
23 changes: 22 additions & 1 deletion packages/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,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 Expand Up @@ -92,4 +92,25 @@ function client(options = {}) {
});
}

const ANONYMOUS = "ANONYMOUS";
export 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]);
};
}

export { xml, jid, client };
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

0 comments on commit f987c4e

Please sign in to comment.