v0.1: OpenMFA — live TOTP delivery to Discord, Slack or Telegram

Platform-neutral successor to Discord Authenticator, forked from its v0.1.2
state with fresh history. Same vault, same UI, same live-message mechanism —
now behind one `Sink` that speaks three platforms.

Why each platform needs what it needs (this drove the whole settings model):
- Discord: an incoming-webhook URL. Webhooks can edit their own messages.
- Slack: a bot token (xoxb-, scope chat:write) + channel ID. Slack incoming
  webhooks can post but never edit, and editing IS the live mechanism, so
  chat.postMessage / chat.update via a bot is the only path.
- Telegram: a bot token + chat id. No webhook concept exists. The app detects
  the chat id itself from the bot's pending updates ("send /start, then
  Detect"), since users rarely know it.

- core: Provider enum; settings hold all three configs so switching back and
  forth never re-asks for tokens; per-platform shape validation that names the
  common mistakes (xoxp- user token, #name instead of channel ID); a
  destination key that clears message ids on platform/channel change but not
  on token rotation
- delivery: shared DeliveryError + pacing Governor; Discord client refactored
  onto them; new Slack client (classifies Slack's ok:false-on-HTTP-200
  errors) and Telegram client ("message is not modified" is success; chat
  detection via getUpdates); provider-agnostic message builders — code in a
  copyable span everywhere, rollover time as live as each platform allows
- http-api: setup accepts an optional provider; settings PUT/GET carry the
  full model; /api/slack/whoami, /api/telegram/whoami, /api/telegram/chats
- webui: three-way optional picker on setup and in Settings → Delivery with
  guided per-platform fields; "Check token" (Slack) and "Detect chat"
  (Telegram); inline white mark via currentColor
- brand: white mark on the dark plate; blue stays with the Discord sibling
- docs/TEAMS.md: exploration for v0.2 — Teams connectors were disabled May
  2026 and Workflows webhooks can't edit, so live tiles need a Bot Framework
  bot; options for the inbound endpoint on a homelab and a recommended scope

Verified against mock Discord, Slack and Telegram servers speaking each
platform's real protocol shape: edits target the same message rather than
re-posting, deleted tiles are recovered, dead credentials are surfaced with a
next step, renames refresh without a rollover, and switching platform reposts
on the new one and leaves the old one alone. 138 tests.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Miles WardandClaude Fable 5.1 committed 2026-09-09 00:23:45 -04:00
commit ad3720edbf
59 files changed
+14893

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Keep the Docker build context small. The multi-stage Dockerfile COPYs
# only what it needs.
target/
**/target/
# Runtime state (vault + keys live here — must never enter an image layer).
data/
/tmp/
# VCS + editor noise.
.git/
.github/
.idea/
.vscode/
.DS_Store
*.swp
# Claude / local tooling state.
.claude/
MEMORY.md
# Docs & meta.
docs/
*.md
!README.md
LICENSE*
+15
View File
@@ -0,0 +1,15 @@
/target
.DS_Store
*.swp
# Runtime state. These only ever live in the work directory, so the patterns
# are anchored there rather than matching any file with those names anywhere
# in the tree.
/data/
**/vault.key
**/vault.json
**/auth.json
**/settings.json.tmp
# Per-user Claude Code state.
.claude/
Generated
+2791
View File
File diff suppressed because it is too large. Load diff
+115
View File
@@ -0,0 +1,115 @@
[workspace]
resolver = "2"
members = [
"crates/core",
"crates/render",
"crates/delivery",
"crates/webui",
"crates/http-api",
"crates/openmfa",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/OpenMFA"
authors = ["OpenMFA contributors"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["trace", "limit"] }
# rustls, never native-tls: an OpenSSL dependency would break the static
# musl build. `multipart` carries the rendered tile up to Discord.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
clap = { version = "4", features = ["derive", "env"] }
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting"] }
parking_lot = "0.12"
url = "2"
percent-encoding = "2"
base64 = "0.22"
# ── TOTP primitives ──
# The RFC 6238 core is ~30 lines over HMAC, and we implement it directly
# rather than pulling `totp-rs`. The deciding factor was otpauth:// parsing:
# real-world URIs routinely carry a path issuer that disagrees with the
# `?issuer=` query param (Microsoft does this), and secrets that are
# lowercase or `=`-padded (Google, Dropbox). totp-rs rejects all of those,
# so we'd be writing the normalisation and reconciliation layer anyway.
# Pinned to the mature 0.10/0.12 RustCrypto generation.
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
base32 = "0.5"
# ── Secrets at rest ──
# XChaCha20-Poly1305 over AES-GCM for the 192-bit nonce: large enough to
# generate randomly per record with no birthday-collision worry, and
# constant-time in software on every target without AES-NI.
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# ── Imaging ──
# default-features = false is REQUIRED: `image`'s default feature set
# enables `avif`, dragging in the whole rav1e encoder for a format we never
# touch. (Never enable `avif-native` — that one is C bindings to libdav1d
# and would break the static musl build outright.)
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
rqrr = "0.10"
# Tile rendering. The tile is drawn as SVG and rasterised, rather than
# blitting glyphs by hand: text centring, rounded corners and gradients are
# all one attribute each in SVG and a pile of arithmetic otherwise.
#
# `default-features = false` matters here too — resvg defaults to
# `system-fonts` + `memmap-fonts`, and a static binary in a slim container has
# no system fonts to find. Text would silently render blank. We disable both
# and register the embedded faces explicitly.
resvg = { version = "0.48", default-features = false, features = ["text"] }
usvg = { version = "0.48", default-features = false, features = ["text"] }
tiny-skia = "0.12"
openmfa-core = { path = "crates/core" }
openmfa-render = { path = "crates/render" }
openmfa-delivery = { path = "crates/delivery" }
openmfa-webui = { path = "crates/webui" }
openmfa-http-api = { path = "crates/http-api" }
[workspace.lints.rust]
unsafe_code = "deny"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
doc_markdown = "allow"
items_after_statements = "allow"
cast_possible_truncation = "allow"
cast_lossless = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
similar_names = "allow"
too_many_lines = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
opt-level = 3
+347
View File
@@ -0,0 +1,347 @@
<p align="center">
<img src="docs/brand/icon-256.png" width="128" height="128" alt="OpenMFA" />
</p>
<h1 align="center">OpenMFA</h1>
<p align="center">
A self-hosted TOTP authenticator whose codes appear in
<strong>Discord, Slack or Telegram</strong> as live messages that rewrite
themselves as each code rolls.
</p>
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](https://gitea.milesward.dev/mward4/Discord-Authenticator),
which remains the Discord-only sibling; this is the platform-neutral project.
---
## Contents
- [Which platform, and what each one needs](#which-platform-and-what-each-one-needs)
- [How the live delivery works](#how-the-live-delivery-works)
- [Security: read this before you use it](#security-read-this-before-you-use-it)
- [Running it](#running-it)
- [Using it](#using-it)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Roadmap](#roadmap)
---
## 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](#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
```bash
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
```bash
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](https://www.beautifului.dev/) — OKLCH colour
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
JetBrains Mono. 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`](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.
## Licence
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`).
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "openmfa-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Domain core for OpenMFA: TOTP, vault, settings, auth."
[dependencies]
serde.workspace = true
serde_json.workspace = true
time.workspace = true
parking_lot.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid.workspace = true
url.workspace = true
percent-encoding.workspace = true
base64.workspace = true
hmac.workspace = true
sha1.workspace = true
sha2.workspace = true
base32.workspace = true
chacha20poly1305.workspace = true
argon2.workspace = true
rand.workspace = true
zeroize.workspace = true
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
+347
View File
@@ -0,0 +1,347 @@
//! Single-admin authentication, in the Sonarr/Radarr mould.
//!
//! On a fresh install no admin exists and the UI shows a first-run form. Once
//! created, those credentials gate `/api/*`, and the admin can rotate the
//! username and password from Settings.
//!
//! Multi-user RBAC is not a goal — this is one person's vault of their own
//! second factors, so one record is enough.
//!
//! Unlike the vault, a corrupt `auth.json` falls back to "no admin
//! configured" rather than refusing to start: that puts the operator back
//! into the setup flow instead of locking them out of their own box. The
//! vault stays encrypted either way, so this is not a bypass — a fresh admin
//! still can't read secrets without the vault key.
use crate::{Error, Result};
use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
use argon2::Argon2;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// Argon2id PHC string. The plaintext never outlives the request that set it.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash. Safe to send to the browser.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
///
/// Every method here that hashes or verifies runs Argon2id, which is
/// deliberately expensive (tens of milliseconds). Callers on an async runtime
/// should invoke these from `spawn_blocking`.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<Inner>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openmfa::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}),
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been created? Drives the first-run / login fork.
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup. Refuses to run twice, so an exposed instance can't be
/// re-bootstrapped by whoever finds the URL.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash_password(password)?,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::conflict("an admin account already exists"));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(target: "openmfa::auth", username = %admin.username, "admin account created");
Ok((&admin).into())
}
/// Verify credentials. `Ok(None)` means "wrong", distinct from `Err`,
/// which means the check itself could not be performed.
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
let parsed = PasswordHash::new(&admin.password_hash)
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
match Argon2::default().verify_password(password.as_bytes(), &parsed) {
Ok(()) => Ok(Some((&admin).into())),
Err(_) => Ok(None),
}
}
/// Rotate username and/or password. `current_password` must match the
/// existing hash — proof that the person at the keyboard is the owner,
/// not just someone holding a stolen session cookie.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::conflict("no admin account exists yet"))?;
let parsed = PasswordHash::new(&existing.password_hash)
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
if Argon2::default()
.verify_password(current_password.as_bytes(), &parsed)
.is_err()
{
return Err(Error::invalid("current password is incorrect"));
}
let mut updated = existing;
if let Some(u) = new_username.map(str::trim).filter(|s| !s.is_empty()) {
validate_username(u)?;
updated.username = u.to_string();
}
if let Some(p) = new_password.filter(|s| !s.is_empty()) {
validate_password(p)?;
updated.password_hash = hash_password(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
self.inner.write().admin = Some(updated.clone());
self.persist();
tracing::info!(target: "openmfa::auth", username = %updated.username, "admin credentials updated");
Ok((&updated).into())
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "openmfa::auth", "could not serialize auth.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, body).is_ok() {
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::error!(target: "openmfa::auth", "could not commit auth.json: {e}");
}
}
}
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::crypto(format!("could not hash password: {e}")))
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::invalid("username must not be empty"));
}
if u.len() > 64 {
return Err(Error::invalid("username must be 64 characters or fewer"));
}
if !u.chars().all(|c| c.is_ascii_graphic()) {
return Err(Error::invalid(
"username must use printable ASCII with no spaces",
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.chars().count() < 8 {
return Err(Error::invalid("password must be at least 8 characters"));
}
if p.len() > 256 {
return Err(Error::invalid("password must be 256 bytes or fewer"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn starts_unconfigured() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let p = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(p.username, "admin");
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn password_is_never_stored_in_the_clear() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let raw = std::fs::read_to_string(dir.path().join("auth.json")).unwrap();
assert!(!raw.contains("hunter2hunter2"));
assert!(
raw.contains("$argon2id$"),
"expected an Argon2id PHC string"
);
}
#[test]
fn bootstrap_refuses_a_second_run() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
assert!(matches!(
s.bootstrap("other", "anotherpass1"),
Err(Error::Conflict(_))
));
}
#[test]
fn survives_a_reload() {
let dir = tempdir().unwrap();
AdminStore::load_or_default(dir.path())
.bootstrap("admin", "hunter2hunter2")
.unwrap();
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn rotation_requires_the_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
assert!(s
.update_credentials("nope", None, Some("newpassword1"))
.is_err());
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn rotation_can_change_just_one_field() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Password only — username sticks.
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
// Username only — password sticks. Empty strings mean "unchanged".
s.update_credentials("newpassword1", Some("bob"), Some(""))
.unwrap();
assert!(s.verify("bob", "newpassword1").unwrap().is_some());
}
#[test]
fn input_validation() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("has space", "hunter2hunter2").is_err());
assert!(s.bootstrap("admin", "short").is_err());
assert!(s.bootstrap(&"a".repeat(65), "hunter2hunter2").is_err());
}
#[test]
fn corrupt_auth_file_falls_back_to_setup_mode() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("auth.json"), b"{ not json").unwrap();
assert!(!AdminStore::load_or_default(dir.path()).is_configured());
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Envelope encryption for the TOTP secrets on disk.
//!
//! **Threat model, stated plainly.** This app must mint codes unattended,
//! around the clock, to keep the Discord tiles live. That rules out deriving
//! the vault key from the admin password: nobody is there to type it after a
//! container restart. So the key necessarily sits somewhere the process can
//! read at boot.
//!
//! What that buys you, and what it doesn't:
//!
//! * **Protects against** a leaked or backed-up `vault.json` — the ciphertext
//! alone is useless. That is the realistic homelab accident: an appdata
//! share browsed over SMB, a snapshot copied somewhere careless, a bind
//! mount that ends up in a backup tarball.
//! * **Does not protect against** an attacker who can read the whole work
//! directory, since `vault.key` lives there by default. For that case set
//! `OPENMFA_MASTER_KEY` and keep the key outside the mounted volume entirely.
//!
//! [`VaultKey::load_or_create`] documents the precedence.
use crate::{Error, Result};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
use zeroize::Zeroize;
/// Filename of the generated key inside the work directory.
pub const KEY_FILE: &str = "vault.key";
/// Environment variable holding a base64 32-byte key. Takes precedence over
/// the key file so operators can keep the key off the data volume.
pub const KEY_ENV: &str = "OPENMFA_MASTER_KEY";
/// One encrypted value. Stored inline in `vault.json`.
///
/// XChaCha20-Poly1305 rather than AES-GCM specifically for the nonce width:
/// 192 bits is wide enough to pick at random for every write without any
/// birthday-collision concern, which is exactly the access pattern here.
/// It is also constant-time in software on hosts without AES-NI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sealed {
/// base64 (standard alphabet) 24-byte nonce.
pub nonce: String,
/// base64 ciphertext with the Poly1305 tag appended.
pub ciphertext: String,
}
#[derive(Clone)]
pub struct VaultKey {
key: Key,
}
impl std::fmt::Debug for VaultKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never let the key reach a log line via a derived Debug.
f.write_str("VaultKey(<redacted>)")
}
}
impl VaultKey {
/// Resolve the vault key, in precedence order:
///
/// 1. `$OPENMFA_MASTER_KEY` — base64 of exactly 32 bytes. Nothing is written
/// to disk in this mode.
/// 2. `<work_dir>/vault.key` — read if present.
/// 3. Otherwise a fresh random key is generated and written there with
/// mode 0600.
pub fn load_or_create(work_dir: &Path) -> Result<Self> {
if let Ok(encoded) = std::env::var(KEY_ENV) {
let encoded = encoded.trim();
if !encoded.is_empty() {
let mut raw = b64_decode(encoded)
.map_err(|_| Error::crypto(format!("{KEY_ENV} is not valid base64")))?;
if raw.len() != 32 {
raw.zeroize();
return Err(Error::crypto(format!(
"{KEY_ENV} must decode to exactly 32 bytes"
)));
}
let key = *Key::from_slice(&raw);
raw.zeroize();
tracing::info!(target: "openmfa::crypto", "vault key loaded from {KEY_ENV}");
return Ok(Self { key });
}
}
let path = work_dir.join(KEY_FILE);
if path.exists() {
let mut raw = std::fs::read(&path)?;
if raw.len() != 32 {
raw.zeroize();
return Err(Error::crypto(format!(
"{} is {} bytes, expected 32 — refusing to start rather than \
generate a new key and orphan the existing vault",
path.display(),
raw.len()
)));
}
let key = *Key::from_slice(&raw);
raw.zeroize();
return Ok(Self { key });
}
// Fresh install: mint a key and persist it before anything is sealed.
let key = XChaCha20Poly1305::generate_key(&mut OsRng);
std::fs::create_dir_all(work_dir)?;
write_private(&path, key.as_slice())?;
tracing::info!(
target: "openmfa::crypto",
path = %path.display(),
"generated a new vault key"
);
Ok(Self { key })
}
/// Build a key directly from 32 raw bytes. Test and migration helper.
pub fn from_bytes(raw: &[u8]) -> Result<Self> {
if raw.len() != 32 {
return Err(Error::crypto("vault key must be exactly 32 bytes"));
}
Ok(Self {
key: *Key::from_slice(raw),
})
}
pub fn seal(&self, plaintext: &[u8]) -> Result<Sealed> {
let cipher = XChaCha20Poly1305::new(&self.key);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| Error::crypto("could not encrypt secret"))?;
Ok(Sealed {
nonce: b64_encode(&nonce),
ciphertext: b64_encode(&ciphertext),
})
}
pub fn open(&self, sealed: &Sealed) -> Result<Vec<u8>> {
let nonce = b64_decode(&sealed.nonce)
.map_err(|_| Error::crypto("stored nonce is not valid base64"))?;
if nonce.len() != 24 {
return Err(Error::crypto("stored nonce has the wrong length"));
}
let ciphertext = b64_decode(&sealed.ciphertext)
.map_err(|_| Error::crypto("stored ciphertext is not valid base64"))?;
let cipher = XChaCha20Poly1305::new(&self.key);
cipher
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| {
Error::crypto("could not decrypt secret — the vault key does not match this vault")
})
}
}
/// Write `bytes` to `path` readable only by the owner.
///
/// The mode is set at creation time on Unix rather than chmod-ed afterwards,
/// so the key is never briefly world-readable.
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
}
fn b64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn b64_decode(s: &str) -> std::result::Result<Vec<u8>, base64::DecodeError> {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(s.trim())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn seal_open_round_trip() {
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
let sealed = k.seal(b"super secret bytes").unwrap();
assert_eq!(k.open(&sealed).unwrap(), b"super secret bytes");
}
#[test]
fn each_seal_uses_a_fresh_nonce() {
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
let a = k.seal(b"same plaintext").unwrap();
let b = k.seal(b"same plaintext").unwrap();
// Identical input must not produce identical ciphertext, or the
// vault would leak which accounts share a secret.
assert_ne!(a.nonce, b.nonce);
assert_ne!(a.ciphertext, b.ciphertext);
}
#[test]
fn a_different_key_cannot_open_it() {
let a = VaultKey::from_bytes(&[1u8; 32]).unwrap();
let b = VaultKey::from_bytes(&[2u8; 32]).unwrap();
let sealed = a.seal(b"secret").unwrap();
assert!(b.open(&sealed).is_err());
}
#[test]
fn tampered_ciphertext_is_rejected() {
let k = VaultKey::from_bytes(&[9u8; 32]).unwrap();
let mut sealed = k.seal(b"secret").unwrap();
// Flip a byte: Poly1305 must catch it rather than return garbage.
let mut raw = b64_decode(&sealed.ciphertext).unwrap();
raw[0] ^= 0xff;
sealed.ciphertext = b64_encode(&raw);
assert!(k.open(&sealed).is_err());
}
#[test]
fn key_file_is_created_once_and_reused() {
let dir = tempdir().unwrap();
let k1 = VaultKey::load_or_create(dir.path()).unwrap();
let sealed = k1.seal(b"persisted").unwrap();
// A second load must reuse the same key, or every restart would
// orphan the vault.
let k2 = VaultKey::load_or_create(dir.path()).unwrap();
assert_eq!(k2.open(&sealed).unwrap(), b"persisted");
}
#[cfg(unix)]
#[test]
fn key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let _ = VaultKey::load_or_create(dir.path()).unwrap();
let meta = std::fs::metadata(dir.path().join(KEY_FILE)).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
#[test]
fn refuses_a_truncated_key_file() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(KEY_FILE), b"too short").unwrap();
// Silently generating a fresh key here would strand every stored
// secret, so this must be a hard error.
assert!(VaultKey::load_or_create(dir.path()).is_err());
}
}
+64
View File
@@ -0,0 +1,64 @@
//! One error type for the whole domain core.
use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Caller supplied something we can't accept. Surfaces as HTTP 400 and
/// the message is written to be read by a human in a toast.
#[error("{0}")]
Invalid(String),
/// Asked for something that isn't there. Surfaces as HTTP 404.
#[error("{0} not found")]
NotFound(String),
/// Refused because it would clobber existing state. Surfaces as 409.
#[error("{0}")]
Conflict(String),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
/// Vault could not be sealed or unsealed. Deliberately vague — the
/// detail goes to the log, not to the caller.
#[error("cryptography error: {0}")]
Crypto(String),
#[error(transparent)]
Other(#[from] anyhow_lite::Any),
}
impl Error {
pub fn invalid(msg: impl fmt::Display) -> Self {
Self::Invalid(msg.to_string())
}
pub fn not_found(what: impl fmt::Display) -> Self {
Self::NotFound(what.to_string())
}
pub fn conflict(msg: impl fmt::Display) -> Self {
Self::Conflict(msg.to_string())
}
pub fn crypto(msg: impl fmt::Display) -> Self {
Self::Crypto(msg.to_string())
}
}
/// A minimal boxed-error shim so `core` doesn't need the `anyhow` crate
/// just to carry the occasional opaque third-party error.
pub mod anyhow_lite {
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct Any(pub String);
impl Any {
pub fn new(e: impl std::fmt::Display) -> Self {
Self(e.to_string())
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Domain core for OpenMFA.
//!
//! Deliberately free of network I/O — this crate owns the TOTP algorithm,
//! the encrypted vault, admin credentials and settings, and nothing else.
//! Talking to Discord lives in `openmfa-delivery`, drawing tiles in `openmfa-render`,
//! and HTTP in `openmfa-http-api`, so the security-critical parts here can be
//! tested without a socket in sight.
#![forbid(unsafe_code)]
pub mod auth;
pub mod crypto;
pub mod error;
pub mod migration;
pub mod settings;
pub mod totp;
pub mod vault;
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use crypto::{Sealed, VaultKey};
pub use error::{Error, Result};
pub use migration::{is_migration_uri, parse_migration, ImportBatch, Skipped};
pub use settings::{
validate_slack, validate_telegram, validate_webhook_url, DiscordConfig, Provider, PushMode,
Settings, SettingsStore, SlackConfig, TelegramConfig,
};
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
pub use vault::{
Account, AccountPublic, DeliveryState, ImportOutcome, NewAccount, PushTarget, Vault,
};
/// Parse any link the "add an account" flow accepts.
///
/// A plain `otpauth://` URI yields a batch of one; a Google Authenticator
/// `otpauth-migration://` export yields however many accounts it carries. The
/// callers upstream — preview and import — therefore only ever deal in
/// batches and need no special case for the single-account path.
pub fn parse_import(uri: &str) -> Result<ImportBatch> {
if is_migration_uri(uri) {
return parse_migration(uri);
}
Ok(ImportBatch {
accounts: vec![parse_otpauth(uri)?],
..ImportBatch::default()
})
}
/// Seconds since the Unix epoch.
///
/// TOTP is defined against wall-clock time, so this is one of the few places
/// a monotonic clock is the wrong tool: if the operator's clock is corrected,
/// the codes must follow it.
pub fn unix_now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
/// Milliseconds since the Unix epoch — sent to the browser so its countdown
/// tracks the server's clock rather than the viewing device's.
pub fn unix_now_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
+733
View File
@@ -0,0 +1,733 @@
//! Google Authenticator export parsing (`otpauth-migration://offline?data=…`).
//!
//! The payload is a base64 protobuf holding many accounts at once. Google
//! splits large exports across several QR codes, each carrying `batch_index`
//! and `batch_size` so the user knows how many screens to scan.
//!
//! ## Why the protobuf is hand-decoded
//!
//! Pulling `prost` would mean either a `protoc` build dependency or a
//! generated file checked in, for a schema that is four messages long and has
//! not changed since 2020. The wire format we need is two wire types (varint
//! and length-delimited), so the reader below is short enough to audit.
//! Unknown fields are skipped properly, so a future Google addition won't
//! break parsing.
//!
//! Schema (Google Authenticator 5.10, reverse-engineered but stable):
//!
//! ```text
//! MigrationPayload { otp_parameters=1 (repeated), version=2,
//! batch_size=3, batch_index=4, batch_id=5 }
//! OtpParameters { secret=1 (bytes), name=2, issuer=3,
//! algorithm=4, digits=5, type=6, counter=7 }
//! Algorithm { UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4 }
//! DigitCount { UNSPECIFIED=0, SIX=1, EIGHT=2 }
//! OtpType { UNSPECIFIED=0, HOTP=1, TOTP=2 }
//! ```
//!
//! Two traps worth naming, because both silently produce wrong codes:
//!
//! * **`secret` is raw bytes, not base32.** It has to be base32-encoded to
//! become the secret an `otpauth://` URI would carry.
//! * **There is no period field.** Google only ever exports 30-second TOTP,
//! so 30 is assumed rather than guessed at.
use crate::totp::{Algorithm, OtpAuth, Totp};
use crate::{Error, Result};
/// Google never exports a period; every entry is the 30-second default.
const ASSUMED_PERIOD: u64 = 30;
/// An account present in the export that we deliberately did not import.
#[derive(Debug, Clone)]
pub struct Skipped {
pub name: String,
pub reason: String,
}
/// One decoded export QR code.
#[derive(Debug, Clone, Default)]
pub struct ImportBatch {
pub accounts: Vec<OtpAuth>,
pub skipped: Vec<Skipped>,
/// 0-based index of this QR within the export, when present.
pub batch_index: Option<i32>,
/// Total number of QR codes in the export, when present.
pub batch_size: Option<i32>,
/// Identifies which export a QR belongs to, so codes from two different
/// export sessions aren't mistaken for parts of one.
pub batch_id: Option<i32>,
}
impl ImportBatch {
/// Human-readable "Part 2 of 3", when the export was split.
pub fn part_label(&self) -> Option<String> {
match (self.batch_index, self.batch_size) {
(Some(i), Some(n)) if n > 1 => Some(format!("Part {} of {n}", i + 1)),
_ => None,
}
}
}
/// Is this a Google Authenticator export link?
pub fn is_migration_uri(uri: &str) -> bool {
uri.trim()
.to_ascii_lowercase()
.starts_with("otpauth-migration://")
}
/// Parse an `otpauth-migration://offline?data=…` link into its accounts.
pub fn parse_migration(uri: &str) -> Result<ImportBatch> {
let uri = uri.trim();
let parsed = url::Url::parse(uri)
.map_err(|e| Error::invalid(format!("not a valid export link: {e}")))?;
if !parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
return Err(Error::invalid(
"that is not a Google Authenticator export link",
));
}
// `url` percent-decodes query values for us. Google's base64 contains
// `+` and `/`, which arrive percent-encoded in a well-formed URI.
let data = parsed
.query_pairs()
.find(|(k, _)| k.eq_ignore_ascii_case("data"))
.map(|(_, v)| v.into_owned())
.ok_or_else(|| Error::invalid("the export link has no ?data= parameter"))?;
if data.trim().is_empty() {
return Err(Error::invalid("the export link's data parameter is empty"));
}
let raw = decode_base64_lenient(&data)?;
decode_payload(&raw)
}
/// Decode base64 across the variants that turn up in the wild.
///
/// Google emits standard-alphabet padded base64, but the link is often
/// copied through tools that swap in the URL-safe alphabet or strip padding.
/// Trying all four costs nothing and avoids an unhelpful failure on a link
/// that is perfectly recoverable.
fn decode_base64_lenient(s: &str) -> Result<Vec<u8>> {
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
// Whitespace can be introduced by line-wrapping when a link is pasted.
let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect();
// A macro rather than a slice of trait objects: base64's `Engine` is not
// dyn-compatible, and the alternatives are all zero-sized consts anyway.
macro_rules! try_decode {
($engine:expr) => {
if let Ok(bytes) = $engine.decode(&cleaned) {
if !bytes.is_empty() {
return Ok(bytes);
}
}
};
}
try_decode!(STANDARD);
try_decode!(STANDARD_NO_PAD);
try_decode!(URL_SAFE);
try_decode!(URL_SAFE_NO_PAD);
Err(Error::invalid(
"the export link's data is not valid base64 — copy the whole link, \
including everything after data=",
))
}
// ── Minimal protobuf reader ───────────────────────────────────────────────
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
fn done(&self) -> bool {
self.pos >= self.buf.len()
}
fn varint(&mut self) -> Option<u64> {
let mut value: u64 = 0;
let mut shift = 0u32;
loop {
let byte = *self.buf.get(self.pos)?;
self.pos += 1;
// 10 groups of 7 bits is the most a u64 can hold; more than that
// is a malformed (or hostile) message rather than a big number.
if shift > 63 {
return None;
}
value |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Some(value);
}
shift += 7;
}
}
/// Returns `(field_number, wire_type)`.
fn tag(&mut self) -> Option<(u32, u8)> {
let key = self.varint()?;
let field = u32::try_from(key >> 3).ok()?;
let wire = (key & 0x07) as u8;
if field == 0 {
return None; // field 0 is illegal; treat as corruption
}
Some((field, wire))
}
fn length_delimited(&mut self) -> Option<&'a [u8]> {
let len = usize::try_from(self.varint()?).ok()?;
let end = self.pos.checked_add(len)?;
let slice = self.buf.get(self.pos..end)?;
self.pos = end;
Some(slice)
}
/// Advance past a field we don't care about, so unknown additions to the
/// schema don't desynchronise the parse.
fn skip(&mut self, wire: u8) -> Option<()> {
match wire {
0 => {
self.varint()?;
}
1 => self.pos = self.pos.checked_add(8)?,
2 => {
self.length_delimited()?;
}
5 => self.pos = self.pos.checked_add(4)?,
// 3/4 are the deprecated start/end-group types, which this schema
// never uses. Anything else is corruption.
_ => return None,
}
if self.pos > self.buf.len() {
return None;
}
Some(())
}
}
fn corrupt() -> Error {
Error::invalid(
"the export data could not be decoded — re-export from Google \
Authenticator and scan the fresh QR code",
)
}
fn decode_payload(raw: &[u8]) -> Result<ImportBatch> {
let mut out = ImportBatch::default();
let mut r = Reader::new(raw);
while !r.done() {
let (field, wire) = r.tag().ok_or_else(corrupt)?;
match (field, wire) {
(1, 2) => {
let entry = r.length_delimited().ok_or_else(corrupt)?;
match decode_entry(entry) {
Ok(Some(account)) => out.accounts.push(account),
Ok(None) => {}
Err(skipped) => out.skipped.push(skipped),
}
}
(3, 0) => out.batch_size = r.varint().ok_or_else(corrupt)?.try_into().ok(),
(4, 0) => out.batch_index = r.varint().ok_or_else(corrupt)?.try_into().ok(),
(5, 0) => out.batch_id = r.varint().ok_or_else(corrupt)?.try_into().ok(),
// version (2) and anything Google adds later.
_ => r.skip(wire).ok_or_else(corrupt)?,
}
}
if out.accounts.is_empty() && out.skipped.is_empty() {
return Err(Error::invalid("that export link contains no accounts"));
}
Ok(out)
}
/// Decode one `OtpParameters`. `Err(Skipped)` means "present but not
/// importable", which the UI reports rather than silently dropping.
fn decode_entry(buf: &[u8]) -> std::result::Result<Option<OtpAuth>, Skipped> {
let mut secret: Vec<u8> = Vec::new();
let mut name = String::new();
let mut issuer = String::new();
let mut algorithm = Algorithm::Sha1;
let mut digits: u8 = 6;
let mut otp_type: u64 = 2; // default to TOTP when unspecified
let mut bad_algorithm: Option<u64> = None;
let mut r = Reader::new(buf);
while !r.done() {
let Some((field, wire)) = r.tag() else { break };
match (field, wire) {
(1, 2) => secret = r.length_delimited().unwrap_or_default().to_vec(),
(2, 2) => {
name =
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
}
(3, 2) => {
issuer =
String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned();
}
(4, 0) => match r.varint().unwrap_or(1) {
0 | 1 => algorithm = Algorithm::Sha1, // unspecified means SHA1
2 => algorithm = Algorithm::Sha256,
3 => algorithm = Algorithm::Sha512,
other => bad_algorithm = Some(other), // MD5 and anything new
},
(5, 0) => {
digits = match r.varint().unwrap_or(1) {
2 => 8,
_ => 6, // unspecified and SIX both mean 6
};
}
(6, 0) => otp_type = r.varint().unwrap_or(2),
_ => {
if r.skip(wire).is_none() {
break;
}
}
}
}
// Split "Issuer:account", the same shape an otpauth:// label uses. The
// dedicated issuer field wins when both are present.
let (label_issuer, account) = match name.split_once(':') {
Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()),
None => (None, name.trim().to_string()),
};
let issuer_final = if issuer.trim().is_empty() {
label_issuer.unwrap_or_default()
} else {
issuer.trim().to_string()
};
let display = if account.is_empty() && issuer_final.is_empty() {
"(unnamed)".to_string()
} else if account.is_empty() {
issuer_final.clone()
} else {
account.clone()
};
if otp_type == 1 {
return Err(Skipped {
name: display,
reason: "counter-based (HOTP) codes aren't supported".into(),
});
}
if let Some(alg) = bad_algorithm {
let which = if alg == 4 {
"MD5"
} else {
"an unknown algorithm"
};
return Err(Skipped {
name: display,
reason: format!("uses {which}, which isn't supported"),
});
}
if secret.is_empty() {
return Err(Skipped {
name: display,
reason: "has no secret".into(),
});
}
// The protobuf carries raw secret bytes; Totp expects the same, so no
// base32 round-trip is needed here (only for display/export).
match Totp::new(secret, algorithm, digits, ASSUMED_PERIOD) {
Ok(totp) => Ok(Some(OtpAuth {
totp,
issuer: if issuer_final.is_empty() {
account.clone()
} else {
issuer_final
},
label: account,
})),
Err(e) => Err(Skipped {
name: display,
reason: e.to_string(),
}),
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::totp::encode_secret;
// Minimal protobuf *encoder*, so these tests drive the parser with real
// wire bytes rather than a mock.
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut byte = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
byte |= 0x80;
}
out.push(byte);
if v == 0 {
break;
}
}
}
fn put_tag(field: u32, wire: u8, out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | u64::from(wire), out);
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_tag(field, 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_tag(field, 0, out);
put_varint(v, out);
}
#[derive(Clone)]
struct Entry {
secret: Vec<u8>,
name: &'static str,
issuer: &'static str,
algorithm: u64,
digits: u64,
otp_type: u64,
}
impl Default for Entry {
fn default() -> Self {
Self {
secret: b"12345678901234567890".to_vec(),
name: "[email protected]",
issuer: "Cloudflare",
algorithm: 1, // SHA1
digits: 1, // SIX
otp_type: 2, // TOTP
}
}
}
fn encode_entry(e: &Entry) -> Vec<u8> {
let mut b = Vec::new();
put_bytes(1, &e.secret, &mut b);
put_bytes(2, e.name.as_bytes(), &mut b);
put_bytes(3, e.issuer.as_bytes(), &mut b);
put_uint(4, e.algorithm, &mut b);
put_uint(5, e.digits, &mut b);
put_uint(6, e.otp_type, &mut b);
b
}
fn encode_payload(entries: &[Entry], batch: Option<(i32, i32, i32)>) -> Vec<u8> {
let mut b = Vec::new();
for e in entries {
put_bytes(1, &encode_entry(e), &mut b);
}
put_uint(2, 1, &mut b); // version
if let Some((size, index, id)) = batch {
put_uint(3, size as u64, &mut b);
put_uint(4, index as u64, &mut b);
put_uint(5, id as u64, &mut b);
}
b
}
/// Build a link the way Google does: standard base64, percent-encoded
/// into the query string.
fn make_uri(payload: &[u8]) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
format!("otpauth-migration://offline?data={escaped}")
}
fn uri_for(entries: &[Entry]) -> String {
make_uri(&encode_payload(entries, None))
}
#[test]
fn detects_migration_links() {
assert!(is_migration_uri("otpauth-migration://offline?data=abc"));
assert!(is_migration_uri("OTPAUTH-MIGRATION://offline?data=abc"));
assert!(!is_migration_uri("otpauth://totp/X?secret=A"));
}
#[test]
fn imports_a_single_account() {
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
assert_eq!(b.accounts.len(), 1);
assert!(b.skipped.is_empty());
let a = &b.accounts[0];
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(a.totp.digits, 6);
assert_eq!(a.totp.period, 30, "Google exports are always 30s");
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
}
#[test]
fn secret_is_raw_bytes_not_base32() {
// The single most dangerous trap: treating the protobuf secret as if
// it were already base32 yields plausible-looking but wrong codes.
// The RFC 6238 vector secret is ASCII "12345678901234567890".
let b = parse_migration(&uri_for(&[Entry::default()])).unwrap();
let totp = &b.accounts[0].totp;
assert_eq!(totp.secret, b"12345678901234567890");
// And it base32-encodes to the well-known test value.
assert_eq!(
encode_secret(&totp.secret),
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
);
// Which means it produces the official RFC 6238 code at t=59.
assert_eq!(totp.code_at(59), "287082");
}
#[test]
fn imports_several_accounts_from_one_qr() {
let entries = vec![
Entry {
name: "[email protected]",
issuer: "Cloudflare",
..Entry::default()
},
Entry {
name: "[email protected]",
issuer: "Plex",
..Entry::default()
},
Entry {
name: "[email protected]",
issuer: "Ubisoft",
..Entry::default()
},
];
let b = parse_migration(&uri_for(&entries)).unwrap();
assert_eq!(b.accounts.len(), 3);
let issuers: Vec<_> = b.accounts.iter().map(|a| a.issuer.as_str()).collect();
assert_eq!(issuers, ["Cloudflare", "Plex", "Ubisoft"]);
}
#[test]
fn reports_batch_position() {
let uri = make_uri(&encode_payload(&[Entry::default()], Some((3, 1, 42))));
let b = parse_migration(&uri).unwrap();
assert_eq!(b.batch_size, Some(3));
assert_eq!(b.batch_index, Some(1));
assert_eq!(b.batch_id, Some(42));
// batch_index is 0-based; humans count from one.
assert_eq!(b.part_label().as_deref(), Some("Part 2 of 3"));
// A single-QR export shouldn't claim to be part of a series.
let solo = parse_migration(&uri_for(&[Entry::default()])).unwrap();
assert!(solo.part_label().is_none());
}
#[test]
fn honours_algorithm_and_digit_variants() {
for (code, want) in [(2, Algorithm::Sha256), (3, Algorithm::Sha512)] {
let e = Entry {
algorithm: code,
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.algorithm, want);
}
let e = Entry {
digits: 2,
..Entry::default()
}; // EIGHT
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.digits, 8);
}
#[test]
fn unspecified_enums_fall_back_to_the_common_defaults() {
let e = Entry {
algorithm: 0,
digits: 0,
otp_type: 0,
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].totp.algorithm, Algorithm::Sha1);
assert_eq!(b.accounts[0].totp.digits, 6);
}
#[test]
fn skips_unsupported_entries_but_keeps_the_rest() {
let entries = vec![
Entry {
name: "[email protected]",
..Entry::default()
},
Entry {
name: "[email protected]",
otp_type: 1,
..Entry::default()
}, // HOTP
Entry {
name: "[email protected]",
algorithm: 4,
..Entry::default()
}, // MD5
Entry {
name: "[email protected]",
secret: vec![],
..Entry::default()
},
];
let b = parse_migration(&uri_for(&entries)).unwrap();
// One good account survives; the other three are reported, not dropped.
assert_eq!(b.accounts.len(), 1);
assert_eq!(b.accounts[0].label, "[email protected]");
assert_eq!(b.skipped.len(), 3);
let reason = |n: &str| {
b.skipped
.iter()
.find(|s| s.name == n)
.map(|s| s.reason.clone())
.unwrap()
};
assert!(reason("[email protected]").contains("HOTP"));
assert!(reason("[email protected]").contains("MD5"));
assert!(reason("[email protected]").contains("no secret"));
}
#[test]
fn name_may_carry_the_issuer_prefix() {
let e = Entry {
name: "Cloudflare:[email protected]",
issuer: "",
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].issuer, "Cloudflare");
assert_eq!(b.accounts[0].label, "[email protected]");
}
#[test]
fn issuer_field_wins_over_the_name_prefix() {
let e = Entry {
name: "Stale:[email protected]",
issuer: "Microsoft",
..Entry::default()
};
let b = parse_migration(&uri_for(&[e])).unwrap();
assert_eq!(b.accounts[0].issuer, "Microsoft");
assert_eq!(b.accounts[0].label, "[email protected]");
}
#[test]
fn accepts_every_base64_variant() {
use base64::engine::general_purpose::{
STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD,
};
use base64::Engine;
// A payload whose base64 is guaranteed to contain +/ (or -_).
let entries: Vec<Entry> = (0..4)
.map(|i| Entry {
secret: vec![0xfb, 0xff, 0xbf, i],
..Entry::default()
})
.collect();
let raw = encode_payload(&entries, None);
for (name, encoded) in [
("standard", STANDARD.encode(&raw)),
("standard-nopad", STANDARD_NO_PAD.encode(&raw)),
("urlsafe", URL_SAFE.encode(&raw)),
("urlsafe-nopad", URL_SAFE_NO_PAD.encode(&raw)),
] {
let escaped = encoded
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
let uri = format!("otpauth-migration://offline?data={escaped}");
let b = parse_migration(&uri).unwrap_or_else(|e| panic!("{name} should decode: {e}"));
assert_eq!(b.accounts.len(), 4, "{name}");
}
}
#[test]
fn unknown_fields_do_not_break_parsing() {
// Forward compatibility: if Google adds a field, we must skip it
// rather than desynchronise and mangle everything after it.
let mut entry = encode_entry(&Entry::default());
put_bytes(99, b"some future string", &mut entry);
put_uint(98, 1234, &mut entry);
let mut payload = Vec::new();
put_bytes(1, &entry, &mut payload);
put_bytes(77, b"future payload field", &mut payload);
put_uint(76, 5, &mut payload);
let b = parse_migration(&make_uri(&payload)).unwrap();
assert_eq!(b.accounts.len(), 1);
assert_eq!(b.accounts[0].issuer, "Cloudflare");
}
#[test]
fn rejects_malformed_input_clearly() {
assert!(parse_migration("otpauth-migration://offline").is_err());
assert!(parse_migration("otpauth-migration://offline?data=").is_err());
assert!(parse_migration("otpauth-migration://offline?data=!!!not base64!!!").is_err());
assert!(parse_migration("otpauth://totp/X?secret=JBSWY3DPEHPK3PXP").is_err());
assert!(parse_migration("nonsense").is_err());
// Truncated protobuf: a length prefix that runs past the buffer.
let uri = make_uri(&[0x0a, 0x7f, 0x01, 0x02]);
assert!(parse_migration(&uri).is_err());
}
#[test]
fn an_export_with_no_accounts_is_an_error() {
// Version field only, no entries.
let mut payload = Vec::new();
put_uint(2, 1, &mut payload);
let e = parse_migration(&make_uri(&payload))
.unwrap_err()
.to_string();
assert!(e.contains("no accounts"), "got: {e}");
}
#[test]
fn a_large_export_round_trips() {
// Google splits at roughly ten per QR; make sure nothing chokes on a
// full batch, and that every secret survives intact.
let entries: Vec<Entry> = (0..10)
.map(|i| Entry {
secret: vec![i; 20],
name: "[email protected]",
..Entry::default()
})
.collect();
let b = parse_migration(&uri_for(&entries)).unwrap();
assert_eq!(b.accounts.len(), 10);
for (i, a) in b.accounts.iter().enumerate() {
assert_eq!(
a.totp.secret,
vec![i as u8; 20],
"entry {i} secret corrupted"
);
}
}
}
+727
View File
@@ -0,0 +1,727 @@
//! Runtime-mutable settings: where codes are delivered, and how.
//!
//! OpenMFA delivers to exactly one chat platform at a time — Discord, Slack or
//! Telegram — but keeps the configuration for all three, so switching back and
//! forth doesn't mean re-pasting tokens.
//!
//! The three platforms have different authentication shapes, and it's worth
//! being clear about why:
//!
//! * **Discord** needs only an incoming-webhook URL. Webhooks can create,
//! edit and delete their own messages, so no bot is involved.
//! * **Slack** incoming webhooks can *post* but cannot edit — and editing in
//! place is the whole live-tile mechanism. So Slack needs a bot token
//! (`xoxb-…`, scope `chat:write`) plus the channel ID, and uses
//! `chat.postMessage` / `chat.update`.
//! * **Telegram** has no webhook concept at all; everything is a bot. A bot
//! token from @BotFather plus the chat ID, using `sendMessage` /
//! `editMessageText`.
use crate::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const MAX_URL_LEN: usize = 2048;
/// Which chat platform receives the live codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
Discord,
Slack,
Telegram,
}
impl Provider {
pub fn as_str(self) -> &'static str {
match self {
Self::Discord => "discord",
Self::Slack => "slack",
Self::Telegram => "telegram",
}
}
/// Human-facing name for toasts and logs.
pub fn display_name(self) -> &'static str {
match self {
Self::Discord => "Discord",
Self::Slack => "Slack",
Self::Telegram => "Telegram",
}
}
/// Whether this platform can carry the rendered image tile.
///
/// Only Discord for now: a webhook can attach a file and replace it on
/// edit. Slack's file upload can't be swapped inside an existing message
/// without deleting and re-posting, and Telegram's `editMessageMedia` is
/// deferred to a later release. Both get the code as copyable text.
pub fn supports_image(self) -> bool {
matches!(self, Self::Discord)
}
}
impl std::fmt::Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.display_name())
}
}
/// How each account's live message is composed. Image modes only apply to
/// providers that [`Provider::supports_image`]; others always send text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PushMode {
/// Text only — one JSON edit per period, nothing uploaded, digits
/// selectable so they can actually be copied.
Embed,
/// A rendered WebP tile, uploaded fresh on every edit.
Image,
/// The tile *and* the code as text.
#[default]
Both,
}
impl PushMode {
pub fn has_image(self) -> bool {
matches!(self, Self::Image | Self::Both)
}
pub fn has_text(self) -> bool {
matches!(self, Self::Embed | Self::Both)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct DiscordConfig {
pub webhook_url: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct SlackConfig {
/// Bot user OAuth token, `xoxb-…`. Needs the `chat:write` scope, and the
/// bot must be a member of the channel (`/invite @OpenMFA`).
pub bot_token: String,
/// Channel ID such as `C0123ABCDEF` — not the `#name`. `chat.update`
/// requires the ID, so we insist on it up front rather than have the
/// first edit fail.
pub channel: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct TelegramConfig {
/// Bot token from @BotFather, `123456789:AAF…`.
pub bot_token: String,
/// Numeric chat ID (negative for groups, `-100…` for supergroups) or a
/// public `@channelusername`.
pub chat_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Settings {
/// Master switch for delivery. Off, nothing is pushed and no tiles are
/// maintained.
pub push_enabled: bool,
/// Active platform. `None` until the operator picks one — first-run setup
/// makes this optional, so an install can exist before any chat is wired.
pub provider: Option<Provider>,
pub discord: DiscordConfig,
pub slack: SlackConfig,
pub telegram: TelegramConfig,
pub push_mode: PushMode,
}
impl Default for Settings {
fn default() -> Self {
Self {
push_enabled: true,
provider: None,
discord: DiscordConfig::default(),
slack: SlackConfig::default(),
telegram: TelegramConfig::default(),
push_mode: PushMode::default(),
}
}
}
impl Settings {
/// True when delivery is on, a platform is chosen, and its configuration
/// is complete enough to send.
pub fn is_usable(&self) -> bool {
self.push_enabled && self.provider.is_some_and(|p| self.is_configured(p))
}
/// Does a given platform have everything it needs?
pub fn is_configured(&self, p: Provider) -> bool {
match p {
Provider::Discord => !self.discord.webhook_url.trim().is_empty(),
Provider::Slack => {
!self.slack.bot_token.trim().is_empty() && !self.slack.channel.trim().is_empty()
}
Provider::Telegram => {
!self.telegram.bot_token.trim().is_empty()
&& !self.telegram.chat_id.trim().is_empty()
}
}
}
/// Identifies *where* messages land. When this changes, every stored
/// message id refers to a message in some other place and is discarded.
///
/// Deliberately excludes tokens: rotating a bot token for the same
/// channel leaves the existing tiles editable, and if the new token turns
/// out to be a different bot the engine recovers from the resulting
/// "can't edit that message" error by reposting.
pub fn destination_key(&self) -> Option<String> {
let p = self.provider?;
Some(match p {
Provider::Discord => format!("discord:{}", self.discord.webhook_url.trim()),
Provider::Slack => format!("slack:{}", self.slack.channel.trim()),
Provider::Telegram => format!("telegram:{}", self.telegram.chat_id.trim()),
})
}
/// Effective mode: image modes collapse to text on platforms that can't
/// carry the tile.
pub fn effective_mode(&self) -> PushMode {
match self.provider {
Some(p) if p.supports_image() => self.push_mode,
_ => PushMode::Embed,
}
}
/// Validate without persisting — first-run setup uses this so a mistyped
/// token is rejected before the admin account exists, rather than leaving
/// a half-configured install that needs the password to fix.
pub fn check(&self) -> Result<Self> {
let mut c = self.clone();
c.normalise();
c.validate()?;
Ok(c)
}
fn normalise(&mut self) {
self.discord.webhook_url = self.discord.webhook_url.trim().to_string();
self.slack.bot_token = self.slack.bot_token.trim().to_string();
self.slack.channel = self.slack.channel.trim().to_string();
self.telegram.bot_token = self.telegram.bot_token.trim().to_string();
self.telegram.chat_id = self.telegram.chat_id.trim().to_string();
}
/// Validate whatever is filled in. Empty fields are fine (the platform is
/// simply not configured); non-empty fields must be well-formed, and the
/// *active* platform must be complete when delivery is on.
fn validate(&self) -> Result<()> {
if !self.discord.webhook_url.is_empty() {
validate_webhook_url(&self.discord.webhook_url)?;
}
if !self.slack.bot_token.is_empty() || !self.slack.channel.is_empty() {
validate_slack(&self.slack)?;
}
if !self.telegram.bot_token.is_empty() || !self.telegram.chat_id.is_empty() {
validate_telegram(&self.telegram)?;
}
if self.push_enabled {
if let Some(p) = self.provider {
if !self.is_configured(p) {
return Err(Error::invalid(format!(
"{p} is selected but not fully configured"
)));
}
}
}
Ok(())
}
}
// ── Validators ────────────────────────────────────────────────────────────
/// Validate a Discord incoming-webhook URL.
///
/// Checked strictly on purpose: a URL that is merely *syntactically* valid
/// fails later inside a background loop where the only evidence is a log
/// line. Catching the shape here means the person who pasted it finds out
/// while they still have the page open.
pub fn validate_webhook_url(url: &str) -> Result<()> {
let url = url.trim();
if url.is_empty() {
return Err(Error::invalid("the webhook URL is empty"));
}
if url.len() > MAX_URL_LEN {
return Err(Error::invalid(format!(
"the webhook URL exceeds the {MAX_URL_LEN}-character cap"
)));
}
let parsed = url::Url::parse(url).map_err(|_| Error::invalid("that is not a valid URL"))?;
if parsed.scheme() != "https" {
return Err(Error::invalid("the webhook URL must start with https://"));
}
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
let host_ok = matches!(
host.as_str(),
"discord.com" | "discordapp.com" | "ptb.discord.com" | "canary.discord.com"
) || host.ends_with(".discord.com");
if !host_ok {
return Err(Error::invalid(
"that does not look like a Discord webhook URL (expected a discord.com address)",
));
}
let segments: Vec<_> = parsed
.path_segments()
.map(|s| s.filter(|p| !p.is_empty()).collect())
.unwrap_or_default();
match segments.iter().position(|s| *s == "webhooks") {
Some(i) if segments.len() >= i + 3 => Ok(()),
_ => Err(Error::invalid(
"that Discord URL is missing the webhook id and token — copy the full \
URL from Server Settings → Integrations → Webhooks",
)),
}
}
/// Validate Slack bot token + channel ID shape.
pub fn validate_slack(c: &SlackConfig) -> Result<()> {
let token = c.bot_token.trim();
if token.is_empty() {
return Err(Error::invalid("the Slack bot token is empty"));
}
if token.starts_with("xoxp-") {
return Err(Error::invalid(
"that is a Slack *user* token (xoxp-). Use the Bot User OAuth Token (xoxb-) \
from your app's OAuth & Permissions page",
));
}
if !token.starts_with("xoxb-") {
return Err(Error::invalid(
"a Slack bot token starts with xoxb- — copy the Bot User OAuth Token from \
OAuth & Permissions",
));
}
if token.len() < 20
|| !token
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
return Err(Error::invalid("that Slack token doesn't look complete"));
}
let channel = c.channel.trim();
if channel.is_empty() {
return Err(Error::invalid("the Slack channel ID is empty"));
}
if channel.starts_with('#') {
return Err(Error::invalid(
"use the channel *ID* (like C0123ABCDEF), not the #name — open the channel, \
click its name, and copy the ID at the bottom of the details pane",
));
}
let shape_ok = matches!(channel.chars().next(), Some('C' | 'G' | 'D'))
&& channel.len() >= 9
&& channel.chars().all(|ch| ch.is_ascii_alphanumeric());
if !shape_ok {
return Err(Error::invalid(
"a Slack channel ID looks like C0123ABCDEF (or G… for a private channel)",
));
}
Ok(())
}
/// Validate Telegram bot token + chat id shape.
pub fn validate_telegram(c: &TelegramConfig) -> Result<()> {
let token = c.bot_token.trim();
if token.is_empty() {
return Err(Error::invalid("the Telegram bot token is empty"));
}
let Some((id, secret)) = token.split_once(':') else {
return Err(Error::invalid(
"a Telegram bot token looks like 123456789:AAF… — copy it exactly as \
@BotFather gave it to you",
));
};
if id.is_empty() || !id.chars().all(|ch| ch.is_ascii_digit()) || secret.len() < 30 {
return Err(Error::invalid(
"that Telegram bot token doesn't look complete",
));
}
let chat = c.chat_id.trim();
if chat.is_empty() {
return Err(Error::invalid("the Telegram chat ID is empty"));
}
let numeric = chat
.strip_prefix('-')
.unwrap_or(chat)
.chars()
.all(|ch| ch.is_ascii_digit())
&& chat != "-";
let username = chat.starts_with('@')
&& chat.len() > 5
&& chat[1..]
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
if !numeric && !username {
return Err(Error::invalid(
"a Telegram chat ID is a number (negative for groups) or a public @channel — \
use Detect below to find it",
));
}
Ok(())
}
// ── Store ─────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct SettingsStore {
path: PathBuf,
inner: RwLock<Settings>,
}
impl SettingsStore {
/// Load from `<work_dir>/settings.json`, or start with defaults. Never
/// fails: a bad settings file is not a reason to refuse to boot.
pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
let path = work_dir.join("settings.json");
let initial = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<Settings>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "openmfa::settings",
"settings.json present but unreadable ({e}); using defaults"
);
Settings::default()
}),
Err(_) => Settings::default(),
};
Arc::new(Self {
path,
inner: RwLock::new(initial),
})
}
pub fn snapshot(&self) -> Settings {
self.inner.read().clone()
}
/// Replace settings wholesale.
///
/// Returns `true` when the destination changed — a different platform, or
/// a different channel on the same one — which the caller must treat as
/// "every stored message id is now meaningless".
pub fn replace(&self, mut incoming: Settings) -> Result<(Settings, bool)> {
incoming.normalise();
incoming.validate()?;
let changed = {
let mut g = self.inner.write();
let changed = g.destination_key() != incoming.destination_key();
*g = incoming.clone();
changed
};
self.persist();
tracing::info!(
target: "openmfa::settings",
enabled = incoming.push_enabled,
provider = incoming.provider.map_or("none", Provider::as_str),
mode = ?incoming.push_mode,
destination_changed = changed,
"settings updated"
);
Ok((incoming, changed))
}
fn persist(&self) {
let snap = self.snapshot();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "openmfa::settings", "could not serialize settings: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, body).is_ok() {
if let Err(e) = std::fs::rename(&tmp, &self.path) {
tracing::error!(target: "openmfa::settings", "could not commit settings: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
const HOOK: &str = "https://discord.com/api/webhooks/123456789012345678/abcdefTOKEN-_123";
const XOXB: &str = "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx";
const TG: &str = "123456789:AAFabcdefghijklmnopqrstuvwxyz0123456789";
fn discord() -> Settings {
Settings {
provider: Some(Provider::Discord),
discord: DiscordConfig {
webhook_url: HOOK.into(),
},
..Settings::default()
}
}
fn slack() -> Settings {
Settings {
provider: Some(Provider::Slack),
slack: SlackConfig {
bot_token: XOXB.into(),
channel: "C0123ABCDEF".into(),
},
..Settings::default()
}
}
fn telegram() -> Settings {
Settings {
provider: Some(Provider::Telegram),
telegram: TelegramConfig {
bot_token: TG.into(),
chat_id: "-1001234567890".into(),
},
..Settings::default()
}
}
#[test]
fn defaults_have_no_provider_and_are_not_usable() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path()).snapshot();
assert!(s.provider.is_none());
assert!(!s.is_usable(), "nothing configured, nothing to push to");
assert_eq!(s.push_mode, PushMode::Both);
}
#[test]
fn each_provider_is_usable_when_complete() {
for s in [discord(), slack(), telegram()] {
assert!(s.is_usable(), "{:?}", s.provider);
}
// Selected but half-filled is not usable.
let mut half = slack();
half.slack.channel.clear();
assert!(!half.is_usable());
}
#[test]
fn image_modes_collapse_to_text_off_discord() {
let mut s = slack();
s.push_mode = PushMode::Both;
assert_eq!(s.effective_mode(), PushMode::Embed);
let mut t = telegram();
t.push_mode = PushMode::Image;
assert_eq!(t.effective_mode(), PushMode::Embed);
assert_eq!(discord().effective_mode(), PushMode::Both);
}
#[test]
fn discord_webhook_validation() {
for url in [
HOOK,
"https://discord.com/api/v10/webhooks/123/tok",
"https://discordapp.com/api/webhooks/123/tok",
"https://canary.discord.com/api/webhooks/123/tok",
] {
assert!(validate_webhook_url(url).is_ok(), "should accept {url}");
}
for url in [
"",
"not a url",
"http://discord.com/api/webhooks/1/t",
"https://evil.example.com/api/webhooks/1/t",
"https://notdiscord.com/api/webhooks/1/t",
"https://discord.com/api/webhooks/123",
"https://discord.com/channels/1/2",
] {
assert!(validate_webhook_url(url).is_err(), "should reject {url}");
}
}
#[test]
fn slack_validation_steers_the_common_mistakes() {
let ok = SlackConfig {
bot_token: XOXB.into(),
channel: "C0123ABCDEF".into(),
};
assert!(validate_slack(&ok).is_ok());
// Private channels have G-prefixed ids.
let g = SlackConfig {
bot_token: XOXB.into(),
channel: "G0123ABCDEF".into(),
};
assert!(validate_slack(&g).is_ok());
// A user token is the classic wrong paste; name it specifically.
let user = SlackConfig {
bot_token: "xoxp-1234567890-abc".into(),
channel: "C0123ABCDEF".into(),
};
let e = validate_slack(&user).unwrap_err().to_string();
assert!(e.contains("user"), "{e}");
// #name instead of an ID.
let name = SlackConfig {
bot_token: XOXB.into(),
channel: "#general".into(),
};
let e = validate_slack(&name).unwrap_err().to_string();
assert!(e.contains("ID"), "{e}");
for bad in ["", "general", "12345", "c0123abcdef"] {
let c = SlackConfig {
bot_token: XOXB.into(),
channel: bad.into(),
};
assert!(validate_slack(&c).is_err(), "should reject channel {bad:?}");
}
let short = SlackConfig {
bot_token: "xoxb-short".into(),
channel: "C0123ABCDEF".into(),
};
assert!(validate_slack(&short).is_err());
}
#[test]
fn telegram_validation() {
for chat in ["123456", "-987654", "-1001234567890", "@my_channel"] {
let c = TelegramConfig {
bot_token: TG.into(),
chat_id: chat.into(),
};
assert!(validate_telegram(&c).is_ok(), "should accept chat {chat}");
}
for chat in ["", "-", "abc", "@ab", "12a3", "@bad-name"] {
let c = TelegramConfig {
bot_token: TG.into(),
chat_id: chat.into(),
};
assert!(
validate_telegram(&c).is_err(),
"should reject chat {chat:?}"
);
}
for tok in [
"",
"no-colon",
":onlysecret",
"abc:AAFabcdefghijklmnopqrstuvwxyz0123456789",
"123:short",
] {
let c = TelegramConfig {
bot_token: tok.into(),
chat_id: "123".into(),
};
assert!(
validate_telegram(&c).is_err(),
"should reject token {tok:?}"
);
}
}
#[test]
fn selecting_an_unconfigured_provider_with_push_on_is_refused() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
let r = s.replace(Settings {
push_enabled: true,
provider: Some(Provider::Slack),
..Settings::default()
});
assert!(r.is_err());
// Off, it's a legitimate half-configured state.
assert!(s
.replace(Settings {
push_enabled: false,
provider: Some(Provider::Slack),
..Settings::default()
})
.is_ok());
}
#[test]
fn destination_change_detection() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
let (_, changed) = s.replace(discord()).unwrap();
assert!(changed, "first destination counts as a change");
// Same destination, different mode: message ids stay valid.
let mut same = discord();
same.push_mode = PushMode::Embed;
assert!(!s.replace(same).unwrap().1);
// Switching platform invalidates everything.
assert!(s.replace(slack()).unwrap().1);
// Same Slack channel, rotated token: NOT a destination change — the
// tiles are still there and the engine self-heals if it can't edit.
let mut rotated = slack();
rotated.slack.bot_token = "xoxb-9999999999-9999999999999-zyxwvutsrqponmlkjihgfedc".into();
assert!(!s.replace(rotated).unwrap().1);
// Different channel on the same platform is a change.
let mut other = slack();
other.slack.channel = "C9999ZZZZZZ".into();
assert!(s.replace(other).unwrap().1);
}
#[test]
fn switching_provider_keeps_the_other_configs() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
let mut both = slack();
both.discord.webhook_url = HOOK.into();
s.replace(both).unwrap();
let mut back = s.snapshot();
back.provider = Some(Provider::Discord);
let (saved, _) = s.replace(back).unwrap();
assert_eq!(
saved.slack.channel, "C0123ABCDEF",
"Slack config must survive"
);
assert_eq!(saved.provider, Some(Provider::Discord));
}
#[test]
fn persists_across_a_reload() {
let dir = tempdir().unwrap();
SettingsStore::load_or_default(dir.path())
.replace(telegram())
.unwrap();
let v = SettingsStore::load_or_default(dir.path()).snapshot();
assert_eq!(v.provider, Some(Provider::Telegram));
assert!(v.is_usable());
}
#[test]
fn corrupt_file_falls_back_to_defaults() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("settings.json"), b"{ nope").unwrap();
assert!(SettingsStore::load_or_default(dir.path())
.snapshot()
.provider
.is_none());
}
#[test]
fn provider_serialises_lowercase() {
assert_eq!(
serde_json::to_string(&Provider::Telegram).unwrap(),
"\"telegram\""
);
let s: Settings = serde_json::from_str(r#"{"provider":"slack"}"#).unwrap();
assert_eq!(s.provider, Some(Provider::Slack));
}
}
+514
View File
@@ -0,0 +1,514 @@
//! RFC 4226 (HOTP) / RFC 6238 (TOTP) and `otpauth://` URI parsing.
//!
//! Implemented directly over HMAC rather than pulling a TOTP crate. The
//! algorithm itself is about thirty lines; the reason to own it is the URI
//! parser. Real-world `otpauth://` URIs are messier than the spec:
//!
//! * **The issuer appears twice and the two disagree.** The spec puts it in
//! the path label (`Issuer:account`) *and* in `?issuer=`. Microsoft emits
//! URIs where those differ, and strict parsers reject the account outright.
//! We take the query parameter as authoritative and fall back to the label.
//! * **Secrets are not always canonical base32.** RFC 4648 says uppercase and
//! we accept padding, but Google has shipped lowercase secrets and several
//! providers pad (or don't) inconsistently, and users paste secrets with
//! the spaces the provider printed them with. We normalise before decoding.
//!
//! Both cases are "the QR code from a service you actually use fails to
//! import", so leniency here is the difference between working and not.
use crate::{Error, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::{Sha256, Sha512};
/// Run HMAC-`$hash` over `$msg`, returning the raw digest bytes.
///
/// A macro rather than a generic function on purpose: expressing "any
/// RustCrypto digest usable with `Hmac`" as a where-clause drags in half a
/// dozen `digest` traits and tends to break on every RustCrypto major bump.
/// The three call sites are known and closed, so monomorphising by hand is
/// both shorter and more durable. HMAC accepts a key of any length, so
/// `new_from_slice` cannot fail for a secret already checked as non-empty.
macro_rules! mac_bytes {
($hash:ty, $key:expr, $msg:expr) => {{
let mut mac = <Hmac<$hash>>::new_from_slice($key).expect("HMAC accepts keys of any length");
mac.update($msg);
mac.finalize().into_bytes().to_vec()
}};
}
/// Hash backing the HMAC. SHA-1 is the near-universal default; the others
/// appear rarely but are part of RFC 6238 and cost nothing to support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "UPPERCASE")]
pub enum Algorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
impl Algorithm {
pub fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "SHA1",
Self::Sha256 => "SHA256",
Self::Sha512 => "SHA512",
}
}
fn parse(s: &str) -> Result<Self> {
match s.trim().to_ascii_uppercase().as_str() {
"SHA1" | "SHA-1" => Ok(Self::Sha1),
"SHA256" | "SHA-256" => Ok(Self::Sha256),
"SHA512" | "SHA-512" => Ok(Self::Sha512),
other => Err(Error::invalid(format!(
"unsupported algorithm '{other}' (expected SHA1, SHA256 or SHA512)"
))),
}
}
}
/// Everything needed to produce codes for one account. The secret lives
/// here in plaintext; callers are responsible for keeping this struct out
/// of anything that gets serialized to disk unencrypted.
#[derive(Debug, Clone)]
pub struct Totp {
pub secret: Vec<u8>,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
}
impl Totp {
pub fn new(secret: Vec<u8>, algorithm: Algorithm, digits: u8, period: u64) -> Result<Self> {
if secret.is_empty() {
return Err(Error::invalid("the shared secret is empty"));
}
if !(6..=8).contains(&digits) {
return Err(Error::invalid(format!(
"digits must be between 6 and 8 (got {digits})"
)));
}
if period == 0 {
return Err(Error::invalid("period must be at least 1 second"));
}
Ok(Self {
secret,
algorithm,
digits,
period,
})
}
/// Which time step `unix_secs` falls in. T0 is 0 per RFC 6238 §4.1.
pub fn counter_at(&self, unix_secs: u64) -> u64 {
unix_secs / self.period
}
/// Unix second at which the code covering `unix_secs` stops being valid.
pub fn expires_at(&self, unix_secs: u64) -> u64 {
(self.counter_at(unix_secs) + 1) * self.period
}
/// Seconds remaining on the code covering `unix_secs`.
pub fn remaining_at(&self, unix_secs: u64) -> u64 {
self.expires_at(unix_secs) - unix_secs
}
/// The code for the step containing `unix_secs`.
pub fn code_at(&self, unix_secs: u64) -> String {
self.hotp(self.counter_at(unix_secs))
}
/// The code for the step *after* the one containing `unix_secs`. The UI
/// and the Discord pusher both pre-fetch this so a rollover never has a
/// request on the critical path.
pub fn next_code_at(&self, unix_secs: u64) -> String {
self.hotp(self.counter_at(unix_secs) + 1)
}
/// RFC 4226 §5.3 — HMAC, dynamic truncation, modulo.
fn hotp(&self, counter: u64) -> String {
let msg = counter.to_be_bytes();
let digest: Vec<u8> = match self.algorithm {
Algorithm::Sha1 => mac_bytes!(Sha1, &self.secret, &msg),
Algorithm::Sha256 => mac_bytes!(Sha256, &self.secret, &msg),
Algorithm::Sha512 => mac_bytes!(Sha512, &self.secret, &msg),
};
// Dynamic truncation: the low nibble of the last byte picks a
// 4-byte window; the top bit is masked off to dodge sign issues in
// the languages the RFC was written for.
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
let bin = u32::from_be_bytes([
digest[offset] & 0x7f,
digest[offset + 1],
digest[offset + 2],
digest[offset + 3],
]);
let modulo = 10_u32.pow(u32::from(self.digits));
format!("{:0width$}", bin % modulo, width = usize::from(self.digits))
}
}
// ── otpauth:// parsing ────────────────────────────────────────────────────
/// A parsed `otpauth://totp/...` URI: the crypto parameters plus the
/// human-facing naming.
#[derive(Debug, Clone)]
pub struct OtpAuth {
pub totp: Totp,
pub issuer: String,
pub label: String,
}
/// Parse an `otpauth://totp/...` URI.
///
/// Deliberately lenient — see the module docs for why. `hotp://` counter-based
/// URIs are rejected with a specific message rather than a generic parse
/// failure, because a user scanning a counter-based QR deserves to know that's
/// what happened.
pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
let uri = uri.trim();
let parsed =
url::Url::parse(uri).map_err(|e| Error::invalid(format!("not a valid URI: {e}")))?;
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
// Callers should route these to `crate::parse_import`, which
// understands them. Reaching here means a single-URI code path
// was handed a multi-account link.
return Err(Error::invalid(
"this is a Google Authenticator export link — use the import flow, \
which reads all of its accounts at once.",
));
}
return Err(Error::invalid("expected a link starting with otpauth://"));
}
match parsed
.host_str()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"totp" => {}
"hotp" => {
return Err(Error::invalid(
"this is a counter-based (HOTP) code. OpenMFA only \
supports time-based (TOTP) codes.",
))
}
other => {
return Err(Error::invalid(format!(
"unsupported OTP type '{other}' (expected totp)"
)))
}
}
// ── label: /Issuer:Account, or just /Account ──
let raw_label = percent_decode(parsed.path().trim_start_matches('/'));
let (label_issuer, account) = match raw_label.split_once(':') {
Some((iss, acct)) => (Some(iss.trim().to_string()), acct.trim().to_string()),
None => (None, raw_label.trim().to_string()),
};
// ── query parameters ──
let mut secret_raw = None;
let mut query_issuer = None;
let mut algorithm = Algorithm::Sha1;
let mut digits: u8 = 6;
let mut period: u64 = 30;
for (k, v) in parsed.query_pairs() {
match k.as_ref().to_ascii_lowercase().as_str() {
"secret" => secret_raw = Some(v.into_owned()),
"issuer" => {
let v = v.trim().to_string();
if !v.is_empty() {
query_issuer = Some(v);
}
}
"algorithm" => algorithm = Algorithm::parse(&v)?,
"digits" => {
digits = v
.trim()
.parse()
.map_err(|_| Error::invalid(format!("digits '{v}' is not a number")))?;
}
"period" => {
period = v
.trim()
.parse()
.map_err(|_| Error::invalid(format!("period '{v}' is not a number")))?;
}
// Unknown parameters are ignored rather than fatal — providers
// append their own (`image=`, `lock=`, …).
_ => {}
}
}
let secret_raw =
secret_raw.ok_or_else(|| Error::invalid("the link has no ?secret= parameter"))?;
let secret = decode_secret(&secret_raw)?;
// Query issuer wins over the path label. When only the label has one we
// take it; when neither does, fall back to the account name so the UI
// always has something to show.
let issuer = query_issuer
.or(label_issuer)
.unwrap_or_else(|| account.clone());
Ok(OtpAuth {
totp: Totp::new(secret, algorithm, digits, period)?,
issuer,
label: account,
})
}
/// Decode a base32 shared secret, normalising the ways providers mangle it.
///
/// Accepts lowercase, `=` padding, and the spaces/hyphens services print
/// secrets with for manual entry.
pub fn decode_secret(raw: &str) -> Result<Vec<u8>> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != '-' && *c != '=')
.collect::<String>()
.to_ascii_uppercase();
if cleaned.is_empty() {
return Err(Error::invalid("the shared secret is empty"));
}
base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &cleaned)
.filter(|b| !b.is_empty())
.ok_or_else(|| {
Error::invalid(
"the shared secret is not valid base32 (expected letters A–Z and digits 2–7)",
)
})
}
/// Encode a secret back to base32 — used when re-exporting an account.
pub fn encode_secret(secret: &[u8]) -> String {
base32::encode(base32::Alphabet::Rfc4648 { padding: false }, secret)
}
fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// RFC 6238 Appendix B. The three secrets are the ASCII string
/// "12345678901234567890" truncated/repeated to each hash's block size.
fn rfc_secret(alg: Algorithm) -> Vec<u8> {
let seed = b"1234567890";
let len = match alg {
Algorithm::Sha1 => 20,
Algorithm::Sha256 => 32,
Algorithm::Sha512 => 64,
};
seed.iter().copied().cycle().take(len).collect()
}
#[test]
fn rfc6238_test_vectors() {
let times = [
59_u64,
1_111_111_109,
1_111_111_111,
1_234_567_890,
2_000_000_000,
20_000_000_000,
];
let sha1 = [
"94287082", "07081804", "14050471", "89005924", "69279037", "65353130",
];
let sha256 = [
"46119246", "68084774", "67062674", "91819424", "90698825", "77737706",
];
let sha512 = [
"90693936", "25091201", "99943326", "93441116", "38618901", "47863826",
];
for (alg, expected) in [
(Algorithm::Sha1, sha1),
(Algorithm::Sha256, sha256),
(Algorithm::Sha512, sha512),
] {
let totp = Totp::new(rfc_secret(alg), alg, 8, 30).unwrap();
for (t, want) in times.iter().zip(expected.iter()) {
assert_eq!(&totp.code_at(*t), want, "{} at t={t}", alg.as_str());
}
}
}
#[test]
fn six_digit_codes_are_the_low_six_of_the_eight() {
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
// RFC vector at t=59 is 94287082 → the 6-digit form is its last six.
assert_eq!(totp.code_at(59), "287082");
}
#[test]
fn codes_are_zero_padded_to_width() {
// Every generated code must be exactly `digits` long, including the
// ~10% of values with a leading zero. 07081804 above already covers
// the 8-digit case; assert the invariant broadly here.
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
for t in (0..30_000).step_by(37) {
assert_eq!(totp.code_at(t).len(), 6);
}
}
#[test]
fn period_boundaries() {
let totp = Totp::new(vec![1, 2, 3, 4], Algorithm::Sha1, 6, 30).unwrap();
assert_eq!(totp.counter_at(0), 0);
assert_eq!(totp.counter_at(29), 0);
assert_eq!(totp.counter_at(30), 1);
assert_eq!(totp.expires_at(0), 30);
assert_eq!(totp.expires_at(29), 30);
assert_eq!(totp.expires_at(30), 60);
assert_eq!(totp.remaining_at(25), 5);
// The next code at t is the current code one period later.
assert_eq!(totp.next_code_at(10), totp.code_at(40));
}
#[test]
fn rejects_bad_parameters() {
assert!(Totp::new(vec![], Algorithm::Sha1, 6, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 5, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 9, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 6, 0).is_err());
}
#[test]
fn parses_a_canonical_uri() {
let a = parse_otpauth(
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
)
.unwrap();
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(a.totp.digits, 6);
assert_eq!(a.totp.period, 30);
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
}
#[test]
fn query_issuer_wins_over_a_disagreeing_label() {
// The Microsoft case: the label says one thing, ?issuer= another.
// A strict parser rejects this outright; we take the query param.
let a = parse_otpauth(
"otpauth://totp/Issuer1:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Microsoft",
)
.unwrap();
assert_eq!(a.issuer, "Microsoft");
assert_eq!(a.label, "[email protected]");
}
#[test]
fn falls_back_to_the_label_issuer_then_the_account() {
let a =
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(a.issuer, "Plex");
let b = parse_otpauth("otpauth://totp/[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(b.issuer, "[email protected]");
assert_eq!(b.label, "[email protected]");
}
#[test]
fn percent_encoded_labels_survive() {
let a = parse_otpauth(
"otpauth://totp/Big%20Corp%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP",
)
.unwrap();
assert_eq!(a.issuer, "Big Corp");
assert_eq!(a.label, "[email protected]");
}
#[test]
fn honours_explicit_parameters() {
let a = parse_otpauth(
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60",
)
.unwrap();
assert_eq!(a.totp.algorithm, Algorithm::Sha256);
assert_eq!(a.totp.digits, 8);
assert_eq!(a.totp.period, 60);
}
#[test]
fn unknown_query_parameters_are_ignored() {
assert!(parse_otpauth(
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&image=https://x/y.png&lock=false"
)
.is_ok());
}
#[test]
fn secret_normalisation() {
// Lowercase (Google has shipped these).
assert_eq!(
decode_secret("jbswy3dpehpk3pxp").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Padded.
assert_eq!(
decode_secret("JBSWY3DPEHPK3PXP===").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Spaced and hyphenated, the way providers print them for typing.
assert_eq!(
decode_secret("jbsw y3dp ehpk 3pxp").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
assert_eq!(
decode_secret("JBSW-Y3DP-EHPK-3PXP").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Base32 excludes 0/1/8/9 — these must fail rather than silently decode.
assert!(decode_secret("01890189").is_err());
assert!(decode_secret("").is_err());
assert!(decode_secret(" ").is_err());
}
#[test]
fn secret_round_trips() {
let bytes = decode_secret("JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(encode_secret(&bytes), "JBSWY3DPEHPK3PXP");
}
#[test]
fn rejects_non_totp_links_with_specific_messages() {
// HOTP must name the actual problem, not just "parse error".
let e = parse_otpauth("otpauth://hotp/X:y?secret=JBSWY3DPEHPK3PXP&counter=1")
.unwrap_err()
.to_string();
assert!(e.contains("counter-based"), "got: {e}");
let e = parse_otpauth("otpauth-migration://offline?data=abc")
.unwrap_err()
.to_string();
assert!(e.contains("export"), "got: {e}");
assert!(parse_otpauth("https://example.com").is_err());
assert!(parse_otpauth("not a uri at all").is_err());
// Missing secret.
assert!(parse_otpauth("otpauth://totp/X:y?issuer=X").is_err());
}
}
+910
View File
@@ -0,0 +1,910 @@
//! The account vault — TOTP secrets, their naming, and their Discord state.
//!
//! Storage policy mirrors the rest of the app: the in-memory copy is
//! authoritative and disk is the crash-survival cache, written atomically via
//! write-to-temp-then-rename. Secrets are sealed individually (see
//! [`crate::crypto`]) rather than the file being encrypted wholesale, so the
//! non-sensitive metadata stays readable for debugging and a single corrupt
//! record can't take the whole vault with it.
//!
//! Unlike settings and auth, a `vault.json` that won't parse is **fatal**.
//! Everywhere else falling back to defaults is the friendly move; here it
//! would silently present an empty vault and invite the operator to re-add
//! accounts they already have, so we refuse to start instead.
use crate::crypto::{Sealed, VaultKey};
use crate::totp::{Algorithm, OtpAuth, Totp};
use crate::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
/// Live-message bookkeeping for one account's Discord tile.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DeliveryState {
/// Snowflake of the message we keep editing.
///
/// **A string, deliberately.** Snowflakes exceed 2^53 and would be
/// silently corrupted by any JSON layer that treats them as numbers.
pub message_id: Option<String>,
/// Last delivery failure, surfaced in the UI. `None` once a push succeeds.
pub last_error: Option<String>,
/// Code most recently written to Discord — lets the pusher skip an edit
/// when nothing actually changed.
pub last_code: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_push_at: Option<OffsetDateTime>,
}
/// One stored account. The `secret` field is ciphertext.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub issuer: String,
pub label: String,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
pub secret: Sealed,
#[serde(default = "default_true")]
pub push_enabled: bool,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(default)]
pub delivery: DeliveryState,
}
fn default_true() -> bool {
true
}
/// An override wins when it has content; otherwise keep the parsed value.
fn pick(override_: Option<&str>, parsed: &str) -> String {
override_
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(parsed)
.to_string()
}
fn display_name(issuer: &str, label: &str) -> String {
match (issuer.is_empty(), label.is_empty()) {
(false, false) => format!("{issuer} ({label})"),
(false, true) => issuer.to_string(),
(true, false) => label.to_string(),
(true, true) => "(unnamed)".to_string(),
}
}
/// Projection safe to hand to the browser: no secret, no message id.
#[derive(Debug, Clone, Serialize)]
pub struct AccountPublic {
pub id: String,
pub issuer: String,
pub label: String,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
pub push_enabled: bool,
/// True once a tile exists in Discord and the last push succeeded.
pub push_live: bool,
pub push_error: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
impl From<&Account> for AccountPublic {
fn from(a: &Account) -> Self {
Self {
id: a.id.clone(),
issuer: a.issuer.clone(),
label: a.label.clone(),
algorithm: a.algorithm,
digits: a.digits,
period: a.period,
push_enabled: a.push_enabled,
push_live: a.delivery.message_id.is_some() && a.delivery.last_error.is_none(),
push_error: a.delivery.last_error.clone(),
created_at: a.created_at,
}
}
}
/// One account queued for import, with optional renames applied on the way in.
#[derive(Debug, Clone)]
pub struct NewAccount {
pub parsed: OtpAuth,
pub issuer: Option<String>,
pub label: Option<String>,
pub push_enabled: bool,
}
/// Result of a bulk import.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ImportOutcome {
pub added: Vec<AccountPublic>,
/// Display names of entries already in the vault, skipped rather than
/// duplicated. Re-importing the same Google export is a normal thing to
/// do — it should be a no-op, not ten more rows.
pub duplicates: Vec<String>,
}
/// An account plus its decrypted TOTP parameters, for the push loop.
#[derive(Debug, Clone)]
pub struct PushTarget {
pub id: String,
pub issuer: String,
pub label: String,
pub totp: Totp,
pub message_id: Option<String>,
pub last_code: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct VaultFile {
#[serde(default)]
accounts: Vec<Account>,
}
#[derive(Clone)]
pub struct Vault {
path: Arc<PathBuf>,
key: Arc<VaultKey>,
inner: Arc<RwLock<Vec<Account>>>,
}
#[allow(clippy::missing_fields_in_debug)] // secrets must never reach a log line
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("accounts", &self.inner.read().len())
.finish()
}
}
impl Vault {
/// Load `<work_dir>/vault.json`, or start empty if it doesn't exist yet.
///
/// A file that exists but won't parse is an error, not a fallback — see
/// the module docs.
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
let path = work_dir.join("vault.json");
let accounts = match std::fs::read_to_string(&path) {
Ok(text) => {
serde_json::from_str::<VaultFile>(&text)
.map_err(|e| {
Error::invalid(format!(
"{} exists but could not be parsed ({e}). Refusing to start \
with an empty vault — move the file aside to start fresh.",
path.display()
))
})?
.accounts
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
tracing::info!(target: "openmfa::vault", count = accounts.len(), "vault loaded");
Ok(Self {
path: Arc::new(path),
key: Arc::new(key),
inner: Arc::new(RwLock::new(accounts)),
})
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Public list, oldest first. Stable ordering matters: it's the order
/// tiles are (re)created in Discord, so accounts don't shuffle position
/// in the channel after a restart.
pub fn list(&self) -> Vec<AccountPublic> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().collect();
v.sort_by_key(|a| a.created_at);
v.into_iter().map(AccountPublic::from).collect()
}
pub fn get(&self, id: &str) -> Option<AccountPublic> {
self.inner
.read()
.iter()
.find(|a| a.id == id)
.map(AccountPublic::from)
}
/// Decrypt one account's TOTP parameters.
pub fn totp_for(&self, id: &str) -> Result<Totp> {
let g = self.inner.read();
let a = g
.iter()
.find(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
self.decrypt(a)
}
/// Every account, with secrets decrypted, in stable order — what the
/// code endpoint and the push loop both iterate.
pub fn all_with_totp(&self) -> Vec<(AccountPublic, Totp)> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().collect();
v.sort_by_key(|a| a.created_at);
v.into_iter()
.filter_map(|a| match self.decrypt(a) {
Ok(t) => Some((AccountPublic::from(a), t)),
Err(e) => {
// One unreadable record must not blank the whole list.
tracing::error!(
target: "openmfa::vault",
id = %a.id, issuer = %a.issuer,
"could not decrypt secret: {e}"
);
None
}
})
.collect()
}
/// Push-enabled accounts with their decrypted parameters and message state.
pub fn push_targets(&self) -> Vec<PushTarget> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().filter(|a| a.push_enabled).collect();
v.sort_by_key(|a| a.created_at);
v.into_iter()
.filter_map(|a| {
let totp = self.decrypt(a).ok()?;
Some(PushTarget {
id: a.id.clone(),
issuer: a.issuer.clone(),
label: a.label.clone(),
totp,
message_id: a.delivery.message_id.clone(),
last_code: a.delivery.last_code.clone(),
})
})
.collect()
}
/// Message ids for accounts that are *not* pushing — their tiles need
/// removing from the channel.
pub fn orphaned_messages(&self) -> Vec<(String, String)> {
self.inner
.read()
.iter()
.filter(|a| !a.push_enabled)
.filter_map(|a| a.delivery.message_id.clone().map(|m| (a.id.clone(), m)))
.collect()
}
fn decrypt(&self, a: &Account) -> Result<Totp> {
let secret = self.key.open(&a.secret)?;
Totp::new(secret, a.algorithm, a.digits, a.period)
}
/// Add an account parsed from an `otpauth://` URI. `issuer`/`label`
/// override the parsed values when non-empty, so the user can rename in
/// the confirm step without editing the URI.
pub fn add(
&self,
parsed: &OtpAuth,
issuer: Option<&str>,
label: Option<&str>,
push_enabled: bool,
) -> Result<AccountPublic> {
let issuer = issuer
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(&parsed.issuer)
.to_string();
let label = label
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(&parsed.label)
.to_string();
if issuer.len() > 128 || label.len() > 128 {
return Err(Error::invalid(
"issuer and account must be 128 characters or fewer",
));
}
let account = Account {
id: uuid::Uuid::new_v4().simple().to_string(),
issuer,
label,
algorithm: parsed.totp.algorithm,
digits: parsed.totp.digits,
period: parsed.totp.period,
secret: self.key.seal(&parsed.totp.secret)?,
push_enabled,
created_at: OffsetDateTime::now_utc(),
delivery: DeliveryState::default(),
};
let public = AccountPublic::from(&account);
self.inner.write().push(account);
self.persist();
tracing::info!(
target: "openmfa::vault",
issuer = %public.issuer, id = %public.id,
"account added"
);
Ok(public)
}
/// Rename an account and/or toggle its Discord push.
pub fn update(
&self,
id: &str,
issuer: Option<&str>,
label: Option<&str>,
push_enabled: Option<bool>,
) -> Result<AccountPublic> {
let public = {
let mut g = self.inner.write();
let a = g
.iter_mut()
.find(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
let mut renamed = false;
if let Some(v) = issuer.map(str::trim).filter(|s| !s.is_empty()) {
if v.len() > 128 {
return Err(Error::invalid("issuer must be 128 characters or fewer"));
}
renamed |= a.issuer != v;
a.issuer = v.to_string();
}
if let Some(v) = label.map(str::trim) {
if v.len() > 128 {
return Err(Error::invalid("account must be 128 characters or fewer"));
}
renamed |= a.label != v;
a.label = v.to_string();
}
if let Some(v) = push_enabled {
if v != a.push_enabled {
// Re-enabling clears a stale error so the UI doesn't keep
// showing a failure from before the account was paused.
a.delivery.last_error = None;
}
a.push_enabled = v;
}
// A rename changes what the Discord tile should say, but not the
// code — and the push loop skips any account whose code it has
// already sent. Without this the new name would sit invisible
// until the code next rolled, up to a full period later. Clearing
// the last-sent code makes the very next tick treat the tile as
// stale, so the rename shows up in about a second.
if renamed {
a.delivery.last_code = None;
}
AccountPublic::from(&*a)
};
self.persist();
Ok(public)
}
/// Remove an account. Returns its Discord message id, if any, so the
/// caller can delete the tile from the channel.
pub fn remove(&self, id: &str) -> Result<Option<String>> {
let removed = {
let mut g = self.inner.write();
let idx = g
.iter()
.position(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
g.remove(idx)
};
self.persist();
tracing::info!(
target: "openmfa::vault",
issuer = %removed.issuer, id = %removed.id,
"account removed"
);
Ok(removed.delivery.message_id)
}
/// Record a successful push.
pub fn mark_pushed(&self, id: &str, message_id: &str, code: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
a.delivery.message_id = Some(message_id.to_string());
a.delivery.last_code = Some(code.to_string());
a.delivery.last_error = None;
a.delivery.last_push_at = Some(OffsetDateTime::now_utc());
}
self.persist();
}
/// Record a delivery failure against one account.
pub fn mark_push_error(&self, id: &str, error: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
// Don't rewrite (and re-persist) the same error every 30 seconds
// while a webhook stays broken.
if a.delivery.last_error.as_deref() == Some(error) {
return;
}
a.delivery.last_error = Some(error.to_string());
}
self.persist();
}
/// Forget an account's message id — used when Discord reports the
/// message is gone (someone deleted it) so the next pass re-creates it.
pub fn clear_message_id(&self, id: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
a.delivery.message_id = None;
a.delivery.last_code = None;
}
self.persist();
}
/// Drop every stored message id. Called when the webhook URL changes:
/// the old ids belong to a different channel and are meaningless now.
pub fn clear_all_messages(&self) {
{
let mut g = self.inner.write();
for a in g.iter_mut() {
a.delivery = DeliveryState::default();
}
}
self.persist();
}
/// Is an identical account already stored?
///
/// Matched on the decrypted secret *and* the naming. Secret-only matching
/// would block the legitimate case of deliberately holding one secret
/// under two labels; naming-only matching would miss a re-export where
/// the issuer string changed.
pub fn contains(&self, totp: &Totp, issuer: &str, label: &str) -> bool {
let issuer = issuer.trim();
let label = label.trim();
self.inner.read().iter().any(|a| {
a.issuer == issuer
&& a.label == label
&& a.digits == totp.digits
&& a.period == totp.period
&& a.algorithm == totp.algorithm
&& self
.key
.open(&a.secret)
.is_ok_and(|plain| plain == totp.secret)
})
}
/// Add several accounts at once, skipping any already present.
///
/// Persists once at the end rather than per account: a ten-account Google
/// export would otherwise mean ten separate rewrites of `vault.json`.
pub fn add_many(&self, items: &[NewAccount]) -> Result<ImportOutcome> {
let mut outcome = ImportOutcome::default();
let mut staged: Vec<Account> = Vec::new();
for item in items {
let issuer = pick(item.issuer.as_deref(), &item.parsed.issuer);
let label = pick(item.label.as_deref(), &item.parsed.label);
if issuer.len() > 128 || label.len() > 128 {
return Err(Error::invalid(
"issuer and account must be 128 characters or fewer",
));
}
// Check against what's stored *and* what this batch has already
// staged, so an export containing the same account twice doesn't
// slip a duplicate through.
let dup_in_vault = self.contains(&item.parsed.totp, &issuer, &label);
let dup_in_batch = staged
.iter()
.any(|a| a.issuer == issuer && a.label == label);
if dup_in_vault || dup_in_batch {
outcome.duplicates.push(display_name(&issuer, &label));
continue;
}
staged.push(Account {
id: uuid::Uuid::new_v4().simple().to_string(),
issuer,
label,
algorithm: item.parsed.totp.algorithm,
digits: item.parsed.totp.digits,
period: item.parsed.totp.period,
secret: self.key.seal(&item.parsed.totp.secret)?,
push_enabled: item.push_enabled,
created_at: OffsetDateTime::now_utc(),
delivery: DeliveryState::default(),
});
}
if staged.is_empty() {
return Ok(outcome);
}
outcome.added = staged.iter().map(AccountPublic::from).collect();
{
let mut g = self.inner.write();
g.extend(staged);
}
self.persist();
tracing::info!(
target: "openmfa::vault",
added = outcome.added.len(),
duplicates = outcome.duplicates.len(),
"bulk import complete"
);
Ok(outcome)
}
fn persist(&self) {
let snapshot = VaultFile {
accounts: self.inner.read().clone(),
};
let body = match serde_json::to_vec_pretty(&snapshot) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "openmfa::vault", "could not serialize vault: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::error!(target: "openmfa::vault", "could not write vault temp file: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::error!(target: "openmfa::vault", "could not commit vault file: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::totp::parse_otpauth;
use tempfile::tempdir;
fn sample() -> OtpAuth {
parse_otpauth(
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
)
.unwrap()
}
fn vault(dir: &Path) -> Vault {
Vault::load(dir, VaultKey::from_bytes(&[3u8; 32]).unwrap()).unwrap()
}
#[test]
fn add_list_and_decrypt() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
assert!(v.is_empty());
let a = v.add(&sample(), None, None, true).unwrap();
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(v.len(), 1);
// The decrypted secret must produce the same codes as the original.
let totp = v.totp_for(&a.id).unwrap();
assert_eq!(totp.code_at(59), sample().totp.code_at(59));
}
#[test]
fn secrets_are_never_stored_in_the_clear() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap();
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
// Neither the base32 form nor the raw bytes may appear on disk.
assert!(!raw.contains("JBSWY3DPEHPK3PXP"));
assert!(!raw.contains("Hello!"));
// But the metadata should still be readable.
assert!(raw.contains("Cloudflare"));
}
#[test]
fn overrides_win_over_parsed_names() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v
.add(&sample(), Some("Work CF"), Some("ops@corp"), false)
.unwrap();
assert_eq!(a.issuer, "Work CF");
assert_eq!(a.label, "ops@corp");
assert!(!a.push_enabled);
// Blank overrides fall back to the parsed values.
let b = v.add(&sample(), Some(" "), None, true).unwrap();
assert_eq!(b.issuer, "Cloudflare");
}
#[test]
fn survives_a_reload() {
let dir = tempdir().unwrap();
let id = {
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap().id
};
let v2 = vault(dir.path());
assert_eq!(v2.len(), 1);
assert!(v2.totp_for(&id).is_ok());
}
#[test]
fn a_wrong_key_leaves_metadata_but_no_codes() {
let dir = tempdir().unwrap();
let id = {
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap().id
};
let other = Vault::load(dir.path(), VaultKey::from_bytes(&[9u8; 32]).unwrap()).unwrap();
assert_eq!(other.len(), 1, "the record still loads");
assert!(
other.totp_for(&id).is_err(),
"but its secret must not decrypt"
);
// The list endpoint must not panic or drop the account.
assert_eq!(other.list().len(), 1);
// all_with_totp skips what it can't decrypt rather than failing.
assert!(other.all_with_totp().is_empty());
}
#[test]
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("vault.json"), b"{ not json").unwrap();
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
assert!(r.is_err());
}
#[test]
fn update_and_remove() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
let up = v.update(&a.id, Some("Renamed"), None, Some(false)).unwrap();
assert_eq!(up.issuer, "Renamed");
assert!(!up.push_enabled);
// Label was not supplied, so it must be untouched.
assert_eq!(up.label, "[email protected]");
assert!(v.remove(&a.id).is_ok());
assert!(v.is_empty());
assert!(v.remove(&a.id).is_err(), "second remove is a 404");
}
#[test]
fn push_bookkeeping() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
assert!(!v.get(&a.id).unwrap().push_live);
v.mark_pushed(&a.id, "123456789012345678", "111222");
let p = v.get(&a.id).unwrap();
assert!(p.push_live);
assert!(p.push_error.is_none());
v.mark_push_error(&a.id, "webhook returned 404");
let p = v.get(&a.id).unwrap();
assert!(!p.push_live);
assert_eq!(p.push_error.as_deref(), Some("webhook returned 404"));
// Message id survives an error (we keep editing the same tile), but
// clearing it forces a re-create.
assert!(v.push_targets()[0].message_id.is_some());
v.clear_message_id(&a.id);
assert!(v.push_targets()[0].message_id.is_none());
}
fn newacct(uri: &str, push: bool) -> NewAccount {
NewAccount {
parsed: parse_otpauth(uri).unwrap(),
issuer: None,
label: None,
push_enabled: push,
}
}
#[test]
fn bulk_import_adds_everything_once() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let items: Vec<_> = ["Cloudflare", "Plex", "Ubisoft"]
.iter()
.map(|i| {
newacct(
&format!(
"otpauth://totp/{i}:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer={i}"
),
true,
)
})
.collect();
let out = v.add_many(&items).unwrap();
assert_eq!(out.added.len(), 3);
assert!(out.duplicates.is_empty());
assert_eq!(v.len(), 3);
// Re-importing the same export must be a no-op, not three more rows.
let again = v.add_many(&items).unwrap();
assert!(again.added.is_empty());
assert_eq!(again.duplicates.len(), 3);
assert_eq!(v.len(), 3, "vault must not grow on a repeat import");
assert!(again.duplicates[0].contains("Cloudflare"));
}
#[test]
fn duplicates_within_one_batch_are_collapsed() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let one = newacct(
"otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Plex",
true,
);
let out = v.add_many(&[one.clone(), one]).unwrap();
assert_eq!(out.added.len(), 1);
assert_eq!(out.duplicates.len(), 1);
assert_eq!(v.len(), 1);
}
#[test]
fn the_same_secret_under_a_different_name_is_not_a_duplicate() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let base =
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Plex")
.unwrap();
v.add_many(&[NewAccount {
parsed: base.clone(),
issuer: None,
label: None,
push_enabled: true,
}])
.unwrap();
// Deliberately holding one secret under a second label stays allowed.
let out = v
.add_many(&[NewAccount {
parsed: base,
issuer: Some("Plex (work)".into()),
label: None,
push_enabled: true,
}])
.unwrap();
assert_eq!(out.added.len(), 1);
assert_eq!(v.len(), 2);
}
#[test]
fn contains_matches_on_secret_and_naming() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a =
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Plex")
.unwrap();
assert!(!v.contains(&a.totp, "Plex", "[email protected]"));
v.add(&a, None, None, true).unwrap();
assert!(v.contains(&a.totp, "Plex", "[email protected]"));
assert!(!v.contains(&a.totp, "Plex", "[email protected]"));
// A different secret under the same name is not a match.
let b =
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=GEZDGNBVGY3TQOJQ&issuer=Plex")
.unwrap();
assert!(!v.contains(&b.totp, "Plex", "[email protected]"));
}
#[test]
fn bulk_import_persists_once_and_survives_reload() {
let dir = tempdir().unwrap();
{
let v = vault(dir.path());
let items: Vec<_> = (0..5)
.map(|i| {
newacct(
&format!("otpauth://totp/Svc{i}:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Svc{i}"),
i % 2 == 0,
)
})
.collect();
assert_eq!(v.add_many(&items).unwrap().added.len(), 5);
}
let reloaded = vault(dir.path());
assert_eq!(reloaded.len(), 5);
// The per-account push flag must survive the round trip.
assert_eq!(reloaded.push_targets().len(), 3);
}
#[test]
fn an_empty_import_is_harmless() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let out = v.add_many(&[]).unwrap();
assert!(out.added.is_empty() && out.duplicates.is_empty());
assert!(v.is_empty());
}
#[test]
fn message_ids_are_persisted_as_strings() {
// Snowflakes exceed 2^53; a JSON number would round-trip lossily.
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "1409876543210987654", "000000");
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
assert!(
raw.contains("\"1409876543210987654\""),
"must be quoted: {raw}"
);
let v2 = vault(dir.path());
assert_eq!(
v2.push_targets()[0].message_id.as_deref(),
Some("1409876543210987654")
);
}
#[test]
fn push_targets_exclude_paused_accounts_and_keep_order() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), Some("First"), None, true).unwrap();
let b = v.add(&sample(), Some("Second"), None, false).unwrap();
let targets = v.push_targets();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].issuer, "First");
// The paused one shows up as an orphan only once it has a tile.
assert!(v.orphaned_messages().is_empty());
v.mark_pushed(&b.id, "999", "000000");
assert_eq!(v.orphaned_messages().len(), 1);
// Stable ordering: oldest first, regardless of push state.
let all = v.list();
assert_eq!(all[0].id, a.id);
assert_eq!(all[1].id, b.id);
}
#[test]
fn changing_webhook_clears_every_message_id() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "555", "000000");
v.clear_all_messages();
assert!(v.push_targets()[0].message_id.is_none());
assert!(!v.get(&a.id).unwrap().push_live);
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "openmfa-delivery"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Discord webhook client and the live-tile push engine."
[dependencies]
openmfa-core.workspace = true
openmfa-render.workspace = true
reqwest.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
thiserror.workspace = true
parking_lot.workspace = true
time.workspace = true
[lints]
workspace = true
[dev-dependencies]
axum.workspace = true
tempfile = "3"
+312
View File
@@ -0,0 +1,312 @@
//! Discord webhook transport: create, edit and delete one message.
//!
//! Discord is the one platform where a plain incoming webhook can edit its
//! own messages, so no bot is involved. The webhook URL is the whole
//! credential.
//!
//! Observed bucket is roughly 5 requests/second per webhook; the governor runs
//! at 2/second, which absorbs both the undocumented per-channel write limit
//! and any other client sharing the channel.
use crate::error::{DeliveryError, Result};
use crate::pacing::Governor;
use std::time::Duration;
use tokio::time::Instant;
const MIN_INTERVAL: Duration = Duration::from_millis(500);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
/// One configured webhook endpoint.
#[derive(Debug, Clone)]
pub struct Webhook {
/// `https://discord.com/api/webhooks/{id}/{token}`, trailing slash stripped.
base: String,
}
impl Webhook {
pub fn new(url: &str) -> Self {
Self {
base: url.trim().trim_end_matches('/').to_string(),
}
}
/// `?wait=true` is what makes the design work: without it Discord returns
/// 204 with an empty body and we never learn the message id.
fn create_url(&self) -> String {
format!("{}?wait=true", self.base)
}
fn message_url(&self, message_id: &str) -> String {
format!("{}/messages/{message_id}", self.base)
}
}
/// A Discord message body, with an optional file to upload as `files[0]`.
#[derive(Debug, Clone)]
pub struct Payload {
/// Complete JSON. A PATCH replaces each provided top-level field wholesale,
/// so the full `embeds` array is resent every time.
pub json: serde_json::Value,
/// `(filename, bytes)` — the filename must match the embed's
/// `attachment://` reference byte for byte.
pub file: Option<(String, Vec<u8>)>,
}
pub struct DiscordClient {
http: reqwest::Client,
governor: Governor,
}
impl std::fmt::Debug for DiscordClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DiscordClient")
}
}
impl Default for DiscordClient {
fn default() -> Self {
Self::new()
}
}
impl DiscordClient {
pub fn new() -> Self {
Self {
http: crate::http_client(REQUEST_TIMEOUT),
governor: Governor::new(MIN_INTERVAL),
}
}
pub async fn create(&self, hook: &Webhook, payload: &Payload) -> Result<String> {
let body = self
.send(reqwest::Method::POST, &hook.create_url(), payload)
.await?;
body.get("id")
.and_then(serde_json::Value::as_str)
// Snowflakes exceed 2^53; insist on the string Discord sends.
.map(str::to_string)
.ok_or_else(|| {
DeliveryError::Transient("Discord accepted the message but returned no id".into())
})
}
pub async fn edit(&self, hook: &Webhook, message_id: &str, payload: &Payload) -> Result<()> {
self.send(
reqwest::Method::PATCH,
&hook.message_url(message_id),
payload,
)
.await
.map(|_| ())
}
pub async fn delete(&self, hook: &Webhook, message_id: &str) -> Result<()> {
let mut guard = self.governor.acquire().await;
let resp = self
.http
.delete(hook.message_url(message_id))
.send()
.await
.map_err(|e| DeliveryError::Transient(format!("could not reach Discord: {e}")))?;
*guard = Instant::now() + MIN_INTERVAL;
drop(guard);
if resp.status() == reqwest::StatusCode::NOT_FOUND || resp.status().is_success() {
return Ok(());
}
Err(classify(resp.status(), &read_error(resp).await))
}
async fn send(
&self,
method: reqwest::Method,
url: &str,
payload: &Payload,
) -> Result<serde_json::Value> {
let mut guard = self.governor.acquire().await;
for attempt in 0..2 {
let resp = self
.build(method.clone(), url, payload)?
.send()
.await
.map_err(|e| DeliveryError::Transient(format!("could not reach Discord: {e}")))?;
let status = resp.status();
let reset_after = header_f64(resp.headers(), "x-ratelimit-reset-after");
let remaining = header_f64(resp.headers(), "x-ratelimit-remaining");
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let body = read_error(resp).await;
let wait = body
.retry_after
.or(reset_after)
.map_or(Duration::from_secs(1), Duration::from_secs_f64)
.min(MAX_BACKOFF);
*guard = Instant::now() + wait + MIN_INTERVAL;
if attempt == 0 {
tracing::warn!(
target: "openmfa::discord",
wait_ms = wait.as_millis() as u64,
"rate limited by Discord; backing off"
);
tokio::time::sleep(wait).await;
continue;
}
return Err(DeliveryError::Transient(format!(
"rate limited by Discord (retry in {:.1}s)",
wait.as_secs_f64()
)));
}
let gap = match (remaining, reset_after) {
(Some(r), Some(reset)) if r <= 0.0 => {
Duration::from_secs_f64(reset).min(MAX_BACKOFF) + MIN_INTERVAL
}
_ => MIN_INTERVAL,
};
*guard = Instant::now() + gap;
if status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null));
}
if status.is_server_error() && attempt == 0 {
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
return Err(classify(status, &read_error(resp).await));
}
Err(DeliveryError::Transient(
"Discord did not accept the request after a retry".into(),
))
}
fn build(
&self,
method: reqwest::Method,
url: &str,
payload: &Payload,
) -> Result<reqwest::RequestBuilder> {
let req = self.http.request(method, url);
match &payload.file {
Some((filename, bytes)) => {
let part = reqwest::multipart::Part::bytes(bytes.clone())
.file_name(filename.clone())
.mime_str("image/webp")
.map_err(|e| DeliveryError::Transient(format!("bad attachment: {e}")))?;
let form = reqwest::multipart::Form::new()
.text("payload_json", payload.json.to_string())
.part("files[0]", part);
Ok(req.multipart(form))
}
None => Ok(req.json(&payload.json)),
}
}
}
#[derive(Debug, Default)]
struct ApiError {
message: Option<String>,
code: Option<i64>,
retry_after: Option<f64>,
}
async fn read_error(resp: reqwest::Response) -> ApiError {
let text = resp.text().await.unwrap_or_default();
let mut out = ApiError::default();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
out.message = v
.get("message")
.and_then(|m| m.as_str())
.map(str::to_string);
out.code = v.get("code").and_then(serde_json::Value::as_i64);
out.retry_after = v.get("retry_after").and_then(serde_json::Value::as_f64);
}
if out.message.is_none() && !text.is_empty() {
out.message = Some(text.chars().take(200).collect());
}
out
}
/// Discord's JSON error codes are what distinguish "message deleted"
/// (recoverable) from "webhook deleted" (not); both arrive as 404.
fn classify(status: reqwest::StatusCode, err: &ApiError) -> DeliveryError {
let detail = err
.message
.clone()
.unwrap_or_else(|| format!("HTTP {status}"));
match err.code {
Some(10008) => DeliveryError::MessageGone,
Some(10015) => DeliveryError::Fatal(
"the Discord webhook no longer exists — recreate it in Discord and paste the new URL"
.into(),
),
Some(10003) => {
DeliveryError::Fatal("the Discord channel for this webhook no longer exists".into())
}
Some(50027) => DeliveryError::Fatal(
"the Discord webhook token is invalid — copy the webhook URL again".into(),
),
Some(50013) => {
DeliveryError::Fatal("the webhook lacks permission to post in that channel".into())
}
Some(40005) => {
DeliveryError::Fatal("the rendered tile is too large for Discord to accept".into())
}
_ => match status.as_u16() {
401 | 403 => DeliveryError::Fatal(format!("Discord rejected the webhook: {detail}")),
404 => DeliveryError::Fatal(format!("Discord webhook not found: {detail}")),
400 => DeliveryError::Fatal(format!("Discord rejected the message: {detail}")),
_ => DeliveryError::Transient(format!("Discord returned {status}: {detail}")),
},
}
}
fn header_f64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<f64> {
headers.get(name)?.to_str().ok()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn webhook_urls() {
let w = Webhook::new("https://discord.com/api/webhooks/123/tok/");
assert_eq!(
w.create_url(),
"https://discord.com/api/webhooks/123/tok?wait=true"
);
assert_eq!(
w.message_url("9"),
"https://discord.com/api/webhooks/123/tok/messages/9"
);
}
fn err(code: Option<i64>, msg: &str) -> ApiError {
ApiError {
message: Some(msg.into()),
code,
retry_after: None,
}
}
#[test]
fn deleted_message_is_recoverable_and_dead_webhook_is_fatal() {
assert!(matches!(
classify(
reqwest::StatusCode::NOT_FOUND,
&err(Some(10008), "Unknown Message")
),
DeliveryError::MessageGone
));
for code in [10015, 10003, 50027, 50013] {
assert!(classify(reqwest::StatusCode::NOT_FOUND, &err(Some(code), "x")).is_fatal());
}
assert!(matches!(
classify(reqwest::StatusCode::BAD_GATEWAY, &err(None, "oops")),
DeliveryError::Transient(_)
));
}
}
+35
View File
@@ -0,0 +1,35 @@
//! One failure vocabulary for every platform.
//!
//! The three-way split is the important part, and it is the same on Discord,
//! Slack and Telegram: a message that has *gone* is recovered from by
//! reposting; a *fatal* problem (revoked token, deleted channel) must stop the
//! loop rather than spin; anything else is worth another try next tick.
//! Conflating the first two is expensive — every platform will eventually ban
//! a client that hammers 4xx responses.
#[derive(Debug, thiserror::Error)]
pub enum DeliveryError {
/// The message we were editing no longer exists, or belongs to a bot we
/// no longer are. Self-healing: drop the stored id and post a fresh one.
#[error("the message no longer exists")]
MessageGone,
/// The destination itself is unusable. Needs a human — a new token or
/// URL, the bot re-invited, the channel restored. Retrying only burns
/// toward a ban, so callers must back all the way off.
#[error("{0}")]
Fatal(String),
/// Network blip, 5xx, or a rate limit we couldn't wait out. Worth
/// retrying on the next pass.
#[error("{0}")]
Transient(String),
}
impl DeliveryError {
pub fn is_fatal(&self) -> bool {
matches!(self, Self::Fatal(_))
}
}
pub type Result<T> = std::result::Result<T, DeliveryError>;
+276
View File
@@ -0,0 +1,276 @@
//! Keeps one live message per account in a chat platform, edited in place as
//! each code rolls. Discord, Slack and Telegram are supported.
//!
//! ## How "live" actually works
//!
//! None of these platforms has a streaming primitive, and the obvious ideas
//! fail: an externally-hosted image URL is fetched once and cached (and must
//! be reachable anonymously, which would publish the codes); an animated
//! countdown drifts out of phase because playback starts at decode time. What
//! works everywhere is **editing the message**: post once, remember the id,
//! rewrite it every period. Every client sees the change at once.
//!
//! Each platform needs a different credential to be allowed to edit:
//!
//! | Platform | Credential | Why |
//! |----------|----------------------------------|--------------------------------------------------|
//! | Discord | incoming-webhook URL | webhooks can edit their own messages |
//! | Slack | bot token `xoxb-` + channel ID | incoming webhooks can post but never edit |
//! | Telegram | bot token + chat id | there is no webhook concept; everything is a bot |
//!
//! The engine below is platform-blind: it resolves a [`Sink`] from settings
//! each pass and only ever calls `create`, `edit` and `delete`.
pub mod discord;
pub mod error;
pub mod message;
pub mod pacing;
pub mod sink;
pub mod slack;
pub mod telegram;
pub use discord::{DiscordClient, Payload, Webhook};
pub use error::{DeliveryError, Result};
pub use sink::{Clients, Sink};
pub use slack::{SlackClient, SlackIdentity};
pub use telegram::{BotIdentity, ChatInfo, TelegramClient};
use message::Content;
use openmfa_core::{unix_now, Provider, SettingsStore, Vault};
use std::sync::Arc;
use std::time::Duration;
/// How often the engine wakes to look for work. Codes roll on period
/// boundaries, so a one-second cadence puts an edit within a second of the
/// code changing; remaining latency is the deliberate pacing in each client.
const TICK: Duration = Duration::from_secs(1);
/// Shared HTTP client factory — rustls, timeout, identifying user agent.
pub(crate) fn http_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.user_agent(concat!(
"OpenMFA/",
env!("CARGO_PKG_VERSION"),
" (+https://gitea.milesward.dev/mward4/OpenMFA)"
))
.build()
.expect("reqwest client builds with rustls")
}
/// API base URLs. Overridable for tests, for corporate proxies, and for
/// Telegram's officially supported self-hosted Bot API servers.
#[derive(Debug, Clone)]
pub struct Endpoints {
pub slack_base: String,
pub telegram_base: String,
}
impl Default for Endpoints {
fn default() -> Self {
Self {
slack_base: slack::DEFAULT_BASE.to_string(),
telegram_base: telegram::DEFAULT_BASE.to_string(),
}
}
}
impl Endpoints {
/// Defaults, with `OPENMFA_SLACK_API_BASE` / `OPENMFA_TELEGRAM_API_BASE`
/// honoured when set.
pub fn from_env() -> Self {
let mut e = Self::default();
if let Ok(v) = std::env::var("OPENMFA_SLACK_API_BASE") {
if !v.trim().is_empty() {
e.slack_base = v;
}
}
if let Ok(v) = std::env::var("OPENMFA_TELEGRAM_API_BASE") {
if !v.trim().is_empty() {
e.telegram_base = v;
}
}
e
}
}
/// Owns the push loop.
#[derive(Clone)]
pub struct PushEngine {
vault: Vault,
settings: Arc<SettingsStore>,
clients: Arc<Clients>,
/// Guarantees only one [`PushEngine::sync_once`] pass runs at a time.
/// Two overlapping passes could both see a new account with no message
/// id and both post — two tiles, only one of which ever updates again.
sync_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for PushEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PushEngine")
}
}
impl PushEngine {
pub fn new(vault: Vault, settings: Arc<SettingsStore>, endpoints: &Endpoints) -> Self {
Self {
vault,
settings,
clients: Arc::new(Clients {
discord: DiscordClient::new(),
slack: SlackClient::new(&endpoints.slack_base),
telegram: TelegramClient::new(&endpoints.telegram_base),
}),
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Run forever. Spawned as a detached task at startup.
pub async fn run(self) {
tracing::info!(target: "openmfa::push", "delivery engine started");
let mut ticker = tokio::time::interval(TICK);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
self.sync_once().await;
}
}
/// One pass: bring every tile up to date and tidy away any that shouldn't
/// exist. Returns how many messages were written.
pub async fn sync_once(&self) -> usize {
let _guard = self.sync_lock.lock().await;
let settings = self.settings.snapshot();
let Some(sink) = Sink::from_settings(&self.clients, &settings) else {
return 0;
};
let provider = sink.provider();
let mode = settings.effective_mode();
let mut written = 0;
for (id, message_id) in self.vault.orphaned_messages() {
match sink.delete(&message_id).await {
Ok(()) => {
self.vault.clear_message_id(&id);
tracing::info!(target: "openmfa::push", account = %id, %provider, "removed paused account's tile");
}
Err(e) => {
tracing::warn!(target: "openmfa::push", account = %id, "could not remove tile: {e}");
}
}
}
let now = unix_now();
for target in self.vault.push_targets() {
let code = target.totp.code_at(now);
// For most of every period every account short-circuits here.
if target.message_id.is_some() && target.last_code.as_deref() == Some(code.as_str()) {
continue;
}
let expires_at = target.totp.expires_at(now);
let image = if mode.has_image() {
match openmfa_render::tile_webp(&openmfa_render::Tile {
issuer: &target.issuer,
label: &target.label,
code: &code,
period: target.totp.period,
}) {
Ok(bytes) => Some(bytes),
Err(e) => {
tracing::error!(target: "openmfa::push", account = %target.id, "could not render tile: {e}");
None
}
}
} else {
None
};
let content = Content {
issuer: target.issuer.clone(),
label: target.label.clone(),
code: code.clone(),
expires_at,
period: target.totp.period,
mode,
image,
};
let result = match &target.message_id {
Some(id) => sink.edit(id, &content).await.map(|()| id.clone()),
None => sink.create(&content).await,
};
match result {
Ok(message_id) => {
self.vault.mark_pushed(&target.id, &message_id, &code);
written += 1;
}
Err(DeliveryError::MessageGone) => {
tracing::info!(target: "openmfa::push", account = %target.id, %provider, "tile is gone; will repost");
self.vault.clear_message_id(&target.id);
}
Err(e) => {
if e.is_fatal() {
tracing::error!(target: "openmfa::push", account = %target.id, issuer = %target.issuer, %provider, "push failed permanently: {e}");
} else {
tracing::warn!(target: "openmfa::push", account = %target.id, issuer = %target.issuer, %provider, "push failed, will retry: {e}");
}
self.vault.mark_push_error(&target.id, &e.to_string());
}
}
}
written
}
/// Post a one-off message to prove the active destination works.
pub async fn send_test(&self) -> std::result::Result<(), String> {
let settings = self.settings.snapshot();
let Some(p) = settings.provider else {
return Err("no chat platform is selected".into());
};
if !settings.is_configured(p) {
return Err(format!("{p} isn't fully configured yet"));
}
// Test regardless of the master switch — the point is to check the
// credentials before turning delivery on.
let mut probe = settings.clone();
probe.push_enabled = true;
let sink = Sink::from_settings(&self.clients, &probe)
.ok_or_else(|| "destination is not configured".to_string())?;
sink.send_test().await.map_err(|e| e.to_string())
}
/// Delete one tile, best-effort. Used when an account is removed.
pub async fn remove_message(&self, message_id: &str) {
let settings = self.settings.snapshot();
let mut probe = settings.clone();
probe.push_enabled = true;
if let Some(sink) = Sink::from_settings(&self.clients, &probe) {
if let Err(e) = sink.delete(message_id).await {
tracing::warn!(target: "openmfa::push", "could not remove tile: {e}");
}
}
}
/// Validate a Slack token and report who it belongs to.
pub async fn slack_whoami(&self, token: &str) -> Result<SlackIdentity> {
self.clients.slack.whoami(token).await
}
/// Validate a Telegram token and report the bot's handle.
pub async fn telegram_whoami(&self, token: &str) -> Result<BotIdentity> {
self.clients.telegram.whoami(token).await
}
/// Chats that have messaged the bot — backs the "Detect chat" button.
pub async fn telegram_chats(&self, token: &str) -> Result<Vec<ChatInfo>> {
self.clients.telegram.recent_chats(token).await
}
/// Which platform is active, for the UI.
pub fn active_provider(&self) -> Option<Provider> {
self.settings.snapshot().provider
}
}
+381
View File
@@ -0,0 +1,381 @@
//! Builds the message body for one account's live tile, per platform.
//!
//! Every platform gets the same two load-bearing details:
//!
//! 1. **The code is in a code span, ungrouped.** Discord and Slack render a
//! copy button on fenced code; Telegram makes `<code>` tap-to-copy on
//! mobile. What lands on the clipboard is exactly the contents, so it's
//! `482913`, never `482 913`. The grouped, readable form lives on the
//! rendered tile, where Discord can carry one.
//!
//! 2. **A rollover time.** Discord's `<t:…:R>` ticks live in the client for
//! free. Slack's `<!date^…>` renders once, in the viewer's local time, and
//! doesn't tick; Telegram has neither, so it gets a plain UTC clock. In all
//! three the digits are refreshed by an edit at the boundary regardless —
//! the countdown is a courtesy, not the mechanism.
use crate::discord::Payload;
use openmfa_core::PushMode;
use std::fmt::Write as _;
use time::OffsetDateTime;
/// What every builder receives. Provider-agnostic on purpose so the engine
/// never has to know which platform it's talking to.
#[derive(Debug, Clone)]
pub struct Content {
pub issuer: String,
pub label: String,
/// Digits only.
pub code: String,
/// Unix second at which this code stops being valid.
pub expires_at: u64,
pub period: u64,
pub mode: PushMode,
/// Rendered WebP tile, when the mode and platform call for one.
pub image: Option<Vec<u8>>,
}
// ── Discord ───────────────────────────────────────────────────────────────
pub fn discord(c: &Content) -> Payload {
let mut description = String::new();
if c.mode.has_text() {
description.push_str("```\n");
description.push_str(&c.code);
description.push_str("\n```\n");
}
if !c.label.trim().is_empty() {
description.push_str(&escape_discord(&c.label));
description.push_str(" · ");
}
let _ = write!(description, "rolls <t:{}:R>", c.expires_at);
let mut embed = serde_json::json!({
"title": clip(&c.issuer, 240),
"description": description,
"color": color_for(&c.issuer),
});
// `attachments` must be the complete post-edit set: an empty array clears
// a tile left over from a previous mode, whereas omitting the field would
// append and the message would accumulate images forever.
let (attachments, file) = match &c.image {
Some(bytes) if c.mode.has_image() => {
let name = format!("code-{}.webp", c.expires_at);
embed["image"] = serde_json::json!({ "url": format!("attachment://{name}") });
(
serde_json::json!([{ "id": "0", "filename": name }]),
Some((name, bytes.clone())),
)
}
_ => (serde_json::json!([]), None),
};
Payload {
json: serde_json::json!({ "embeds": [embed], "attachments": attachments }),
file,
}
}
pub fn test_discord() -> Payload {
Payload {
json: serde_json::json!({
"embeds": [{
"title": "OpenMFA",
"description": "Connected. Live codes will appear here, one message per account, \
updated in place as each code rolls.",
"color": 0x4f_8a_f5,
}],
"attachments": [],
}),
file: None,
}
}
// ── Slack ─────────────────────────────────────────────────────────────────
/// Returns `(fallback text, blocks)`. The fallback is what notifications and
/// old clients show; the blocks are what renders in-channel.
pub fn slack(c: &Content) -> (String, serde_json::Value) {
let issuer = escape_mrkdwn(&c.issuer);
let label = escape_mrkdwn(&c.label);
let head = if label.is_empty() {
format!("*{issuer}*")
} else {
format!("*{issuer}* · {label}")
};
// `<!date^…>` renders in the viewer's local time zone, with the literal
// after `|` as the fallback for clients that can't.
let when = format!(
"rolls <!date^{}^{{time_secs}}|at {} UTC>",
c.expires_at,
clock(c.expires_at)
);
let blocks = serde_json::json!([
{
"type": "section",
"text": { "type": "mrkdwn", "text": format!("{head}\n```{}```", c.code) }
},
{
"type": "context",
"elements": [{ "type": "mrkdwn", "text": format!("{when} · OpenMFA") }]
}
]);
let fallback = format!("{}: {}", c.issuer, c.code);
(fallback, blocks)
}
pub fn test_slack() -> (String, serde_json::Value) {
let text = "OpenMFA connected. Live codes will appear here, one message per account, \
updated in place as each code rolls.";
(
text.to_string(),
serde_json::json!([{ "type": "section", "text": { "type": "mrkdwn", "text": format!("*OpenMFA*\n{text}") } }]),
)
}
// ── Telegram ──────────────────────────────────────────────────────────────
/// HTML for `parse_mode: HTML`. `<code>` is tap-to-copy in the mobile apps.
pub fn telegram(c: &Content) -> String {
let issuer = escape_html(&c.issuer);
let label = escape_html(&c.label);
let mut s = format!("<b>{issuer}</b>");
if !label.is_empty() {
let _ = write!(s, " · {label}");
}
let _ = write!(
s,
"\n<code>{}</code>\n<i>rolls at {} UTC</i>",
c.code,
clock(c.expires_at)
);
s
}
pub fn test_telegram() -> String {
"<b>OpenMFA</b>\nConnected. Live codes will appear here, one message per account, \
updated in place as each code rolls."
.to_string()
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// `HH:MM:SS` in UTC for a unix second.
fn clock(unix: u64) -> String {
OffsetDateTime::from_unix_timestamp(i64::try_from(unix).unwrap_or_default()).map_or_else(
|_| "??:??:??".into(),
|t| format!("{:02}:{:02}:{:02}", t.hour(), t.minute(), t.second()),
)
}
/// Stable per-issuer colour, matching the tile's accent and the web UI's
/// avatar so one account reads the same everywhere.
fn color_for(issuer: &str) -> u32 {
let mut hash: i32 = 0;
for ch in issuer.chars() {
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
}
let hue = (hash.unsigned_abs() % 360) as f32;
let (r, g, b) = hsl_rgb(hue, 0.62, 0.58);
(u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)
}
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
fn hsl_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
(to(r1), to(g1), to(b1))
}
/// Neutralise Discord markdown in user text so `**ops**` renders literally.
fn escape_discord(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '*' | '_' | '~' | '`' | '>' | '|' | '\\' | '[' | ']') {
out.push('\\');
}
out.push(ch);
}
out
}
/// Slack mrkdwn has no escape character for its formatting marks, so the
/// safe move is to drop them from user text; `&`, `<`, `>` become entities as
/// Slack requires.
fn escape_mrkdwn(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.trim().chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'*' | '_' | '~' | '`' => {}
c => out.push(c),
}
}
out
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.trim().chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
c => out.push(c),
}
}
out
}
fn clip(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn content(issuer: &str, label: &str, mode: PushMode, image: Option<Vec<u8>>) -> Content {
Content {
issuer: issuer.into(),
label: label.into(),
code: "482913".into(),
expires_at: 1_756_402_860,
period: 30,
mode,
image,
}
}
// ── discord ──
#[test]
fn discord_embed_mode_has_copyable_digits_and_no_file() {
let p = discord(&content(
"Cloudflare",
"[email protected]",
PushMode::Embed,
None,
));
let d = p.json["embeds"][0]["description"].as_str().unwrap();
assert!(d.contains("```\n482913\n```"), "{d}");
assert!(
d.contains("<t:1756402860:R>"),
"live countdown missing: {d}"
);
assert!(p.file.is_none());
assert_eq!(p.json["attachments"], serde_json::json!([]));
}
#[test]
fn discord_image_mode_wires_the_attachment() {
let p = discord(&content("Ubisoft", "u", PushMode::Image, Some(vec![0; 16])));
let (name, _) = p.file.as_ref().unwrap();
assert_eq!(
p.json["embeds"][0]["image"]["url"].as_str().unwrap(),
format!("attachment://{name}")
);
assert_eq!(p.json["attachments"][0]["id"], "0");
assert!(!p.json["embeds"][0]["description"]
.as_str()
.unwrap()
.contains("482913"));
}
#[test]
fn discord_markdown_in_names_is_neutralised() {
let p = discord(&content("Corp", "**ops**", PushMode::Embed, None));
assert!(p.json["embeds"][0]["description"]
.as_str()
.unwrap()
.contains("\\*\\*ops\\*\\*"));
}
// ── slack ──
#[test]
fn slack_puts_the_code_in_a_fenced_block_with_a_local_time() {
let (fallback, blocks) = slack(&content(
"Cloudflare",
"[email protected]",
PushMode::Embed,
None,
));
let section = blocks[0]["text"]["text"].as_str().unwrap();
assert!(section.contains("*Cloudflare*"), "{section}");
assert!(section.contains("```482913```"), "{section}");
let ctx = blocks[1]["elements"][0]["text"].as_str().unwrap();
// Slack date token renders in the viewer's zone; fallback carries UTC.
assert!(ctx.contains("<!date^1756402860^{time_secs}|at "), "{ctx}");
assert!(ctx.contains("UTC>"), "{ctx}");
assert!(fallback.contains("482913"));
}
#[test]
fn slack_strips_formatting_marks_and_escapes_entities() {
let (_, blocks) = slack(&content("A*B_C", "x<y>&z", PushMode::Embed, None));
let t = blocks[0]["text"]["text"].as_str().unwrap();
assert!(t.contains("*ABC*"), "{t}");
assert!(t.contains("x&lt;y&gt;&amp;z"), "{t}");
}
#[test]
fn slack_omits_the_separator_for_an_empty_label() {
let (_, blocks) = slack(&content("Plex", " ", PushMode::Embed, None));
assert!(!blocks[0]["text"]["text"].as_str().unwrap().contains("·"));
}
// ── telegram ──
#[test]
fn telegram_uses_code_tags_and_escapes_html() {
let html = telegram(&content("Cloud<flare>", "a&b", PushMode::Embed, None));
assert!(html.contains("<b>Cloud&lt;flare&gt;</b>"), "{html}");
assert!(html.contains(" · a&amp;b"), "{html}");
assert!(html.contains("<code>482913</code>"), "{html}");
assert!(html.contains("rolls at "), "{html}");
assert!(html.contains(" UTC</i>"), "{html}");
}
#[test]
fn telegram_never_leaks_grouped_digits() {
// Tap-to-copy must yield exactly the digits.
assert!(!telegram(&content("X", "y", PushMode::Embed, None)).contains("482 913"));
}
// ── shared ──
#[test]
fn clock_formats_utc() {
assert_eq!(clock(0), "00:00:00");
assert_eq!(clock(1_756_402_860), "17:41:00");
}
#[test]
fn colour_is_stable_and_in_range() {
assert_eq!(color_for("Cloudflare"), color_for("Cloudflare"));
assert_ne!(color_for("Cloudflare"), color_for("Plex"));
assert!(color_for("Anything") <= 0xff_ff_ff);
}
#[test]
fn test_messages_build() {
assert!(test_discord().file.is_none());
assert!(test_slack().0.contains("OpenMFA"));
assert!(test_telegram().contains("<b>OpenMFA</b>"));
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Serialises and spaces requests to one destination.
//!
//! TOTP windows are globally aligned — every account's code rolls on the same
//! `floor(unix / period)` boundary — so the naive implementation tries to edit
//! every tile at the same instant, and N accounts become an N-request burst.
//! The governor turns that burst into a trickle. It only smooths *bursts*;
//! sustained per-minute limits are a function of how many accounts you have,
//! and each platform's cap is documented alongside its client.
use std::time::Duration;
use tokio::sync::{Mutex, MutexGuard};
use tokio::time::Instant;
#[derive(Debug)]
pub struct Governor {
gate: Mutex<Instant>,
min_interval: Duration,
}
impl Governor {
pub fn new(min_interval: Duration) -> Self {
Self {
gate: Mutex::new(Instant::now()),
min_interval,
}
}
pub fn min_interval(&self) -> Duration {
self.min_interval
}
/// Wait until it's this caller's turn, then hold the lock for the
/// duration of the request so nothing else overtakes. The caller sets
/// `*guard = Instant::now() + gap` before releasing to schedule the next.
pub async fn acquire(&self) -> MutexGuard<'_, Instant> {
let guard = self.gate.lock().await;
let now = Instant::now();
if *guard > now {
tokio::time::sleep(*guard - now).await;
}
guard
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn spaces_successive_calls() {
let g = Governor::new(Duration::from_millis(300));
let start = Instant::now();
{
let mut guard = g.acquire().await;
*guard = Instant::now() + g.min_interval();
}
let _second = g.acquire().await;
assert!(start.elapsed() >= Duration::from_millis(300));
}
}
+156
View File
@@ -0,0 +1,156 @@
//! One destination, whatever the platform.
//!
//! An enum rather than a trait object: there are exactly three variants, the
//! set is closed, and `async fn` in traits is not object-safe. The engine
//! calls `create` / `edit` / `delete` and never learns which arm ran.
use crate::discord::{DiscordClient, Webhook};
use crate::error::Result;
use crate::message::{self, Content};
use crate::slack::SlackClient;
use crate::telegram::TelegramClient;
use openmfa_core::{Provider, Settings};
pub enum Sink<'a> {
Discord {
client: &'a DiscordClient,
hook: Webhook,
},
Slack {
client: &'a SlackClient,
token: String,
channel: String,
},
Telegram {
client: &'a TelegramClient,
token: String,
chat_id: String,
},
}
/// The per-platform clients the engine keeps alive across passes, so each
/// one's rate-limit governor persists.
pub struct Clients {
pub discord: DiscordClient,
pub slack: SlackClient,
pub telegram: TelegramClient,
}
impl Sink<'_> {
/// Resolve the active destination from settings, or `None` when delivery
/// is off or the chosen platform is incomplete.
pub fn from_settings<'a>(clients: &'a Clients, s: &Settings) -> Option<Sink<'a>> {
if !s.is_usable() {
return None;
}
Some(match s.provider? {
Provider::Discord => Sink::Discord {
client: &clients.discord,
hook: Webhook::new(&s.discord.webhook_url),
},
Provider::Slack => Sink::Slack {
client: &clients.slack,
token: s.slack.bot_token.clone(),
channel: s.slack.channel.clone(),
},
Provider::Telegram => Sink::Telegram {
client: &clients.telegram,
token: s.telegram.bot_token.clone(),
chat_id: s.telegram.chat_id.clone(),
},
})
}
pub fn provider(&self) -> Provider {
match self {
Sink::Discord { .. } => Provider::Discord,
Sink::Slack { .. } => Provider::Slack,
Sink::Telegram { .. } => Provider::Telegram,
}
}
/// Post a new tile; returns the platform's message id as a string.
pub async fn create(&self, c: &Content) -> Result<String> {
match self {
Sink::Discord { client, hook } => client.create(hook, &message::discord(c)).await,
Sink::Slack {
client,
token,
channel,
} => {
let (text, blocks) = message::slack(c);
client.post(token, channel, &text, &blocks).await
}
Sink::Telegram {
client,
token,
chat_id,
} => client.send(token, chat_id, &message::telegram(c)).await,
}
}
/// Rewrite an existing tile in place.
pub async fn edit(&self, id: &str, c: &Content) -> Result<()> {
match self {
Sink::Discord { client, hook } => client.edit(hook, id, &message::discord(c)).await,
Sink::Slack {
client,
token,
channel,
} => {
let (text, blocks) = message::slack(c);
client.update(token, channel, id, &text, &blocks).await
}
Sink::Telegram {
client,
token,
chat_id,
} => client.edit(token, chat_id, id, &message::telegram(c)).await,
}
}
pub async fn delete(&self, id: &str) -> Result<()> {
match self {
Sink::Discord { client, hook } => client.delete(hook, id).await,
Sink::Slack {
client,
token,
channel,
} => client.delete(token, channel, id).await,
Sink::Telegram {
client,
token,
chat_id,
} => client.delete(token, chat_id, id).await,
}
}
/// Post a one-off "connected" message to prove the destination works.
pub async fn send_test(&self) -> Result<()> {
match self {
Sink::Discord { client, hook } => client
.create(hook, &message::test_discord())
.await
.map(|_| ()),
Sink::Slack {
client,
token,
channel,
} => {
let (text, blocks) = message::test_slack();
client
.post(token, channel, &text, &blocks)
.await
.map(|_| ())
}
Sink::Telegram {
client,
token,
chat_id,
} => client
.send(token, chat_id, &message::test_telegram())
.await
.map(|_| ()),
}
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Slack transport via the Web API with a bot token.
//!
//! ## Why a bot token and not an incoming webhook
//!
//! Slack incoming webhooks can post but **cannot edit or delete**, and editing
//! in place is the entire live-tile mechanism. So OpenMFA needs a Slack app
//! with a Bot User OAuth Token (`xoxb-…`) carrying the `chat:write` scope,
//! invited to the target channel. `chat.postMessage` returns the message `ts`,
//! and `chat.update` rewrites it.
//!
//! ## Slack's error convention
//!
//! Almost every failure comes back as **HTTP 200** with `{"ok": false,
//! "error": "…"}`. Only rate limiting (429) and outages use HTTP status. So
//! the `ok` field, not the status code, is what gets classified.
//!
//! ## Limits
//!
//! `chat.update` is Tier 3 — about 50 calls per minute per workspace. Each
//! account costs two edits a minute, so the practical ceiling is roughly
//! **20 accounts** on one workspace. The governor spaces calls a second apart
//! to smooth the rollover burst; it can't raise the sustained limit.
use crate::error::{DeliveryError, Result};
use crate::pacing::Governor;
use std::time::Duration;
use tokio::time::Instant;
pub const DEFAULT_BASE: &str = "https://slack.com/api";
const MIN_INTERVAL: Duration = Duration::from_secs(1);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
/// What `auth.test` tells us about a token — surfaced in the UI so the
/// operator can see "connected as @openmfa in Acme" before saving.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SlackIdentity {
pub team: String,
pub user: String,
pub bot_id: Option<String>,
}
pub struct SlackClient {
http: reqwest::Client,
base: String,
governor: Governor,
}
#[allow(clippy::missing_fields_in_debug)] // http client + governor add nothing useful
impl std::fmt::Debug for SlackClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SlackClient")
.field("base", &self.base)
.finish()
}
}
impl SlackClient {
/// `base` is normally [`DEFAULT_BASE`]; overridable for tests and proxies.
pub fn new(base: &str) -> Self {
Self {
http: crate::http_client(REQUEST_TIMEOUT),
base: base.trim_end_matches('/').to_string(),
governor: Governor::new(MIN_INTERVAL),
}
}
/// Post a message; returns its `ts`, which is the id `chat.update` needs.
pub async fn post(
&self,
token: &str,
channel: &str,
text: &str,
blocks: &serde_json::Value,
) -> Result<String> {
let body = serde_json::json!({
"channel": channel,
"text": text,
"blocks": blocks,
// Live tiles shouldn't render link previews for anything in them.
"unfurl_links": false,
"unfurl_media": false,
});
let v = self.call(token, "chat.postMessage", &body).await?;
v.get("ts")
.and_then(|t| t.as_str())
.map(str::to_string)
.ok_or_else(|| {
DeliveryError::Transient("Slack accepted the message but returned no ts".into())
})
}
pub async fn update(
&self,
token: &str,
channel: &str,
ts: &str,
text: &str,
blocks: &serde_json::Value,
) -> Result<()> {
let body = serde_json::json!({
"channel": channel,
"ts": ts,
"text": text,
"blocks": blocks,
});
self.call(token, "chat.update", &body).await.map(|_| ())
}
pub async fn delete(&self, token: &str, channel: &str, ts: &str) -> Result<()> {
let body = serde_json::json!({ "channel": channel, "ts": ts });
match self.call(token, "chat.delete", &body).await {
Ok(_) | Err(DeliveryError::MessageGone) => Ok(()),
Err(e) => Err(e),
}
}
/// Validate a token and learn who it is.
pub async fn whoami(&self, token: &str) -> Result<SlackIdentity> {
let v = self
.call(token, "auth.test", &serde_json::json!({}))
.await?;
let s = |k: &str| {
v.get(k)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
};
Ok(SlackIdentity {
team: s("team"),
user: s("user"),
bot_id: v.get("bot_id").and_then(|x| x.as_str()).map(str::to_string),
})
}
/// One Web API call, paced, with a single retry on 429 / 5xx.
async fn call(
&self,
token: &str,
method: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = format!("{}/{method}", self.base);
let mut guard = self.governor.acquire().await;
for attempt in 0..2 {
let resp = self
.http
.post(&url)
.bearer_auth(token)
.json(body)
.send()
.await
.map_err(|e| DeliveryError::Transient(format!("could not reach Slack: {e}")))?;
let status = resp.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let wait = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map_or(Duration::from_secs(1), Duration::from_secs)
.min(MAX_BACKOFF);
*guard = Instant::now() + wait + MIN_INTERVAL;
if attempt == 0 {
tracing::warn!(target: "openmfa::slack", wait_s = wait.as_secs(), "rate limited by Slack; backing off");
tokio::time::sleep(wait).await;
continue;
}
return Err(DeliveryError::Transient(format!(
"rate limited by Slack (retry in {}s)",
wait.as_secs()
)));
}
*guard = Instant::now() + MIN_INTERVAL;
if status.is_server_error() {
if attempt == 0 {
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
return Err(DeliveryError::Transient(format!("Slack returned {status}")));
}
let text = resp.text().await.unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| {
DeliveryError::Transient(format!("Slack returned a non-JSON response ({status})"))
})?;
if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
return Ok(v);
}
let code = v
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown_error");
return Err(classify(code, method));
}
Err(DeliveryError::Transient(
"Slack did not accept the request after a retry".into(),
))
}
}
/// Map Slack's string error codes onto the three-way outcome.
fn classify(code: &str, method: &str) -> DeliveryError {
match code {
// The tile is gone, or was posted by a different bot: repost.
"message_not_found" | "cant_update_message" | "cant_delete_message" | "edit_window_closed" => {
DeliveryError::MessageGone
}
"invalid_auth" | "not_authed" | "token_revoked" | "token_expired" | "account_inactive" => {
DeliveryError::Fatal(
"Slack rejected the bot token — copy the Bot User OAuth Token again from OAuth & Permissions"
.into(),
)
}
"missing_scope" => DeliveryError::Fatal(
"the Slack app is missing the chat:write scope — add it under OAuth & Permissions and reinstall"
.into(),
),
"channel_not_found" => DeliveryError::Fatal(
"Slack can't find that channel — check the channel ID (not the #name)".into(),
),
"not_in_channel" => DeliveryError::Fatal(
"the bot isn't in that channel — run /invite @YourBot in the channel".into(),
),
"is_archived" | "channel_is_archived" => {
DeliveryError::Fatal("that Slack channel is archived".into())
}
"restricted_action" | "no_permission" | "ekm_access_denied" => DeliveryError::Fatal(
"the Slack workspace's policy blocks the bot from posting there".into(),
),
"msg_too_long" | "invalid_blocks" | "invalid_blocks_format" => {
DeliveryError::Fatal(format!("Slack rejected the message body ({code})"))
}
"ratelimited" | "internal_error" | "service_unavailable" | "fatal_error" | "request_timeout" => {
DeliveryError::Transient(format!("Slack {method}: {code}"))
}
other => DeliveryError::Fatal(format!("Slack {method} failed: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classification() {
assert!(matches!(
classify("message_not_found", "chat.update"),
DeliveryError::MessageGone
));
assert!(matches!(
classify("cant_update_message", "chat.update"),
DeliveryError::MessageGone
));
assert!(classify("invalid_auth", "x").is_fatal());
assert!(classify("channel_not_found", "x").is_fatal());
assert!(classify("not_in_channel", "x").is_fatal());
assert!(matches!(
classify("ratelimited", "x"),
DeliveryError::Transient(_)
));
}
#[test]
fn fatal_messages_say_what_to_do() {
let e = classify("not_in_channel", "chat.postMessage").to_string();
assert!(e.contains("/invite"), "{e}");
let e = classify("missing_scope", "chat.postMessage").to_string();
assert!(e.contains("chat:write"), "{e}");
}
#[test]
fn base_normalised() {
let c = SlackClient::new("https://slack.com/api/");
assert_eq!(c.base, "https://slack.com/api");
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Telegram Bot API transport.
//!
//! Telegram has no webhook concept — everything is a bot. A token from
//! @BotFather plus a chat id is the whole credential. `sendMessage` returns a
//! `message_id`; `editMessageText` rewrites it. Messages a bot sent itself can
//! be edited indefinitely (the 48-hour rule applies only to messages it did
//! not author).
//!
//! ## Finding the chat id
//!
//! Users rarely know their chat id. [`TelegramClient::recent_chats`] reads the
//! bot's pending updates and lists every chat that has messaged it, so the UI
//! can offer "send /start to your bot, then click Detect".
//!
//! ## Limits
//!
//! Roughly 30 messages/second overall, **one per second per private chat**,
//! and **20 per minute per group**. Each account costs two edits a minute, so
//! a group tops out around **10 accounts**; a private chat around 30. The
//! governor spaces calls a second apart; it can't raise the per-minute cap.
//!
//! One quirk worth knowing: editing a message to identical text is a `400
//! message is not modified` error. That is not a failure — it means the tile
//! already says the right thing — so it is treated as success.
use crate::error::{DeliveryError, Result};
use crate::pacing::Governor;
use std::time::Duration;
use tokio::time::Instant;
pub const DEFAULT_BASE: &str = "https://api.telegram.org";
const MIN_INTERVAL: Duration = Duration::from_secs(1);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
/// A chat the bot has heard from — what "Detect" shows.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct ChatInfo {
pub id: String,
/// `private`, `group`, `supergroup` or `channel`.
pub kind: String,
/// Group/channel title, or the person's name for a private chat.
pub title: String,
}
/// What `getMe` reports.
#[derive(Debug, Clone, serde::Serialize)]
pub struct BotIdentity {
pub username: String,
pub name: String,
}
pub struct TelegramClient {
http: reqwest::Client,
base: String,
governor: Governor,
}
#[allow(clippy::missing_fields_in_debug)] // http client + governor add nothing useful
impl std::fmt::Debug for TelegramClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelegramClient")
.field("base", &self.base)
.finish()
}
}
impl TelegramClient {
/// `base` is normally [`DEFAULT_BASE`]. Telegram officially supports
/// self-hosted Bot API servers, and tests point this at a mock.
pub fn new(base: &str) -> Self {
Self {
http: crate::http_client(REQUEST_TIMEOUT),
base: base.trim_end_matches('/').to_string(),
governor: Governor::new(MIN_INTERVAL),
}
}
fn url(&self, token: &str, method: &str) -> String {
format!("{}/bot{token}/{method}", self.base)
}
/// Send an HTML-formatted message; returns its `message_id`.
pub async fn send(&self, token: &str, chat_id: &str, html: &str) -> Result<String> {
let body = serde_json::json!({
"chat_id": chat_id,
"text": html,
"parse_mode": "HTML",
"disable_web_page_preview": true,
// A tile appearing is not worth a phone buzz; edits never notify.
"disable_notification": true,
});
let v = self.call(token, "sendMessage", &body).await?;
v.pointer("/result/message_id")
.and_then(serde_json::Value::as_i64)
.map(|n| n.to_string())
.ok_or_else(|| {
DeliveryError::Transient("Telegram accepted the message but returned no id".into())
})
}
pub async fn edit(
&self,
token: &str,
chat_id: &str,
message_id: &str,
html: &str,
) -> Result<()> {
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id.parse::<i64>().unwrap_or_default(),
"text": html,
"parse_mode": "HTML",
"disable_web_page_preview": true,
});
self.call(token, "editMessageText", &body).await.map(|_| ())
}
pub async fn delete(&self, token: &str, chat_id: &str, message_id: &str) -> Result<()> {
let body = serde_json::json!({
"chat_id": chat_id,
"message_id": message_id.parse::<i64>().unwrap_or_default(),
});
match self.call(token, "deleteMessage", &body).await {
Ok(_) | Err(DeliveryError::MessageGone) => Ok(()),
Err(e) => Err(e),
}
}
/// Validate a token and learn the bot's handle.
pub async fn whoami(&self, token: &str) -> Result<BotIdentity> {
let v = self.call(token, "getMe", &serde_json::json!({})).await?;
let s = |p: &str| {
v.pointer(p)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string()
};
Ok(BotIdentity {
username: s("/result/username"),
name: s("/result/first_name"),
})
}
/// Every distinct chat present in the bot's pending updates.
///
/// This is how "Detect chat" works: the operator messages the bot (or
/// adds it to a group and says something), then we read the backlog.
/// Note `getUpdates` and a configured webhook are mutually exclusive on
/// Telegram's side; OpenMFA never sets a webhook, so this is safe.
pub async fn recent_chats(&self, token: &str) -> Result<Vec<ChatInfo>> {
let body = serde_json::json!({ "limit": 100, "timeout": 0 });
let v = self.call(token, "getUpdates", &body).await?;
let mut out: Vec<ChatInfo> = Vec::new();
for upd in v
.get("result")
.and_then(|r| r.as_array())
.into_iter()
.flatten()
{
// A message can arrive under several keys depending on where it came from.
let chat = [
"message",
"edited_message",
"channel_post",
"my_chat_member",
"chat_member",
]
.iter()
.find_map(|k| upd.get(k).and_then(|m| m.get("chat")));
let Some(chat) = chat else { continue };
let id = match chat.get("id") {
Some(serde_json::Value::Number(n)) => n.to_string(),
_ => continue,
};
if out.iter().any(|c| c.id == id) {
continue;
}
let kind = chat
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown")
.to_string();
let title = chat
.get("title")
.and_then(|t| t.as_str())
.map(str::to_string)
.or_else(|| {
let first = chat
.get("first_name")
.and_then(|t| t.as_str())
.unwrap_or("");
let last = chat.get("last_name").and_then(|t| t.as_str()).unwrap_or("");
let name = format!("{first} {last}").trim().to_string();
(!name.is_empty()).then_some(name)
})
.or_else(|| {
chat.get("username")
.and_then(|t| t.as_str())
.map(|u| format!("@{u}"))
})
.unwrap_or_else(|| id.clone());
out.push(ChatInfo { id, kind, title });
}
Ok(out)
}
async fn call(
&self,
token: &str,
method: &str,
body: &serde_json::Value,
) -> Result<serde_json::Value> {
let url = self.url(token, method);
let mut guard = self.governor.acquire().await;
for attempt in 0..2 {
let resp =
self.http.post(&url).json(body).send().await.map_err(|e| {
DeliveryError::Transient(format!("could not reach Telegram: {e}"))
})?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
let v: serde_json::Value =
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let wait = v
.pointer("/parameters/retry_after")
.and_then(serde_json::Value::as_u64)
.map_or(Duration::from_secs(1), Duration::from_secs)
.min(MAX_BACKOFF);
*guard = Instant::now() + wait + MIN_INTERVAL;
if attempt == 0 {
tracing::warn!(target: "openmfa::telegram", wait_s = wait.as_secs(), "rate limited by Telegram; backing off");
tokio::time::sleep(wait).await;
continue;
}
return Err(DeliveryError::Transient(format!(
"rate limited by Telegram (retry in {}s)",
wait.as_secs()
)));
}
*guard = Instant::now() + MIN_INTERVAL;
if status.is_server_error() {
if attempt == 0 {
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
return Err(DeliveryError::Transient(format!(
"Telegram returned {status}"
)));
}
if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
return Ok(v);
}
let description = v
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
return classify(status, &description, method);
}
Err(DeliveryError::Transient(
"Telegram did not accept the request after a retry".into(),
))
}
}
/// Telegram signals everything through a free-text `description`, so this
/// matches on the stable phrases the Bot API has used for years.
fn classify(
status: reqwest::StatusCode,
description: &str,
method: &str,
) -> Result<serde_json::Value> {
let d = description.to_ascii_lowercase();
// Not an error: the tile already shows exactly this text.
if d.contains("message is not modified") {
return Ok(serde_json::Value::Null);
}
if d.contains("message to edit not found")
|| d.contains("message to delete not found")
|| d.contains("message can't be edited")
|| d.contains("message identifier is not specified")
{
return Err(DeliveryError::MessageGone);
}
if status == reqwest::StatusCode::UNAUTHORIZED || d.contains("unauthorized") {
return Err(DeliveryError::Fatal(
"Telegram rejected the bot token — copy it again from @BotFather".into(),
));
}
if status == reqwest::StatusCode::NOT_FOUND && d.contains("not found") && method != "getUpdates"
{
// A malformed token makes the whole /bot<token>/ path 404.
return Err(DeliveryError::Fatal(
"Telegram doesn't recognise that bot token — check it against @BotFather".into(),
));
}
if d.contains("chat not found") {
return Err(DeliveryError::Fatal(
"Telegram can't find that chat — use Detect, or make sure the bot has been messaged or added"
.into(),
));
}
if d.contains("bot was blocked") || d.contains("user is deactivated") {
return Err(DeliveryError::Fatal("the user has blocked the bot".into()));
}
if d.contains("bot is not a member")
|| d.contains("bot was kicked")
|| d.contains("not enough rights")
{
return Err(DeliveryError::Fatal(
"the bot isn't allowed to post in that chat — add it (as admin, for channels)".into(),
));
}
if d.contains("can't parse entities") {
return Err(DeliveryError::Fatal(format!(
"Telegram rejected the message formatting: {description}"
)));
}
if status.is_client_error() {
return Err(DeliveryError::Fatal(format!(
"Telegram {method} failed: {description}"
)));
}
Err(DeliveryError::Transient(format!(
"Telegram {method}: {status} {description}"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::StatusCode as S;
#[test]
fn urls_embed_the_token() {
let c = TelegramClient::new("https://api.telegram.org/");
assert_eq!(
c.url("123:abc", "sendMessage"),
"https://api.telegram.org/bot123:abc/sendMessage"
);
}
#[test]
fn not_modified_is_success() {
assert!(classify(
S::BAD_REQUEST,
"Bad Request: message is not modified: ...",
"editMessageText"
)
.is_ok());
}
#[test]
fn classification() {
assert!(matches!(
classify(
S::BAD_REQUEST,
"Bad Request: message to edit not found",
"editMessageText"
),
Err(DeliveryError::MessageGone)
));
assert!(classify(S::UNAUTHORIZED, "Unauthorized", "sendMessage")
.unwrap_err()
.is_fatal());
assert!(classify(S::NOT_FOUND, "Not Found", "sendMessage")
.unwrap_err()
.is_fatal());
assert!(
classify(S::BAD_REQUEST, "Bad Request: chat not found", "sendMessage")
.unwrap_err()
.is_fatal()
);
assert!(classify(
S::FORBIDDEN,
"Forbidden: bot was blocked by the user",
"sendMessage"
)
.unwrap_err()
.is_fatal());
assert!(matches!(
classify(S::BAD_GATEWAY, "", "sendMessage"),
Err(DeliveryError::Transient(_))
));
}
#[test]
fn fatal_messages_point_at_the_fix() {
let e = classify(S::BAD_REQUEST, "Bad Request: chat not found", "sendMessage")
.unwrap_err()
.to_string();
assert!(e.contains("Detect"), "{e}");
}
}
+805
View File
@@ -0,0 +1,805 @@
//! End-to-end coverage of the live-tile mechanism against mock Discord, Slack
//! and Telegram servers.
//!
//! The mocks speak each platform's real protocol shape — Slack's `ok:false`
//! errors on HTTP 200, Telegram's `/bot<token>/<method>` paths and
//! `description` strings, Discord's `?wait=true` and JSON error codes — so
//! these assert on the things that actually break in production, not on a
//! stubbed trait.
use axum::{
extract::{Path, Query},
http::HeaderMap,
routing::post,
Json, Router,
};
use openmfa_core::{parse_otpauth, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
// ── recording ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct Recorded {
method: String,
path: String,
query: HashMap<String, String>,
bearer: Option<String>,
content_type: String,
body: Vec<u8>,
}
impl Recorded {
fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
}
fn is_multipart(&self) -> bool {
self.content_type.starts_with("multipart/form-data")
}
fn multipart_payload(&self) -> serde_json::Value {
let text = String::from_utf8_lossy(&self.body);
let Some(start) = text.find('{') else {
return serde_json::Value::Null;
};
let mut depth = 0usize;
for (i, ch) in text.as_bytes().iter().enumerate().skip(start) {
match ch {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return serde_json::from_str(&text[start..=i])
.unwrap_or(serde_json::Value::Null);
}
}
_ => {}
}
}
serde_json::Value::Null
}
}
#[derive(Clone, Default)]
struct Log(Arc<Mutex<Vec<Recorded>>>);
impl Log {
fn push(
&self,
method: &str,
path: String,
q: HashMap<String, String>,
h: &HeaderMap,
body: &[u8],
) {
let ct = h
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bearer = h
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string);
self.0.lock().unwrap().push(Recorded {
method: method.into(),
path,
query: q,
bearer,
content_type: ct,
body: body.to_vec(),
});
}
fn all(&self) -> Vec<Recorded> {
self.0.lock().unwrap().clone()
}
fn len(&self) -> usize {
self.0.lock().unwrap().len()
}
fn count(&self, pred: impl Fn(&Recorded) -> bool) -> usize {
self.0.lock().unwrap().iter().filter(|r| pred(r)).count()
}
}
async fn serve(app: Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
format!("http://{addr}")
}
// ── mock Discord ──────────────────────────────────────────────────────────
#[derive(Clone)]
struct DiscordMock {
log: Log,
/// When set, PATCH answers 404 with this Discord error code.
patch_error: Arc<Mutex<Option<i64>>>,
next_id: Arc<Mutex<u64>>,
}
async fn discord_mock() -> (String, DiscordMock) {
let m = DiscordMock {
log: Log::default(),
patch_error: Arc::new(Mutex::new(None)),
next_id: Arc::new(Mutex::new(1)),
};
let create = {
let m = m.clone();
move |Path((id, tok)): Path<(String, String)>,
Query(q): Query<HashMap<String, String>>,
h: HeaderMap,
b: axum::body::Bytes| {
let m = m.clone();
async move {
m.log.push("POST", format!("/{id}/{tok}"), q, &h, &b);
let mut n = m.next_id.lock().unwrap();
let msg = format!("90000000000000000{n}");
*n += 1;
Json(serde_json::json!({ "id": msg }))
}
}
};
let edit = {
let m = m.clone();
move |Path((id, tok, mid)): Path<(String, String, String)>,
Query(q): Query<HashMap<String, String>>,
h: HeaderMap,
b: axum::body::Bytes| {
let m = m.clone();
async move {
m.log
.push("PATCH", format!("/{id}/{tok}/messages/{mid}"), q, &h, &b);
if let Some(code) = *m.patch_error.lock().unwrap() {
return (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({ "message": "err", "code": code })),
);
}
(
axum::http::StatusCode::OK,
Json(serde_json::json!({ "id": mid })),
)
}
}
};
let delete = {
let m = m.clone();
move |Path((id, tok, mid)): Path<(String, String, String)>,
h: HeaderMap,
b: axum::body::Bytes| {
let m = m.clone();
async move {
m.log.push(
"DELETE",
format!("/{id}/{tok}/messages/{mid}"),
HashMap::new(),
&h,
&b,
);
axum::http::StatusCode::NO_CONTENT
}
}
};
let app = Router::new()
.route("/api/webhooks/{id}/{token}", post(create))
.route(
"/api/webhooks/{id}/{token}/messages/{mid}",
axum::routing::patch(edit).delete(delete),
);
let base = serve(app).await;
(format!("{base}/api/webhooks/123/tok"), m)
}
// ── mock Slack ────────────────────────────────────────────────────────────
#[derive(Clone)]
struct SlackMock {
log: Log,
/// When set, chat.update answers `{ok:false, error:<this>}` (HTTP 200,
/// exactly as Slack does).
update_error: Arc<Mutex<Option<String>>>,
}
async fn slack_mock() -> (String, SlackMock) {
let m = SlackMock {
log: Log::default(),
update_error: Arc::new(Mutex::new(None)),
};
let handler = {
let m = m.clone();
move |Path(method): Path<String>, h: HeaderMap, b: axum::body::Bytes| {
let m = m.clone();
async move {
m.log
.push("POST", format!("/{method}"), HashMap::new(), &h, &b);
// Slack rejects missing auth as ok:false, still HTTP 200.
let authed = h
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("Bearer xoxb-"));
if !authed {
return Json(serde_json::json!({ "ok": false, "error": "not_authed" }));
}
match method.as_str() {
"chat.postMessage" => Json(
serde_json::json!({ "ok": true, "ts": "1700000000.000100", "channel": "C0TEST" }),
),
"chat.update" => {
if let Some(e) = m.update_error.lock().unwrap().clone() {
return Json(serde_json::json!({ "ok": false, "error": e }));
}
Json(serde_json::json!({ "ok": true, "ts": "1700000000.000100" }))
}
"chat.delete" => Json(serde_json::json!({ "ok": true })),
"auth.test" => Json(
serde_json::json!({ "ok": true, "team": "Acme", "user": "openmfa", "bot_id": "B0BOT" }),
),
_ => Json(serde_json::json!({ "ok": false, "error": "unknown_method" })),
}
}
}
};
let base = serve(Router::new().route("/{method}", post(handler))).await;
(base, m)
}
// ── mock Telegram ─────────────────────────────────────────────────────────
#[derive(Clone)]
struct TelegramMock {
log: Log,
/// When set, editMessageText answers HTTP 400 with this description.
edit_error: Arc<Mutex<Option<String>>>,
next_id: Arc<Mutex<i64>>,
}
async fn telegram_mock() -> (String, TelegramMock) {
let m = TelegramMock {
log: Log::default(),
edit_error: Arc::new(Mutex::new(None)),
next_id: Arc::new(Mutex::new(500)),
};
let handler = {
let m = m.clone();
move |Path((token, method)): Path<(String, String)>, h: HeaderMap, b: axum::body::Bytes| {
let m = m.clone();
async move {
m.log
.push("POST", format!("/{token}/{method}"), HashMap::new(), &h, &b);
if token != "bot123:TESTTOKEN" {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "ok": false, "description": "Unauthorized" })),
);
}
let ok = |v: serde_json::Value| {
(
axum::http::StatusCode::OK,
Json(serde_json::json!({ "ok": true, "result": v })),
)
};
match method.as_str() {
"sendMessage" => {
let mut n = m.next_id.lock().unwrap();
*n += 1;
ok(serde_json::json!({ "message_id": *n, "chat": { "id": -100 } }))
}
"editMessageText" => {
if let Some(d) = m.edit_error.lock().unwrap().clone() {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "ok": false, "description": d })),
);
}
ok(serde_json::json!({ "message_id": 1 }))
}
"deleteMessage" => ok(serde_json::json!(true)),
"getMe" => ok(
serde_json::json!({ "username": "openmfa_bot", "first_name": "OpenMFA" }),
),
"getUpdates" => ok(serde_json::json!([
{ "update_id": 1, "message": { "chat": { "id": 42, "type": "private", "first_name": "Miles", "last_name": "Ward" } } },
{ "update_id": 2, "message": { "chat": { "id": -1_001_234, "type": "supergroup", "title": "Homelab" } } },
{ "update_id": 3, "message": { "chat": { "id": 42, "type": "private", "first_name": "Miles" } } },
{ "update_id": 4, "channel_post": { "chat": { "id": -1_009_999, "type": "channel", "title": "Codes" } } }
])),
_ => (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({ "ok": false, "description": "Not Found" })),
),
}
}
}
};
let base = serve(Router::new().route("/{token}/{method}", post(handler))).await;
(base, m)
}
// ── fixture ───────────────────────────────────────────────────────────────
/// `settings.json` is written directly rather than through `replace()`, which
/// validates the URL is a real discord.com address and the tokens are
/// well-formed — right in production, wrong for pointing at a mock.
#[allow(clippy::needless_pass_by_value)] // test helper; by-value reads cleaner at call sites
fn fixture(
dir: &std::path::Path,
settings: serde_json::Value,
period: u64,
) -> (Vault, Arc<SettingsStore>) {
std::fs::write(dir.join("settings.json"), settings.to_string()).unwrap();
let vault = Vault::load(dir, VaultKey::from_bytes(&[5u8; 32]).unwrap()).unwrap();
let uri = format!("otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare&period={period}");
vault
.add(&parse_otpauth(&uri).unwrap(), None, None, true)
.unwrap();
(vault, SettingsStore::load_or_default(dir))
}
fn discord_settings(url: &str, mode: &str) -> serde_json::Value {
serde_json::json!({ "push_enabled": true, "provider": "discord", "discord": { "webhook_url": url }, "push_mode": mode })
}
fn slack_settings() -> serde_json::Value {
serde_json::json!({ "push_enabled": true, "provider": "slack", "slack": { "bot_token": "xoxb-test-token", "channel": "C0TEST" }, "push_mode": "both" })
}
fn telegram_settings() -> serde_json::Value {
serde_json::json!({ "push_enabled": true, "provider": "telegram", "telegram": { "bot_token": "123:TESTTOKEN", "chat_id": "-100" }, "push_mode": "both" })
}
fn engine(vault: Vault, settings: Arc<SettingsStore>, slack: &str, telegram: &str) -> PushEngine {
PushEngine::new(
vault,
settings,
&Endpoints {
slack_base: slack.into(),
telegram_base: telegram.into(),
},
)
}
// ── Discord ───────────────────────────────────────────────────────────────
#[tokio::test]
async fn discord_first_pass_creates_then_later_passes_edit_in_place() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = discord_mock().await;
let (vault, settings) = fixture(dir.path(), discord_settings(&url, "embed"), 1);
let eng = engine(
vault.clone(),
settings,
"http://127.0.0.1:1",
"http://127.0.0.1:1",
);
assert_eq!(eng.sync_once().await, 1);
let first = mock.log.all();
assert_eq!(first[0].method, "POST");
// Without wait=true Discord replies 204 with no body and nothing could ever be edited.
assert_eq!(first[0].query.get("wait").map(String::as_str), Some("true"));
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
eng.sync_once().await;
assert_eq!(
mock.log.len(),
1,
"an unchanged code must not cost a request"
);
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(eng.sync_once().await, 1);
let log = mock.log.all();
assert_eq!(log[1].method, "PATCH");
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
}
#[tokio::test]
async fn discord_image_mode_uploads_a_webp_wired_to_the_embed() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = discord_mock().await;
let (vault, settings) = fixture(dir.path(), discord_settings(&url, "both"), 30);
engine(vault, settings, "http://127.0.0.1:1", "http://127.0.0.1:1")
.sync_once()
.await;
let r = &mock.log.all()[0];
assert!(r.is_multipart());
let p = r.multipart_payload();
let name = p["attachments"][0]["filename"].as_str().unwrap();
assert_eq!(
p["embeds"][0]["image"]["url"].as_str().unwrap(),
format!("attachment://{name}")
);
let riff = r
.body
.windows(4)
.position(|w| w == b"RIFF")
.expect("no WebP in body");
assert_eq!(&r.body[riff + 8..riff + 12], b"WEBP");
}
#[tokio::test]
async fn discord_deleted_message_is_reposted_and_dead_webhook_is_surfaced() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = discord_mock().await;
let (vault, settings) = fixture(dir.path(), discord_settings(&url, "embed"), 1);
let eng = engine(
vault.clone(),
settings,
"http://127.0.0.1:1",
"http://127.0.0.1:1",
);
eng.sync_once().await;
let first_id = vault.push_targets()[0].message_id.clone().unwrap();
*mock.patch_error.lock().unwrap() = Some(10008); // Unknown Message
tokio::time::sleep(Duration::from_millis(1100)).await;
eng.sync_once().await;
assert!(
vault.push_targets()[0].message_id.is_none(),
"deleted tile must clear the id"
);
assert!(
vault.list()[0].push_error.is_none(),
"a deleted tile is not the user's problem"
);
*mock.patch_error.lock().unwrap() = None;
eng.sync_once().await;
assert_ne!(
vault.push_targets()[0].message_id.as_ref().unwrap(),
&first_id
);
*mock.patch_error.lock().unwrap() = Some(10015); // Unknown Webhook
tokio::time::sleep(Duration::from_millis(1100)).await;
eng.sync_once().await;
let a = &vault.list()[0];
assert!(!a.push_live);
assert!(a
.push_error
.as_deref()
.unwrap_or_default()
.contains("recreate it"));
}
// ── Slack ─────────────────────────────────────────────────────────────────
#[tokio::test]
async fn slack_posts_with_bearer_token_then_updates_the_same_ts() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = slack_mock().await;
let (vault, settings) = fixture(dir.path(), slack_settings(), 1);
let eng = engine(vault.clone(), settings, &base, "http://127.0.0.1:1");
assert_eq!(eng.sync_once().await, 1);
let first = &mock.log.all()[0];
assert_eq!(first.path, "/chat.postMessage");
assert_eq!(
first.bearer.as_deref(),
Some("xoxb-test-token"),
"must authenticate as the bot"
);
let body = first.json();
assert_eq!(body["channel"], "C0TEST");
// Code in a fenced block, local-time rollover in the context line.
let section = body["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(section.contains("*Cloudflare*"), "{section}");
assert!(section.contains("```"), "{section}");
assert!(body["blocks"][1]["elements"][0]["text"]
.as_str()
.unwrap()
.contains("<!date^"));
assert_eq!(
vault.push_targets()[0].message_id.as_deref(),
Some("1700000000.000100")
);
eng.sync_once().await;
assert_eq!(mock.log.len(), 1, "unchanged code must not cost a request");
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(eng.sync_once().await, 1);
let second = &mock.log.all()[1];
assert_eq!(
second.path, "/chat.update",
"the live mechanism is chat.update, never a second post"
);
assert_eq!(second.json()["ts"], "1700000000.000100");
assert_eq!(second.json()["channel"], "C0TEST");
}
#[tokio::test]
async fn slack_ok_false_errors_are_classified_not_ignored() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = slack_mock().await;
let (vault, settings) = fixture(dir.path(), slack_settings(), 1);
let eng = engine(vault.clone(), settings, &base, "http://127.0.0.1:1");
eng.sync_once().await;
// Slack says the message is gone — HTTP 200, ok:false. Must repost.
*mock.update_error.lock().unwrap() = Some("message_not_found".into());
tokio::time::sleep(Duration::from_millis(1100)).await;
eng.sync_once().await;
assert!(vault.push_targets()[0].message_id.is_none());
*mock.update_error.lock().unwrap() = None;
eng.sync_once().await;
assert_eq!(
mock.log.count(|r| r.path == "/chat.postMessage"),
2,
"reposted after message_not_found"
);
// A revoked token is fatal and must be explained.
*mock.update_error.lock().unwrap() = Some("invalid_auth".into());
tokio::time::sleep(Duration::from_millis(1100)).await;
eng.sync_once().await;
let err = vault.list()[0].push_error.clone().unwrap_or_default();
assert!(err.contains("Bot User OAuth Token"), "{err}");
}
#[tokio::test]
async fn slack_whoami_and_test_message() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = slack_mock().await;
let (vault, settings) = fixture(dir.path(), slack_settings(), 30);
let eng = engine(vault, settings, &base, "http://127.0.0.1:1");
let id = eng.slack_whoami("xoxb-test-token").await.unwrap();
assert_eq!(id.team, "Acme");
assert_eq!(id.user, "openmfa");
eng.send_test().await.unwrap();
assert!(mock
.log
.all()
.iter()
.any(|r| r.path == "/chat.postMessage"
&& r.json()["text"].as_str().unwrap().contains("OpenMFA")));
// Wrong token shape is rejected by Slack as not_authed → fatal.
assert!(eng
.slack_whoami("xoxp-user-token")
.await
.unwrap_err()
.is_fatal());
}
// ── Telegram ──────────────────────────────────────────────────────────────
#[tokio::test]
async fn telegram_sends_then_edits_the_same_message_id() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = telegram_mock().await;
let (vault, settings) = fixture(dir.path(), telegram_settings(), 1);
let eng = engine(vault.clone(), settings, "http://127.0.0.1:1", &base);
assert_eq!(eng.sync_once().await, 1);
let first = &mock.log.all()[0];
assert_eq!(
first.path, "/bot123:TESTTOKEN/sendMessage",
"token rides in the path"
);
let body = first.json();
assert_eq!(body["chat_id"], "-100");
assert_eq!(body["parse_mode"], "HTML");
let text = body["text"].as_str().unwrap();
assert!(text.contains("<b>Cloudflare</b>"), "{text}");
assert!(text.contains("<code>"), "tap-to-copy needs <code>: {text}");
assert_eq!(
body["disable_notification"], true,
"a tile appearing must not buzz the phone"
);
let mid = vault.push_targets()[0].message_id.clone().unwrap();
assert_eq!(mid, "501");
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(eng.sync_once().await, 1);
let second = &mock.log.all()[1];
assert_eq!(second.path, "/bot123:TESTTOKEN/editMessageText");
assert_eq!(second.json()["message_id"], 501);
}
#[tokio::test]
async fn telegram_not_modified_is_success_and_not_found_reposts() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = telegram_mock().await;
let (vault, settings) = fixture(dir.path(), telegram_settings(), 1);
let eng = engine(vault.clone(), settings, "http://127.0.0.1:1", &base);
eng.sync_once().await;
// "message is not modified" means the tile already says this — not an error.
*mock.edit_error.lock().unwrap() = Some("Bad Request: message is not modified: specified new message content and reply markup are exactly the same".into());
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(eng.sync_once().await, 1, "counted as written");
assert!(
vault.list()[0].push_error.is_none(),
"must not surface as a failure"
);
assert!(vault.list()[0].push_live);
// Deleted by the user → repost.
*mock.edit_error.lock().unwrap() = Some("Bad Request: message to edit not found".into());
tokio::time::sleep(Duration::from_millis(1100)).await;
eng.sync_once().await;
assert!(vault.push_targets()[0].message_id.is_none());
*mock.edit_error.lock().unwrap() = None;
eng.sync_once().await;
assert_eq!(mock.log.count(|r| r.path.ends_with("/sendMessage")), 2);
}
#[tokio::test]
async fn telegram_bad_token_is_fatal_with_a_next_step() {
let dir = tempfile::tempdir().unwrap();
let (base, _mock) = telegram_mock().await;
let (vault, settings) = fixture(
dir.path(),
serde_json::json!({ "push_enabled": true, "provider": "telegram", "telegram": { "bot_token": "999:WRONG", "chat_id": "-100" } }),
30,
);
let eng = engine(vault.clone(), settings, "http://127.0.0.1:1", &base);
eng.sync_once().await;
let a = &vault.list()[0];
assert!(!a.push_live);
assert!(a
.push_error
.as_deref()
.unwrap_or_default()
.contains("BotFather"));
}
#[tokio::test]
async fn telegram_detect_lists_each_chat_once() {
let dir = tempfile::tempdir().unwrap();
let (base, _mock) = telegram_mock().await;
let (vault, settings) = fixture(dir.path(), telegram_settings(), 30);
let eng = engine(vault, settings, "http://127.0.0.1:1", &base);
let chats = eng.telegram_chats("123:TESTTOKEN").await.unwrap();
let ids: Vec<_> = chats.iter().map(|c| c.id.as_str()).collect();
// Chat 42 appears twice in the updates and once here; channel posts count too.
assert_eq!(ids, ["42", "-1001234", "-1009999"]);
assert_eq!(chats[0].title, "Miles Ward");
assert_eq!(chats[0].kind, "private");
assert_eq!(chats[1].title, "Homelab");
assert_eq!(chats[2].kind, "channel");
let me = eng.telegram_whoami("123:TESTTOKEN").await.unwrap();
assert_eq!(me.username, "openmfa_bot");
}
// ── cross-platform behaviour ──────────────────────────────────────────────
#[tokio::test]
async fn renaming_refreshes_the_tile_on_every_platform_without_a_rollover() {
for (which, settings_json) in [
("slack", slack_settings()),
("telegram", telegram_settings()),
] {
let dir = tempfile::tempdir().unwrap();
let (sbase, smock) = slack_mock().await;
let (tbase, tmock) = telegram_mock().await;
let (vault, settings) = fixture(dir.path(), settings_json, 30);
let eng = engine(vault.clone(), settings, &sbase, &tbase);
eng.sync_once().await;
eng.sync_once().await;
let before = smock.log.len() + tmock.log.len();
assert_eq!(before, 1, "{which}: idle pass must be free");
let id = vault.list()[0].id.clone();
vault.update(&id, Some("Renamed Co"), None, None).unwrap();
assert_eq!(eng.sync_once().await, 1, "{which}: rename must push");
let last = if which == "slack" {
smock.log.all().pop().unwrap()
} else {
tmock.log.all().pop().unwrap()
};
let text = last.json().to_string();
assert!(
text.contains("Renamed Co"),
"{which}: new name missing in {text}"
);
assert!(
last.path.contains("update") || last.path.contains("edit"),
"{which}: must be an edit, was {}",
last.path
);
}
}
#[tokio::test]
async fn several_accounts_are_paced_rather_than_bursted_on_slack() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = slack_mock().await;
let (vault, settings) = fixture(dir.path(), slack_settings(), 30);
for n in 0..3 {
vault
.add(
&parse_otpauth(&format!(
"otpauth://totp/Svc{n}:x?secret=JBSWY3DPEHPK3PXP&issuer=Svc{n}"
))
.unwrap(),
None,
None,
true,
)
.unwrap();
}
let eng = engine(vault, settings, &base, "http://127.0.0.1:1");
let start = std::time::Instant::now();
assert_eq!(eng.sync_once().await, 4);
assert_eq!(mock.log.len(), 4);
// Slack pacing is 1 s; four calls need at least ~3 s of spacing.
assert!(
start.elapsed() >= Duration::from_millis(2800),
"took {:?}",
start.elapsed()
);
}
#[tokio::test]
async fn switching_platform_after_clearing_ids_reposts_to_the_new_one() {
let dir = tempfile::tempdir().unwrap();
let (dbase, dmock) = discord_mock().await;
let (sbase, smock) = slack_mock().await;
let (vault, settings) = fixture(dir.path(), discord_settings(&dbase, "embed"), 30);
let eng = engine(
vault.clone(),
settings.clone(),
&sbase,
"http://127.0.0.1:1",
);
eng.sync_once().await;
assert_eq!(dmock.log.len(), 1);
// Operator switches to Slack. The HTTP layer does exactly this: replace
// settings, and clear every stored id because the destination changed.
let mut s = settings.snapshot();
// The mock's http:// webhook would fail real validation; the operator is
// leaving Discord anyway, so clear it as the UI's Save would.
s.discord.webhook_url.clear();
s.provider = Some(openmfa_core::Provider::Slack);
// A well-formed token: this path goes through real validation, unlike the
// fixtures that write settings.json directly. The mock accepts any xoxb-.
s.slack.bot_token = "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx".into();
s.slack.channel = "C0TESTCHAN".into();
let (_, changed) = settings.replace(s).unwrap();
assert!(changed);
vault.clear_all_messages();
assert_eq!(eng.sync_once().await, 1);
assert_eq!(
smock.log.count(|r| r.path == "/chat.postMessage"),
1,
"posted fresh on Slack"
);
assert_eq!(dmock.log.len(), 1, "Discord untouched after the switch");
}
#[tokio::test]
async fn nothing_is_sent_while_push_is_disabled_or_unconfigured() {
let dir = tempfile::tempdir().unwrap();
let (base, mock) = slack_mock().await;
let (vault, settings) = fixture(
dir.path(),
serde_json::json!({ "push_enabled": false, "provider": "slack", "slack": { "bot_token": "xoxb-test-token", "channel": "C0TEST" } }),
30,
);
let eng = engine(vault, settings, &base, "http://127.0.0.1:1");
assert_eq!(eng.sync_once().await, 0);
assert_eq!(mock.log.len(), 0);
// No provider at all — the fresh-install state.
let dir2 = tempfile::tempdir().unwrap();
let (vault2, settings2) = fixture(dir2.path(), serde_json::json!({ "push_enabled": true }), 30);
let eng2 = engine(vault2, settings2, &base, "http://127.0.0.1:1");
assert_eq!(eng2.sync_once().await, 0);
assert!(eng2
.send_test()
.await
.unwrap_err()
.contains("no chat platform"));
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "openmfa-http-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "HTTP API and asset serving for OpenMFA."
[dependencies]
openmfa-core.workspace = true
openmfa-delivery.workspace = true
openmfa-render.workspace = true
openmfa-webui.workspace = true
axum.workspace = true
tower-http.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
uuid.workspace = true
parking_lot.workspace = true
image.workspace = true
rqrr.workspace = true
[lints]
workspace = true
[dev-dependencies]
qrcode = { version = "0.14", default-features = false, features = ["image"] }
base64.workspace = true
@@ -0,0 +1,83 @@
//! Emit a Google Authenticator export QR for manual testing:
//! `cargo run -p openmfa-http-api --example make-google-export -- out.png 8`
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_varint(u64::from(field) << 3, out);
put_varint(v, out);
}
fn main() {
let mut a = std::env::args().skip(1);
let out = a
.next()
.expect("usage: make-google-export <out.png> [count]");
let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap();
let names = [
"Cloudflare",
"Plex",
"Ubisoft",
"GitHub",
"Steam",
"Proton",
"Fastmail",
"Namecheap",
"Vercel",
"Backblaze",
];
let mut payload = Vec::new();
for i in 0..n {
let mut e = Vec::new();
put_bytes(1, &[0x41 + i as u8; 20], &mut e);
put_bytes(2, b"[email protected]", &mut e);
put_bytes(3, names[i % names.len()].as_bytes(), &mut e);
put_uint(4, 1, &mut e);
// Give one entry 8 digits so the UI's variant chips get exercised.
put_uint(5, if i == 2 { 2 } else { 1 }, &mut e);
// And make one HOTP so the "skipped" path shows up.
put_uint(6, if i == 4 { 1 } else { 2 }, &mut e);
put_bytes(1, &e, &mut payload);
}
put_uint(2, 1, &mut payload);
put_uint(3, 2, &mut payload); // batch_size
put_uint(4, 0, &mut payload); // batch_index
put_uint(5, 77, &mut payload);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
let uri = format!("otpauth-migration://offline?data={escaped}");
let code = qrcode::QrCode::new(uri.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(6, 6)
.quiet_zone(true)
.build();
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
println!(
"wrote {out} ({n} entries, {} bytes of payload)",
payload.len()
);
}
+14
View File
@@ -0,0 +1,14 @@
//! Write a QR PNG for manual testing: `cargo run -p openmfa-http-api --example make-qr -- <out.png> <text>`
fn main() {
let mut a = std::env::args().skip(1);
let out = a.next().expect("usage: make-qr <out.png> <text>");
let text = a.next().expect("usage: make-qr <out.png> <text>");
let code = qrcode::QrCode::new(text.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(8, 8)
.quiet_zone(true)
.build();
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
println!("wrote {out}");
}
+462
View File
@@ -0,0 +1,462 @@
//! Router, asset serving, and the account / code / settings endpoints.
use crate::auth;
use crate::state::AppState;
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use openmfa_core as da;
use openmfa_core::{unix_now, unix_now_millis, Settings};
use serde::Deserialize;
use serde_json::json;
/// Cap on an uploaded QR image. Generous enough for a full phone photo,
/// small enough that it can't be used to exhaust memory.
const MAX_UPLOAD: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/api/me", get(auth::api_me))
.route("/api/setup", post(auth::api_setup))
.route("/api/login", post(auth::api_login))
.route("/api/logout", post(auth::api_logout))
.route("/api/credentials", post(auth::api_update_credentials))
.route("/api/accounts", get(list_accounts))
.route("/api/accounts/preview", post(preview_account))
.route("/api/accounts/import", post(import_accounts))
.route(
"/api/accounts/{id}",
axum::routing::patch(update_account).delete(delete_account),
)
.route("/api/codes", get(list_codes))
.route(
"/api/qr/decode",
post(decode_qr).layer(DefaultBodyLimit::max(MAX_UPLOAD)),
)
.route("/api/settings", get(get_settings).put(put_settings))
.route("/api/settings/test", post(test_delivery))
.route("/api/slack/whoami", post(slack_whoami))
.route("/api/telegram/whoami", post(telegram_whoami))
.route("/api/telegram/chats", post(telegram_chats));
Router::new()
.merge(api)
.route("/", get(index))
.route("/assets/app.js", get(asset_js))
.route("/assets/app.css", get(asset_css))
.route("/assets/logo.svg", get(asset_logo))
.route("/healthz", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
auth::require_auth,
))
.with_state(state)
}
// ── Assets ────────────────────────────────────────────────────────────────
/// `no-cache, must-revalidate` plus the `?v=` version on every asset URL:
/// together they bound staleness at one release, so a browser can never pair
/// last release's JavaScript with this release's API.
const CACHE: &str = "no-cache, must-revalidate";
async fn index(State(state): State<AppState>) -> Response {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
openmfa_webui::index_html(state.version),
)
.into_response()
}
async fn asset_js() -> Response {
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
openmfa_webui::app_js(),
)
.into_response()
}
async fn asset_css() -> Response {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
openmfa_webui::app_css(),
)
.into_response()
}
async fn asset_logo() -> Response {
(
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, CACHE),
],
openmfa_webui::logo_svg(),
)
.into_response()
}
// ── Accounts ──────────────────────────────────────────────────────────────
async fn list_accounts(State(state): State<AppState>) -> Response {
Json(state.vault.list()).into_response()
}
#[derive(Debug, Deserialize)]
struct PreviewBody {
uri: String,
}
/// Describe what a link contains, without storing anything.
///
/// Handles both a plain `otpauth://` URI (one account) and a Google
/// Authenticator `otpauth-migration://` export (many), so the UI has a single
/// code path. Each entry is flagged if it is already in the vault, letting the
/// import screen pre-uncheck it rather than failing later.
async fn preview_account(State(state): State<AppState>, Json(body): Json<PreviewBody>) -> Response {
let batch = match da::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
};
let accounts: Vec<_> = batch
.accounts
.iter()
.enumerate()
.map(|(index, a)| {
json!({
"index": index,
"issuer": a.issuer,
"label": a.label,
"algorithm": a.totp.algorithm.as_str(),
"digits": a.totp.digits,
"period": a.totp.period,
"duplicate": state.vault.contains(&a.totp, &a.issuer, &a.label),
})
})
.collect();
let skipped: Vec<_> = batch
.skipped
.iter()
.map(|s| json!({ "name": s.name, "reason": s.reason }))
.collect();
(
// Names and parameters of the user's accounts — never cache.
[(header::CACHE_CONTROL, "no-store")],
Json(json!({
"accounts": accounts,
"skipped": skipped,
"part": batch.part_label(),
"batch_id": batch.batch_id,
"is_export": da::is_migration_uri(&body.uri),
})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct ImportItem {
/// Index into the preview's `accounts` array.
index: usize,
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
label: Option<String>,
#[serde(default = "default_true")]
push_enabled: bool,
}
#[derive(Debug, Deserialize)]
struct ImportBody {
uri: String,
items: Vec<ImportItem>,
}
/// Import the selected accounts from a link.
///
/// The client sends back the original link plus the indices it wants, and the
/// server re-parses it. That keeps secrets from ever making the round trip
/// out to the browser and back, which they would if the preview had returned
/// them.
async fn import_accounts(State(state): State<AppState>, Json(body): Json<ImportBody>) -> Response {
let batch = match da::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
};
let mut queued = Vec::with_capacity(body.items.len());
for item in &body.items {
let Some(parsed) = batch.accounts.get(item.index) else {
return bad_request(&format!(
"selection {} is out of range for this link — reload and try again",
item.index
));
};
queued.push(da::NewAccount {
parsed: parsed.clone(),
issuer: item.issuer.clone(),
label: item.label.clone(),
push_enabled: item.push_enabled,
});
}
match state.vault.add_many(&queued) {
// The push loop picks new accounts up on its next tick; we don't kick
// a sync from here, so only one pass is ever in flight.
Ok(outcome) => (StatusCode::CREATED, Json(outcome)).into_response(),
Err(e) => crate::error_response(&e),
}
}
#[derive(Debug, Deserialize)]
struct UpdateBody {
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
push_enabled: Option<bool>,
}
async fn update_account(
State(state): State<AppState>,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Response {
match state.vault.update(
&id,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
Ok(a) => Json(a).into_response(),
Err(e) => crate::error_response(&e),
}
}
async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -> Response {
match state.vault.remove(&id) {
Ok(message_id) => {
// Take the tile down too, best-effort and off the response path —
// the account is already gone from the vault either way.
if let Some(mid) = message_id {
let engine = state.engine.clone();
tokio::spawn(async move { engine.remove_message(&mid).await });
}
StatusCode::NO_CONTENT.into_response()
}
Err(e) => crate::error_response(&e),
}
}
fn default_true() -> bool {
true
}
// ── Codes ─────────────────────────────────────────────────────────────────
/// Current and next code for every account, plus the server clock.
///
/// Returning the *next* code as well is what lets the browser roll over at a
/// period boundary with no request in flight — one fetch covers two periods,
/// so the digits never blank out. Shipping the server's clock lets the UI
/// drive its countdown off our time rather than the viewing device's, which
/// may be minutes off.
async fn list_codes(State(state): State<AppState>) -> Response {
let now = unix_now();
let codes: Vec<_> = state
.vault
.all_with_totp()
.into_iter()
.map(|(account, totp)| {
json!({
"id": account.id,
"code": totp.code_at(now),
"next": totp.next_code_at(now),
"period": totp.period,
// Milliseconds, to pair with the JS clock.
"expires_at": totp.expires_at(now) * 1000,
})
})
.collect();
(
[(header::CACHE_CONTROL, "no-store")],
Json(json!({ "server_time": unix_now_millis(), "codes": codes })),
)
.into_response()
}
// ── QR ────────────────────────────────────────────────────────────────────
/// Decode an uploaded image and return the first `otpauth://` payload.
///
/// This backs both the file-upload tab and the camera tab's fallback path —
/// browsers without `BarcodeDetector` (Safari and Firefox, so every iPhone)
/// post frames here instead, which keeps the front-end free of a vendored QR
/// library.
async fn decode_qr(mut multipart: Multipart) -> Response {
let mut bytes: Option<Vec<u8>> = None;
loop {
match multipart.next_field().await {
Ok(Some(field)) => {
if field.name() == Some("image") {
match field.bytes().await {
Ok(b) => bytes = Some(b.to_vec()),
Err(e) => {
return bad_request(&format!("could not read the upload: {e}"));
}
}
break;
}
}
Ok(None) => break,
Err(e) => return bad_request(&format!("could not read the upload: {e}")),
}
}
let Some(bytes) = bytes else {
return bad_request("no image was uploaded");
};
match crate::qr::decode(&bytes) {
Ok(found) => {
// An image can hold several codes — a Google export screenshot
// legitimately does. Return them all, OTP links first, so the
// caller can import every account in one pass instead of only
// whichever happened to be detected first.
let mut uris: Vec<String> = found
.iter()
.filter(|s| {
let l = s.to_ascii_lowercase();
l.starts_with("otpauth://") || l.starts_with("otpauth-migration://")
})
.cloned()
.collect();
if uris.is_empty() {
// Nothing OTP-shaped; hand back whatever was there so the UI
// can say what it actually found.
uris = found;
}
match uris.first() {
Some(first) => (
[(header::CACHE_CONTROL, "no-store")],
Json(json!({ "uri": first, "uris": uris })),
)
.into_response(),
None => bad_request("no QR code found in that image"),
}
}
Err(e) => bad_request(&e),
}
}
// ── Settings ──────────────────────────────────────────────────────────────
async fn get_settings(State(state): State<AppState>) -> Response {
(
// Tokens and the webhook URL are secrets; keep them out of any cache.
[(header::CACHE_CONTROL, "no-store")],
Json(state.settings.snapshot()),
)
.into_response()
}
async fn put_settings(State(state): State<AppState>, Json(incoming): Json<Settings>) -> Response {
match state.settings.replace(incoming) {
Ok((s, destination_changed)) => {
if destination_changed {
// Stored message ids belong to the old destination — a
// different platform or channel — and mean nothing now.
// Dropping them makes the next pass post a fresh tile per
// account. Tiles in the old place are left behind: by the
// time we hear about the change the old credentials may
// already be gone, so there's nothing reliable to delete with.
state.vault.clear_all_messages();
tracing::info!(
target: "openmfa::http",
provider = s.provider.map_or("none", da::Provider::as_str),
"destination changed; every tile will be reposted"
);
}
Json(s).into_response()
}
Err(e) => crate::error_response(&e),
}
}
async fn test_delivery(State(state): State<AppState>) -> Response {
match state.engine.send_test().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
}
}
#[derive(Debug, Deserialize)]
struct TokenBody {
token: String,
}
/// Validate a Slack bot token and say who it is, so the settings page can
/// show "connected as @openmfa in Acme" before anything is saved.
async fn slack_whoami(State(state): State<AppState>, Json(body): Json<TokenBody>) -> Response {
let token = body.token.trim();
if let Err(e) = da::validate_slack(&da::SlackConfig {
bot_token: token.to_string(),
channel: "C000000000".into(), // shape only; the channel isn't checked here
}) {
return crate::error_response(&e);
}
match state.engine.slack_whoami(token).await {
Ok(id) => Json(id).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
/// Validate a Telegram bot token and report the bot's @handle.
async fn telegram_whoami(State(state): State<AppState>, Json(body): Json<TokenBody>) -> Response {
match state.engine.telegram_whoami(body.token.trim()).await {
Ok(id) => Json(id).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
/// Chats that have messaged the bot — the "Detect chat" button. The operator
/// sends /start to the bot (or adds it to a group and says anything), then
/// clicks Detect, and picks from the list instead of hunting for a chat id.
async fn telegram_chats(State(state): State<AppState>, Json(body): Json<TokenBody>) -> Response {
match state.engine.telegram_chats(body.token.trim()).await {
Ok(chats) => Json(json!({ "chats": chats })).into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
fn bad_request(msg: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
+420
View File
@@ -0,0 +1,420 @@
//! Forms auth: sessions, the gate over `/api/*`, and the login surface.
//!
//! Three states the front-end switches on:
//!
//! * **Unconfigured** — no admin yet. Everything passes through and `/api/me`
//! reports `setup_required`, which puts the browser into the first-run form.
//! * **Signed in** — the session cookie resolves to a live record.
//! * **Signed out** — `/api/*` returns 401 and the UI shows the login card.
//!
//! Only the auth surface itself is public once an admin exists. Unlike a PXE
//! server, nothing here needs to be readable by an unauthenticated client:
//! this app's entire job is guarding secrets, so the allowlist stays tiny.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use openmfa_core as da;
use openmfa_core::AdminPublic;
use parking_lot::RwLock;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Sliding idle lifetime. Every authenticated request pushes it out.
const SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
pub const SESSION_COOKIE: &str = "openmfa_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Sessions deliberately do not survive a restart —
/// the process holds the vault key, so a restart is a good moment to require
/// the password again.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a session token. UUIDv4 gives 122 bits of randomness, comfortably
/// above the 64–128 typical for session identifiers.
pub fn create(&self, username: &str) -> String {
let id = uuid::Uuid::new_v4().simple().to_string();
self.inner.write().insert(
id.clone(),
Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
},
);
id
}
/// Resolve a token and refresh its idle timer. Expired entries are
/// evicted on contact so the map can't grow without bound.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate everything — used after a credential change so a cookie
/// minted under the old password stops working.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookies ───────────────────────────────────────────────────────────────
fn cookie(value: &str, max_age: i64) -> String {
// HttpOnly keeps the token away from any injected script; SameSite=Lax
// blocks cross-site POSTs while still allowing normal navigation.
//
// No `Secure` flag: Unraid templates default to a plain-http LAN address,
// and setting Secure there would silently drop the cookie and make the
// app unusable. Behind a TLS terminator the cookie still travels
// encrypted; this only means the browser won't *enforce* it.
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}")
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
if let Some(v) = part
.trim()
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string());
}
}
None
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Paths reachable without a session once an admin exists.
fn is_public(path: &str) -> bool {
if !path.starts_with("/api/") {
// The UI shell, its assets, and health probes. The shell itself shows
// nothing sensitive — every byte of vault data comes from /api/*.
return true;
}
matches!(
path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
)
}
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Before setup there is nobody to authenticate against. `/api/setup`
// refuses to run twice, so this window closes permanently on first use.
if !state.admin.is_configured() {
return next.run(req).await;
}
if is_public(req.uri().path()) {
return next.run(req).await;
}
let ok = parse_cookie(req.headers()).is_some_and(|t| state.sessions.touch(&t).is_some());
if ok {
return next.run(req).await;
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
/// Optional at setup: an install can exist before any chat is wired, and
/// the same fields are editable later in Settings.
#[serde(default)]
pub provider: Option<da::Provider>,
#[serde(default)]
pub discord: da::DiscordConfig,
#[serde(default)]
pub slack: da::SlackConfig,
#[serde(default)]
pub telegram: da::TelegramConfig,
}
/// First-run setup: create the admin and, optionally, wire a chat platform in
/// one step so the operator lands on a working install.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "an admin account already exists" })),
)
.into_response();
}
// Validate the delivery config BEFORE creating anything, so a mistyped
// token doesn't leave a half-configured install that needs the password
// to fix. Delivery is on only when a platform was actually chosen.
let proposed = da::Settings {
push_enabled: body.provider.is_some(),
provider: body.provider,
discord: body.discord.clone(),
slack: body.slack.clone(),
telegram: body.telegram.clone(),
push_mode: da::PushMode::default(),
};
let checked = match proposed.check() {
Ok(c) => c,
Err(e) => return crate::error_response(&e),
};
// Argon2 is intentionally expensive; keep it off the async workers.
let admin = state.admin.clone();
let (username, password) = (body.username.clone(), body.password.clone());
let result = tokio::task::spawn_blocking(move || admin.bootstrap(&username, &password))
.await
.unwrap_or_else(|e| Err(da::Error::crypto(e)));
match result {
Ok(public) => {
if let Err(e) = state.settings.replace(checked) {
tracing::warn!(target: "openmfa::http", "could not store delivery settings at setup: {e}");
}
let token = state.sessions.create(&public.username);
session_response(StatusCode::CREATED, &public, &token)
}
Err(e) => crate::error_response(&e),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(da::Error::crypto(e)));
match verdict {
Ok(Some(public)) => {
let token = state.sessions.create(&public.username);
session_response(StatusCode::OK, &public, &token)
}
// Deliberately vague: no distinction between "no such user" and
// "wrong password", so the endpoint can't be used to enumerate.
Ok(None) => (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response(),
Err(e) => crate::error_response(&e),
}
}
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie("", 0)).expect("cookie header is ASCII"),
);
resp
}
/// Bootstrap status for the front-end shell.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
let version = state.version;
if !state.admin.is_configured() {
return Json(json!({
"setup_required": true,
"authenticated": false,
"version": version,
}))
.into_response();
}
let user = parse_cookie(&headers).and_then(|t| state.sessions.touch(&t));
match user {
Some(_) => Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"version": version,
}))
.into_response(),
None => Json(json!({
"setup_required": false,
"authenticated": false,
"version": version,
}))
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin credentials. The middleware already proved a valid
/// session; requiring the current password additionally proves the person at
/// the keyboard is the owner and not someone using a borrowed browser.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
)
})
.await
.unwrap_or_else(|e| Err(da::Error::crypto(e)));
match result {
Ok(public) => {
// Every prior cookie dies with the old password.
state.sessions.revoke_all();
let token = state.sessions.create(&public.username);
session_response(StatusCode::OK, &public, &token)
}
Err(e) => crate::error_response(&e),
}
}
fn session_response(status: StatusCode, user: &AdminPublic, token: &str) -> Response {
let mut resp = (status, Json(json!({ "authenticated": true, "user": user }))).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie(token, SESSION_TTL.as_secs().cast_signed()))
.expect("cookie header is ASCII"),
);
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_lifecycle() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.touch(&t).is_none(), "a revoked token must not resolve");
}
#[test]
fn revoke_all_clears_every_session() {
let s = SessionStore::default();
let a = s.create("one");
let b = s.create("two");
s.revoke_all();
assert!(s.touch(&a).is_none());
assert!(s.touch(&b).is_none());
}
#[test]
fn only_the_auth_surface_is_public() {
for p in [
"/",
"/assets/app.js",
"/healthz",
"/api/setup",
"/api/login",
"/api/me",
] {
assert!(is_public(p), "{p} should be public");
}
// Everything holding vault data must be gated — this is the whole
// point of the app, so the allowlist stays deliberately short.
for p in [
"/api/accounts",
"/api/accounts/abc",
"/api/codes",
"/api/settings",
"/api/qr/decode",
"/api/credentials",
] {
assert!(!is_public(p), "{p} must require auth");
}
}
#[test]
fn cookie_parsing_picks_the_right_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("a=b; {SESSION_COOKIE}=tok123; c=d")).unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("tok123"));
// A cookie whose name merely starts the same must not match.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(
header::COOKIE,
HeaderValue::from_static("openmfa_session_other=nope"),
);
assert!(parse_cookie(&h2).is_none());
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
#[test]
fn logout_cookie_expires_immediately() {
assert!(cookie("", 0).contains("Max-Age=0"));
assert!(cookie("x", 60).contains("HttpOnly"));
assert!(cookie("x", 60).contains("SameSite=Lax"));
}
}
+53
View File
@@ -0,0 +1,53 @@
//! HTTP surface: the JSON API, the embedded UI, and the auth gate.
#![forbid(unsafe_code)]
pub mod app;
pub mod auth;
pub mod qr;
pub mod state;
pub use app::router;
pub use state::AppState;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
/// Map a domain error onto an HTTP response.
///
/// The message is written to be shown verbatim in a toast, so this keeps
/// whatever `openmfa-core` said rather than substituting a generic one —
/// with the exception of crypto failures, whose detail goes to the log
/// instead of the browser.
pub fn error_response(e: &openmfa_core::Error) -> Response {
use openmfa_core::Error;
let (status, message) = match e {
Error::Invalid(m) => (StatusCode::BAD_REQUEST, m.clone()),
Error::NotFound(_) => (StatusCode::NOT_FOUND, e.to_string()),
Error::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
Error::Crypto(detail) => {
tracing::error!(target: "openmfa::http", "cryptography error: {detail}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"the vault could not be read — check the server log".to_string(),
)
}
Error::Io(detail) => {
tracing::error!(target: "openmfa::http", "i/o error: {detail}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"could not write to disk — check the server log".to_string(),
)
}
other => {
tracing::error!(target: "openmfa::http", "unexpected error: {other}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"something went wrong — check the server log".to_string(),
)
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
+443
View File
@@ -0,0 +1,443 @@
//! Decode a QR code out of an uploaded image.
//!
//! People do not hand you clean 800×800 renders. They hand you a phone photo
//! of a laptop screen, a 180×180 crop out of a support page, or a full 12 MP
//! capture where the code is a thumbnail in the corner. A single decode
//! attempt fails on most of those, so this walks a ladder of cheap
//! transformations until one sticks.
//!
//! Note we deliberately do **not** pre-binarise: `rqrr` (a port of quirc)
//! already runs its own adaptive threshold, and feeding it an
//! already-thresholded image usually makes detection worse, not better.
use image::{DynamicImage, GrayImage};
/// Upper bound on the decoded payload, so a malicious QR can't hand us a
/// megabyte of text to shuttle around.
const MAX_PAYLOAD: usize = 4096;
/// Below this, modules are likely under 2px and quirc struggles; upscaling
/// first materially improves the hit rate on screenshots.
const SMALL_EDGE: u32 = 600;
/// Above this, we're wasting time on pixels that add no information — and
/// sensor noise actively hurts. Phone photos land here.
const LARGE_EDGE: u32 = 1400;
pub type Result<T> = std::result::Result<T, String>;
/// Find the first QR payload in `bytes`.
///
/// Returns every distinct payload found, first one first — a Google
/// Authenticator export screenshot can legitimately carry several codes.
pub fn decode(bytes: &[u8]) -> Result<Vec<String>> {
if bytes.is_empty() {
return Err("the uploaded file is empty".into());
}
reject_unsupported(bytes)?;
let img = image::load_from_memory(bytes)
.map_err(|e| format!("could not read that image ({e}). Try a PNG or JPEG screenshot."))?;
let found = walk_ladder(&img);
if found.is_empty() {
return Err(
"no QR code found in that image. Crop tighter around the code, or use a \
screenshot rather than a photo of a screen."
.into(),
);
}
Ok(found)
}
/// Try progressively more aggressive preparations until something decodes.
fn walk_ladder(img: &DynamicImage) -> Vec<String> {
let gray = img.to_luma8();
let (w, h) = (gray.width(), gray.height());
let long_edge = w.max(h);
// 1. As-is. Most clean screenshots stop here.
let hits = read(gray.clone());
if !hits.is_empty() {
return hits;
}
// 2. Resize toward the range quirc handles best.
let resized = if long_edge < SMALL_EDGE {
// Integer-ish upscale so module edges stay sharp.
let factor = (SMALL_EDGE as f32 / long_edge as f32).ceil().min(6.0) as u32;
Some(image::imageops::resize(
&gray,
w * factor,
h * factor,
image::imageops::FilterType::Lanczos3,
))
} else if long_edge > LARGE_EDGE {
let scale = LARGE_EDGE as f32 / long_edge as f32;
Some(image::imageops::resize(
&gray,
((w as f32 * scale) as u32).max(1),
((h as f32 * scale) as u32).max(1),
image::imageops::FilterType::Lanczos3,
))
} else {
None
};
let base = resized.unwrap_or_else(|| gray.clone());
let hits = read(base.clone());
if !hits.is_empty() {
return hits;
}
// 3. Stretch contrast. Helps dim photos and low-contrast dark-mode
// screenshots where the "black" modules are only mid-grey.
let stretched = autocontrast(&base);
let hits = read(stretched.clone());
if !hits.is_empty() {
return hits;
}
// 4. Rotations. quirc tolerates moderate skew but not a sideways photo.
for turns in 1..4 {
let mut rotated = base.clone();
for _ in 0..turns {
rotated = image::imageops::rotate90(&rotated);
}
let hits = read(rotated);
if !hits.is_empty() {
return hits;
}
}
Vec::new()
}
/// Run the detector and collect every distinct payload.
fn read(gray: GrayImage) -> Vec<String> {
let mut prepared = rqrr::PreparedImage::prepare(gray);
let mut out: Vec<String> = Vec::new();
for grid in prepared.detect_grids() {
// A grid that fails to decode is normal (partially occluded, too
// blurry); the others in the image may still be fine.
if let Ok((_meta, content)) = grid.decode() {
let content = content.trim().to_string();
if !content.is_empty() && content.len() <= MAX_PAYLOAD && !out.contains(&content) {
out.push(content);
}
}
}
out
}
/// Linear contrast stretch over the 2nd–98th percentile.
///
/// Percentiles rather than min/max so one blown-out highlight or a single
/// dark speck can't flatten the whole image.
fn autocontrast(img: &GrayImage) -> GrayImage {
let mut hist = [0u32; 256];
for p in img.pixels() {
hist[p.0[0] as usize] += 1;
}
let total: u32 = hist.iter().sum();
if total == 0 {
return img.clone();
}
let cut = total / 50; // 2%
let mut acc = 0;
let mut lo = 0u8;
for (v, count) in hist.iter().enumerate() {
acc += count;
if acc > cut {
lo = v as u8;
break;
}
}
acc = 0;
let mut hi = 255u8;
for (v, count) in hist.iter().enumerate().rev() {
acc += count;
if acc > cut {
hi = v as u8;
break;
}
}
if hi <= lo {
return img.clone();
}
let span = f32::from(hi) - f32::from(lo);
let mut out = img.clone();
for p in out.pixels_mut() {
let v = (f32::from(p.0[0]) - f32::from(lo)) / span;
p.0[0] = (v.clamp(0.0, 1.0) * 255.0) as u8;
}
out
}
/// Give a useful message for formats we knowingly can't decode, instead of a
/// generic parse failure.
///
/// HEIC matters specifically because it is the iPhone camera default, and
/// there is no pure-Rust HEIC decoder — the C libheif would break the static
/// musl build. Telling someone to screenshot instead is a one-step fix; "could
/// not read that image" would leave them stuck.
fn reject_unsupported(bytes: &[u8]) -> Result<()> {
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
let brand = &bytes[8..12];
if matches!(
brand,
b"heic" | b"heix" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"avif"
) {
return Err(
"that looks like a HEIC/AVIF image, which this app can't read. On iPhone, \
take a screenshot instead (screenshots are PNG), or export the photo as JPEG."
.into(),
);
}
}
if bytes.starts_with(b"%PDF") {
return Err("that's a PDF. Export or screenshot the QR code as an image first.".into());
}
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
return Err(
"that's an SVG, which this app can't rasterise. Save or screenshot the QR \
code as a PNG first."
.into(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const URI: &str = "otpauth://totp/Test:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Test";
/// Render `data` as a real QR PNG so these tests exercise the actual
/// detector rather than a mock. `module_px` controls how many pixels each
/// QR module occupies, which is exactly the variable that decides whether
/// the ladder's upscale step is needed.
fn qr_png(data: &str, module_px: u32) -> Vec<u8> {
let code = qrcode::QrCode::new(data.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(module_px, module_px)
.quiet_zone(true)
.build();
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn decodes_a_clean_qr_code() {
let found = decode(&qr_png(URI, 6)).unwrap();
assert_eq!(found, vec![URI.to_string()]);
}
#[test]
fn decodes_a_tiny_qr_via_the_upscale_rung() {
// 1px modules is the small-screenshot case the ladder exists for.
let found = decode(&qr_png(URI, 1)).unwrap();
assert_eq!(found[0], URI);
}
#[test]
fn decodes_an_oversized_qr_via_the_downscale_rung() {
let found = decode(&qr_png(URI, 26)).unwrap();
assert_eq!(found[0], URI);
}
#[test]
fn decodes_a_rotated_qr() {
let png = qr_png(URI, 6);
let img = image::load_from_memory(&png).unwrap();
for turns in 1..4 {
let mut rotated = img.to_luma8();
for _ in 0..turns {
rotated = image::imageops::rotate90(&rotated);
}
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(rotated)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let found = decode(&buf.into_inner())
.unwrap_or_else(|e| panic!("rotation {turns}×90° failed: {e}"));
assert_eq!(found[0], URI);
}
}
#[test]
fn decodes_a_jpeg_screenshot() {
// JPEG is what you get from a photo or a re-shared screenshot, and
// its ringing artefacts are exactly what breaks naive decoders.
let img = image::load_from_memory(&qr_png(URI, 8)).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
}
/// Build a Google Authenticator export QR the way the real app emits
/// one, so the upload path is exercised end to end: image bytes -> QR
/// decode -> protobuf parse -> accounts.
fn google_export_png(n: usize, module_px: u32) -> Vec<u8> {
fn put_varint(v: u64, out: &mut Vec<u8>) {
let mut v = v;
loop {
let mut b = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
b |= 0x80;
}
out.push(b);
if v == 0 {
break;
}
}
}
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
put_varint((u64::from(field) << 3) | 2, out);
put_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
fn put_uint(field: u32, v: u64, out: &mut Vec<u8>) {
put_varint(u64::from(field) << 3, out);
put_varint(v, out);
}
let mut payload = Vec::new();
for i in 0..n {
let mut e = Vec::new();
put_bytes(1, &[0x41 + i as u8; 20], &mut e); // secret
put_bytes(2, format!("user{i}@example.com").as_bytes(), &mut e);
put_bytes(3, format!("Service{i}").as_bytes(), &mut e);
put_uint(4, 1, &mut e); // SHA1
put_uint(5, 1, &mut e); // 6 digits
put_uint(6, 2, &mut e); // TOTP
put_bytes(1, &e, &mut payload);
}
put_uint(2, 1, &mut payload);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let escaped = b64
.replace('+', "%2B")
.replace('/', "%2F")
.replace('=', "%3D");
qr_png(
&format!("otpauth-migration://offline?data={escaped}"),
module_px,
)
}
#[test]
fn decodes_a_google_export_qr_end_to_end() {
let png = google_export_png(5, 4);
let found = decode(&png).unwrap();
assert_eq!(found.len(), 1, "one QR, one payload");
assert!(found[0].starts_with("otpauth-migration://"));
// And that payload really does carry five importable accounts.
let batch = openmfa_core::parse_import(&found[0]).unwrap();
assert_eq!(batch.accounts.len(), 5);
assert_eq!(batch.accounts[0].issuer, "Service0");
assert_eq!(batch.accounts[4].label, "[email protected]");
}
#[test]
fn decodes_several_qr_codes_from_one_screenshot() {
// A screenshot of a multi-part export can contain more than one code.
// Stacking two here proves `decode` returns both rather than the
// first, which is what lets the UI import every account in one pass.
let a = image::load_from_memory(&qr_png(URI, 5)).unwrap().to_luma8();
let b = image::load_from_memory(&google_export_png(2, 5))
.unwrap()
.to_luma8();
let w = a.width().max(b.width());
let mut canvas =
GrayImage::from_pixel(w + 40, a.height() + b.height() + 60, image::Luma([255]));
image::imageops::replace(&mut canvas, &a, 20, 20);
image::imageops::replace(&mut canvas, &b, 20, i64::from(a.height()) + 40);
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(canvas)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let found = decode(&buf.into_inner()).unwrap();
assert_eq!(
found.len(),
2,
"both codes should be returned, got {found:?}"
);
assert!(found.iter().any(|u| u.starts_with("otpauth://")));
assert!(found.iter().any(|u| u.starts_with("otpauth-migration://")));
}
#[test]
fn empty_input_is_rejected() {
assert!(decode(&[]).is_err());
}
#[test]
fn heic_gets_an_actionable_message() {
// ftyp box with a HEIC brand.
let mut b = vec![0u8; 12];
b[4..8].copy_from_slice(b"ftyp");
b[8..12].copy_from_slice(b"heic");
let e = decode(&b).unwrap_err();
assert!(e.contains("screenshot"), "unhelpful: {e}");
}
#[test]
fn pdf_and_svg_get_specific_messages() {
assert!(decode(b"%PDF-1.7 ...").unwrap_err().contains("PDF"));
assert!(decode(b"<svg xmlns=...").unwrap_err().contains("SVG"));
}
#[test]
fn a_non_image_is_reported_clearly() {
let e = decode(b"this is definitely not an image at all").unwrap_err();
assert!(e.contains("could not read"), "got: {e}");
}
#[test]
fn an_image_with_no_qr_says_so() {
// Flat grey 200x200 PNG — decodes fine as an image, contains no code.
let img = GrayImage::from_pixel(200, 200, image::Luma([128]));
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let e = decode(&buf.into_inner()).unwrap_err();
assert!(e.contains("no QR code found"), "got: {e}");
}
#[test]
fn autocontrast_stretches_a_low_contrast_image() {
// All values squeezed into 100..140 should come out spanning wider.
let mut img = GrayImage::new(20, 20);
for (i, p) in img.pixels_mut().enumerate() {
p.0[0] = 100 + (i % 40) as u8;
}
let out = autocontrast(&img);
let lo = out.pixels().map(|p| p.0[0]).min().unwrap();
let hi = out.pixels().map(|p| p.0[0]).max().unwrap();
assert!(hi - lo > 140, "expected a wider range, got {lo}..{hi}");
}
#[test]
fn autocontrast_leaves_a_flat_image_alone() {
// A uniform image has no range to stretch; the guard must not divide
// by zero or blow the pixels out.
let img = GrayImage::from_pixel(10, 10, image::Luma([128]));
let out = autocontrast(&img);
assert!(out.pixels().all(|p| p.0[0] == 128));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Shared handler state.
use openmfa_core::{AdminStore, SettingsStore, Vault};
use openmfa_delivery::PushEngine;
use std::sync::Arc;
use crate::auth::SessionStore;
#[derive(Clone)]
pub struct AppState {
pub admin: AdminStore,
pub vault: Vault,
pub settings: Arc<SettingsStore>,
pub sessions: SessionStore,
pub engine: PushEngine,
pub version: &'static str,
}
#[allow(clippy::missing_fields_in_debug)] // vault + sessions must not be dumped
impl std::fmt::Debug for AppState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppState")
.field("accounts", &self.vault.len())
.field("configured", &self.admin.is_configured())
.finish()
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "openmfa"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Self-hosted TOTP authenticator that streams live codes to Discord."
[[bin]]
name = "openmfa"
path = "src/main.rs"
[dependencies]
openmfa-core.workspace = true
openmfa-delivery.workspace = true
openmfa-http-api.workspace = true
tokio.workspace = true
axum.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
[lints]
workspace = true
+126
View File
@@ -0,0 +1,126 @@
//! OpenMFA — a self-hosted TOTP vault whose codes stream to a
//! Discord channel as live, self-updating messages.
#![forbid(unsafe_code)]
use anyhow::Context;
use clap::Parser;
use openmfa_core::{AdminStore, SettingsStore, Vault, VaultKey};
use openmfa_delivery::{Endpoints, PushEngine};
use openmfa_http_api::AppState;
use std::net::SocketAddr;
use std::path::PathBuf;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Parser)]
#[command(
name = "openmfa",
version = VERSION,
about = "Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram"
)]
struct Args {
/// Address to serve the web UI and API on.
#[arg(long, env = "OPENMFA_HTTP_ADDR", default_value = "0.0.0.0:8720")]
http_addr: SocketAddr,
/// Directory holding the vault, key, settings and admin account.
#[arg(long, env = "OPENMFA_WORK_DIR", default_value = "/var/lib/openmfa")]
work_dir: PathBuf,
/// `tracing-subscriber` filter expression.
#[arg(long, env = "OPENMFA_LOG", default_value = "info,openmfa=info")]
log: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(&args.log))
.with_target(true)
.init();
std::fs::create_dir_all(&args.work_dir).with_context(|| {
format!(
"could not create the work directory {}",
args.work_dir.display()
)
})?;
// The key must resolve before the vault: without it every stored secret
// is unreadable, and starting anyway would present an apparently-empty
// vault and invite the operator to re-add accounts they already have.
let key = VaultKey::load_or_create(&args.work_dir)
.context("could not load or create the vault key")?;
let vault = Vault::load(&args.work_dir, key).context("could not load the vault")?;
let admin = AdminStore::load_or_default(&args.work_dir);
let settings = SettingsStore::load_or_default(&args.work_dir);
let engine = PushEngine::new(vault.clone(), settings.clone(), &Endpoints::from_env());
tokio::spawn(engine.clone().run());
let state = AppState {
admin: admin.clone(),
vault,
settings,
sessions: openmfa_http_api::auth::SessionStore::default(),
engine,
version: VERSION,
};
let app = openmfa_http_api::router(state);
let listener = tokio::net::TcpListener::bind(args.http_addr)
.await
.with_context(|| format!("could not bind {}", args.http_addr))?;
tracing::info!(
target: "openmfa",
version = VERSION,
addr = %args.http_addr,
work_dir = %args.work_dir.display(),
"OpenMFA started"
);
if !admin.is_configured() {
tracing::info!(
target: "openmfa",
"no admin account yet — open the web UI to complete first-run setup"
);
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
tracing::info!(target: "openmfa", "shutting down");
Ok(())
}
/// Wait for Ctrl-C or SIGTERM so `docker stop` exits cleanly instead of
/// waiting out the kill timeout.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(target: "openmfa", "could not listen for SIGTERM: {e}");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {}
() = terminate => {}
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openmfa-render"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Renders the live code tile pushed to Discord."
[dependencies]
image.workspace = true
resvg.workspace = true
usvg.workspace = true
tiny-skia.workspace = true
thiserror.workspace = true
tracing.workspace = true
[lints]
workspace = true
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo
//! avatar and the Unraid template need:
//! `cargo run -p openmfa-render --example brand -- docs/brand`
fn main() {
let dir = std::env::args()
.nth(1)
.unwrap_or_else(|| "docs/brand".into());
let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg");
for size in [512u32, 256, 128] {
let png = openmfa_render::rasterise_svg(&svg, size, size).expect("render");
let path = format!("{dir}/icon-{size}.png");
std::fs::write(&path, &png).expect("write");
println!("wrote {path} ({} bytes)", png.len());
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Dump sample tiles for eyeballing: `cargo run -p openmfa-render --example preview`.
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| ".".into());
for (issuer, label, code, period) in [
("Cloudflare", "[email protected]", "482913", 30),
("Plex", "[email protected]", "071204", 30),
("Ubisoft", "casperadmin", "93810245", 30),
] {
let t = openmfa_render::Tile {
issuer,
label,
code,
period,
};
let png = openmfa_render::tile_png(&t).unwrap();
let webp = openmfa_render::tile_webp(&t).unwrap();
std::fs::write(format!("{out}/tile-{issuer}.png"), &png).unwrap();
std::fs::write(format!("{out}/tile-{issuer}.webp"), &webp).unwrap();
println!(
"{issuer:12} png {:>6} B webp {:>6} B",
png.len(),
webp.len()
);
}
}
+406
View File
@@ -0,0 +1,406 @@
//! Draws the code tile that gets attached to each Discord message.
//!
//! The tile is authored as SVG and rasterised with resvg. Hand-blitting
//! glyphs was the alternative and it loses on every axis that matters here:
//! centring text, rounded corners and the accent gradient are one attribute
//! each in SVG, and arithmetic otherwise.
//!
//! **There is deliberately no countdown drawn on the tile.** An image is a
//! snapshot; by the time someone looks at it, any timer baked into the pixels
//! is stale and confidently wrong. Discord's own `<t:…:R>` relative timestamp
//! ticks live in the client for free, so the countdown belongs in the message
//! text and the tile stays a clean statement of the current code.
#![forbid(unsafe_code)]
use image::ImageEncoder;
use std::sync::OnceLock;
/// Rendered at 2× the nominal display size so the digits stay crisp on
/// retina displays without asking Discord to upscale.
const WIDTH: u32 = 760;
const HEIGHT: u32 = 264;
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("could not parse the tile SVG: {0}")]
Svg(#[from] usvg::Error),
#[error("could not allocate a {0}×{1} pixmap")]
Pixmap(u32, u32),
#[error("could not encode the tile: {0}")]
Encode(#[from] image::ImageError),
}
pub type Result<T> = std::result::Result<T, RenderError>;
/// What to draw.
#[derive(Debug, Clone)]
pub struct Tile<'a> {
pub issuer: &'a str,
pub label: &'a str,
/// The code, digits only — grouping is applied here, not by the caller.
pub code: &'a str,
pub period: u64,
}
/// Encode the tile as lossless WebP.
///
/// WebP because it's what Discord renders natively and what was asked for.
/// Note that pure-Rust WebP encoding is **lossless-only** — there is no
/// pure-Rust lossy encoder, and the lossy one lives in libwebp (C), which
/// would break the static musl build. That is fine here: the tile is flat
/// colour and large text, exactly the content lossless compresses well, and
/// the result lands in the low tens of kilobytes.
pub fn tile_webp(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = Vec::new();
image::codecs::webp::WebPEncoder::new_lossless(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out)
}
/// Encode the tile as PNG. Kept as a fallback and for tests.
pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
/// Rasterise arbitrary SVG source to a PNG at a given size.
///
/// Exposed so the brand assets (README image, Gitea avatar, Unraid template
/// icon) are generated from the same `icon.svg` the app ships, rather than
/// being hand-exported and drifting out of sync with it.
pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
let tree = usvg::Tree::from_str(svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?;
// Scale the document to fill the requested pixmap.
let size = tree.size();
let scale = (width as f32 / size.width()).min(height as f32 / size.height());
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
width,
height,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
let svg = build_svg(tile);
let tree = usvg::Tree::from_str(&svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(WIDTH, HEIGHT).ok_or(RenderError::Pixmap(WIDTH, HEIGHT))?;
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
// tiny-skia stores premultiplied alpha; PNG and WebP both want
// straight alpha. Skipping this makes every antialiased edge — and the
// rounded corners in particular — render subtly too dark.
let mut rgba = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
Ok(rgba)
}
/// Font database and render options, built once.
///
/// `fontdb_mut()` clones the database when it's already shared, so building
/// this per render would copy both faces on every push.
fn options() -> &'static usvg::Options<'static> {
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
OPTS.get_or_init(|| {
let mut opt = usvg::Options::default();
{
let db = opt.fontdb_mut();
db.load_font_data(FONT_REGULAR.to_vec());
db.load_font_data(FONT_BOLD.to_vec());
}
// Must match the family name inside the TTFs, or resvg falls back to
// its default ("Times New Roman") and finds nothing.
opt.font_family = "JetBrains Mono".to_string();
opt
})
}
const FONT_REGULAR: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../assets/JetBrainsMono-Bold.ttf");
/// Group a code the way every authenticator app does: `123 456`, `1234 5678`.
fn group(code: &str) -> String {
let n = code.chars().count();
if n < 6 {
return code.to_string();
}
let mid = if n == 8 { 4 } else { n.div_ceil(2) };
let (a, b) = code.split_at(mid);
format!("{a} {b}")
}
/// Stable per-issuer accent hue, matching how the web UI colours avatars so
/// a tile in Discord reads as the same account as the row in the browser.
fn hue_for(seed: &str) -> u16 {
let mut hash: i32 = 0;
for ch in seed.chars() {
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
}
(hash.unsigned_abs() % 360) as u16
}
/// HSL → `#rrggbb`. Saturation and lightness are fixed so every generated
/// accent sits at the same visual weight regardless of hue.
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
fn hsl_hex(h: f32, s: f32, l: f32) -> String {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", to(r1), to(g1), to(b1))
}
/// XML-escape text that lands inside the SVG. Issuer and label are
/// user-controlled, so an unescaped `&` or `<` would produce an SVG that
/// fails to parse — and the tile would silently stop updating.
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// Control characters are not legal XML content at all.
c if (c as u32) < 0x20 && c != '\t' => out.push(' '),
c => out.push(c),
}
}
out
}
/// Truncate to a character budget, with an ellipsis. The tile is fixed
/// width, so an over-long issuer would otherwise run off the edge.
fn clip(s: &str, max: usize) -> String {
let n = s.chars().count();
if n <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn build_svg(tile: &Tile<'_>) -> String {
let hue = hue_for(if tile.issuer.is_empty() {
tile.label
} else {
tile.issuer
});
let accent = hsl_hex(f32::from(hue), 0.62, 0.58);
let accent_soft = hsl_hex(f32::from(hue), 0.55, 0.42);
let issuer = esc(&clip(tile.issuer, 26));
let label = esc(&clip(tile.label, 34));
let code = esc(&group(tile.code));
// Long codes need to shrink to stay inside the tile. 8 digits plus a
// separator is 9 glyphs against 6+1=7 for the common case.
let code_size = if tile.code.chars().count() >= 8 {
96
} else {
116
};
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1c2027"/>
<stop offset="1" stop-color="#141821"/>
</linearGradient>
<linearGradient id="rail" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{accent}"/>
<stop offset="1" stop-color="{accent_soft}"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" rx="28" fill="url(#bg)"/>
<rect x="0.75" y="0.75" width="{ring_w}" height="{ring_h}" rx="27.25"
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1.5"/>
<!-- Accent rail, tinted per issuer to match the web UI's avatar colour. -->
<rect x="30" y="34" width="9" height="{rail_h}" rx="4.5" fill="url(#rail)"/>
<text x="62" y="74" font-family="JetBrains Mono" font-weight="700"
font-size="30" fill="#f2f4f8">{issuer}</text>
<text x="62" y="108" font-family="JetBrains Mono" font-weight="400"
font-size="21" fill="#98a2b3">{label}</text>
<text x="62" y="{code_y}" font-family="JetBrains Mono" font-weight="700"
font-size="{code_size}" fill="#ffffff" letter-spacing="4">{code}</text>
<text x="{meta_x}" y="{meta_y}" text-anchor="end" font-family="JetBrains Mono"
font-weight="400" font-size="18" fill="#6b7484">{period}s</text>
</svg>"##,
WIDTH = WIDTH,
HEIGHT = HEIGHT,
ring_w = WIDTH - 1,
ring_h = HEIGHT - 1,
rail_h = HEIGHT - 68,
code_y = HEIGHT - 52,
meta_x = WIDTH - 32,
meta_y = HEIGHT - 30,
period = tile.period,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample<'a>(issuer: &'a str, code: &'a str) -> Tile<'a> {
Tile {
issuer,
label: "[email protected]",
code,
period: 30,
}
}
#[test]
fn renders_webp_with_a_valid_header() {
let bytes = tile_webp(&sample("Cloudflare", "123456")).unwrap();
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
// RIFF container with a WEBP fourcc at offset 8.
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
}
#[test]
fn webp_round_trips_back_to_the_expected_size() {
let bytes = tile_webp(&sample("Plex", "987654")).unwrap();
let decoded = image::load_from_memory(&bytes).expect("decodes as an image");
assert_eq!(decoded.width(), WIDTH);
assert_eq!(decoded.height(), HEIGHT);
}
#[test]
fn renders_png_too() {
let bytes = tile_png(&sample("Ubisoft", "246810")).unwrap();
assert_eq!(&bytes[1..4], b"PNG");
}
#[test]
fn actually_draws_something() {
// A blank canvas would still encode cleanly, so assert the render
// produced non-background pixels — this is what catches the font
// database silently failing to load.
let rgba = rasterise(&sample("Cloudflare", "123456")).unwrap();
let white = rgba
.chunks_exact(4)
.filter(|p| p[0] > 220 && p[1] > 220 && p[2] > 220 && p[3] > 200)
.count();
assert!(
white > 500,
"expected the digits to paint many near-white pixels, saw {white}"
);
}
#[test]
fn code_grouping() {
assert_eq!(group("123456"), "123 456");
assert_eq!(group("12345678"), "1234 5678");
assert_eq!(group("1234567"), "1234 567");
assert_eq!(group("12345"), "12345");
}
#[test]
fn issuer_hue_is_stable_and_varies() {
assert_eq!(hue_for("Cloudflare"), hue_for("Cloudflare"));
assert_ne!(hue_for("Cloudflare"), hue_for("Plex"));
}
#[test]
fn hsl_conversion_spans_the_wheel() {
// Six anchor hues must land in six distinct colours, and stay in range.
let mut seen = std::collections::HashSet::new();
for h in [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] {
let hex = hsl_hex(h, 0.62, 0.58);
assert_eq!(hex.len(), 7, "{hex}");
assert!(hex.starts_with('#'));
seen.insert(hex);
}
assert_eq!(seen.len(), 6);
}
#[test]
fn xml_special_characters_cannot_break_the_svg() {
// An unescaped ampersand here would make the SVG unparseable and the
// account's tile would stop updating with no obvious cause.
let tile = Tile {
issuer: "Ben & Jerry's <Corp>",
label: "a\"b'c & <script>",
code: "111222",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
let svg = build_svg(&tile);
assert!(svg.contains("&amp;"));
assert!(!svg.contains("<script>"));
}
#[test]
fn over_long_names_are_clipped() {
let long = "X".repeat(200);
let tile = Tile {
issuer: &long,
label: &long,
code: "123456",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
assert!(clip(&long, 26).chars().count() <= 26);
}
#[test]
fn eight_digit_and_unicode_content_render() {
assert!(tile_webp(&sample("Steam", "12345678")).is_ok());
assert!(tile_webp(&sample("Ünïcøde ✓ Ω", "123456")).is_ok());
// Empty issuer must not panic — hue falls back to the label.
assert!(tile_webp(&sample("", "123456")).is_ok());
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "openmfa-webui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Embedded, offline web UI for OpenMFA."
[dependencies]
[lints]
workspace = true
+818
View File
@@ -0,0 +1,818 @@
/* OpenMFA — web UI.
*
* Visual language follows Beautiful UI (https://www.beautifului.dev/):
* OKLCH ramps, hairline-ring shadows, inset fields / raised buttons,
* Inter + JetBrains Mono. Tokens below were taken from that system and
* extended with the few this app needs (code display, countdown ring).
*
* Fully offline: no CDN, no webfonts fetched at runtime. The font stack
* falls back to the platform UI face, which on every OS Miles runs is
* already a well-tuned grotesque.
*/
/* ── Tokens ───────────────────────────────────────────────────────────── */
:root {
color-scheme: light;
--page: oklch(98.5% .001 286.376);
--canvas: oklch(96.1% .002 247.84);
--surface: oklch(100% 0 0);
--inset: oklch(97.9% .002 247.839);
--hover: oklch(97% .002 247.839);
--hover-2: oklch(93.3% .003 247.86);
--ink: oklch(24.7% .006 258.361);
--ink-2: oklch(50.6% .01 264.477);
--ink-3: oklch(69.5% .009 264.505);
--line: oklch(94.6% .003 264.542);
--line-strong: oklch(91.2% .005 258.326);
--line-soft: oklch(96.6% .002 264.542);
--field: oklch(96.1% .001 286.375);
--stripe: oklch(40.5% 0 0/.075);
--stripe-bg: oklch(97% 0 0);
--accent: oklch(62.6% .205 254.947);
--accent-ink: oklch(55.6% .187 255.617);
--accent-tint: oklch(96% .019 252.878);
--on-accent: oklch(100% 0 0);
--green: oklch(60.3% .155 150.883);
--green-tint: oklch(60.3% .155 150.883/.12);
--orange: oklch(68.9% .179 49.902);
--orange-tint: oklch(68.9% .179 49.902/.12);
--red: oklch(62.1% .192 23.042);
--red-tint: oklch(62.1% .192 23.042/.12);
/* Beautiful UI's signature: hairline ring + very soft graduated drop. */
--shadow-card: 0 0 0 1px oklch(94.6% .003 264.542),
0 18px 47px 0 #00000008, 0 7.5px 19px 0 #00000005,
0 4px 10.5px 0 #00000005, 0 2.3px 5.8px 0 #00000003,
0 1.2px 3.1px 0 #00000003, 0 .5px 1.3px 0 #00000003;
--shadow-btn: 0 0 0 1px oklch(91.2% .005 258.326), 0 0 4px 0 #0000000a;
--shadow-raised: 0 0 0 1px oklch(91.2% .005 258.326), 0 2px 10px #0000000f;
--shadow-overlay: 0 0 0 1px oklch(91.2% .005 258.326), 0 16px 48px #00000021;
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.12);
--ring: 0 0 0 3px var(--accent-tint);
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--topbar-h: 60px;
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
--ease: cubic-bezier(.22, 1, .36, 1);
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: oklch(20.9% .004 264.477);
--canvas: oklch(23.1% .004 264.487);
--surface: oklch(26% .006 271.191);
--inset: oklch(24.3% .004 264.492);
--hover: oklch(28.9% .006 271.22);
--hover-2: oklch(31.8% .007 274.747);
--ink: oklch(96.4% .002 247.839);
--ink-2: oklch(73.1% .008 260.731);
--ink-3: oklch(54.1% .01 264.484);
--line: oklch(30.8% .006 258.354);
--line-strong: oklch(35.6% .007 264.474);
--line-soft: oklch(27.8% .006 258.354);
--field: oklch(29.3% .006 271.223);
--stripe: oklch(100% 0 0/.055);
--stripe-bg: oklch(22.6% .004 264.485);
--accent: oklch(68% .173 253.301);
--accent-ink: oklch(78.8% .113 248.33);
--accent-tint: oklch(68% .173 253.301/.16);
--on-accent: oklch(20% .01 253);
--green: oklch(70.5% .154 153.814);
--green-tint: oklch(70.5% .154 153.814/.14);
--orange: oklch(74.6% .156 55.642);
--orange-tint: oklch(74.6% .156 55.642/.14);
--red: oklch(66.6% .18 21.433);
--red-tint: oklch(66.6% .18 21.433/.14);
--shadow-card: 0 0 0 1px oklch(100% 0 0/.11), 0 1px 2px oklch(0% 0 0/.2),
0 2px 6px oklch(0% 0 0/.2);
--shadow-btn: 0 0 0 1px oklch(100% 0 0/.1), 0 1px 2px oklch(0% 0 0/.3);
--shadow-raised: 0 0 0 1px oklch(100% 0 0/.13), 0 2px 10px oklch(0% 0 0/.22);
--shadow-overlay: 0 0 0 1px oklch(100% 0 0/.15), 0 8px 28px oklch(0% 0 0/.34);
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.4);
}
/* ── Base ─────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
background: var(--page);
color: var(--ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.02em; }
h1 { font-size: 21px; }
h2 { font-size: 17px; }
h3 { font-size: 14px; }
p { margin: 0; }
a { color: var(--accent-ink); text-decoration: none; }
a:hover { text-decoration: underline; }
::selection { background: var(--accent-tint); }
:focus-visible {
outline: none;
box-shadow: var(--ring), var(--shadow-btn);
}
/* Diagonal-stripe backdrop — the Beautiful UI panel treatment. Used
behind the pre-auth screens so setup/login feel like a product, not
a bare form. */
.striped {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
var(--stripe) 0 1px,
transparent 1px 9px
);
}
/* ── Shell ────────────────────────────────────────────────────────────── */
.shell { display: flex; flex-direction: column; min-height: 100vh; }
/* Three columns: the search occupies the middle one, so it lands on the same
centre line as the account tiles below no matter how wide the brand or the
action cluster get. The outer columns are equal-width and allowed to
shrink, which keeps the centring exact rather than approximate. */
.topbar {
position: sticky; top: 0; z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
align-items: center; gap: 12px;
height: var(--topbar-h);
padding: 0 16px;
background: color-mix(in oklab, var(--page) 82%, transparent);
backdrop-filter: saturate(1.6) blur(12px);
border-bottom: 1px solid var(--line);
}
.topbar .brand { justify-self: start; min-width: 0; }
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
.topbar .actions {
justify-self: end;
display: flex; align-items: center; gap: 2px;
}
/* True centring needs room for all three: the brand wordmark (~210px), the
420px search, the action cluster (~110px), plus gaps and padding — about
800px in total. Below that the grid starts clipping "OpenMFA",
so fall back to a plain row where the brand keeps its space and the search
takes whatever is left. */
@media (max-width: 820px) {
.topbar { display: flex; }
.topbar .brand { flex: 0 0 auto; }
.topbar .search { flex: 1 1 auto; justify-self: auto; }
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
}
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
.brand svg { width: 26px; height: 26px; display: block; }
.brand strong {
font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap;
}
@media (max-width: 560px) { .brand strong { display: none; } }
.topbar .spacer { flex: 1; }
.main {
flex: 1;
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 22px 16px 128px;
}
/* ── Search ───────────────────────────────────────────────────────────── */
.search {
position: relative;
width: 100%;
max-width: 420px;
justify-self: center;
}
.search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 15px; height: 15px; color: var(--ink-3); pointer-events: none;
}
.search input {
width: 100%; height: 34px;
padding: 0 30px 0 31px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: box-shadow .15s var(--ease), border-color .15s var(--ease);
}
.search input::placeholder { color: var(--ink-3); }
.search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.search .clear {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: none; place-items: center;
width: 20px; height: 20px; padding: 0;
border: 0; border-radius: 5px;
background: transparent; color: var(--ink-3); cursor: pointer;
}
.search .clear:hover { background: var(--hover-2); color: var(--ink); }
.search.has-value .clear { display: grid; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 34px; padding: 0 13px;
font: inherit; font-size: 13.5px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border: 0; border-radius: var(--radius);
box-shadow: var(--shadow-btn);
cursor: pointer;
white-space: nowrap;
transition: background .14s var(--ease), transform .14s var(--ease),
box-shadow .14s var(--ease);
}
.btn:hover { background: var(--hover); }
.btn:active { transform: translateY(0.5px); }
.btn[disabled] { opacity: .5; pointer-events: none; }
.btn svg { width: 15px; height: 15px; flex: none; }
.btn-primary {
background: var(--accent); color: var(--on-accent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 1px 2px #00000026;
}
.btn-primary:hover { background: var(--accent-ink); }
.btn-danger { color: var(--red); }
.btn-danger:hover { background: var(--red-tint); }
.btn-ghost { background: transparent; box-shadow: none; color: var(--ink-2); }
.btn-ghost:hover { background: var(--hover-2); color: var(--ink); }
.btn-lg { height: 40px; padding: 0 18px; font-size: 14px; }
.btn-block { width: 100%; }
.icon-btn {
display: grid; place-items: center;
width: 32px; height: 32px; padding: 0;
border: 0; border-radius: var(--radius);
background: transparent; color: var(--ink-2);
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.icon-btn:hover { background: var(--hover-2); color: var(--ink); }
.icon-btn svg { width: 17px; height: 17px; }
/* Theme toggle shows one glyph per theme. */
.theme-toggle .t-moon { display: none; }
:root[data-theme="dark"] .theme-toggle .t-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .t-moon { display: block; }
/* ── Cards & panels ───────────────────────────────────────────────────── */
.card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 14px; }
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.card-head h2 { flex: 1; }
.card-body { padding: 16px; }
.card-sub { font-size: 12.5px; color: var(--ink-2); margin-top: 3px; }
/* ── Forms ────────────────────────────────────────────────────────────── */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.field label {
display: block; margin-bottom: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-2);
}
.field .hint { margin-top: 6px; font-size: 12px; color: var(--ink-3); }
.input, .select, textarea.input {
width: 100%; height: 38px;
padding: 0 11px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
}
textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
resize: vertical; font-family: var(--mono); font-size: 12.5px;
line-height: 1.6; }
.input::placeholder { color: var(--ink-3); }
.input:focus, .select:focus, textarea.input:focus {
outline: none; border-color: var(--accent);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.input[aria-invalid="true"] { border-color: var(--red); }
.select { appearance: none; cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
background-position: calc(100% - 15px) 17px, calc(100% - 10px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 30px;
}
.row { display: flex; gap: 10px; }
.row > * { flex: 1; min-width: 0; }
/* Switch */
.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch .track {
position: relative; flex: none;
width: 34px; height: 20px;
background: var(--hover-2);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line-strong);
transition: background .18s var(--ease);
}
.switch .track::after {
content: ""; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--surface); border-radius: 50%;
box-shadow: 0 1px 2px #00000033;
transition: transform .18s var(--ease);
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(14px); }
.switch input:focus-visible + .track { box-shadow: inset 0 0 0 1px var(--line-strong), var(--ring); }
.switch .switch-label { font-size: 13.5px; }
/* ── Account list ─────────────────────────────────────────────────────── */
.accounts { display: flex; flex-direction: column; gap: 10px; }
.acct {
display: grid;
grid-template-columns: 38px 1fr auto;
align-items: center;
gap: 13px;
padding: 13px 15px;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: background .14s var(--ease);
}
.acct:hover { background: var(--hover); }
.acct .avatar {
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 10px;
font-size: 15px; font-weight: 600;
color: var(--on-accent);
background: var(--accent);
box-shadow: inset 0 0 0 1px #0000001a;
user-select: none;
}
.acct .meta { min-width: 0; }
.acct .issuer {
font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .label {
font-size: 12px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .right { display: flex; align-items: center; gap: 11px; }
/* The code itself — the thing people came for. Tabular figures so the
digits don't shimmy as they change, and a word-gap in the middle the
way every authenticator app renders it. */
.code {
font-family: var(--mono);
font-size: 25px;
font-weight: 500;
letter-spacing: 0.055em;
font-variant-numeric: tabular-nums;
color: var(--ink);
cursor: pointer;
border: 0; background: none; padding: 2px 4px;
border-radius: 6px;
transition: color .2s var(--ease), background .14s var(--ease);
line-height: 1.1;
}
.code:hover { background: var(--hover-2); }
.code .gap { display: inline-block; width: 0.42em; }
/* Last 5 seconds: the code is about to roll. */
.acct.expiring .code { color: var(--orange); }
/* Countdown ring */
.ring { width: 26px; height: 26px; flex: none; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 2.5; }
.ring .bg { stroke: var(--line-strong); }
.ring .fg {
stroke: var(--accent); stroke-linecap: round;
transition: stroke-dashoffset .95s linear, stroke .3s var(--ease);
}
.acct.expiring .ring .fg { stroke: var(--orange); }
/* Discord push status */
.push-dot {
width: 7px; height: 7px; border-radius: 50%; flex: none;
background: var(--ink-3);
}
.push-dot.live { background: var(--green); box-shadow: 0 0 0 3px var(--green-tint); }
.push-dot.error { background: var(--red); box-shadow: 0 0 0 3px var(--red-tint); }
.push-dot.paused { background: var(--ink-3); }
/* ── Chips ────────────────────────────────────────────────────────────── */
.chip {
display: inline-flex; align-items: center; gap: 5px;
height: 22px; padding: 0 8px;
font-size: 11.5px; font-weight: 500;
color: var(--ink-2);
background: var(--inset);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
white-space: nowrap;
}
.chip.ok { color: var(--green); background: var(--green-tint); box-shadow: none; }
.chip.warn { color: var(--orange); background: var(--orange-tint); box-shadow: none; }
.chip.err { color: var(--red); background: var(--red-tint); box-shadow: none; }
/* ── Empty state ──────────────────────────────────────────────────────── */
.empty {
display: flex; flex-direction: column; align-items: center;
gap: 5px;
padding: 56px 24px 60px;
text-align: center;
border-radius: var(--radius-xl);
}
.empty .glyph {
display: grid; place-items: center;
width: 60px; height: 60px; margin-bottom: 14px;
border-radius: 18px;
color: var(--accent);
background: var(--accent-tint);
}
.empty .glyph svg { width: 28px; height: 28px; }
.empty h2 { font-size: 16px; }
.empty p { font-size: 13px; color: var(--ink-2); max-width: 330px; }
.empty .btn { margin-top: 16px; }
/* ── Floating action button ───────────────────────────────────────────── */
.fab {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
height: 46px; padding: 0 19px;
font: inherit; font-size: 14px; font-weight: 500;
color: var(--on-accent);
background: var(--accent);
border: 0; border-radius: 999px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 6px 20px color-mix(in oklab, var(--accent) 40%, transparent),
0 2px 6px #0000001f;
cursor: pointer;
transition: transform .16s var(--ease), background .16s var(--ease);
}
.fab:hover { background: var(--accent-ink); transform: translateY(-1px); }
.fab:active { transform: translateY(0); }
.fab svg { width: 17px; height: 17px; }
/* ── Modal ────────────────────────────────────────────────────────────── */
.scrim {
position: fixed; inset: 0; z-index: 40;
display: grid; place-items: center;
padding: 20px;
background: oklch(0% 0 0 / .45);
backdrop-filter: blur(3px);
animation: fade .16s var(--ease);
}
.modal {
width: 100%; max-width: 460px;
max-height: calc(100vh - 40px);
display: flex; flex-direction: column;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
animation: pop .2s var(--ease);
overflow: hidden;
}
.modal-head {
display: flex; align-items: center; gap: 10px;
padding: 15px 16px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 { flex: 1; }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-foot {
display: flex; justify-content: flex-end; gap: 8px;
padding: 13px 16px;
border-top: 1px solid var(--line);
background: var(--inset);
}
@keyframes fade { from { opacity: 0 } }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98) } }
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.tabs {
display: flex; gap: 3px;
padding: 3px;
background: var(--inset);
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--line);
margin-bottom: 16px;
}
.tabs button {
flex: 1;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 32px; padding: 0 8px;
font: inherit; font-size: 12.5px; font-weight: 500;
color: var(--ink-2);
background: transparent; border: 0; border-radius: 7px;
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.tabs button svg { width: 14px; height: 14px; }
.tabs button:hover { color: var(--ink); }
.tabs button.active {
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow-btn);
}
/* ── Dropzone (QR upload) ─────────────────────────────────────────────── */
.dropzone {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 34px 20px;
text-align: center;
color: var(--ink-2);
background: var(--inset);
border: 1.5px dashed var(--line-strong);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color .16s var(--ease), background .16s var(--ease);
}
.dropzone:hover, .dropzone.over {
border-color: var(--accent);
background: var(--accent-tint);
}
.dropzone svg { width: 26px; height: 26px; color: var(--ink-3); margin-bottom: 8px; }
.dropzone.over svg { color: var(--accent); }
.dropzone strong { font-size: 13.5px; color: var(--ink); font-weight: 500; }
.dropzone span { font-size: 12px; }
/* Camera scanner */
.scanner {
position: relative;
aspect-ratio: 1;
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background: oklch(15% 0 0);
box-shadow: inset 0 0 0 1px var(--line);
}
.scanner video { width: 100%; height: 100%; object-fit: cover; display: block; }
.scanner .reticle {
position: absolute; inset: 16%;
border-radius: 14px;
box-shadow: 0 0 0 3px oklch(100% 0 0 / .85),
0 0 0 9999px oklch(0% 0 0 / .45);
}
/* ── Segmented provider picker ──────────────────────────────────────── */
.segmented {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
padding: 3px;
background: var(--inset);
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--line);
}
.segmented button {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 36px; padding: 0 10px;
font: inherit; font-size: 13px; font-weight: 500;
color: var(--ink-2);
background: transparent; border: 0; border-radius: 7px;
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.segmented button:hover { color: var(--ink); }
.segmented button.active {
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow-btn);
}
.segmented .dot {
width: 9px; height: 9px; border-radius: 50%; flex: none;
box-shadow: inset 0 0 0 1px #0000001f;
opacity: .55;
}
.segmented button.active .dot { opacity: 1; }
.provider-fields:not(:empty) { margin-top: 14px; }
.provider-fields .alert { margin-bottom: 14px; }
.brand svg { width: 26px; height: 26px; display: block; }
.auth-card .brand svg { width: 34px; height: 34px; }
/* ── Import list (Google export / multi-account) ───────────────────── */
.import-head {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; margin: 14px 0 8px;
}
.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
/* Capped so a 20-account export scrolls inside the modal rather than
pushing the Import button off the bottom of the screen. */
.import-list {
max-height: 320px;
overflow-y: auto;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px var(--line);
background: var(--inset);
}
.import-row {
display: grid;
grid-template-columns: auto 30px 1fr auto;
align-items: center;
gap: 11px;
padding: 9px 12px;
cursor: pointer;
transition: background .12s var(--ease);
}
.import-row + .import-row { border-top: 1px solid var(--line-soft); }
.import-row:hover { background: var(--hover); }
.import-row input[type="checkbox"] {
width: 16px; height: 16px; margin: 0;
accent-color: var(--accent);
cursor: pointer;
flex: none;
}
.import-row .avatar {
width: 30px; height: 30px; border-radius: 8px; font-size: 13px;
}
.import-row .meta { min-width: 0; }
.import-row .issuer { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .label { font-size: 11.5px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.import-row .tags .chip { height: 19px; padding: 0 7px; font-size: 10.5px; }
/* An account already in the vault is de-emphasised but still selectable. */
.import-row:has(input:not(:checked)) .meta { opacity: .55; }
/* ── Toast ────────────────────────────────────────────────────────────── */
.toasts {
position: fixed; left: 50%; bottom: 22px; z-index: 60;
transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border-radius: 999px;
box-shadow: var(--shadow-overlay);
animation: toast-in .22s var(--ease);
}
.toast svg { width: 15px; height: 15px; flex: none; }
.toast.ok svg { color: var(--green); }
.toast.err svg { color: var(--red); }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) } }
/* ── Auth screens (setup / login) ─────────────────────────────────────── */
.auth-wrap {
min-height: 100vh;
display: grid; place-items: center;
padding: 24px;
}
.auth-card {
width: 100%; max-width: 400px;
padding: 28px;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
}
.auth-card .brand {
justify-content: center;
margin-bottom: 6px;
}
.auth-card .brand svg { width: 34px; height: 34px; }
.auth-card h1 { text-align: center; font-size: 19px; margin-top: 12px; }
.auth-card .sub {
text-align: center; font-size: 13px; color: var(--ink-2);
margin: 5px 0 22px;
}
.auth-card .btn { margin-top: 6px; }
.alert {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; margin-bottom: 14px;
font-size: 12.5px; line-height: 1.45;
border-radius: var(--radius);
}
.alert svg { width: 15px; height: 15px; flex: none; margin-top: 1px; }
.alert.err { color: var(--red); background: var(--red-tint); }
.alert.warn { color: var(--orange); background: var(--orange-tint); }
.alert.info { color: var(--ink-2); background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line); }
/* ── Settings ─────────────────────────────────────────────────────────── */
.settings-nav { display: flex; gap: 4px; margin-bottom: 18px; flex-wrap: wrap; }
.kv { display: flex; justify-content: space-between; gap: 16px;
padding: 9px 0; font-size: 13px; }
.kv + .kv { border-top: 1px solid var(--line-soft); }
.kv dt { color: var(--ink-2); }
.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px;
text-align: right; word-break: break-all; }
/* ── Utility ──────────────────────────────────────────────────────────── */
.mono { font-family: var(--mono); }
.muted { color: var(--ink-2); }
.small { font-size: 12.5px; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stack-s { display: flex; flex-direction: column; gap: 8px; }
.hidden { display: none !important; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<title>OpenMFA</title>
<!-- `?v=…` is replaced server-side with the running version so every
upgrade ships brand-new asset URLs; combined with
`Cache-Control: no-cache, must-revalidate` on the asset handlers the
worst-case staleness window is one release. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
<!-- Paint the stored theme before first paint to avoid a light→dark flash. -->
<script>
(function () {
try {
var stored = localStorage.getItem('openmfa-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
//! Offline-only web UI. Every byte the browser needs (HTML, CSS, JS, the
//! brand mark) is compiled into the binary via `include_str!`. No CDN, no
//! remote fonts, no external images — the UI renders identically on an
//! air-gapped LAN, which matters for a box holding TOTP secrets.
//!
//! Visual language follows Beautiful UI (<https://www.beautifului.dev/>):
//! OKLCH ramps, hairline-ring shadows, inset fields and raised buttons.
#![forbid(unsafe_code)]
/// Render the single-page shell.
///
/// `asset_version` is interpolated into every asset URL as `?v=…` so each
/// release ships fresh URLs and no browser or intermediary proxy can keep
/// serving the previous release's `app.js` against this release's API.
#[must_use]
pub fn index_html(asset_version: &str) -> String {
INDEX_HTML.replace("{{ASSET_VERSION}}", asset_version)
}
#[must_use]
pub fn app_js() -> &'static str {
APP_JS
}
#[must_use]
pub fn app_css() -> &'static str {
APP_CSS
}
#[must_use]
pub fn logo_svg() -> &'static str {
LOGO_SVG
}
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="OpenMFA">
<!-- OpenMFA mark: white shield-and-arc on the product's dark surface.
Blue is reserved for the Discord-only sibling project; OpenMFA is
platform-neutral, so its mark is monochrome. Literal colours rather
than currentColor because this renders as a favicon, a Gitea avatar and
an Unraid icon, none of which provide a CSS context. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#232833"/>
<stop offset="1" stop-color="#14171e"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<rect x="1" y="1" width="510" height="510" rx="113"
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="2"/>
<g transform="translate(96 96) scale(10)" fill="none" stroke="#ffffff">
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="#ffffff" fill-opacity=".12" stroke="none"/>
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
stroke-width="1.9" stroke-linejoin="round"/>
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="#ffffff" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+81
View File
@@ -0,0 +1,81 @@
# syntax=docker/dockerfile:1.7
#
# OpenMFA — multi-stage build.
#
# build : cross-compiles to x86_64-unknown-linux-musl with cargo-zigbuild.
# runtime : Alpine + the static binary. ~15 MB total.
#
# ## Why cross-compile instead of building for the target platform
#
# The obvious `FROM --platform=linux/amd64 rust` runs the entire compiler
# under QEMU when the builder is an Apple-Silicon Mac. That is roughly 15×
# slower, and the emulated linker intermittently SIGSEGVs part-way through.
#
# Pinning this stage to $BUILDPLATFORM instead runs rustc natively at full
# speed and emits x86_64 via cargo-zigbuild, which uses `zig cc` as the
# cross-linker — zig ships a musl sysroot for every target, so there is no
# cross-gcc toolchain to assemble. Deterministic, and minutes rather than
# half an hour.
ARG RUST_VERSION=1.95
########## build ##########
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild discovers it as
# `python3 -m ziglang`) supplies the musl sysroot and the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# Deliberately NOT copying rust-toolchain.toml: it pins `channel = "stable"`
# for local development, and inside the image that makes rustup download a
# second full toolchain — slow, and enough to fill a small Colima disk.
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \
cargo zigbuild --release --target x86_64-unknown-linux-musl \
--bin openmfa \
&& cp target/x86_64-unknown-linux-musl/release/openmfa /openmfa \
&& ls -l /openmfa
########## runtime ##########
# Alpine, not Debian: the binary is fully static musl and shells out to
# nothing, and its TLS roots are compiled in via webpki-roots — so the image
# needs no ca-certificates package and no runtime libraries at all. What it
# does need is a shell, for the bind-mount chown in the entrypoint (see there
# for why that matters on Unraid).
FROM alpine:3.21 AS runtime
RUN apk add --no-cache tini su-exec \
&& addgroup -g 10001 -S openmfa \
&& adduser -u 10001 -S -G openmfa -h /var/lib/openmfa openmfa \
&& mkdir -p /var/lib/openmfa \
&& chown -R openmfa:openmfa /var/lib/openmfa
COPY --from=build /openmfa /usr/local/bin/openmfa
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/openmfa
# NOT `USER openmfa` — the entrypoint starts as root to fix bind-mount ownership,
# then drops to uid 10001 via su-exec. Its non-root branch handles the case
# where the platform has already assigned a uid.
WORKDIR /var/lib/openmfa
ENV OPENMFA_WORK_DIR=/var/lib/openmfa \
OPENMFA_HTTP_ADDR=0.0.0.0:8720 \
OPENMFA_LOG=info,openmfa=info
EXPOSE 8720/tcp
VOLUME ["/var/lib/openmfa"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8720/healthz || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Fix bind-mount ownership, then drop privileges.
#
# Bind-mounted volumes arrive owned by whatever the host says — on Unraid
# that is typically nobody:users (99:100), and on a plain `docker run -v`
# it is usually root:root. The service runs as uid 10001 and could not
# otherwise write the vault, so when we start as root we chown the data
# directory first and only then hand off.
#
# When the container is already running as non-root (some platforms assign
# their own uid), we skip the chown and exec directly — there is nothing
# useful we could do about permissions from there anyway.
set -e
OPENMFA_UID=${OPENMFA_UID:-10001}
OPENMFA_GID=${OPENMFA_GID:-10001}
DATA_DIR=${OPENMFA_WORK_DIR:-/var/lib/openmfa}
if [ "$(id -u)" = "0" ]; then
mkdir -p "$DATA_DIR"
chown -R "${OPENMFA_UID}:${OPENMFA_GID}" "$DATA_DIR" 2>/dev/null || true
# The vault key is the one file that must never be group- or
# world-readable; a permissive umask on the host can otherwise leave it
# readable to anyone who can browse the appdata share.
if [ -f "$DATA_DIR/vault.key" ]; then
chmod 600 "$DATA_DIR/vault.key" 2>/dev/null || true
fi
exec su-exec "${OPENMFA_UID}:${OPENMFA_GID}" /usr/local/bin/openmfa "$@"
fi
exec /usr/local/bin/openmfa "$@"
+79
View File
@@ -0,0 +1,79 @@
<?xml version="1.0"?>
<!--
Unraid Docker template for OpenMFA.
Drop this into /boot/config/plugins/dockerMan/templates-user/ on the Unraid
box, or add the repo as a template repository under Docker → Add Container.
Bridge networking is correct: OpenMFA makes outbound HTTPS calls to the chat
platform you choose and serves one HTTP port.
Web UI: http://<unraid-ip>:8720/
-->
<Container version="2">
<Name>OpenMFA</Name>
<Repository>gitea.milesward.dev/mward4/openmfa:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/openmfa</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://gitea.milesward.dev/mward4/OpenMFA/issues</Support>
<Project>https://gitea.milesward.dev/mward4/OpenMFA</Project>
<Overview>
Self-hosted TOTP authenticator with live delivery to Discord, Slack or
Telegram.
Add accounts by uploading a QR code, scanning with the camera, pasting an
otpauth:// link, or importing a Google Authenticator export (one export QR
carries many accounts; you get a checklist, and duplicates are detected).
The web UI lists every account with its rotating code, the way Google or
Microsoft Authenticator does.
The distinctive part: each account keeps ONE message in your chosen
channel, rewritten in place every time the code rolls. Three accounts means
three live tiles that keep themselves current. Codes are posted as copyable
text; on Discord a rendered WebP tile is attached as well.
Pick the platform during first-run setup or later in Settings. Discord
needs only a webhook URL. Slack needs a bot token (its webhooks can't edit
messages). Telegram needs a bot token, and the app detects your chat ID for
you.
SECURITY NOTE: anyone who can read that channel can read these codes, which
is most of the point of a second factor. Use a private channel only you are
in, and treat the webhook URL or bot token as a secret. TOTP secrets are
encrypted at rest with XChaCha20-Poly1305.
</Overview>
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8720]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/OpenMFA/raw/branch/main/docs/brand/icon-256.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires>
One of: a Discord incoming-webhook URL; a Slack app with the chat:write
bot scope plus a channel ID; or a Telegram bot token from @BotFather. All
can be added after install from Settings → Delivery.
</Requires>
<Config Name="WebUI Port" Target="8720" Default="8720" Mode="tcp"
Description="Port for the web UI and API."
Type="Port" Display="always" Required="true" Mask="false">8720</Config>
<Config Name="Data" Target="/var/lib/openmfa"
Default="/mnt/user/appdata/openmfa"
Mode="rw"
Description="Vault, encryption key, admin account and settings. BACK THIS UP — losing it loses every stored TOTP secret."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openmfa</Config>
<Config Name="Master key (optional)" Target="OPENMFA_MASTER_KEY" Default=""
Mode=""
Description="Base64 of 32 random bytes. Set this to keep the vault key OFF the appdata share, so a leaked backup of the share is useless on its own. Generate with: openssl rand -base64 32. Leave blank to auto-generate a key file inside the data folder instead. WARNING: if you set this later, existing secrets become unreadable — export them first."
Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Log filter" Target="OPENMFA_LOG" Default="info,openmfa=info"
Mode=""
Description="tracing-subscriber EnvFilter expression. Use info,openmfa=debug for more detail."
Type="Variable" Display="advanced" Required="false" Mask="false">info,openmfa=info</Config>
</Container>
+34
View File
@@ -0,0 +1,34 @@
# OpenMFA — single-container deployment.
#
# docker compose up -d
#
# Then open http://<this-host>:8720/ and complete first-run setup.
#
# No special networking: outbound HTTPS to Discord, Slack or Telegram, and
# one served HTTP port. Bridge mode is fine.
services:
openmfa:
image: gitea.milesward.dev/mward4/openmfa:latest
container_name: openmfa
restart: unless-stopped
ports:
- "8720:8720"
environment:
OPENMFA_HTTP_ADDR: "0.0.0.0:8720"
OPENMFA_LOG: "info,openmfa=info"
# Optional. Supply a base64 32-byte key to keep the vault key OFF the
# data volume — then a leaked copy of the volume is useless on its own.
# Generate one with: openssl rand -base64 32
# If unset, a key is generated into the volume as `vault.key` (0600).
# OPENMFA_MASTER_KEY: "…"
volumes:
# Vault, key, admin account and settings. Back this up — losing it
# loses every stored TOTP secret.
- ./data/work:/var/lib/openmfa
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8720/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
+74
View File
@@ -0,0 +1,74 @@
# Publishing to Gitea
Everything here needs a Gitea **personal access token**, which is why it isn't
automated: the token should only ever exist in your own shell.
Create one at **Gitea → Settings → Applications → Generate New Token** with
scopes:
- `write:repository` — to create the repo and push
- `write:package` — to push the container image
Export it once per shell session:
```bash
export GITEA_TOKEN='<paste-your-token>'
```
## 1. Create the repository
```bash
curl -s -X POST "https://gitea.milesward.dev/api/v1/user/repos" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d '{"name":"OpenMFA","description":"Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram. Container-native Rust.","private":false,"auto_init":false}'
```
The repo is public so Unraid can pull the image anonymously without registry
credentials; the Gitea instance itself is not exposed beyond the LAN.
## 2. Push the code and the tag
```bash
cd "/Users/casperadmin/OpenMFA" && git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" push -u origin main --follow-tags
```
That pushes `main` and the `v0.1` tag together.
## 3. Push the container image
The image is already built and tagged locally as
`gitea.milesward.dev/mward4/openmfa:0.1` and `:latest`.
```bash
printf '%s' "$GITEA_TOKEN" | docker login gitea.milesward.dev --username mward4 --password-stdin && docker push gitea.milesward.dev/mward4/openmfa:0.1 && docker push gitea.milesward.dev/mward4/openmfa:latest && docker logout gitea.milesward.dev
```
If the image isn't built (a fresh machine, say), `scripts/build-and-publish-unraid.sh`
does the clone, build, login and push in one pass:
```bash
GITEA_TOKEN="$GITEA_TOKEN" ./scripts/build-and-publish-unraid.sh
```
## 4. Deploy on Unraid
Add a container with Repository set to:
```
gitea.milesward.dev/mward4/openmfa:0.1
```
Since the package is in a private registry, add the Gitea credentials under
Unraid's Docker → Registries first, or the pull will 401.
Map `/var/lib/openmfa` to `/mnt/user/appdata/openmfa`,
publish port `8720`, and leave networking on bridge.
Or import `deploy/unraid/openmfa.xml` into
`/boot/config/plugins/dockerMan/templates-user/` to get all of that prefilled.
## Afterwards
Confirm the token isn't left lying around in your shell history:
```bash
unset GITEA_TOKEN
```
+176
View File
@@ -0,0 +1,176 @@
# Microsoft Teams — exploration for v0.2
**Short version:** Teams is meaningfully harder than Discord, Slack and Telegram,
and the reason is structural rather than incidental. Every path that lets an
outsider *post* into Teams without a bot has been retired or cannot edit.
Live tiles in Teams therefore require a **Bot Framework bot**, which means an
Azure Bot registration, an app manifest, and — for a self-hosted homelab — an
inbound HTTPS endpoint or a workaround for not having one. This document lays
out the options and recommends one.
---
## 1. What no longer works
### Office 365 Connectors / "Incoming Webhook"
The classic Teams incoming webhook was an Office 365 Connector. Microsoft
retired the connector platform: URLs had to be migrated by January 2025, the
service was disabled for Teams in **May 2026**, and it is now gone. Even when it
worked, a connector could only *post* a message; there was no edit call. So it
was never a live-tile candidate, and today it isn't a candidate for anything.
### Power Automate "Workflows" webhooks
Microsoft's designated replacement. A Workflows-app webhook accepts a POST and
runs a flow that posts to a channel (Adaptive Cards included, private channels
supported since April 2026). It is fine for notifications. It cannot edit a
message it posted earlier — the flow's "Post card in a chat or channel" action
creates; there is a separate "Update an adaptive card" action, but it requires
the flow to have posted the card *and* to still hold its state, and it is
driven by the flow's own triggers, not by an external caller saying "edit
message X". Chaining that into a 30-second edit loop from outside is not a
supported shape and would be fragile even where it half-works.
**Verdict:** Workflows can be a v0.2 *notification* target (post-only, one
message per roll — noisy), not a live-tile target.
### Microsoft Graph
`PATCH /teams/{team}/channels/{channel}/messages/{id}` exists. But `chatMessage`
update is **delegated-permission only** — an application (daemon) token cannot
call it. A self-hosted service has no signed-in user to delegate from without a
full interactive OAuth flow and refresh-token management, and Graph would also
require the message to have been *posted* by that same user. Not a fit for an
unattended service.
---
## 2. What does work: a Bot Framework bot
A bot registered in Azure Bot Service can post an activity to a channel and
later call `updateActivity` on it — the Teams equivalent of `chat.update`. This
is exactly the primitive OpenMFA needs, and it is the only supported one.
What it involves:
1. **Azure Bot registration** (free tier). Yields a Microsoft App ID and a
client secret (or certificate). No Azure spend is required for the bot
resource itself.
2. **Teams app manifest** declaring the bot, sideloaded into the tenant or
installed to a team. The tenant admin must permit custom app upload.
3. **A messaging endpoint.** Teams sends the bot an HTTPS POST when the bot is
installed, added to a team, or messaged. The bot needs this at least *once*
to learn the `conversation.id` and `serviceUrl` for the target channel —
after that, OpenMFA can post and edit proactively using only stored values
plus a token from the Bot Framework OAuth endpoint.
4. **Proactive messaging.** With a stored conversation reference, the bot
authenticates to `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token`
with its App ID and secret, then calls:
- `POST {serviceUrl}/v3/conversations/{conversationId}/activities` → create,
returns `{ id }`
- `PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}` → **update**
- `DELETE …/activities/{activityId}` → delete
That maps one-to-one onto the `Sink` trait already in `crates/delivery`:
`create` / `edit` / `delete`. The engine would not change.
5. **Message format.** An **Adaptive Card** with the code in a monospace
`TextBlock` (`fontType: Monospace`) and a `Copy` action is the natural tile.
Adaptive Cards don't have a live-ticking timestamp like Discord's `<t:R>`,
but they do support `{{DATE()}}` / `{{TIME()}}` formatting functions that
render in the viewer's locale — the Slack-equivalent "renders once, in local
time" behaviour.
### Rate limits
Bot Framework in Teams allows roughly **1 message per second per bot per
conversation**, with a burst allowance, and Teams throttles harder on channel
messages than on 1:1 chats. Editing counts. With one edit per account per
30 seconds, a channel would comfortably carry **~15–20 accounts**; the same
one-second governor used for Slack and Telegram fits.
### The self-hosting problem, and three answers
The messaging endpoint is the sticking point for a homelab. Teams must be able
to reach the bot over public HTTPS, at least during installation. Options, in
order of preference:
**A. Cloudflare Tunnel (recommended).** Miles already fronts Gitea with
Cloudflare. A named tunnel exposing `/api/teams/messages` on the OpenMFA
container gives Teams a stable public HTTPS URL with no port forwarding and no
certificate management. The endpoint only needs to validate the Bot Framework
JWT on inbound requests and store the conversation reference — a few dozen
lines with `jsonwebtoken` plus the Microsoft OpenID metadata for key discovery.
This keeps OpenMFA fully self-hosted.
**B. One-time capture, then close the door.** Because the conversation
reference is only *learned* via the inbound endpoint, the tunnel could be
opened once during setup, the reference stored in `settings.json`, and the
tunnel torn down. Proactive posting and editing need only outbound HTTPS.
Downside: if the bot is removed and re-added, or the team is recreated, the
reference goes stale and the door has to be reopened. Workable for a
single-user homelab; clunky as a product default.
**C. Manual reference entry.** The `serviceUrl` for a tenant is stable
(`https://smba.trafficmanager.net/<region>/`) and the channel conversation id
is visible in a Teams channel link (`19:…@thread.tacv2`). In principle an
operator could paste both. In practice the id format has changed across Teams
generations and proactive posting to a conversation the bot has never been
installed into is rejected, so this only works after installation anyway. Not
recommended as the primary path, but useful as an override field.
---
## 3. Recommended v0.2 scope
1. **`crates/delivery/src/teams.rs`** — `TeamsClient` with token acquisition
(client-credentials against `botframework.com`), `create` / `update` /
`delete` activities, Adaptive Card builder, and error classification
(`ConversationNotFound` → repost; 401/403 on token → fatal; 429 → back off
using `Retry-After`).
2. **`Provider::Teams`** in `core::settings` with `TeamsConfig { app_id,
app_secret, tenant_id, conversation_id, service_url }`. Validation on the
GUID shapes; `destination_key` on `conversation_id`.
3. **Inbound endpoint** `POST /api/teams/messages`, unauthenticated by session
but **gated by Bot Framework JWT validation** (issuer, audience = App ID,
signature via Microsoft's JWKS). Handles `conversationUpdate` (bot added →
store reference) and ignores everything else. This is the only public
surface OpenMFA would ever expose and it must accept nothing but a valid
Microsoft-signed token.
4. **Settings UI** — a fourth segment in the picker with a guided flow: paste
App ID + secret → OpenMFA shows the public endpoint URL to put in the Azure
Bot's *Messaging endpoint* → generate a downloadable Teams app manifest
(`manifest.json` + icons, zipped) → "install it to your team, then click
Detect" → the stored conversation appears, exactly like Telegram's chat
detection.
5. **Tunnel documentation** — a `cloudflared` sidecar example in
`docker-compose.yml` and an Unraid note.
Effort is roughly two to three times the Slack integration: the API surface is
similar, but JWT validation, manifest generation and the inbound endpoint are
new machinery. Nothing in it changes the engine, the vault, or the other three
platforms.
---
## 4. What I would *not* do
- Don't ship Teams via Workflows webhooks as if it were live. Posting a new
message every 30 seconds fills a channel with 2,880 messages a day per
account. If a post-only Teams mode is wanted for notifications, it should be
a separate, clearly labelled "announce" mode — not the default and not called
live.
- Don't attempt Graph delegated auth. It would make OpenMFA hold a *user's*
refresh token for their Microsoft account to edit chat messages, which is a
larger credential than a bot secret and a worse thing to leak.
---
## Sources
- Microsoft 365 Developer Blog — *Retirement of Office 365 connectors within Microsoft Teams*
- Microsoft Learn — *Create an Incoming Webhook* (Teams), with the Workflows migration notice
- Microsoft Learn — Bot Framework REST API: *Update Activity*, *Send to Conversation*, *Proactive messages in Teams*
- Microsoft Learn — Teams bot rate limiting
- Microsoft Graph — `chatMessage: update` (delegated permissions only)
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="OpenMFA">
<!-- OpenMFA mark: white shield-and-arc on the product's dark surface.
Blue is reserved for the Discord-only sibling project; OpenMFA is
platform-neutral, so its mark is monochrome. Literal colours rather
than currentColor because this renders as a favicon, a Gitea avatar and
an Unraid icon, none of which provide a CSS context. -->
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#232833"/>
<stop offset="1" stop-color="#14171e"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<rect x="1" y="1" width="510" height="510" rx="113"
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="2"/>
<g transform="translate(96 96) scale(10)" fill="none" stroke="#ffffff">
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="#ffffff" fill-opacity=".12" stroke="none"/>
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
stroke-width="1.9" stroke-linejoin="round"/>
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="#ffffff" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# build-and-publish-unraid.sh — build the OpenMFA image and push
# it to the local Gitea container registry, ready to pull from an Unraid
# template.
#
# Run this on any host that can reach Gitea and has Docker. It clones a fresh
# copy rather than building the working tree, so what ships is exactly what is
# committed.
#
# Inputs (env vars, all optional except GITEA_TOKEN):
# GITEA_TOKEN personal access token with `write:package` scope
# GITEA_HOST default: gitea.milesward.dev
# GITEA_OWNER default: mward4
# GITEA_REPO default: OpenMFA
# IMAGE_NAME default: openmfa
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1 (tag/branch to build)
# WORKDIR default: /tmp/openmfa-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
# <GITEA_HOST>/<owner>/openmfa:<tag> (or :latest)
set -euo pipefail
GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-OpenMFA}
IMAGE_NAME=${IMAGE_NAME:-openmfa}
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1}
WORKDIR=${WORKDIR:-/tmp/openmfa-build}
# OCI distribution rejects uppercase repository paths, so the image name is
# lowercased independently of the repo name.
IMAGE_NAME="$(printf '%s' "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]')"
FULL_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:$IMAGE_TAG"
LATEST_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:latest"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "ERROR: set GITEA_TOKEN to a token with write:package scope." >&2
echo " Gitea -> Settings -> Applications -> Generate New Token." >&2
exit 2
fi
for tool in git docker; do
command -v "$tool" >/dev/null 2>&1 \
|| { echo "ERROR: '$tool' not found in PATH" >&2; exit 2; }
done
# A private docker config so the token never lands in ~/.docker/config.json.
# The trap wipes it (and the checkout) on any exit path.
DOCKER_CONFIG="$(mktemp -d)"
export DOCKER_CONFIG
trap 'rm -rf "$DOCKER_CONFIG" "$WORKDIR"' EXIT
echo
echo "==> Workspace : $WORKDIR"
echo "==> Gitea host : $GITEA_HOST"
echo "==> Building ref : $GIT_REF"
echo "==> Image to push : $FULL_TAG"
echo "==> Platform : $PLATFORM"
echo
# Step 1: shallow clone of the exact ref.
rm -rf "$WORKDIR"
git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" \
clone --depth 1 --branch "$GIT_REF" \
"https://$GITEA_HOST/$GITEA_OWNER/$GITEA_REPO.git" "$WORKDIR"
cd "$WORKDIR"
# Step 2: build. --pull so a stale cached base image doesn't bake in a CVE
# that upstream has already fixed.
#
# The Dockerfile cross-compiles from the builder's native architecture, so
# this is fast even when building linux/amd64 on an arm64 host — no QEMU.
docker build \
--pull \
--platform "$PLATFORM" \
--file deploy/docker/Dockerfile \
--tag "$FULL_TAG" \
--tag "$LATEST_TAG" \
.
# Step 3: log in. --password-stdin keeps the token out of `ps` and shell history.
printf '%s' "$GITEA_TOKEN" | docker login \
--username "$GITEA_OWNER" \
--password-stdin \
"$GITEA_HOST"
# Step 4: push both tags.
docker push "$FULL_TAG"
docker push "$LATEST_TAG"
docker logout "$GITEA_HOST" || true
echo
echo "==> Done. Pull from an Unraid template with:"
echo " $FULL_TAG"
echo " $LATEST_TAG"
echo