This repository has been archived on 2026-10-18. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Miles WardandClaude Fable 5.1 3c83cd9928 v0.1.1: monochrome UI, spaced settings tabs, keychain-safe publishing
The accent is ink rather than blue: primary buttons, the floating Add
button, the empty-state shield, the active settings tab, the switch, the
countdown ring and the initials tile. Light mode uses a deep near-black
with a whisper of cool hue, dark mode a near-white — the Daito / Beautiful
UI treatment. Status colours and the platform dots keep their hue because
they carry meaning. The input focus glow stays, now a neutral 4px halo
with a mid-grey edge.

The per-issuer hue helper in the web UI is gone; the rendered chat tile
still uses its issuer colour, where it helps tell accounts apart.

Settings tabs get an 8px gap: each pill carries a 1px hairline ring
outside its box, so 4px read as the pills touching.

build-and-publish-unraid.sh writes registry auth into its private Docker
config instead of `docker login`/`docker logout`. With an empty config
the CLI falls back to docker-credential-osxkeychain, which shares its
keychain item with git's helper: login fails with -25299 and logout
deletes the credential git uses. PUBLISHING.md documents the trap and the
release flow for this version.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-09 00:47:37 -04:00

OpenMFA

OpenMFA

A self-hosted TOTP authenticator whose codes appear in Discord, Slack or Telegram as live messages that rewrite themselves as each code rolls.

The web UI is what you'd expect from Google or Microsoft Authenticator: a searchable list of accounts, each with its six digits and a countdown ring. The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and Ubisoft in the vault, your chosen channel holds three messages, each one staying current on its own.

Written in Rust. One static binary in a 27 MB Alpine image (about 8 MB to pull), no database, no external services beyond the chat platform itself.

OpenMFA grew out of Discord Authenticator, which remains the Discord-only sibling; this is the platform-neutral project.


Contents


Which platform, and what each one needs

The three platforms authenticate differently, and the reason is the same in each case: OpenMFA has to be allowed to edit a message it posted earlier.

Platform You provide Why that, and not something simpler
Discord An incoming-webhook URL Discord webhooks can edit and delete their own messages, so no bot is needed.
Slack A bot token (xoxb-…, scope chat:write) and a channel ID Slack incoming webhooks can post but cannot edit. Only a bot using chat.postMessage / chat.update can keep a tile live.
Telegram A bot token from @BotFather and a chat ID Telegram has no webhook concept; everything is a bot. sendMessage / editMessageText.

Setup walkthroughs are built into the app's Delivery settings, but in brief:

  • Discord — Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.
  • Slack — api.slack.com/apps → Create New App → OAuth & Permissions → add the chat:write bot scope → Install to Workspace → copy the Bot User OAuth Token. Then in the target channel run /invite @YourBot, and copy the channel's ID (click the channel name; it's at the bottom of the details pane). The Check token button confirms which workspace the token belongs to before you save.
  • Telegram — message @BotFather, send /newbot, copy the token. Then send /start to your new bot (or add it to a group and say anything) and click Detect chat — the app lists every chat the bot has heard from so you never have to find a chat ID by hand.

Practical account limits per platform

Each account costs two edits a minute (one per 30-second roll). Rate limits are per-minute, so they cap how many accounts one destination can carry:

Platform Limit that bites Comfortable ceiling
Discord ~5 req/s per webhook; ~30/min reported ~10–12 accounts per webhook
Slack chat.update is Tier 3, ~50/min per workspace ~20 accounts
Telegram, private chat 1 msg/s per chat ~30 accounts
Telegram, group 20 msg/min per group ~10 accounts

Past those, split across several webhooks/channels, or pause push on the accounts you don't need live.


How the live delivery works

The obvious approaches don't survive contact with any of these platforms, and it's worth saying why, because the design is a direct consequence.

A live-updating image URL doesn't work. Pointing a message at an image your server renders on demand fails twice over. The platform fetches the URL once at post time and serves a cached copy, so new bytes at the same URL are never seen. And more seriously, its crawler must reach that URL anonymously — which would mean publishing your live TOTP codes on an unauthenticated public endpoint. That alone disqualifies it.

An animated image doesn't work either. Playback starts whenever the client decodes the file, not on a wall clock. A 30-second countdown baked into frames drifts out of phase with the real window and confidently displays the wrong time remaining — worse than showing nothing.

What does work is editing the message. Post once, remember the id, rewrite it every period. Every client sees the change at once. So each account owns one message for its lifetime, and the app rewrites it once per period — on Discord via PATCH …/messages/{id}, on Slack via chat.update, on Telegram via editMessageText.

Two details make this pleasant rather than merely functional:

  • The digits stay copyable. The code goes into a code span containing the bare digits (482913, not 482 913) so what lands on your clipboard is directly pasteable. Discord and Slack render a copy button on fenced code; Telegram's <code> is tap-to-copy on mobile.
  • A rollover time, as live as the platform allows. Discord's <t:…:R> ticks in the client every second for free. Slack's <!date> renders once in the viewer's local time. Telegram gets a plain UTC clock. In every case the digits themselves are refreshed by the edit at the boundary — the countdown is a courtesy, not the mechanism.

On Discord each message can also carry a rendered WebP tile — issuer, account, the code at a size you can read across a room, with a per-issuer accent colour matching the row in the web UI. Slack and Telegram receive text; see Roadmap.

Rate limiting and failure handling

TOTP windows are globally aligned, so every account's code rolls at the same instant and N accounts would fire an N-request burst. Every call is serialised through a per-platform governor — 500 ms apart on Discord, 1 s on Slack and Telegram — and backs off on 429 using whatever retry hint the platform returns.

Failures are classified rather than blindly retried, because every platform eventually bans a client that hammers 4xx responses:

Meaning Discord Slack Telegram What happens
Message was deleted code 10008 message_not_found, cant_update_message "message to edit not found" Post a fresh one automatically
Credential dead 10015, 50027 invalid_auth, token_revoked 401 / 404 Stop, and show you the reason
Channel gone / no access 10003, 50013 channel_not_found, not_in_channel "chat not found", "bot was blocked" Stop, and show you the reason
Rate limited 429 429 429 Back off, skip the stale edit
Content identical — — "message is not modified" Treated as success

Security: read this before you use it

Anyone who can read that channel can read your codes. That is most of what a second factor is for. This tool deliberately trades some of that security for convenience, and you should decide knowingly:

  • Use a private channel in a server or workspace only you are in.
  • Treat the webhook URL or bot token itself as a secret — it is a write capability for that channel.
  • Anything with access to your Discord, Slack or Telegram account — another logged-in device, a session token, a member of that server — inherits your second factor.
  • Consider leaving push off for your highest-value accounts. It's a per-account toggle, so a mixed setup is fine.

What the app does hold up its end on:

  • TOTP secrets are encrypted at rest with XChaCha20-Poly1305; vault.json contains ciphertext only, and the metadata beside it is deliberately readable.
  • The admin password is hashed with Argon2id.
  • The vault key lives in vault.key (mode 0600), or set OPENMFA_MASTER_KEY to keep it off the data volume entirely — then a leaked copy of that volume is useless on its own.
  • Everything under /api/* requires a session. There is no public read path.
  • The UI is fully offline: no CDN, no remote fonts, no external requests. The only outbound traffic the app ever makes is to the chat platform you chose.

Why the key can't just be your password: the app has to mint codes unattended to keep the tiles live, so nothing can require a human to type a passphrase after a restart. The key is therefore readable by the process at boot. That protects against a leaked backup of the vault file — the realistic homelab accident — but not against someone who can read the whole data directory. OPENMFA_MASTER_KEY closes that second gap if you need it closed.


Running it

Docker Compose

docker compose up -d

Then open http://<host>:8720/.

Unraid

Copy deploy/unraid/openmfa.xml into /boot/config/plugins/dockerMan/templates-user/ and add the container from the Docker tab, or point the Repository field at:

gitea.milesward.dev/mward4/openmfa:latest

Bridge networking is correct — this makes outbound HTTPS calls and serves one port. Map /var/lib/openmfa to somewhere on appdata and back that path up: losing it loses every stored secret.

Building and publishing the image

GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh

Using it

First run asks for a username and password, then offers a three-way choice of Discord, Slack or Telegram — optional, so you can wire a platform later from Settings → Delivery. Switching platforms afterwards keeps the other platforms' details, so you can move back and forth without re-pasting tokens.

Adding an account offers three routes:

  • Upload a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots, photos of a screen, tiny crops and rotated images all work; the decoder walks a ladder of rescale, contrast and rotation attempts before giving up. (HEIC is rejected with a note to screenshot instead — iPhone screenshots are PNG.) If the image holds several QR codes, all of them are read.
  • Camera scanning, using the browser's native barcode detector where it exists and falling back to server-side decoding where it doesn't, which covers Safari and Firefox. Note that browsers only grant camera access over HTTPS or on localhost.
  • Paste an otpauth:// link, or type a secret by hand.

Importing from Google Authenticator

Google Authenticator's Transfer accounts → Export accounts produces a QR code holding many accounts at once — an otpauth-migration:// link wrapping a base64 protobuf. Upload or paste it and you get a checklist of everything inside, with each entry's algorithm, digit count and period, and an Add N accounts button.

  • Large exports are split across several QR codes. Each reports its position, so the UI says "Part 1 of 2" and prompts you for the rest.
  • Re-importing is safe. Anything already in your vault is detected and pre-unticked. Duplicates match on the secret and the naming, so deliberately keeping one secret under two labels still works.
  • Entries that can't be imported are named, not silently dropped — a counter-based (HOTP) account or an MD5 one is listed with the reason.
  • Secrets never reach the browser. The preview returns names and parameters only; on import the client sends back the link plus the indices it selected, and the server re-parses it.

Two traps this format sets, both of which silently produce plausible but wrong codes: the protobuf secret field is raw bytes, not base32, and the payload carries no period field — every Google export is 30-second TOTP.

The otpauth:// parser is deliberately forgiving of real-world URIs: it accepts lowercase and =-padded secrets, and where the issuer in the path disagrees with the ?issuer= parameter — which Microsoft does — it takes the query parameter rather than refusing the account.

In the list, click any code to copy it. / focuses the search box. The pencil on a row opens Edit account: the Name shows in the list and on the tile; the Account field (usually the username or email) is left alone unless you change it. A rename reaches the chat within a second or two — the push loop clears its "already sent" marker so it doesn't wait for the code to roll.


Configuration

All settings are environment variables (or CLI flags of the same name).

Variable Default Meaning
OPENMFA_HTTP_ADDR 0.0.0.0:8720 Listen address for UI and API
OPENMFA_WORK_DIR /var/lib/openmfa Vault, key, settings, admin account
OPENMFA_MASTER_KEY (unset) Base64 of 32 bytes. Overrides vault.key; nothing is written to disk
OPENMFA_LOG info,openmfa=info tracing-subscriber filter
OPENMFA_SLACK_API_BASE https://slack.com/api Override for proxies
OPENMFA_TELEGRAM_API_BASE https://api.telegram.org Override for a self-hosted Bot API server

Generate a master key with openssl rand -base64 32. Setting it after secrets exist makes them unreadable — export first if you're migrating.

Message style (Settings → Delivery, Discord only) chooses what each tile contains: Text (one JSON edit per period, nothing uploaded — the lightest), Image (the WebP tile), or Both (default). The image styles re-upload a tile on every roll — roughly 2,880 attachments per account per day. Harmless, but Text is the frugal choice for many accounts.


Architecture

crates/
  core/       TOTP, vault, crypto, settings (incl. provider config), admin auth,
              Google Authenticator export parsing — no network
  render/     SVG → WebP tile rendering (resvg + embedded JetBrains Mono)
  delivery/   Discord, Slack and Telegram clients behind one Sink; the push engine
  webui/      Embedded HTML/CSS/JS, compiled into the binary
  http-api/   axum router, sessions, QR decoding
  openmfa/    The binary

core is deliberately network-free so the security-critical parts are testable without a socket. The delivery engine is platform-blind: it resolves a Sink from settings each pass and only ever calls create, edit and delete; each platform client owns its own rate-limit governor and its own error classification.

Everything is pure Rust: no OpenSSL, no -sys crates, no C. TLS roots are compiled in via webpki-roots, so the runtime image needs no ca-certificates and the binary is genuinely standalone.

The UI follows Beautiful UI — OKLCH colour ramps, hairline-ring shadows, inset fields against raised buttons, Inter and JetBrains Mono. The accent is monochrome in the vein of Daito: ink in light mode, near-white in dark, so the only colour on screen is status (live, expiring, error) and the three platform dots. Light and dark both ship.

Testing

cargo test --workspace runs the suite, including the RFC 6238 vectors, real QR round-trips, Google export decoding from protobuf wire bytes, and the live push cycle against mock Discord, Slack and Telegram servers that speak each platform's real error conventions — Slack's ok:false on HTTP 200, Telegram's description strings, Discord's JSON error codes — asserting that tiles are edited in place rather than re-posted, that deleted tiles are recovered, that dead credentials are surfaced instead of hammered, and that renames refresh without waiting for a rollover.


Roadmap

  • v0.2 — Microsoft Teams. Harder than the other three: Teams' incoming webhooks were retired in May 2026 and their replacement (Power Automate Workflows) can post but not edit, so live tiles need a Bot Framework bot. See docs/TEAMS.md for the exploration.
  • Image tiles on Telegram via editMessageMedia.
  • A Slack slash command (/openmfa <account>) as a pull alternative to live tiles, in the style of Daito — for teams that would rather fetch a code than have it on screen.
  • Email → chat forwarding for services that send codes by email.

Changelog

  • v0.1.1 — Monochrome UI. The accent is ink rather than blue across the primary buttons, the floating Add an account button, the empty-state shield, the settings tabs and the focus glow on inputs; the settings tabs are spaced so their hairline rings no longer touch.
  • v0.1 — First release: live TOTP tiles to Discord, Slack or Telegram, a three-way provider picker at setup, Google Authenticator export import, QR upload / camera / paste, and the Unraid template.

Licence

MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License 1.1 (crates/render/assets/JetBrainsMono-OFL.txt).

S
Description
Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram. Container-native Rust. ARCHIVED! FOCUSING ON THE DISCORD IMPLEMENTATION, K.I.S.S.
Readme
803 KiB
0 Stars 1 Watchers 0 Forks
OpenMFA v0.2
Latest
2026-09-09 02:08:47 -04:00
Languages
Rust 70%
JavaScript 18.3%
CSS 6.7%
Python 2.6%
Shell 1.2%
Other 1.2%