XMPP as an authentication method, ejabberd as an identity and authentication service #
DISCLAIMER: the minimal setup here assumes you’re not exposing your XMPP server to literally anyone other than your own client code
To start from the very top, the reason why I am even thinking about this is a question of incentivizing operators of decentralized tech.
In short the topic at hand is - you have the best, single most elegantly designed decentralized technology, and it has all this potential to solve so many of these problems in an efficient way … but it either doesn’t get enough adoption or it does but only a part of it after a monopolized entity discovers it and coopts it for their own purposes.
I won’t recount all the various ways this played out in the past, RSS, P2P file sharing, federated social media, cryptocurrencies.
So I started an uncapped twitter thread which focuses on what it takes to get people to participate in a decentralized system as operators, the people who run the services for others to use; and in a very blunt way too - what would these operators stand to gain other than pride in an ethical/principled undertaking?
XMPP #
Decentralized, extensible, built on open standards, designed as SMTP but for chat - anyone can run a server and talk to anyone else.
XMPP got a fair amount of adoption, most tech giants built their chat services using XMPP - but once a significant number of users were present within their own walled gardens the openness stopped.
The brief on XMPP as a procol is if you imagine e-mail as your message making 4 hops to reach its destination:
- clientA -> server1
- server1 -> server2
- server2 -> server3
- server3 -> clientB
then XMPP is your message making one singular hop through continuous connections:
- clientA <- XML stream -> serverA <- XML stream -> serverB <- XML stream -> clientB
Capabilities #
One could write for days about things that are doable via XMPP (think: any kind of communication that you might need between two devices) however some of the more interesting ones are the various IAM authentication (authn for short) and authorization (authz for short) capabilities for
- Oauth over XMPP thanks to which your chat app can log you in with your Google account (for example)
- in reverse, Oauth client login (experimental XEP-0493 at 0.1.1 (2025-03-31)) which would let you login to various apps with your chat account
- it’s a stretch but hypothetically one could leverage XMPP for RBAC (role based access control) by creating and checking someone’s role/affiliation in per-role
groupchats, and - ultimately use of SASL for straightforward authentication of clients to servers and federated authn for identity provider (IdP for short) purposes
the last one being relevant to what I’m proposing as a way to make XMPP more popular: if you’re making a software system of any kind that would benefit from a plug-n-play authn layer, just install & configure ejabberd and you’re set.
Even more so if your system will ever have a need for a robust messaging and/or presence backbone.
What is Jabber, what is ejabberd #
Jabber is XMPP, or, the other way around, XMPP is Jabber. XMPP is a format standardization of the protocol which had evolved within the Jabber project.
However Jabber is now technically just one of many implementations of XMPP. There’s also Snikket, there’s Prosody, Tigase, OpenFire, etc.
ejabberd is the name of the actual server daemon you deal with when you install the Jabber project somewhere.
This document focuses on Jabber only because I’ve experimented with this against Jabber and was able to find relevant information.
I’d expect any of the other implementations have similar ways to accomplish the same.
Can’t say for sure yet though.
What is BOSH and why does it matter #
So the way XMPP works is it establishes an XML stream between two devices. Once two devices can shoot little bits (called stanzas in XMPP ecosystem) of XML to each other they can do a whole lot just by knowing what the other party is supposed to do based off the protocol specification and its various extensions.
And BOSH? Bidirectional streams over synchronous HTTP. It’s just a way for you to use an HTTP request-response cycle to accomplish the same.
A bit clunkier and a bit more bloated than XML streams, sure, but it does its job and drastically reduces the complexity of the whole thing.
Configuring ejabberd #
There’s two things we need here. The first one is optional yes but you might need it in some of the scenarios in which you deploy this setup: ejabberd account registration and the accompanying web app.
The second one is enabling BOSH and will follow right after.
Account registration in ejabberd.yml #
Jabber has modules, and you can configure them in the modules section, namely mod_register which uses trusted_network for the ip_access property out of the box (set to loopback by default) and depending on your setup you might want to allow public registration.
Remember please to put it behind a verification, a captcha or an OTP/MFA of some kind.
The comment block about the Jabber SPAM Manifesto that ejabberd.yml has out of the box is about this.
The least you can do is toggle built-in CATPCHA on in the configuration (ensure you have imagemagick installed too), which consists of three moving parts:
- the main thing to configure in
ejabberd.ymlare thecaptcha_cmd(a workingcaptcha.shcomes bundled with ejabberd) andcaptcha_urlproperties - these are at the root level of YAML indentation & not part of other properties - adding
/captchato therequest_handlerslist - adding a
captcha_protectedgate to themod_registerconfiguration.
The other two you will find throughout the remaining config examples in this document but the first part is as follows:
...
hosts: ...
captcha_cmd: /opt/ejabberd/bin/captcha.sh
captcha_url: http://localhost:5280/captcha
...
Below you can see an example setup allowing anyone who can access the HTTP server to register an account.
...
modules:
...
mod_register:
## Only accept registration requests from the "trusted"
## network (see access_rules section above).
## Think twice before enabling registration from any
## address. See the Jabber SPAM Manifesto for details:
## https://github.com/ge0rg/jabber-spam-fighting-manifesto
captcha_protected: true
ip_access:
allow: all
...
Then in the listen section find the block pointing to port: PORT_HTTP.
NOTE: Either the TLS one or the one without TLS (there’s a block for each) depending on whtether you’re setting this up for prod or just testing it out locally.
Within you will find a request_handlers subsection to which you have to add a path pointing to mod_register_web:
...
listen:
...
-
port: PORT_HTTP
ip: "::"
module: ejabberd_http
request_handlers:
/admin: ejabberd_web_admin
/captcha: ejabberd_captcha
/.well-known/acme-challenge: ejabberd_acme
/: mod_register_web
...
NOTE: see how we put the path at the bottom - this is because they are evaluated in the order they appear in and none of the other paths following it will work if you put the root path (just the slash, /) anywhere other than at the very end
To reconfigure the running server use
ejabberctl reload_config
Then all what’s left is toggling BOSH on.
Configuring BOSH in ejabberd.yml #
mod_bosh should already be enabled by default, you will probably find something like
mod_bosh: {} in the modules section.
The accessible HTTP location however has to be defined, much like the mod_register_web mapping to /, we want to enable the e.g. /bosh path on the HTTP handler:
...
listen:
...
-
port: PORT_HTTP
ip: "::"
module: ejabberd_http
request_handlers:
/admin: ejabberd_web_admin
/bosh: mod_bosh
/captcha: ejabberd_captcha
/.well-known/acme-challenge: ejabberd_acme
/: mod_register_web
and that should be it. Reload or restart your setup and you should be able to talk to your ejabberd settup via HTTP/BOSH.
The client #
Simplest possible example of this I can think of is a Python/Flask hello world web application which lets you login with your XMPP account and when your login is successful it says “hello $username” instead of “hello world” as long as your Flask session cookie hadn’t expired.
This means with the configuration above, someone is able to register a new XMPP account then login to your Flask web application/system with their XMPP credentials.
Arguably with the simplest approach (using SASL PLAIN) your application/system now becomes an attack surface due to handling actual credentials however while this is supposed to be a demo you have numerous valid approaches to take to harden your setup and even have your publicly exposed XMPP server acting as a federated IdP for your and other systems.
Or the inverse, allow XMPP authentication to your application/system from literally any XMPP user.
So, back to the client.
Python client for XMPP BOSH (authn) #
I tried to make it as small and dependency-free as possible because I knew it could be done, honestly. I’d done it with Clojure a couple of years ago and it amounted to 3-4 functions and no additional dependencies.
I found and read through https://github.com/pelletier/boshclient for inspiration before I set to write a Python(3) version and ended up doing something very similar:
from dataclasses import dataclass
from http.client import HTTPConnection
from xml.dom import minidom
import base64
import random
import typing
import urllib.parse
@dataclass
class Client:
conn: HTTPConnection
@classmethod
def create(cls, host: str, port: int):
return Client(conn=HTTPConnection(host, port))
def post(
self, path: str, params: dict | None = None, headers: dict | None = None
) -> tuple[int, typing.Any]:
if params is not None:
if isinstance(params, dict):
params = urllib.parse.urlencode(params)
self.conn.request("POST", path, params or {}, headers or {})
resp = self.conn.getresponse()
return resp.status, resp.read().decode("utf-8")
@dataclass
class Bosh:
client: Client
rid: int
sid: str | None
@classmethod
def create(cls):
raise NotImplementedError
@classmethod
def greet(cls, client: Client):
bosh = Bosh(client=client, rid=random.randint(0, 999**2), sid=None)
stanza = minidom.Document()
body = stanza.createElement("body")
body.setAttribute("rid", str(bosh.rid))
body.setAttribute("to", client.conn.host)
body.setAttribute("xml:lang", "en")
body.setAttribute("wait", "60")
body.setAttribute("hold", "1")
body.setAttribute("content", "text/xml; charset=utf-8")
body.setAttribute("ver", "1.6")
body.appendChild(stanza.createTextNode(""))
stanza.appendChild(body)
doc = bosh.exchange(stanza)
sid = doc.getAttribute("sid")
bosh.sid = sid
return bosh
def exchange(self, stanza: minidom.Document) -> minidom.Document:
self.rid += 1
pretty = stanza.toprettyxml()
_, res = self.client.post("/bosh", pretty, {"content-type": "application/xml"})
return minidom.parseString(res).documentElement
def login(self, usr: str, pwd: str) -> str | None:
stanza = minidom.Document()
body = stanza.createElement("body")
body.setAttribute("rid", str(self.rid))
body.setAttribute("sid", self.sid)
body.setAttribute("xmlns", "http://jabber.org/protocol/httpbind")
auth = stanza.createElement("auth")
auth.setAttribute("xmlns", "urn:ietf:params:xml:ns:xmpp-sasl")
auth.setAttribute("mechanism", "PLAIN")
auth.appendChild(
stanza.createTextNode(
base64.b64encode(bytes(f"\0{usr}\0{pwd}", "utf-8")).decode("utf-8")
)
)
body.appendChild(auth)
stanza.appendChild(body)
error: str | None = None
_ = self.exchange(stanza)
if _:
_ = next(iter(_.getElementsByTagName("failure")), None)
if _:
_ = next(iter(_.getElementsByTagName("text")), None)
error = _.firstChild.nodeValue
return error
if __name__ == "__main__":
# for example
c = Client.create("localhost", 5280)
b = Bosh.greet(c)
print(b.login(usr="user1", pwd="user1"))
This is the bare minimum one needs to talk to an XMPP server via BOSH without 3rd party dependencies.
The idea is to either expand the Bosh class with additional fundamental XMPP behaviors or simply inherit from it in a custom class I can use as a stand in for the subset of behaviors I might need.
Then I can construct and parse XML stanzas much the same way you see it done in the handshake/login implemented here.
In here you will find the simplest HTTP client I could think of which you can pass into your Bosh client, and the BOSH client itself can be started with an XMPP handshake and session then used to perform a login against a live XMPP server.
No error means the login succeeded (yeah this can be improved I know).
In the Flask app then you can use the client to have a login endpoint perform the actual login like
...
from flask import redirect, session
...
@app.route("/login")
def login(...):
...
client = Client.create(BOSH_HOST, BOSH_PORT)
bosh = Bosh.greet(client)
...
error: str | None = bosh.login(
username, password
)
if error is not None:
raise Exception(error)
session["username"] = username
return redirect("/")
and elsewhere, then, use the identity that was verified by XMPP like so
...
def home(...):
...
who = "world"
if "username" in session:
who = session["username"]
return f"hello {who}"
All together #
I’ve put up a code repository which does all of this in one go, I’d used flask-smorest instead of Flask (same framework essentially with a bit extra) so you can start the containerized setup and navigate your browser to http://localhost:5005/docs to see the behavior via Swagger.
You have to uncomment the jabber 5280 port exposure in order to see the account registration screens, it’s commented out because by default we don’t want to expose this willy nilly.
Later on it would also be wise to isolate mod_register_web and provide it on its own port/listener.
[Next: Datomic REST API, how to use Datomic from Python or PHP, Ruby, Go]