Log a Python Matrix bot or client into a homeserver that uses the new OAuth 2.0 login (Matrix Authentication Service, the "next generation auth" of MSC3861, stable in the Matrix spec since v1.15), then keep it logged in by refreshing its token. It is for people who write bots, bridges and integrations with matrix-nio or mautrix-python and hit a server where password login is gone (SSO only) or where tokens expire after a few minutes.
It does the whole flow and nothing else: server discovery, dynamic client registration, the device code login (for headless bots), the browser login with PKCE (for desktop apps and CLIs), token refresh, revocation and storage, with two small adapters for matrix-nio and mautrix-python. One dependency: httpx.
pip install matrix-oauth-loginPython 3.9 or newer.
This runs as written, with no server: FakeAuthServer is an in-memory homeserver and
authorization server shipped in matrix_oauth_login.testing, the same one the test suite
uses. Swap it for a real homeserver in the next section.
import asyncio
from matrix_oauth_login import ClientMetadata, OAuthClient
from matrix_oauth_login.testing import FakeAuthServer
async def main() -> None:
server = FakeAuthServer() # https://matrix.example.org, user @alice:example.org
app = ClientMetadata(client_name="My bot", client_uri="https://example.org")
async with OAuthClient(server.homeserver, app, transport=server.transport) as client:
flow = await client.start_device_login()
print("Open", flow.verification_uri_complete, "code", flow.user_code)
server.approve(flow.user_code) # here the user approves in the browser
session = await client.finish_device_login(flow)
print("Logged in as", session.user_id, "on device", session.device_id)
asyncio.run(main())Output (the codes and the device id are random):
Open https://auth.example.org/link?code=A3FB40 code A3FB40
Logged in as @alice:example.org on device LJ4SK66D9Q
The user opens a link on any device and approves; the bot polls until they do. Polling
honours slow_down (5 more seconds), and rides out network errors, HTTP 429 and 5xx answers
by doubling the interval (at most 60 seconds) until the code expires; access_denied,
expired_token and any other OAuth error stop it.
import asyncio
from matrix_oauth_login import ClientMetadata, FileTokenStore, OAuthClient, resolve_homeserver
async def main() -> None:
homeserver = await resolve_homeserver("matrix.org") # reads .well-known/matrix/client
app = ClientMetadata(client_name="My bot", client_uri="https://example.org")
async with OAuthClient(homeserver, app) as client:
flow = await client.start_device_login()
print("Open", flow.verification_uri_complete or flow.verification_uri)
print("and enter", flow.user_code)
session = await client.finish_device_login(flow) # polls until approved
FileTokenStore("session.json").save(session) # written atomically, mode 0600
asyncio.run(main())loopback_login listens on 127.0.0.1 (or ::1, nothing else) on a free port, opens the
browser on the server's login page (SSO included), catches the redirect, checks state and
exchanges the code. If the browser cannot be opened, it prints the URL to stderr and keeps
waiting. A request to the local server with a wrong state gets a 400 page and is ignored.
The client is registered with the port-less redirect URI http://127.0.0.1/callback
(http://[::1]/callback for ::1), and each login sends that URI with the actual port: the
server ignores the port of a loopback redirect only when the registered URI has none
(RFC 8252 section 7.3). If you pass a client_id registered earlier, that client must have
the port-less URI among its redirect_uris
(matrix_oauth_login.loopback.registered_redirect_uri() returns it).
import asyncio
from matrix_oauth_login import ClientMetadata, OAuthClient, loopback_login
async def main() -> None:
app = ClientMetadata(client_name="My CLI", client_uri="https://example.org")
async with OAuthClient("https://matrix-client.matrix.org", app) as client:
session = await loopback_login(client) # opens the default browser
print(session.user_id, session.device_id)
asyncio.run(main())For a web app with its own redirect page, use the two halves yourself:
request = await client.start_authorization(redirect_uri) gives request.url to send the
user to, and await client.finish_authorization(request, callback_url) turns the URL they come
back on into a Session. Keep request (state, PKCE verifier, device id, client id)
between the two: request.to_dict() gives a JSON-ready dict for your server side session
store, and AuthorizationRequest.from_dict(data) rebuilds it, so another worker with a fresh
OAuthClient on the same homeserver can finish the login. A device code flow carries its
client id the same way.
Access tokens on these servers live minutes, not months. ensure_fresh refreshes the session
when it expires within margin seconds and saves the new one (servers rotate the refresh
token, so the saved copy must be replaced each time). Concurrent ensure_fresh calls on the
same OAuthClient refresh once and share the result.
from matrix_oauth_login import FileTokenStore, OAuthClient, ensure_fresh
store = FileTokenStore("session.json")
session = store.load() # None when the file does not exist
async with OAuthClient(session.homeserver, app, client_id=session.client_id) as client:
session = await ensure_fresh(client, session, store=store, margin=60)
...
await client.revoke(session) # log out: revokes the access and refresh tokensTokenStore is a two method protocol (load, save): put the session in a database or a
secret manager by implementing it. Session.to_dict() and Session.from_dict() serialize it,
and its repr masks the tokens.
from nio import AsyncClient
from matrix_oauth_login.nio import apply_session
nio_client = apply_session(AsyncClient(session.homeserver), session)
await nio_client.sync(timeout=30000)
# after each refresh: apply_session(nio_client, fresh_session)from mautrix.api import HTTPAPI
from mautrix.client import ClientAPI
from matrix_oauth_login.mautrix import apply_session
api = ClientAPI(api=HTTPAPI(base_url=session.homeserver))
apply_session(api, session) # sets api.token, mxid and device_idFakeAuthServer checks what a real server checks (known client id, registered redirect URI,
PKCE verifier, device scope, bearer token on whoami), so your tests catch a wrong request.
import asyncio
from matrix_oauth_login import AccessDenied, ClientMetadata, OAuthClient
from matrix_oauth_login.testing import FakeAuthServer
async def denied() -> None:
server = FakeAuthServer()
app = ClientMetadata(client_name="test", client_uri="https://example.org")
async with OAuthClient(server.homeserver, app, transport=server.transport) as client:
flow = await client.start_device_login()
server.deny(flow.user_code)
try:
await client.finish_device_login(flow)
except AccessDenied:
print("denied, as expected")
print(server.paths()) # every request the client made
asyncio.run(denied())It can also serve only the unstable discovery path (discovery="unstable"), the old
auth_issuer plus OpenID configuration path (discovery="issuer"), or none at all
(discovery="none", the client raises OAuthNotSupported), and force slow_down or
expired_token on a pending device code, and inject an HTTP error or a dropped connection on
the next request to an endpoint (server.fail_next("/oauth2/token", status=503),
server.disconnect_next("/oauth2/token")). Pending device code polls get HTTP 403 like MAS.
It announces a polling interval of 0 so tests do not wait; pass interval=5 to mirror MAS.
- Discovery:
GET /_matrix/client/v1/auth_metadata, then the MSC2965 unstable path, thenauth_issuerplus/.well-known/openid-configuration(the issuer must match). A server that answers none raisesOAuthNotSupported, so you can fall back to password login. - The homeserver URL, the
.well-knownbase_urland every endpoint must be https (allow_insecure=Truefor local development), and the server must offer the S256 PKCE method. - Registration follows MSC2966:
nativeapplication type for loopback and custom scheme redirects,webfor https ones, public client (token_endpoint_auth_method: none), only the grant types the server offers. - Scopes follow MSC2967 (
urn:matrix:client:api:*andurn:matrix:client:device:<id>;unstable_scopes=Truefor theorg.matrix.msc2967names on older servers). The granted scope must contain the requested device, andwhoamimust return that device: otherwise the new tokens are revoked andIdentityMismatchis raised. - The callback
stateis compared in constant time (StateMismatchotherwise), and anerrorin the callback raisesOAuthErrorwith the server's description.
| Option | OAuth login | Token refresh | Language |
|---|---|---|---|
| matrix-nio 0.26.0 | no | no | Python |
| mautrix-python 0.21.1 | no | no | Python |
| matrix-rust-sdk | yes | yes | Rust (bindings for Kotlin and Swift, not Python) |
| Password login through the MAS compatibility layer | only where the server keeps local passwords | no, and compatibility tokens can expire | any |
| Writing the flow by hand | what you write | what you write | any |
| matrix-oauth-login | device code and browser (PKCE) | yes, with rotation and storage | Python, adapters for nio and mautrix |
The two Python SDKs log in with m.login.password or a token you paste. That stops working on
servers that delegate login to an SSO provider (Home Assistant's Matrix integration cannot log
in there, home-assistant/core#104138),
and a bot on a compatibility token dies when the token expires because nothing refreshes it.
Projects that needed it wrote the flow themselves, for example
meshtastic-matrix-relay.
This package is that flow, tested against the specs, in one place you can depend on.
- A project with its own OAuth module, such as meshtastic-matrix-relay's
matrix/oauth.py(discovery, registration, device grant, refresh, revocation, whoami check): the same steps map toOAuthClient.discover,register,start_device_login/finish_device_login,refresh,revoke, and the stored credentials toSession.to_dict(). Its tests can run againstFakeAuthServerinstead of hand-written HTTP mocks. - A matrix-nio integration that only knows password login, such as Home Assistant's Matrix
integration (issue #104138): when
OAuthClient.discover()succeeds, offer the device code login (the user approves on any device, no redirect to host), keep theSessionin the integration's config entry, callensure_freshbefore use andmatrix_oauth_login.nio. apply_sessionafter each refresh. When it raisesOAuthNotSupported, keep the password path as it is.
- Async only (httpx
AsyncClient); call it fromasyncio.runin sync code. - It logs in and keeps the token fresh. It does not sync, send messages or handle encryption: that stays the job of matrix-nio or mautrix-python.
- QR code login (MSC4108) is not implemented.
pip install -e ".[dev]"
pytest -q && ruff check . && ruff format --check . && mypy srcSee CONTRIBUTING.md and CHANGELOG.md.
MIT, see LICENSE.